packages feed

podenv-0.2.0: src/Podenv/Application.hs

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# OPTIONS_GHC -fno-warn-missing-export-lists #-}

-- | This module contains the capability logic.
-- The goal is to convert an Application into a Context
--
-- This module performs read-only IO
module Podenv.Application
  ( prepare,
    preparePure,
    capsAll,
    Cap (..),
    Mode (..),
  )
where

import Data.Map qualified
import Data.Set qualified as Set
import Data.Text qualified as Text
import Podenv.Build qualified
import Podenv.Dhall
import Podenv.Env
import Podenv.Prelude
import Podenv.Runtime qualified as Ctx
import System.Posix.Files qualified

data Mode = Regular | Shell

-- | Converts an Application into a Context
prepare :: Mode -> Application -> Ctx.Name -> IO Ctx.Context
prepare mode app ctxName = do
  appEnv <- Podenv.Env.new
  preparePure mode appEnv app ctxName

-- TODO: make this stricly pure using a PodenvMonad similar to the PandocMonad
preparePure :: Mode -> AppEnv -> Application -> Ctx.Name -> IO Ctx.Context
preparePure mode envBase app ctxName = do
  home <- getContainerHome
  runReaderT (doPrepare app mode ctxName home) (appEnv home)
  where
    appEnv home = envBase & appHomeDir .~ (toString <$> home)

    getContainerHome
      | app ^. appCapabilities . capRoot = pure $ Just "/root"
      | otherwise = case app ^. appRuntime of
        Container cb -> pure $ cb ^. cbImage_home
        Rootfs fp -> fmap toText <$> (envBase ^. rootfsHome) (toString fp)
        Nix _ -> pure $ toText <$> envBase ^. hostHomeDir
        Image _ -> pure Nothing

