packages feed

sandwich-contexts-kubernetes-0.1.0.0: lib/Test/Sandwich/Contexts/Kubernetes/MinikubeCluster.hs

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

{-|

Create and manage Kubernetes clusters via [Minikube](https://minikube.sigs.k8s.io).

-}

module Test.Sandwich.Contexts.Kubernetes.MinikubeCluster (
  -- * Introducing a cluster via Minikube
  introduceMinikubeClusterViaNix
  , introduceMinikubeClusterViaEnvironment
  , introduceMinikubeCluster'

  -- * Bracket-style functions
  , withMinikubeCluster
  , withMinikubeCluster'
  , withMinikubeCluster''

  -- * Image management
  -- | These are lower-level and Minikube-specific; prefer working with the functions in "Test.Sandwich.Contexts.Kubernetes.Images".
  , Images.clusterContainsImageMinikube
  , Images.getLoadedImagesMinikube
  , Images.loadImageMinikube

  -- * Re-exported cluster types
  , kubernetesCluster
  , KubernetesClusterContext (..)
  , HasKubernetesClusterContext

  -- * Types
  , MinikubeClusterOptions (..)
  , defaultMinikubeClusterOptions
  ) where

import Control.Monad
import Control.Monad.IO.Unlift
import Control.Monad.Logger
import qualified Data.List as L
import Data.String.Interpolate
import Data.Text as T
import Kubernetes.Client.Config
import Relude hiding (withFile)
import System.Exit
import System.FilePath
import System.IO.Temp
import Test.Sandwich
import Test.Sandwich.Contexts.Files
import qualified Test.Sandwich.Contexts.Kubernetes.MinikubeCluster.Images as Images
import Test.Sandwich.Contexts.Kubernetes.Types
import Test.Sandwich.Contexts.Kubernetes.Util.UUID
import Test.Sandwich.Contexts.Nix
import UnliftIO.Environment
import UnliftIO.Exception
import UnliftIO.IO
import UnliftIO.Process


data MinikubeClusterOptions = MinikubeClusterOptions {
  minikubeClusterNumNodes :: Int
  , minikubeClusterExtraFlags :: [Text]
  , minikubeClusterNamePrefix :: Maybe Text
  , minikubeClusterDriver :: Maybe Text
  , minikubeClusterCpus :: Maybe Text
  , minikubeClusterMemory :: Maybe Text
  }
defaultMinikubeClusterOptions :: MinikubeClusterOptions
defaultMinikubeClusterOptions = MinikubeClusterOptions {
  minikubeClusterNumNodes = 3
  , minikubeClusterExtraFlags = []
  , minikubeClusterNamePrefix = Nothing
  , minikubeClusterDriver = Nothing
  , minikubeClusterCpus = Nothing
  , minikubeClusterMemory = Nothing
  }

-- * Introduce

type MinikubeClusterContext context =
  LabelValue "kubernetesCluster" KubernetesClusterContext
  :> LabelValue "file-minikube" (EnvironmentFile "minikube")
  :> context

-- | Introduce a Minikube cluster, deriving the @minikube@ binary from the Nix context.
introduceMinikubeClusterViaNix :: (
  HasBaseContext context, MonadUnliftIO m, HasNixContext context
  )
  -- | Options
  => MinikubeClusterOptions
  -- | Child spec
  -> SpecFree (MinikubeClusterContext context) m ()
  -- | Parent spec
  -> SpecFree context m ()
introduceMinikubeClusterViaNix minikubeClusterOptions spec =
  introduceBinaryViaNixPackage @"minikube" "minikube" $
    introduceWith "introduce minikube cluster" kubernetesCluster (void . withMinikubeCluster minikubeClusterOptions) spec

-- | Introduce a Minikube cluster, deriving the @minikube@ binary from the PATH.
introduceMinikubeClusterViaEnvironment :: (
  HasBaseContext context, MonadUnliftIO m
  )
  -- | Options
  => MinikubeClusterOptions
  -> SpecFree (MinikubeClusterContext context) m ()
  -> SpecFree context m ()
introduceMinikubeClusterViaEnvironment minikubeClusterOptions spec =
  introduceBinaryViaEnvironment @"minikube" $
    introduceWith "introduce minikube cluster" kubernetesCluster (void . withMinikubeCluster minikubeClusterOptions) spec

-- | Introduce a Minikube cluster, passing in the @minikube@ binary path.
introduceMinikubeCluster' :: (
  HasBaseContext context, MonadUnliftIO m
  )
  -- | Path to @minikube@ binary
  => FilePath
  -> MinikubeClusterOptions
  -> SpecFree (MinikubeClusterContext context) m ()
  -> SpecFree context m ()
