packages feed

sandwich-contexts-kubernetes-0.1.3.0: lib/Test/Sandwich/Contexts/Kubernetes/PostgresServer.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}

{-|

Deploy a PostgreSQL server onto a Kubernetes cluster.

The server is provided as a generic 'PostgresContext' (from @sandwich-contexts@), so that you can
easily run the same tests against both Kubernetes environments and host-level ones.

-}

module Test.Sandwich.Contexts.Kubernetes.PostgresServer (
  introduceK8SPostgresServer
  , introduceK8SPostgresServer'

  -- * Bracket-style variants
  , withK8SPostgresServer
  , withK8SPostgresServer'

  -- * Types
  , PostgresK8SOptions(..)
  , defaultPostgresK8SOptions

  -- * Re-exports
  , postgres
  , PostgresContext(..)
  , NetworkAddress(..)
  ) where

import Control.Monad
import Data.String.Interpolate
import Data.Text as T
import Relude
import System.Exit
import Test.Sandwich
import Test.Sandwich.Contexts.Files
import Test.Sandwich.Contexts.Kubernetes
import Test.Sandwich.Contexts.Kubernetes.Images
import Test.Sandwich.Contexts.Kubernetes.Util.UUID
import Test.Sandwich.Contexts.PostgreSQL
import UnliftIO.Exception
import UnliftIO.Process
import UnliftIO.Timeout


data PostgresK8SOptions = PostgresK8SOptions {
  postgresK8SNamespace :: Text
  , postgresK8SImage :: Text
  , postgresK8SUsername :: Text
  , postgresK8SPassword :: Text
  , postgresK8SDatabase :: Text
  , postgresK8SPreloadImage :: Bool
  }
defaultPostgresK8SOptions :: Text -> PostgresK8SOptions
defaultPostgresK8SOptions namespace = PostgresK8SOptions {
  postgresK8SNamespace = namespace
  , postgresK8SImage = "docker.io/postgres:15"
  , postgresK8SUsername = "postgres"
  , postgresK8SPassword = "postgres"
  , postgresK8SDatabase = "test"
  , postgresK8SPreloadImage = True
  }

-- | Introduce a PostgreSQL server on a Kubernetes cluster.
introduceK8SPostgresServer :: (
  KubectlBasicWithoutReader context m
  )
  -- | Options
  => PostgresK8SOptions
  -> SpecFree (LabelValue "postgres" PostgresContext :> context) m ()
  -> SpecFree context m ()
introduceK8SPostgresServer options =
  introduceWith "PostgreSQL on K8S" postgres $ \action -> do
    kcc <- getContext kubernetesCluster
    withK8SPostgresServer kcc options action

-- | Same as 'introduceK8SPostgresServer', but allows you to pass in the 'KubernetesClusterContext'.
introduceK8SPostgresServer' :: (
  KubectlBasic context m
  )
  => KubernetesClusterContext
  -- | Options
  -> PostgresK8SOptions
  -> SpecFree (LabelValue "postgres" PostgresContext :> context) m ()
  -> SpecFree context m ()
introduceK8SPostgresServer' kcc options =
  introduceWith "PostgreSQL on K8S" postgres $ \action ->
    withK8SPostgresServer kcc options action

-- | Bracket-style variant of 'introduceK8SPostgresServer'.
withK8SPostgresServer :: (
  MonadFail m, KubernetesBasic context m, HasFile context "kubectl"
  )
  => KubernetesClusterContext
  -- | Options
  -> PostgresK8SOptions
  -> (PostgresContext -> m [Result])
  -> m ()
withK8SPostgresServer kcc options action = do
  kubectlBinary <- askFile @"kubectl"
  withK8SPostgresServer' kubectlBinary kcc options action

-- | Same as 'withK8SPostgresServer', but allows you to pass in the @kubectl@ binary.
withK8SPostgresServer' :: forall m context. (
  MonadFail m, KubernetesBasic context m
  )
  -- | Path to kubectl binary
  => FilePath
  -> KubernetesClusterContext
  -- | Options
  -> PostgresK8SOptions
  -> (PostgresContext -> m [Result])
  -> m ()