doPrepare :: Application -> Mode -> Ctx.Name -> Maybe Text -> AppEnvT Ctx.Context
doPrepare app mode ctxName appHome = do
  uid <- asks _hostUid
  let baseCtx =
        (Ctx.defaultContext ctxName runtimeCtx)
          { Ctx._uid = uid,
            Ctx._namespace = app ^. appNamespace
          }

  ctx <-
    foldM addVolume baseCtx (app ^. appVolumes)
      >>= flip (foldM setCaps) capsAll

  setCommand <- case mode of
    Regular ->
      if app ^. appCapabilities . capHostfile
        then resolveFileArgs $ app ^. appCommand
        else pure $ Ctx.command .~ app ^. appCommand
    Shell -> pure $ Ctx.command .~ ["/bin/sh"]

  pure (validate . setCommand . modifiers $ ctx)
  where
    runtimeCtx = case app ^. appRuntime of
      Image x -> Ctx.Container $ Ctx.ImageName x
      Rootfs root -> Ctx.Bubblewrap $ toString root
      Container cb -> Podenv.Build.containerBuildRuntime cb
      Nix _ -> Podenv.Build.nixRuntime

    validate ctx = case runtimeCtx of
      Ctx.Bubblewrap _ | null (ctx ^. Ctx.command) -> ctx & Ctx.command .~ ["/bin/sh"]
      _ -> ctx

    modifiers :: Ctx.Context -> Ctx.Context
    modifiers = disableSelinux . setRunAs . addSysCaps . addEnvs . setEnv . ensureWorkdir . ensureHome

    ensureHome = case appHome of
      Just fp ->
        let volumeName = fromMaybe (Ctx.unName ctxName) (app ^. appNamespace)
         in Ctx.addMount (toString fp) (Ctx.MkVolume Ctx.RW (Ctx.Volume $ volumeName <> "-home"))
      Nothing -> id

    -- Check if path is part of a mount point
    isMounted :: FilePath -> Ctx.Context -> Bool
    isMounted fp ctx = any isPrefix mountPaths
      where
        mountPaths = Data.Map.keys $ ctx ^. Ctx.mounts
        isPrefix x = fp `isPrefixOf` x

    ensureWorkdir ctx = case appHome of
      (Just x) | isMounted (toString x) ctx -> ctx & Ctx.workdir `setWhenNothing` toString x
      _ -> ctx

    setEnv = case appHome of
      Just x -> Ctx.addEnv "HOME" x
      _ -> id

    -- Some capabilities do not work with selinux
    noSelinuxCaps = [capWayland, capX11]
    hasPrivCap = any (\l -> app ^. appCapabilities . l) noSelinuxCaps
    -- When using host device, selinux also needs to be disabled
    hasDevice ctx = ctx ^. Ctx.devices /= mempty
    -- When using direct host path, its simpler to disable selinux too. That can be improved though
    isHostPath = \case
      Ctx.MkVolume _ (Ctx.HostPath _) -> True
      _anyOtherVolume -> False
    hasHostPath ctx = any isHostPath (toList $ ctx ^. Ctx.mounts)
    disableSelinux ctx
      | hasPrivCap || hasDevice ctx || hasHostPath ctx = ctx & Ctx.selinux .~ False
      | otherwise = ctx

    setRunAs = case appHome of
      -- To keep it simple, when the app home is in `/home`, assume we share the host uid.
      Just h | "/home" `Text.isPrefixOf` h -> Ctx.runAs `setWhenNothing` Ctx.RunAsHostUID
      _ -> id

    addSysCaps ctx = foldr addSysCap ctx (app ^. appSyscaps)
    addSysCap :: Text -> Ctx.Context -> Ctx.Context
    addSysCap syscap = case readMaybe (toString $ "CAP_" <> syscap) of
      Nothing -> error $ "Can't read syscap: " <> show syscap
      Just c -> Ctx.syscaps %~ (Set.insert c)

    addEnvs ctx = foldr addEnv ctx (app ^. appEnviron)
    addEnv :: Text -> Ctx.Context -> Ctx.Context
    addEnv env =
      let (k, v) = Text.breakOn "=" env
       in Ctx.addEnv k (Text.drop 1 v)

    setCaps = capContextApply $ app ^. appCapabilities
    capContextApply :: Capabilities -> Ctx.Context -> Cap -> AppEnvT Ctx.Context
    capContextApply appCaps ctx Cap {..} =
      if appCaps ^. capLens
        then capSet ctx
        else pure ctx

-- | CapInfo describes a capability and how it modify the runtime context
data Cap = Cap
  { capName :: Text,
    capDescription :: Text,
    -- | How to get the capability value from the user provided record:
    capLens :: Lens' Capabilities Bool,
    -- | How the capability change the context:
    capSet :: Ctx.Context -> AppEnvT Ctx.Context
  }

-- | The main list of capabilities
capsAll, capsToggle :: [Cap]
capsAll = capsToggle
capsToggle =
  [ Cap "root" "run as root" capRoot (contextSet Ctx.runAs (Just Ctx.RunAsRoot)),
    Cap "terminal" "allocate a tty" capTerminal setTerminal,
    Cap "interactive" "interactive mode" capInteractive (contextSet Ctx.interactive True),
    Cap "dbus" "share session dbus socket" capDbus setDbus,
    Cap "wayland" "share wayland socket" capWayland setWayland,
    Cap "pipewire" "share pipewire socket" capPipewire setPipewire,
    Cap "video" "share video devices" capVideo setVideo,
    Cap "dri" "share graphic device" capDri setDri,
    Cap "kvm" "share kvm device" capKvm (pure . Ctx.addDevice "/dev/kvm"),
    Cap "tun" "share tun device" capTun (pure . Ctx.addDevice "/dev/net/tun"),
    Cap "alsa" "share alsa devices" capAlsa (pure . Ctx.addDevice "/dev/snd"),
    Cap "pulseaudio" "share pulseaudio socket" capPulseaudio setPulseaudio,
    Cap "ssh" "share ssh agent and keys" capSsh setSsh,
    Cap "gpg" "share gpg agent and keys" capGpg setGpg,
    Cap "x11" "share x11 socket" capX11 setX11,
    Cap "cwd" "mount cwd" capCwd setCwd,
    Cap "network" "enable network" capNetwork setNetwork,
    Cap "hostfile" "mount command file arg" capHostfile pure,
    Cap "rw" "mount rootfs rw" capRw (contextSet Ctx.ro False),
    Cap "privileged" "run with extra privileges" capPrivileged (contextSet Ctx.privileged True)
  ]

