packages feed

salmon-ops-0.2.0.0: src/Salmon/Builtin/Nodes/Gcp/CloudRun.hs

{-# LANGUAGE OverloadedStrings #-}

module Salmon.Builtin.Nodes.Gcp.CloudRun (
    IngressSetting (..),
    SecretBinding (..),
    renderSecretBinding,
    CloudRunOptions (..),
    defaultCloudRunOptions,
    OwnerLabel (..),
    renderOwnerLabel,
    Ownership (..),
    interpretOwnership,
    CloudRunService (..),
    cloudRunService,
    interpretServiceDescribe,
    interpretServicePresence,
    Report (..),
    CloudRunCommand (..),
    cloudRunCommand,
) where

import Control.Exception (throwIO)
import Data.Aeson (Value (..), eitherDecodeStrict)
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Foldable (toList)
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import qualified Data.Text.Encoding.Error as TextError
import GHC.IO.Exception (ExitCode (..))
import System.Process.ByteString (readCreateProcessWithExitCode)
import System.Process.ListLike (proc)

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 (..), Region (..), gcloudProc, withProject, withRegion)
import qualified Salmon.Builtin.Nodes.Gcp.Core as Core
import Salmon.Op.Ref
import Salmon.Op.Track
import Salmon.Reporter

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

data Report
    = RunCloudRunCommand !CloudRunCommand !Binary.Report
    deriving (Show)

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

-- | Ingress settings for a CloudRun service.
data IngressSetting
    = All
    | Internal
    | InternalAndLoadBalancing
    deriving (Eq, Show)

renderIngress :: IngressSetting -> Text
renderIngress All = "all"
renderIngress Internal = "internal"
renderIngress InternalAndLoadBalancing = "internal-and-cloud-load-balancing"

{- | One Secret Manager secret made visible to the container, either as a
file or as an environment variable.

The file form is what credentials want. An environment variable is readable
by anything that can list the process's environment and tends to end up in
logs and crash reports; a mounted file can be read once at start-up and has
a path that is not printed by accident.

Mounting has one wrinkle that has bitten everyone who has done this with
@libpq@: __Cloud Run's secret volumes are read-only and cannot be chmod'ed__,
and libpq refuses a client key whose mode is wider than @0600@. The way
through is to mount somewhere neutral and have the entrypoint copy the
files, which is what "SreBox.Gcp.PostgrestCloudRun" generates.
-}
data SecretBinding
    = -- | mounted at this absolute path
      SecretFile FilePath Text Text
    | -- | injected as this environment variable
      SecretEnvVar Text Text Text
    deriving (Eq, Show)

-- | gcloud's own @--set-secrets@ syntax: @TARGET=SECRET:VERSION@.
renderSecretBinding :: SecretBinding -> Text
renderSecretBinding (SecretFile path name version) =
    Text.pack path <> "=" <> name <> ":" <> version
renderSecretBinding (SecretEnvVar var name version) =
    var <> "=" <> name <> ":" <> version