introduceMinikubeCluster' minikubeBinary minikubeClusterOptions spec =
  introduceFile @"minikube" minikubeBinary $
    introduceWith "introduce minikube cluster" kubernetesCluster (void . withMinikubeCluster minikubeClusterOptions) $
      spec

-- * Implementation

-- | Bracket-style variant for introducing a Minikube cluster, using a @HasFile context "minikube"@ constraint.
withMinikubeCluster :: (
  HasBaseContextMonad context m, HasFile context "minikube"
  , MonadLoggerIO m, MonadUnliftIO m, MonadFail m
  )
  -- | Options
  => MinikubeClusterOptions
  -> (KubernetesClusterContext -> m a)
  -> m a
withMinikubeCluster options action = do
  minikubeBinary <- askFile @"minikube"
  withMinikubeCluster' minikubeBinary options action

-- | Same as 'withMinikubeCluster', but allows you to pass the path to the @minikube@ binary.
withMinikubeCluster' :: (
  HasBaseContextMonad context m
  , MonadLoggerIO m, MonadUnliftIO m, MonadFail m
  )
  -- | Path to @minikube@ binary
  => FilePath
  -> MinikubeClusterOptions
  -> (KubernetesClusterContext -> m a)
  -> m a
withMinikubeCluster' minikubeBinary options@(MinikubeClusterOptions {..}) action = do
  let prefix = fromMaybe "test-minikube-cluster" minikubeClusterNamePrefix
  clusterID <- makeUUID' 5
  let clusterName = [i|#{prefix}-#{clusterID}|]
  withMinikubeCluster'' clusterName minikubeBinary options action

-- | Same as 'withMinikubeCluster'', but allows you to pass the cluster name.
withMinikubeCluster'' :: (
  HasBaseContextMonad context m
  , MonadLoggerIO m, MonadUnliftIO m, MonadFail m
  )
  -- | Cluster name
  => String
  -> FilePath
  -> MinikubeClusterOptions
  -> (KubernetesClusterContext -> m a)
  -> m a
withMinikubeCluster'' clusterName minikubeBinary options@(MinikubeClusterOptions {..}) action = do
  Just dir <- getCurrentFolder

  minikubeDir <- liftIO $ createTempDirectory dir "minikube"

  let minikubeKubeConfigFile = minikubeDir </> "minikube-config"
  writeFile minikubeKubeConfigFile ""

  let startLogFile = minikubeDir </> "minikube-start.log"
  let deleteLogFile = minikubeDir </> "minikube-delete.log"

  withFile startLogFile WriteMode $ \logH ->
    (bracket (startMinikubeCluster minikubeBinary logH clusterName minikubeKubeConfigFile options)
             (\_ -> do
                 info [i|Deleting minikube cluster: #{clusterName}|]

                 let extraFlags = case "--rootless" `L.elem` minikubeClusterExtraFlags of
                       True -> ["--rootless"]
                       False -> []

                 withFile deleteLogFile WriteMode $ \deleteH -> do
                   let deleteCp = (proc minikubeBinary (["delete"
                                                        , "--profile", clusterName
                                                        , "--logtostderr"
                                                        ] <> extraFlags)) {
                         delegate_ctlc = True
                         , create_group = True
                         , std_out = UseHandle deleteH
                         , std_err = UseHandle deleteH
                         }
                   withCreateProcess deleteCp $ \_ _ _ p ->
                     waitForProcess p >>= \case
                       ExitSuccess -> return ()
                       ExitFailure n -> warn [i|Minikube cluster delete failed with code #{n}.|]
             ))
             (\p -> do
                 waitForProcess p >>= \case
                   ExitSuccess -> return ()
                   ExitFailure n -> expectationFailure [i|Minikube cluster creation failed with code #{n}.|]

                 oidcCache <- newTVarIO mempty
                 (m, c) <- liftIO $ mkKubeClientConfig oidcCache $ KubeConfigFile minikubeKubeConfigFile

                 action $ KubernetesClusterContext {
                   kubernetesClusterName = toText clusterName
                   , kubernetesClusterKubeConfigPath = minikubeKubeConfigFile
                   , kubernetesClusterNumNodes = minikubeClusterNumNodes
                   , kubernetesClusterClientConfig = (m, c)
                   , kubernetesClusterType = KubernetesClusterMinikube {
                       kubernetesClusterTypeMinikubeBinary = minikubeBinary
                       , kubernetesClusterTypeMinikubeProfileName = toText clusterName
                       , kubernetesClusterTypeMinikubeFlags = minikubeClusterExtraFlags
                       }
                   }
             )

startMinikubeCluster :: (
  MonadLoggerIO m
  ) => FilePath -> Handle -> String -> String -> MinikubeClusterOptions -> m ProcessHandle
startMinikubeCluster minikubeBinary logH clusterName minikubeKubeConfigFile (MinikubeClusterOptions {..}) = do
  baseEnv <- getEnvironment
  let env = L.nubBy (\x y -> fst x == fst y) (("KUBECONFIG", minikubeKubeConfigFile) : baseEnv)

  -- Note: this doesn't actually work! These options actually go to the docker daemon, not the "start" operation.
  -- It may not be possible to get a label on the Docker container in current minikube.
  -- let labelArgs = case dockerLabels of
  --       x | M.null x -> []
  --       xs -> "--docker-opt" : [[i|label=#{k}=#{v}|] | (k, v) <- M.toList xs]

  let driverAndResourceFlags = case minikubeClusterDriver of
        Nothing -> ["--driver=docker"
                   , [i|--memory=#{fromMaybe "16000mb" minikubeClusterMemory}|]
                   , [i|--cpus=#{fromMaybe "max" minikubeClusterCpus}|]
                   ]
        Just d -> [[i|--driver=#{d}|]
                  , [i|--memory=#{fromMaybe "16000mb" minikubeClusterMemory}|]
                  , [i|--cpus=#{fromMaybe "8" minikubeClusterCpus}|]
                  ]

  let args = ["start"
             , "--profile", clusterName
             , "--logtostderr"
             -- , "--addons=ingress"
             , "--extra-config=kubelet.streaming-connection-idle-timeout=5h"
             ]
             <> driverAndResourceFlags
             <> (fmap toString minikubeClusterExtraFlags)

  info [i|export KUBECONFIG='#{minikubeKubeConfigFile}'|]
  debug [i|Starting minikube with args: #{minikubeBinary} #{T.unwords $ fmap toText args}|]

  (_, _, _, p) <- createProcess (
    (proc minikubeBinary args) {
        delegate_ctlc = True
        , create_group = True
        , env = Just env
        , std_out = UseHandle logH
        , std_err = UseHandle logH
        })
  return p

-- Debugging (in case of certificate issues such as https://github.com/channable/vaultenv/issues/99)
-- import Kubernetes.Client.Auth.OIDC
-- oidcCache :: OIDCCache <- Relude.newTVarIO mempty
-- (m, c) <- mkKubeClientConfig oidcCache $ KubeConfigFile "/tmp/test-minikube-cluster-config-e695417a5bf81acf/minikube-kube-config"
-- import Kubernetes.OpenAPI.Core
-- import Kubernetes.OpenAPI.API.AppsV1 as Kubernetes
-- import Kubernetes.OpenAPI.API.BatchV1 as Kubernetes
-- import Kubernetes.OpenAPI.API.CoreV1 as Kubernetes
-- import Kubernetes.OpenAPI.Core as Kubernetes
-- import Kubernetes.OpenAPI.MimeTypes
-- import Kubernetes.OpenAPI.Model as Kubernetes
-- import Kubernetes.OpenAPI.Client as Kubernetes
-- MimeResult parsedResult _httpResponse <- liftIO (dispatchMime m c (listNamespacedPod (Accept MimeJSON) (Namespace "default")))