setNetwork :: Ctx.Context -> AppEnvT Ctx.Context
setNetwork ctx = do
  setResolvConf <- liftIO $ ensureResolvConf ctx
  pure $ ctx & (Ctx.network .~ True) . setResolvConf

ensureResolvConf :: Ctx.Context -> IO (Ctx.Context -> Ctx.Context)
ensureResolvConf ctx = case ctx ^. Ctx.runtimeCtx of
  -- When using host rootfs, then we need to mount /etc/resolv.conf target when it is a symlink
  Ctx.Bubblewrap "/" -> do
    symlink <- System.Posix.Files.isSymbolicLink <$> System.Posix.Files.getSymbolicLinkStatus "/etc/resolv.conf"
    if symlink
      then do
        realResolvConf <- getSymlinkPath
        pure $ Ctx.addMount realResolvConf $ Ctx.roHostPath realResolvConf
      else pure id
  -- Otherwise we can just mount it directly
  Ctx.Bubblewrap _ -> pure $ Ctx.addMount "/etc/resolv.conf" (Ctx.roHostPath "/etc/resolv.conf")
  _ -> pure id
  where
    getSymlinkPath = do
      realResolvConf <- System.Posix.Files.readSymbolicLink "/etc/resolv.conf"
      pure $
        if "../" `isPrefixOf` realResolvConf
          then drop 2 realResolvConf
          else realResolvConf

setTerminal :: Ctx.Context -> AppEnvT Ctx.Context
setTerminal ctx =
  pure $ ctx & (Ctx.interactive .~ True) . (Ctx.terminal .~ True) . (Ctx.addEnv "TERM" "xterm-256color")

setWayland :: Ctx.Context -> AppEnvT Ctx.Context
setWayland ctx = do
  sktM <- liftIO $ lookupEnv "WAYLAND_DISPLAY"
  case sktM of
    Nothing -> setX11 ctx
    Just skt -> setWayland' skt ctx

setWayland' :: FilePath -> Ctx.Context -> AppEnvT Ctx.Context
setWayland' skt ctx = do
  shareSkt <- addXdgRun skt
  pure $
    ctx
      & Ctx.directMount "/etc/machine-id"
        . shareSkt
        . Ctx.addEnv "GDK_BACKEND" "wayland"
        . Ctx.addEnv "QT_QPA_PLATFORM" "wayland"
        . Ctx.addEnv "WAYLAND_DISPLAY" (toText skt)
        . Ctx.addEnv "XDG_SESSION_TYPE" "wayland"
        . Ctx.addMount "/dev/shm" Ctx.tmpfs

setPipewire :: Ctx.Context -> AppEnvT Ctx.Context
setPipewire ctx = do
  let skt = "pipewire-0" -- TODO discover skt name
  shareSkt <- addXdgRun skt
  pure $
    ctx
      & Ctx.directMount "/etc/machine-id"
        . shareSkt

setDbus :: Ctx.Context -> AppEnvT Ctx.Context
setDbus ctx = do
  let skt = "bus" -- TODO discover skt name
  (sktPath, shareSkt) <- addXdgRun' skt
  pure $
    ctx
      & Ctx.directMount "/etc/machine-id"
        . Ctx.addEnv "DBUS_SESSION_BUS_ADDRESS" ("unix:path=" <> toText sktPath)
        . shareSkt

setVideo :: Ctx.Context -> AppEnvT Ctx.Context
setVideo ctx = do
  devices <- liftIO $ listDirectory "/dev"
  let addDevices =
        map (Ctx.addDevice . toString . mappend "/dev/") $
          filter ("video" `Text.isPrefixOf`) $ map toText devices
  pure $ foldr (\c a -> c a) ctx addDevices