withK8SPostgresServer' kubectlBinary kcc@(KubernetesClusterContext {..}) (PostgresK8SOptions {..}) action = do
  env <- getKubectlEnvironment kcc

  deploymentName <- ("postgres-" <>) <$> makeUUID' 5

  let postgresPort = 5432 :: Int

  when postgresK8SPreloadImage $ do
    debug [i|Preloading postgres image: #{postgresK8SImage}|]
    loadImageIfNecessary' kcc (ImageLoadSpecDocker postgresK8SImage IfNotPresent)

  let yaml = postgresYaml deploymentName postgresK8SNamespace postgresK8SImage
                          postgresK8SUsername postgresK8SPassword postgresK8SDatabase

  let create = do
        (ps, _) <- createProcessWithLoggingAndStdin
          ((proc kubectlBinary ["apply", "-f", "-"]) { env = Just env })
          (toString yaml)
        waitForProcess ps >>= (`shouldBe` ExitSuccess)

  let destroy = do
        info [i|Destroying PostgreSQL K8S resources|]
        (ps, _) <- createProcessWithLoggingAndStdin
          ((proc kubectlBinary ["delete", "-f", "-", "--ignore-not-found"]) { env = Just env })
          (toString yaml)
        waitForProcess ps >>= (`shouldBe` ExitSuccess)

  bracket_ create destroy $ do
    -- Wait for the pod to be ready
    let waitArgs = ["wait", "pod"
                   , "-l", [i|app=#{deploymentName}|]
                   , "--namespace", toString postgresK8SNamespace
                   , "--for", "condition=Ready"
                   , "--timeout=120s"
                   , "--kubeconfig", kubernetesClusterKubeConfigPath
                   ]
    timeout 150_000_000 (do
      (ps, _) <- createProcessWithLogging ((proc kubectlBinary waitArgs) { env = Just env })
      waitForProcess ps >>= (`shouldBe` ExitSuccess)
      ) >>= \case
        Just () -> return ()
        Nothing -> expectationFailure [i|Timed out waiting for PostgreSQL pod to be ready|]

    let serviceName = [i|service/#{deploymentName}|] :: Text
    withKubectlPortForward' kubectlBinary kubernetesClusterKubeConfigPath postgresK8SNamespace
                            (const True) Nothing serviceName (fromIntegral postgresPort) $ \(KubectlPortForwardContext {..}) -> do
      info [i|PostgreSQL port-forwarded to localhost:#{kubectlPortForwardPort}|]

      let ctx = PostgresContext {
            postgresUsername = postgresK8SUsername
            , postgresPassword = postgresK8SPassword
            , postgresDatabase = postgresK8SDatabase
            , postgresAddress = NetworkAddressTCP "localhost" kubectlPortForwardPort
            , postgresConnString = [i|postgresql://#{postgresK8SUsername}:#{postgresK8SPassword}@localhost:#{kubectlPortForwardPort}/#{postgresK8SDatabase}|]
            , postgresContainerAddress = Just $ NetworkAddressTCP (toString deploymentName) (fromIntegral postgresPort)
            }

      void $ action ctx


postgresYaml :: Text -> Text -> Text -> Text -> Text -> Text -> Text
postgresYaml name namespace image username password database = [__i|
  apiVersion: v1
  kind: Pod
  metadata:
    name: #{name}
    namespace: #{namespace}
    labels:
      app: "#{name}"
  spec:
    containers:
    - name: postgres
      image: "#{image}"
      imagePullPolicy: IfNotPresent
      env:
      - name: POSTGRES_USER
        value: "#{username}"
      - name: POSTGRES_PASSWORD
        value: "#{password}"
      - name: POSTGRES_DB
        value: "#{database}"
      ports:
      - containerPort: 5432
      readinessProbe:
        exec:
          command:
          - pg_isready
          - -U
          - "#{username}"
        initialDelaySeconds: 5
        periodSeconds: 2
  ---
  apiVersion: v1
  kind: Service
  metadata:
    name: #{name}
    namespace: #{namespace}
  spec:
    selector:
      app: "#{name}"
    ports:
    - port: 5432
      targetPort: 5432
  |]