{- | The knobs beyond "run this image", grouped so that adding one does not
break every record construction in the tree.
-}
data CloudRunOptions = CloudRunOptions
    { croSecrets :: [SecretBinding]
    , croCpu :: Maybe Text
    , croMemory :: Maybe Text
    , croConcurrency :: Maybe Int
    , croTimeoutSeconds :: Maybe Int
    , croPort :: Maybe Int
    , croAllowUnauthenticated :: Bool
    -- ^ whether the service answers unauthenticated callers. 'False' (the
    -- default) leaves the deploy alone rather than passing
    -- @--no-allow-unauthenticated@, so a service fronted by a load balancer
    -- or governed by an org policy is not fought with on every pass.
    , croInvokerIamCheckDisabled :: Bool
    -- ^ @--no-invoker-iam-check@: the service answers every caller without
    -- consulting IAM at all. This is the way to make a service public under
    -- an organization whose @iam.allowedPolicyMemberDomains@ policy forbids
    -- the @allUsers@ binding that 'croAllowUnauthenticated' asks for: there,
    -- @gcloud run deploy --allow-unauthenticated@ deploys fine, only warns
    -- that the binding was refused, and the service answers 403 to everyone.
    -- Being part of the service's spec rather than a separate IAM write, it
    -- either deploys or fails. 'False' (the default) leaves the deploy alone.
    , croMinInstances :: Maybe Int
    -- ^ @--min-instances@: how many instances stay up with no traffic.
    -- 'Nothing' (the default) leaves the deploy alone, which for a new
    -- service is Cloud Run's scale-to-zero. A service with a background loop
    -- (a scheduler tick, a lease) needs at least one, and needs
    -- 'croCpuAlwaysAllocated' with it: an idle instance is otherwise kept
    -- but given no CPU outside a request. Unsetting it does not put a
    -- service back to zero; say @Just 0@.
    , croCpuAlwaysAllocated :: Bool
    -- ^ @--no-cpu-throttling@: CPU is allocated for the instance's whole
    -- life rather than only while it handles a request ("instance-based
    -- billing"). 'False' (the default) leaves the deploy alone rather than
    -- passing @--cpu-throttling@, so turning this off again is not something
    -- a redeploy does.
    , croOwner :: Maybe OwnerLabel
    -- ^ a label that says this declaration made the service. 'Nothing' (the
    -- default) is the node this module always made: the name is the whole
    -- identity, a deploy lands on whatever service has it and @down@ deletes
    -- it. With a label, see 'OwnerLabel'.
    }
    deriving (Eq, Show)

{- | A label proving who made a service: @key=value@ among the service's own
labels, written by the deploy that creates it (@--update-labels@, so the
labels somebody else put there are left alone).

A node declared with one treats the service's name as an address rather than
as proof:

* a service of that name __without__ the label is somebody else's. The
  @check@ says so, @up@ refuses to deploy over it, and @down@ leaves it
  where it is;
* a service __with__ it is deployed to and deleted as usual;
* when the listing that answers the question fails, @up@ and @down@ both
  throw rather than guess.

The key and the value are label text, so GCP's rules apply to both
(lowercase letters, digits, @-@ and @_@, at most 63 characters, the key
starting with a letter); the caller validates, this module does not. The
label ends up in argv, in the node's notes and in the service's metadata: it
is a name, never a credential.
-}
data OwnerLabel = OwnerLabel
    { ownerKey :: Text
    , ownerValue :: Text
    }
    deriving (Eq, Show)

-- | @key=value@, as @--update-labels@ takes it.
renderOwnerLabel :: OwnerLabel -> Text
renderOwnerLabel o = o.ownerKey <> "=" <> o.ownerValue

-- | Whose a service of the declared name is, as far as its labels say.
data Ownership
    = -- | no service has the name
      Absent
    | -- | the service carries the label
      Ours
    | -- | a service has the name and not the label
      Foreign
    | -- | the listing failed or was not what this module reads
      Undetermined Text
    deriving (Eq, Show)

-- | Nothing set: the same deploy this module made before these knobs existed.
defaultCloudRunOptions :: CloudRunOptions
defaultCloudRunOptions =
    CloudRunOptions
        { croSecrets = []
        , croCpu = Nothing
        , croMemory = Nothing
        , croConcurrency = Nothing
        , croTimeoutSeconds = Nothing
        , croPort = Nothing
        , croAllowUnauthenticated = False
        , croInvokerIamCheckDisabled = False
        , croMinInstances = Nothing
        , croCpuAlwaysAllocated = False
        , croOwner = Nothing
        }

-- | A CloudRun service.
data CloudRunService = CloudRunService
    { crsName :: Text
    , crsProject :: Project
    , crsRegion :: Region
    , crsImage :: Text
    , crsEnv :: Map Text Text
    , crsServiceAccount :: Text
    , crsIngress :: IngressSetting
    , crsMaxInstances :: Maybe Int
    , crsOptions :: CloudRunOptions
    }
    deriving (Eq, Show)

-- | Deploys a CloudRun service from an image already pushed to Artifact
-- Registry.
cloudRunService :: Reporter Report -> Track' (Binary "gcloud") -> CloudRunService -> Op
cloudRunService r gcloudTrack svc =
    withBinary gcloudTrack cloudRunCommand (RunDeploy svc) $ \deploy ->
        withBinary gcloudTrack cloudRunCommand (RunDelete svc) $ \delete ->
            op "gcp-cloudrun-service" nodeps $ \actions ->
                actions
                    { help = Text.unwords ["deploys CloudRun service", svc.crsName]
                    , notes =
                        [ "owned through the label " <> renderOwnerLabel owner <> ": a service of this name without it is neither deployed over nor deleted"
                        | Just owner <- [svc.crsOptions.croOwner]
                        ]
                    , ref = mkRef "gcp-cloudrun-service" (svc.crsProject.projectId, svc.crsRegion.regionName, svc.crsName)
                    , up = refusingForeign >> Core.retryingIO Core.afterEnableRetries Core.afterEnableDelay (deploy r')
                    , -- Presence, not the image: a service running an older
                      -- image than the one declared is still there to delete.
                      -- Checking the image here made `down` skip every service
                      -- whose tag had moved with the code since its deploy.
                      down = case svc.crsOptions.croOwner of
                        Nothing -> Core.downIfPresent (uncurry interpretServicePresence <$> describeService) (delete r')
                        Just owner -> deletingOurs owner (delete r')
                    , check = checkService
                    }
  where
    r' = contramap (RunCloudRunCommand (RunDeploy svc)) r

    -- Asked through a listing, not @describe@: a listing that finds nothing
    -- exits 0 with an empty array, where a failed @describe@ cannot be told
    -- from a service that is not there.
    readOwnership :: OwnerLabel -> IO Ownership
    readOwnership owner = do
        (code, out, _err) <-
            readCreateProcessWithExitCode
                (prepare cloudRunCommand (RunListNamed svc))
                ""
        pure (interpretOwnership owner svc.crsName code (Text.decodeUtf8With TextError.lenientDecode out))

    refusingForeign :: IO ()
    refusingForeign = case svc.crsOptions.croOwner of
        Nothing -> pure ()
        Just owner -> do
            ownership <- readOwnership owner
            case ownership of
                Absent -> pure ()
                Ours -> pure ()
                Foreign ->
                    throwIO . userError . Text.unpack $
                        "a CloudRun service named "
                            <> svc.crsName
                            <> " exists without the label "
                            <> renderOwnerLabel owner
                            <> "; refusing to deploy over a service this declaration did not make"
                Undetermined why ->
                    throwIO . userError . Text.unpack $
                        "could not establish whose CloudRun service " <> svc.crsName <> " is (" <> why <> "); not deploying"

    deletingOurs :: OwnerLabel -> IO () -> IO ()
    deletingOurs owner act = do
        ownership <- readOwnership owner
        case ownership of
            Ours -> act
            Absent -> pure ()
            -- somebody else's: not this node's to remove, and nothing of
            -- this node's is left standing
            Foreign -> pure ()
            Undetermined why ->
                throwIO . userError . Text.unpack $
                    "could not establish whose CloudRun service " <> svc.crsName <> " is (" <> why <> "); not deleting"

    describeService :: IO (ExitCode, Text)
    describeService = do
        (code, out, _err) <-
            readCreateProcessWithExitCode
                (prepare cloudRunCommand (RunDescribe svc))
                ""
        pure (code, Text.decodeUtf8 out)

    checkService :: IO CheckResult
    checkService = uncurry (interpretServiceDescribe svc) <$> describeService

{- | The verdict drawn from @gcloud run services describe --format=json@'s
exit code and output, split out for testability.

The service is satisfied only when what it runs is what was declared, in
four respects, each compared /exactly/ against the service's template (the
revision a deploy would create):

* __the image__, by equality: @img:1@ is not @img:10@, which a substring
  match called the same;
* __the service account__;
* __the plain environment variables__, as a set. @gcloud run deploy
  --set-env-vars@ /replaces/ the service's variables, so a variable the
  service has and the declaration does not is drift too, as is one that has
  a different value or is missing. Variables bound from Secret Manager
  ('croSecrets') have no @value@ and are the secrets' business, not
  compared here.
* __the scaling knobs, when declared__: 'croMinInstances' against the
  template's @autoscaling.knative.dev/minScale@ annotation (absent reads as
  0) and 'croCpuAlwaysAllocated' against @run.googleapis.com/cpu-throttling@
  being @false@. An undeclared knob is not compared, the same way it is not
  passed to the deploy, so whatever the service has is left alone.

Every drift is named in the 'Failure', which is what makes @run up@ deploy
again. The reason gives names, never an environment variable's value: those
go into reports. Output that is not the JSON this expects is 'Unknown' — the
check ran and could not tell — rather than a 'Failure' that would redeploy
every pass.
-}
interpretServiceDescribe :: CloudRunService -> ExitCode -> Text -> CheckResult
interpretServiceDescribe _ (ExitFailure n) _ =
    Failure ("CloudRun service not found (exit " <> Text.pack (show n) <> ")")
interpretServiceDescribe svc ExitSuccess outText =
    case eitherDecodeStrict (Text.encodeUtf8 outText) of
        Left _ -> Unknown
        Right v -> case templateOf v of
            Nothing -> Unknown
            Just tmpl
                | Just owner <- svc.crsOptions.croOwner
                , Map.lookup owner.ownerKey (labelsOf v) /= Just owner.ownerValue ->
                    Failure ("CloudRun service found without the label " <> renderOwnerLabel owner <> ": not made by this declaration")
                | otherwise -> case drifts svc tmpl of
                    [] -> Success
                    ds -> Failure ("CloudRun service found but differs from what is declared: " <> Text.intercalate "; " ds)

-- | A service's own labels (@metadata.labels@), GCP's included.
labelsOf :: Value -> Map Text Text
labelsOf v = case objectField "metadata" v >>= objectField "labels" of
    Just (Object o) -> Map.fromList [(Key.toText k, t) | (k, String t) <- KeyMap.toList o]
    _ -> Map.empty

objectField :: Text -> Value -> Maybe Value
objectField k (Object o) = KeyMap.lookup (Key.fromText k) o
objectField _ _ = Nothing

{- | The verdict drawn from @gcloud run services list --filter
metadata.name=NAME --format=json@: its exit code and output, the declared
label and the declared name.

The filter is not trusted to be exact: only the entries whose
@metadata.name@ /is/ the name count, so a listing that matched more (a
substring, a prefix) does not make somebody else's service ours, nor ours
somebody else's.
-}
interpretOwnership :: OwnerLabel -> Text -> ExitCode -> Text -> Ownership
interpretOwnership _ _ (ExitFailure n) _ = Undetermined ("listing exited " <> Text.pack (show n))
interpretOwnership owner name ExitSuccess outText =
    case eitherDecodeStrict (Text.encodeUtf8 outText) of
        Right (Array entries) ->
            case [e | e <- toList entries, (objectField "metadata" e >>= objectField "name") == Just (String name)] of
                [] -> Absent
                named
                    | all ((== Just owner.ownerValue) . Map.lookup owner.ownerKey . labelsOf) named -> Ours
                    | otherwise -> Foreign
        _ -> Undetermined "the listing is not a JSON array"

-- | The revision template's @spec@: its first container and its service account.
data Template = Template
    { tmplImage :: Maybe Text
    , tmplServiceAccount :: Maybe Text
    , tmplEnv :: Map Text Text
    -- ^ the plain variables only
    , tmplAnnotations :: Map Text Text
    -- ^ the template's @metadata.annotations@, where the scaling knobs live
    }

templateOf :: Value -> Maybe Template
templateOf v = do
    tmpl <- field "spec" v >>= field "template"
    spec <- field "spec" tmpl
    let container = case field "containers" spec of
            Just (Array cs) | (c : _) <- toList cs -> Just c
            _ -> Nothing
        envEntries = case container >>= field "env" of
            Just (Array es) -> toList es
            _ -> []
        plain e = case (field "name" e, field "valueFrom" e) of
            (Just (String n), Nothing) -> Just (n, maybe "" id (textOf =<< field "value" e))
            _ -> Nothing
    pure
        Template
            { tmplImage = textOf =<< (container >>= field "image")
            , tmplServiceAccount = textOf =<< field "serviceAccountName" spec
            , tmplEnv = Map.fromList (mapMaybe plain envEntries)
            , tmplAnnotations = case field "metadata" tmpl >>= field "annotations" of
                Just (Object o) -> Map.fromList [(Key.toText k, t) | (k, String t) <- KeyMap.toList o]
                _ -> Map.empty
            }
  where
    field k (Object o) = KeyMap.lookup (Key.fromText k) o
    field _ _ = Nothing
    textOf (String t) = Just t
    textOf _ = Nothing

drifts :: CloudRunService -> Template -> [Text]
drifts svc t =
    concat
        [ [ "image is " <> shown got <> ", not " <> svc.crsImage
          | got <- [t.tmplImage]
          , got /= Just svc.crsImage
          ]
        , [ "service account is " <> shown got <> ", not " <> svc.crsServiceAccount
          | got <- [t.tmplServiceAccount]
          , got /= Just svc.crsServiceAccount
          ]
        , [ "environment variable " <> k <> " is " <> why
          | (k, why) <- envDrift
          ]
        , [ "min instances is " <> got <> ", not " <> Text.pack (show n)
          | Just n <- [svc.crsOptions.croMinInstances]
          , let got = Map.findWithDefault "0" "autoscaling.knative.dev/minScale" t.tmplAnnotations
          , got /= Text.pack (show n)
          ]
        , [ "CPU is throttled outside requests, not always allocated"
          | svc.crsOptions.croCpuAlwaysAllocated
          , Map.lookup "run.googleapis.com/cpu-throttling" t.tmplAnnotations /= Just "false"
          ]
        ]
  where
    shown = maybe "unset" id
    envDrift =
        [(k, "missing") | k <- Map.keys svc.crsEnv, not (Map.member k t.tmplEnv)]
            <> [(k, "not the declared value") | (k, v) <- Map.toList svc.crsEnv, Just got <- [Map.lookup k t.tmplEnv], got /= v]
            <> [(k, "set but not declared") | k <- Map.keys t.tmplEnv, not (Map.member k svc.crsEnv)]

-- | Whether the service exists at all, whatever it runs: what @down@ asks.
interpretServicePresence :: ExitCode -> Text -> CheckResult
interpretServicePresence (ExitFailure n) _ = Failure ("CloudRun service not found (exit " <> Text.pack (show n) <> ")")
interpretServicePresence ExitSuccess _ = Success

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

data CloudRunCommand
    = RunDeploy CloudRunService
    | RunDescribe CloudRunService
    | RunDelete CloudRunService
    | -- | the services of the region that have this one's name: none, or one
      RunListNamed CloudRunService
    deriving (Show)

{- | The @--set-secrets@ family. One flag carrying every binding, not one
flag per binding: gcloud treats a repeated @--set-secrets@ as a replacement
rather than an addition, so the per-binding form silently deploys with only
the last one.
-}
optionArgs :: CloudRunOptions -> [String]
optionArgs opts =
    concat
        [ if null opts.croSecrets
            then []
            else ["--set-secrets", Text.unpack (Text.intercalate "," (map renderSecretBinding opts.croSecrets))]
        , maybe [] (\v -> ["--cpu", Text.unpack v]) opts.croCpu
        , maybe [] (\v -> ["--memory", Text.unpack v]) opts.croMemory
        , maybe [] (\v -> ["--concurrency", show v]) opts.croConcurrency
        , maybe [] (\v -> ["--timeout", show v]) opts.croTimeoutSeconds
        , maybe [] (\v -> ["--port", show v]) opts.croPort
        , ["--allow-unauthenticated" | opts.croAllowUnauthenticated]
        , ["--no-invoker-iam-check" | opts.croInvokerIamCheckDisabled]
        , maybe [] (\v -> ["--min-instances", show v]) opts.croMinInstances
        , ["--no-cpu-throttling" | opts.croCpuAlwaysAllocated]
        , maybe [] (\o -> ["--update-labels", Text.unpack (renderOwnerLabel o)]) opts.croOwner
        ]

cloudRunCommand :: Command "gcloud" CloudRunCommand
cloudRunCommand = Command $ \cmd -> case cmd of
    RunDeploy svc ->
        gcloudProc $
            withProject svc.crsProject
                ( withRegion svc.crsRegion
                    ( [ "run"
                      , "deploy"
                      , Text.unpack svc.crsName
                      , "--image"
                      , Text.unpack svc.crsImage
                      , "--service-account"
                      , Text.unpack svc.crsServiceAccount
                      , "--ingress"
                      , Text.unpack (renderIngress svc.crsIngress)
                      ]
                        <> concatMap (\(k, v) -> ["--set-env-vars", Text.unpack k <> "=" <> Text.unpack v]) (Map.toList svc.crsEnv)
                        <> maybe [] (\n -> ["--max-instances", show n]) svc.crsMaxInstances
                        <> optionArgs svc.crsOptions
                    )
                )
    RunDescribe svc ->
        gcloudProc $
            withProject svc.crsProject
                ( withRegion svc.crsRegion
                    [ "run"
                    , "services"
                    , "describe"
                    , Text.unpack svc.crsName
                    , "--format=json"
                    ]
                )
    RunDelete svc ->
        gcloudProc $
            withProject svc.crsProject
                ( withRegion svc.crsRegion
                    [ "run"
                    , "services"
                    , "delete"
                    , Text.unpack svc.crsName
                    , "--quiet"
                    ]
                )
    RunListNamed svc ->
        gcloudProc $
            withProject svc.crsProject
                ( withRegion svc.crsRegion
                    [ "run"
                    , "services"
                    , "list"
                    , "--filter"
                    , "metadata.name=" <> Text.unpack svc.crsName
                    , "--format=json"
                    ]
                )