setDri :: Ctx.Context -> AppEnvT Ctx.Context
setDri ctx = do
  nvidia <- liftIO $ doesPathExist "/dev/nvidiactl"
  pure $
    ctx
      & if nvidia
        then Ctx.addDevice "/dev/nvidiactl" . Ctx.addDevice "/dev/nvidia0"
        else Ctx.addDevice "/dev/dri"

setPulseaudio :: Ctx.Context -> AppEnvT Ctx.Context
setPulseaudio ctx = do
  shareSkt <- addXdgRun "pulse"
  uid <- asks _hostUid
  let pulseServer = "/run/user/" <> show uid <> "/pulse/native"
  pure $
    ctx
      & Ctx.directMount "/etc/machine-id"
        . shareSkt
        . Ctx.addEnv "PULSE_SERVER" pulseServer

getHomes :: Text -> AppEnvT (FilePath, FilePath)
getHomes help = do
  hostDir <- fromMaybe (error $ "Need HOME for " <> help) <$> asks _hostHomeDir
  appDir <- fromMaybe (error $ "Application need home for " <> help) <$> asks _appHomeDir
  pure (hostDir, appDir)

mountHomeConfig :: Text -> FilePath -> AppEnvT (Ctx.Context -> Ctx.Context)
mountHomeConfig help fp = do
  (hostDir, appDir) <- getHomes help
  pure $ Ctx.addMount (appDir </> fp) (Ctx.roHostPath $ hostDir </> fp)

setAgent :: String -> IO (Ctx.Context -> Ctx.Context)
setAgent var = do
  value <- lookupEnv var
  pure $ case value of
    Nothing -> id
    Just path -> Ctx.addEnv (toText var) (toText path) . Ctx.directMount (takeDirectory path)

setSsh :: Ctx.Context -> AppEnvT Ctx.Context
setSsh ctx = do
  shareAgent <- liftIO $ setAgent "SSH_AUTH_SOCK"
  pure $ ctx & shareAgent

setGpg :: Ctx.Context -> AppEnvT Ctx.Context
setGpg ctx = do
  shareConfig <- mountHomeConfig "gpg" ".gnupg"
  shareGpg <- addXdgRun "gnupg"
  pure $ ctx & shareGpg . shareConfig

setX11 :: Ctx.Context -> AppEnvT Ctx.Context
setX11 ctx = do
  display <- liftIO $ getEnv "DISPLAY"
  pure $
    ctx
      & Ctx.directMount "/tmp/.X11-unix" . Ctx.addEnv "DISPLAY" (toText display) . Ctx.addMount "/dev/shm" Ctx.tmpfs

setCwd :: Ctx.Context -> AppEnvT Ctx.Context
setCwd ctx = do
  cwd <- asks _hostCwd
  pure $ ctx & Ctx.addMount "/data" (Ctx.rwHostPath cwd) . (Ctx.workdir ?~ "/data")

addVolume :: Ctx.Context -> Text -> AppEnvT Ctx.Context
addVolume ctx volume = do
  containerPath' <- resolveContainerPath containerPath
  hostPath' <- resolveVolume hostPath
  pure $ Ctx.addMount containerPath' hostPath' ctx
  where
    (hostPath, containerPath) = case Text.breakOn ":" volume of
      (x, "") -> (x, x)
      (x, y) -> (x, Text.drop 1 y)

