packages feed

podenv-0.2.0: src/Podenv/Build.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- | This modules contains logic to perform application runtime build
module Podenv.Build
  ( prepare,
    BuildEnv (..),
    containerBuildRuntime,
    nixRuntime,
  )
where

import Control.Monad qualified
import Data.Digest.Pure.SHA qualified as SHA
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import Podenv.Config qualified
import Podenv.Dhall
import Podenv.Prelude
import Podenv.Runtime (ImageName (..))
import Podenv.Runtime qualified
import System.Directory (doesDirectoryExist, renameFile)
import System.Exit (ExitCode (ExitSuccess))
import System.Process.Typed qualified as P

-- | Helper function to run a standalone app, usefull for local build
type AppRunner = Application -> IO ()

-- | A build env contains action to be performed before preparation and execution
data BuildEnv = BuildEnv
  { beInfos :: Text,
    -- | Builds the runtime
    beEnsure :: AppRunner -> IO (),
    -- | Updates the runtime
    beUpdate :: AppRunner -> IO ()
  }

defaultBuildEnv :: Text -> BuildEnv
defaultBuildEnv beInfos = BuildEnv {..}
  where
    beEnsure = const $ pure ()
    beUpdate = const $ pure ()

-- | Create the build env
prepare :: Podenv.Runtime.RuntimeEnv -> Application -> IO (BuildEnv, Application)
prepare re app = case runtime app of
  Image name -> pure (defaultBuildEnv name, addArgs app)
  Container cb -> pure (prepareContainer cb, addArgs app)
  Rootfs fp -> pure (defaultBuildEnv fp, addArgs app)
  Nix expr -> prepareNix re app expr
  where
    addArgs = appCommand %~ (<> Podenv.Runtime.extraArgs re)

containerBuildRuntime :: ContainerBuild -> Podenv.Runtime.RuntimeContext
containerBuildRuntime = Podenv.Runtime.Container . mkImageName

mkImageName :: ContainerBuild -> ImageName
mkImageName containerBuild = ImageName $ "localhost/" <> name
  where
    -- The image name can be set by the container build,
    -- otherwise it default to the Containerfile hash
    name = fromMaybe imageHash (containerBuild ^. cbImage_name)
    imageHash = toText . SHA.showDigest . SHA.sha256 . encodeUtf8 $ containerBuild ^. cbContainerfile

-- | Container build env
prepareContainer :: ContainerBuild -> BuildEnv
prepareContainer containerBuild = BuildEnv {..}
  where
    -- buildenv basic info:
    beInfos = "# Containerfile " <> imageName <> "\n" <> fileContent
    fileContent = containerBuild ^. cbContainerfile

    ImageName imageName = mkImageName containerBuild
    fileName = "Containerfile_" <> toString (imageNameToFP imageName)
      where
        imageNameToFP = Text.replace "/" "_" . Text.replace ":" "-"

    beEnsure = const $ do
      imageReady <- checkImageExist imageName
      unless imageReady $
        buildImage imageName fileName fileContent (containerBuild ^. cbImage_volumes)

    beUpdate = const $ case containerBuild ^. cbImage_update of
      Nothing -> error "The container is missing the `image_update` attribute"
      Just cmd -> do
        buildImage
          imageName
          (fileName <> "-update")
          (unlines ["FROM " <> imageName, "RUN " <> cmd])
          (containerBuild ^. cbImage_volumes)

-- | Nix runtime re-use the host root filesystem, prepareNix added the nix-store volume.
nixRuntime :: Podenv.Runtime.RuntimeContext
nixRuntime = Podenv.Runtime.Bubblewrap "/"

getCertLocation :: IO (Maybe FilePath)
getCertLocation = runMaybeT $ Control.Monad.msum $ [checkEnv] <> (checkPath <$> paths)
  where
    checkEnv :: MaybeT IO FilePath
    checkEnv = do
      env <- lift $ lookupEnv "NIX_SSL_CERT_FILE"
      case env of
        Just fp -> checkPath fp
        Nothing -> mzero
    checkPath :: FilePath -> MaybeT IO FilePath
    checkPath fp = do
      exist <- lift $ doesPathExist fp
      unless exist mzero
      pure fp
    -- Copied from profile.d/nix.sh
    paths =
      [ "/etc/pki/tls/certs/ca-bundle.crt",
        "/etc/ssl/certs/ca-certificates.crt",
        "/etc/ssl/ca-bundle.pem",
        "/etc/ssl/certs/ca-bundle.crt"
      ]

