packages feed

salmon-ops-0.1.0.0: src/Salmon/Builtin/Nodes/Gcp/Billing.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Linking a GCP project to a billing account (@gcloud billing projects
link@) -- the other prerequisite (alongside
"Salmon.Builtin.Nodes.Gcp.ServiceUsage") that a freshly-created project
needs before most other APIs will do anything, since GCP refuses to enable
most billable services on a project with no billing account attached.

Resolving a billing account by its human-facing display name (as the koli
provisioning script this was ported from does, via @gcloud billing accounts
list --filter=displayName:...@) is left to config generation, same as
"Salmon.Builtin.Nodes.Gcp.Core".'Salmon.Builtin.Nodes.Gcp.Core.Project' --
this module only ever takes an already-resolved 'BillingAccount' id.
-}
module Salmon.Builtin.Nodes.Gcp.Billing (
    BillingAccount (..),
    linkBillingAccount,
    interpretBillingDescribe,
    Report (..),
    BillingCommand (..),
    billingCommand,
) where

import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import GHC.IO.Exception (ExitCode (..))
import System.Process.ByteString (readCreateProcessWithExitCode)

import Salmon.Actions.UpDown (CheckResult (..))
import Salmon.Builtin.Extension
import Salmon.Builtin.Nodes.Binary (Binary, Command (..), withBinary)
import qualified Salmon.Builtin.Nodes.Binary as Binary
import Salmon.Builtin.Nodes.Gcp.Core (Project (..), gcloudProc)
import Salmon.Op.Ref
import Salmon.Op.Track
import Salmon.Reporter

-------------------------------------------------------------------------------

data Report
    = RunBillingCommand !BillingCommand !Binary.Report
    deriving (Show)

-------------------------------------------------------------------------------

{- | A GCP billing account id, e.g. @XXXXXX-XXXXXX-XXXXXX@ -- bare, without
the @billingAccounts/@ resource-name prefix @gcloud billing accounts list@
returns it with, the same convention
"Salmon.Builtin.Nodes.Gcp.Core".'Salmon.Builtin.Nodes.Gcp.Core.Project'
uses for a bare project id.
-}
newtype BillingAccount = BillingAccount {billingAccountId :: Text}
    deriving (Eq, Ord, Show)

-- | Idempotently links a project to a billing account.
linkBillingAccount :: Reporter Report -> Track' (Binary "gcloud") -> Project -> BillingAccount -> Op
linkBillingAccount r gcloudTrack project account =
    withBinary gcloudTrack billingCommand (ProjectsLink project account) $ \link ->
        withBinary gcloudTrack billingCommand (ProjectsUnlink project) $ \unlink ->
            op "gcp-billing-link" nodeps $ \actions ->
                actions
                    { help = Text.unwords ["links project", project.projectId, "to billing account", account.billingAccountId]
                    , ref = mkRef "gcp-billing-link" project.projectId
                    , up = link r'
                    , down = unlink r'
                    , check = checkLink
                    }
  where
    r' = contramap (RunBillingCommand (ProjectsLink project account)) r

    checkLink :: IO CheckResult
    checkLink = do
        (code, out, _err) <-
            readCreateProcessWithExitCode
                (prepare billingCommand (ProjectsDescribe project))
                ""
        pure $ interpretBillingDescribe account code (Text.decodeUtf8 out)

{- | The verdict drawn from @gcloud billing projects describe@'s exit code
and output, split out for testability. Plain (YAML-ish) output is used
rather than @--format=json@ so this stays a substring check, the same
shape as "Salmon.Builtin.Nodes.Gcp.Iam".@interpretBindingPolicy@.
-}
interpretBillingDescribe :: BillingAccount -> ExitCode -> Text -> CheckResult
interpretBillingDescribe _account (ExitFailure n) _outText =
    Failure ("could not describe project billing (exit " <> Text.pack (show n) <> ")")
interpretBillingDescribe account ExitSuccess outText =
    if accountLine `Text.isInfixOf` outText && enabledLine `Text.isInfixOf` outText
        then Success
        else Failure ("project not linked to billing account " <> account.billingAccountId)
  where
    accountLine = "billingAccountName: billingAccounts/" <> account.billingAccountId
    enabledLine = "billingEnabled: true"

-------------------------------------------------------------------------------

data BillingCommand
    = ProjectsLink Project BillingAccount
    | ProjectsDescribe Project
    | ProjectsUnlink Project
    deriving (Show)

billingCommand :: Command "gcloud" BillingCommand
billingCommand = Command $ \cmd -> case cmd of
    ProjectsLink project account ->
        gcloudProc
            [ "billing"
            , "projects"
            , "link"
            , Text.unpack project.projectId
            , "--billing-account"
            , Text.unpack account.billingAccountId
            ]
    ProjectsDescribe project ->
        gcloudProc
            [ "billing"
            , "projects"
            , "describe"
            , Text.unpack project.projectId
            ]
    ProjectsUnlink project ->
        gcloudProc
            [ "billing"
            , "projects"
            , "unlink"
            , Text.unpack project.projectId
            ]