resolveFileArgs :: [Text] -> AppEnvT (Ctx.Context -> Ctx.Context)
resolveFileArgs args = do
  fps <- traverse checkExist args
  pure $ foldr ((.) . addFileArg) id fps
  where
    addCommand :: Text -> Ctx.Context -> Ctx.Context
    addCommand arg = Ctx.command %~ (arg :)
    checkExist :: Text -> AppEnvT (Either Text FilePath)
    checkExist arg = do
      fpM <- resolveHostPath arg
      case fpM of
        Nothing -> pure $ Left arg
        Just fp -> do
          exist <- liftIO $ doesPathExist fp
          unless exist (liftIO $ putTextLn $ "Warning, arg path does not exist: " <> arg)
          pure $ bool (Left arg) (Right fp) exist
    addFileArg :: Either Text FilePath -> Ctx.Context -> Ctx.Context
    addFileArg (Left arg) = addCommand arg
    addFileArg (Right fp)
      | hasTrailingPathSeparator fp = error "Directory filearg ar not supported"
      | otherwise =
        let cfp = "/data" </> takeFileName fp
         in addCommand (toText cfp) . Ctx.addMount cfp (Ctx.rwHostPath fp)

-- | Helper functions to manipulate paths
getXdgRuntimeDir :: AppEnvT FilePath
getXdgRuntimeDir = fromMaybe (error "Need XDG_RUNTIME_DIR") <$> asks _hostXdgRunDir

fixPath :: Text -> FilePath
fixPath = toString . Text.drop 1 . Text.dropWhile (/= '/')

resolveContainerPath :: Text -> AppEnvT FilePath
resolveContainerPath path
  | path == "~" || "~/" `Text.isPrefixOf` path = do
    appHome' <- fromMaybe (error "Need app home") <$> asks _appHomeDir
    pure $ appHome' </> fixPath path
  | "/" `Text.isPrefixOf` path = pure $ toString path
  | otherwise = error $ "Invalid container path: " <> path

resolveHostPath :: Text -> AppEnvT (Maybe FilePath)
resolveHostPath path
  | "~/" `Text.isPrefixOf` path = do
    envHome' <- fromMaybe (error "Need HOME") <$> asks _hostHomeDir
    pure $ Just (envHome' </> fixPath path)
  | "./" `Text.isPrefixOf` path = do
    curDir' <- asks _hostCwd
    pure $ Just (curDir' </> fixPath path)
  | "/" `Text.isPrefixOf` path = pure $ Just (toString path)
  | otherwise = pure Nothing

resolveHostPath' :: Text -> AppEnvT FilePath
resolveHostPath' path = do
  pathM <- resolveHostPath path
  case pathM of
    Just x -> pure x
    Nothing -> error $ "Invalid host path: " <> path

resolveVolume :: Text -> AppEnvT Ctx.Volume
resolveVolume name = case Text.uncons name of
  Nothing -> error "Empty host path"
  Just (x, _xs)
    -- TODO: handle suffix such as :Z
    | x `elem` ['~', '.', '/'] -> Ctx.rwHostPath <$> resolveHostPath' name
    | otherwise -> pure $ Ctx.MkVolume Ctx.RW (Ctx.Volume name)

-- | Helper function to share a XDG_RUNTIME_DIR path
addXdgRun :: FilePath -> AppEnvT (Ctx.Context -> Ctx.Context)
addXdgRun fp = snd <$> addXdgRun' fp

-- | Returns the container path and the context setter for xdg
addXdgRun' :: FilePath -> AppEnvT (FilePath, Ctx.Context -> Ctx.Context)
addXdgRun' fp = do
  hostXdg <- getXdgRuntimeDir
  uid <- asks _hostUid
  let containerPath = runDir </> fp
      hostPath = hostXdg </> fp
      runBaseDir = "/run/user"
      runDir = runBaseDir <> "/" <> show uid
  pure
    ( containerPath,
      Ctx.addEnv "XDG_RUNTIME_DIR" (toText runDir)
        -- Podman creates parent directory as root, ensure user can r/w xdgdir using tmpfs
        . Ctx.addMount runBaseDir Ctx.tmpfs
        . Ctx.addMount containerPath (Ctx.rwHostPath hostPath)
    )

-- | Helper for capabilities that are directly represented in the context
contextSet :: Lens' Ctx.Context a -> a -> Ctx.Context -> AppEnvT Ctx.Context
contextSet lens value ctx = pure ((lens .~ value) ctx)