-- | Nix build env
prepareNix :: Podenv.Runtime.RuntimeEnv -> Application -> Flakes -> IO (BuildEnv, Application)
prepareNix re app flakes = do
  certs <- toText . fromMaybe (error "Can't find ca-bundle") <$> getCertLocation
  -- TODO: check howto re-use the host /nix
  pure
    ( BuildEnv
        { beInfos = "# Nix expr:\n" <> Text.unwords nixArgs,
          beEnsure = beEnsure certs,
          beUpdate = const $ error "Nix update is not implemented"
        },
      updateApp certs app
    )
  where
    name = app ^. appName
    fileName = toString $ "nix_" <> name

    -- The location where we expect to find the `nix` command
    nixStore = Podenv.Runtime.volumesDir re </> "nix-store"
    nixCommandProfile = "var/nix/profiles/nix-install"
    nixCommandPath = "/nix/" <> nixCommandProfile <> "/bin/nix"
    nixFlags = ["--extra-experimental-features", "nix-command flakes"]

    -- The nix command args
    nixExtraArgs = case nixpkgs flakes of
      Just pin | not (all (Text.isPrefixOf pin) (installables flakes)) -> ["--override-input", "nixpkgs", pin]
      _ -> []
    nixArgs = nixExtraArgs <> installables flakes

    beEnsure certs runApp = do
      built <- checkIfBuilt fileName (show nixArgs)
      unless built $ do
        ensureNixInstalled
        _ <- runApp (buildApp certs)

        -- save that the build succeeded
        cacheDir <- getCacheDir
        Text.writeFile (cacheDir </> fileName) (show nixArgs)

    debug = when (Podenv.Runtime.verbose re) . hPutStrLn stderr . mappend "[+] "

    ensureNixInstalled = do
      debug $ "Checking if " <> nixStore </> nixCommandProfile <> " exists"
      nixInstalled <- doesSymlinkExist $ nixStore </> nixCommandProfile
      unless nixInstalled $ do
        debug $ "Checking if " <> nixStore </> "store" <> " exists"
        storeExist <- doesDirectoryExist $ nixStore </> "store"
        when storeExist $ error $ "existing nix-store is invalid, try removing " <> toText nixStore

        podenv <- getExecutablePath
        debug $ "[+] Installing nix-store with " <> podenv <> " nix.setup"
        P.runProcess_ $ P.setDelegateCtlc True $ P.proc podenv ["nix.setup"]

    -- The Application to build the expression, it is executed in advance to separate build and execution
    buildApp certs =
      Podenv.Config.defaultApp
        { runtime = Rootfs "/",
          name = "build-" <> name,
          volumes = [toText nixStore <> ":/nix", "nix-setup-home:~/", "nix-cache:~/.cache/nix"],
          environ = ["NIX_SSL_CERT_FILE=" <> certs, "LC_ALL=C.UTF-8", "TERM=xterm"],
          command =
            [toText nixCommandPath, "--verbose"]
              <> nixFlags
              <> ["build", "--no-link"]
              <> nixArgs
        }
        & (appCapabilities . capNetwork .~ True)

    runCommand =
      [toText nixCommandPath] <> nixFlags <> case app ^. appCommand of
        [] ->
          ["run"] <> nixArgs <> case Podenv.Runtime.extraArgs re of
            [] -> []
            xs -> ["--"] <> xs
        appArgs -> ["shell"] <> nixArgs <> ["--command"] <> appArgs <> Podenv.Runtime.extraArgs re
    addCommand = appCommand .~ runCommand
    addVolumes = appVolumes %~ mappend ["nix-store:/nix", "nix-cache:~/.cache/nix", "nix-config:~/.config/nix"]
    addEnvirons certs =
      appEnviron
        %~ mappend
          [ "NIX_SSL_CERT_FILE=" <> certs,
            "TERM=xterm",
            "LC_ALL=C.UTF-8",
            "PATH=/nix/var/nix/profiles/nix-install/bin:/bin"
          ]
    updateApp certs = addCommand . addVolumes . addEnvirons certs

-- | Build a container image
buildImage :: Text -> FilePath -> Text -> [Text] -> IO ()
buildImage imageName fileName containerfile volumes = do
  uid <- getRealUserID
  cacheDir <- getCacheDir
  createDirectoryIfMissing True cacheDir
  let want = fileName <> ".want"
      wantfp = cacheDir </> want
  Text.writeFile wantfp containerfile
  -- podman build does not support regular volume, lets ensure absolute path
  volumesArgs <- traverse (mkVolumeArg cacheDir) volumes
  let buildArgs =
        ["build"]
          <> ["-t", toString imageName]
          <> ["--build-arg", "USER_UID=" <> show uid]
          <> map toString volumesArgs
          <> ["-f", want, cacheDir]
      cmd = Podenv.Runtime.podman buildArgs
  -- putTextLn $ "Building " <> imageName <> " with " <> toText want <> ": " <> show cmd
  P.runProcess_ cmd

  -- save that the build succeeded
  renameFile wantfp (cacheDir </> fileName)
  where
    mkVolumeArg :: FilePath -> Text -> IO Text
    mkVolumeArg cacheDir volume = do
      createDirectoryIfMissing True hostPath
      pure $ "-v=" <> toText hostPath <> ":" <> containerPath <> ":Z"
      where
        (p1, p2) = Text.break (== ':') volume
        hostPath = cacheDir </> toString p1
        containerPath = Text.drop 1 p2

checkImageExist :: Text -> IO Bool
checkImageExist imageName = do
  res <- P.runProcess (Podenv.Runtime.podman ["image", "exists", Text.unpack imageName])
  pure $ res == ExitSuccess

checkIfBuilt :: FilePath -> Text -> IO Bool
checkIfBuilt filename expected = do
  cacheDir <- getCacheDir
  current <- readFileM (cacheDir </> filename)
  pure $ current == expected