podenv-0.1.0: src/Podenv/Config.hs
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | This module contains the logic to load the dhall configuration
module Podenv.Config
( load,
select,
Config (..),
Atom (..),
ApplicationRecord (..),
defaultConfigPath,
defaultApp,
loadSystem,
defaultSystemConfig,
podenvImportTxt,
)
where
import Control.Exception (bracket_)
import qualified Data.Digest.Pure.SHA as SHA
import Data.Either.Validation
import qualified Data.Text as Text
import qualified Data.Text.IO as Text (readFile)
import qualified Dhall
import qualified Dhall.Core as Dhall
import qualified Dhall.Import
import qualified Dhall.Map as DM
import Dhall.Marshal.Decode (DhallErrors (..), extractError)
import qualified Dhall.Parser
import qualified Dhall.Src
import Podenv.Dhall hiding (name)
import Podenv.Prelude
import System.Directory
import System.Environment (setEnv, unsetEnv)
import System.FilePath.Posix (dropExtension, isExtensionOf, splitPath)
import qualified Text.Show
data Config
= -- | A standalone application, e.g. defaultSelector
ConfigDefault ApplicationRecord
| -- | A single application
ConfigApplication Atom
| -- | A collection of applications
ConfigApplications [(Text, Atom)]
data Atom
= -- | A literal application
Lit ApplicationRecord
| -- | A paremeterized application
LamArg ArgName (Text -> ApplicationRecord)
| LamArg2 ArgName ArgName (Text -> Text -> ApplicationRecord)
| -- | A functional application
LamApp (Application -> ApplicationRecord)
-- | A wrapper around the true Application type to manage weakly typed configuration
-- (e.g. so that `{ runtime.image = "ubi8" }` can be manually decoded)
newtype ApplicationRecord = ApplicationRecord {unRecord :: Application}
instance Dhall.FromDhall ApplicationRecord where
autoWith = const appRecordDecoder
newtype ArgName = ArgName Text
instance Text.Show.Show ArgName where
show (ArgName n) = toString n
-- | Config load entrypoint
load :: Maybe Text -> Maybe Text -> IO Config
load selectorM configTxt = case selectorM >>= defaultSelector of
Just c -> pure $ ConfigDefault (ApplicationRecord c)
Nothing -> load' . Dhall.normalize <$> loadExpr configTxt
defaultSelector :: Text -> Maybe Application
defaultSelector s
| "image:" `Text.isPrefixOf` s = imageApp s
| "nix:" `Text.isPrefixOf` s = nixApp s
| "nixpkgs#" `Text.isPrefixOf` s = nixApp' s
| "rootfs:" `Text.isPrefixOf` s = rootfsApp s
| otherwise = Nothing
where
imageApp x = mkApp ("image-" <> mkName x) (Image $ Text.drop (Text.length "image:") x)
nixApp' x = mkApp ("nix-" <> mkName x) (Nix $ Flakes [x] Nothing)
nixApp x = nixApp' $ Text.drop (Text.length "nix:") x
rootfsApp x = mkApp ("rootfs-" <> mkName x) (Rootfs $ Text.drop (Text.length "rootfs:") x)
mkApp name runtime' = Just $ defaultApp & (appName .~ name) . (appRuntime .~ runtime')
mkName = Text.take 6 . toText . SHA.showDigest . SHA.sha1 . encodeUtf8
-- | Inject the package.dhall into the environ so that config can use `env:PODENV`
loadWithEnv :: FilePath -> Dhall.Expr Dhall.Src.Src Dhall.Import -> IO DhallExpr
loadWithEnv baseDir expr = withEnv $ flip evalStateT initialState $ Dhall.Import.loadWith expr
where
-- Set the PODENV environment variable to the frozen url of the current hub
withEnv = bracket_ (setEnv "PODENV" (toString podenvImportTxt)) (unsetEnv "PODENV")
-- Then use a custom Dhall.Import.Status state that inject the static code
-- The goal is to only pretty print (text encode) the package when the cache is cold
initialState = baseStatus & Dhall.Import.remote .~ fetchUrl
baseStatus = Dhall.Import.emptyStatus baseDir
fetchUrl url
| url == podenvUrl = pure (Dhall.pretty podenvPackage)
| otherwise = (baseStatus ^. Dhall.Import.remote) url
-- | Helper function to parse the initial configuration
loadExpr :: Maybe Text -> IO DhallExpr
loadExpr configM = case configM of
Just configTxt -> do
cwd' <- getCurrentDirectory
loadWithEnv cwd' $ exprFromText' configTxt
Nothing -> do
baseDir <- getConfigDir
let configPath = baseDir </> "config.dhall"
-- load main config
configContent <-
bool (pure "(env:PODENV).Hub") (Text.readFile configPath) =<< doesFileExist configPath
let configExpr = exprFromText' configContent
-- lookup local.d configs
let locald = baseDir </> "local.d"
localFiles <- bool (pure []) (listDirectory locald) =<< doesPathExist locald
let localConfig = createLocalRecord locald localFiles
-- adds local.d to the main config using the `//` operator
let expr' = Dhall.Prefer Nothing Dhall.PreferFromSource configExpr localConfig
loadWithEnv baseDir expr'
where
exprFromText' :: Text -> Dhall.Expr Dhall.Src.Src Dhall.Import
exprFromText' configTxt = case Dhall.Parser.exprFromText "<config>" configTxt of
Right expr -> expr
Left e -> error $ "Invalid config: " <> show e
-- | Create a record for local.d config
createLocalRecord :: FilePath -> [FilePath] -> Dhall.Expr Dhall.Src.Src Dhall.Import
createLocalRecord baseDir =
Dhall.RecordLit . fromList . map toRecord . filter (isExtensionOf ".dhall")
where
toRecord :: FilePath -> (Text, Dhall.RecordField Dhall.Src.Src Dhall.Import)
toRecord name = (toText $ dropExtension name, Dhall.makeRecordField (Dhall.Embed $ toImport name))
toImport :: FilePath -> Dhall.Import
toImport name =
let file = Dhall.File (Dhall.Directory $ reverse $ map toText $ splitPath baseDir) (toText name)
in Dhall.Import (Dhall.ImportHashed Nothing (Dhall.Local Dhall.Absolute file)) Dhall.Code
-- | The static hub package expression
podenvUrl :: Dhall.URL
podenvUrl =
Dhall.URL Dhall.HTTPS "raw.githubusercontent.com" path Nothing Nothing
where
hubVersion = case Dhall.extract Dhall.auto (Dhall.renote hubCommit) of
Success x -> Text.dropEnd 1 x
Failure v -> error $ "Unknown hub commit: " <> show v
path = Dhall.File (Dhall.Directory [hubVersion, "hub", "podenv"]) "package.dhall"
podenvImport :: Dhall.Import
podenvImport =
Dhall.Import (Dhall.ImportHashed (Just hash) (Dhall.Remote podenvUrl)) Dhall.Code
where
hash = Dhall.Import.hashExpression (Dhall.alphaNormalize podenvPackage)
podenvImportTxt :: Text
podenvImportTxt = Text.replace "\n " "" $ Dhall.pretty podenvImport
-- | Pure config load
load' :: DhallExpr -> Config
load' expr = case loadConfig "" expr of
Success [(selector, Lit app)] -> ConfigApplication $ Lit (ensureName selector app)
Success [(_, x)] -> ConfigApplication x
Success [] -> error "No application found"
Success xs -> ConfigApplications xs
Failure (DhallErrors (x :| _)) -> error $ show x
-- | When an application doesn't have a name, set it to the selector path
ensureName :: Text -> ApplicationRecord -> ApplicationRecord
ensureName x app = case unRecord app ^. appName of
"" -> ApplicationRecord $ unRecord app & appName .~ x
_ -> app
-- | The main config load function. It recursively descend the
-- tree by extending the selector name.
loadConfig :: Text -> DhallExpr -> DhallParser [(Text, Atom)]
loadConfig baseSelector expr = case expr of
-- When the node is a function, assume it is an app.
Dhall.Lam {} -> (\app -> [(baseSelector, app)]) <$> loadApp expr
Dhall.RecordLit kv
| -- When the node has a "runtime" attribute, assume it is an app.
DM.member "runtime" kv ->
(\app -> [(baseSelector, app)]) <$> loadApp expr
| -- Otherwise, traverse each attributes
otherwise ->
concat <$> traverse (uncurry loadCollection) (DM.toList kv)
where
loadCollection n e
-- Skip leaf starting with `use`, otherwise they can be used and likely fail with:
-- FromDhall: You cannot decode a function if it does not have the correct type
| "use" `Text.isPrefixOf` n = pure []
| otherwise = loadConfig (mkSelector n) (Dhall.recordFieldValue e)
mkSelector name
| baseSelector == mempty = name
| name == "default" = baseSelector
| otherwise = baseSelector <> "." <> name
_ -> extractError $ baseSelector <> ": expected a record literal, but got: " <> Text.take 256 (show expr)
-- | Select the application, returning the unused cli args.
select :: Config -> [Text] -> Either Text ([Text], Application)
select config args = fmap unRecord <$> select' config args
select' :: Config -> [Text] -> Either Text ([Text], ApplicationRecord)
select' config args = case config of
-- config default is always selected, drop the first args
ConfigDefault app -> pure (tail (fromList args), app)
-- config has only one application, don't touch the args
ConfigApplication atom -> selectApp [] args atom
-- config has some applications, the first arg is a selector
ConfigApplications atoms -> case args of
(selector : xs) -> do
atom <- lookup selector atoms `orDie` (selector <> ": not found")
(args', app) <- selectApp atoms xs atom
let name' = Text.intercalate "-" $ take (length args - length args') args
pure (args', ensureName name' app)
[] -> Left "Multiple apps configured, provides a selector"
where
selectApp atoms args' atom = case atom of
-- App is not a function, don't touch the arg
Lit app -> pure (args', app)
-- App needs an argument, the tail is the arg
LamArg arg f -> case args' of
(x : xs) -> pure (xs, f x)
[] -> Left ("Missing argument: " <> show arg)
LamArg2 arg1 arg2 f -> case args' of
(x : y : xs) -> pure (xs, f x y)
(_ : _) -> Left ("Missing argument: " <> show arg2)
_ -> Left ("Missing arguments: " <> show arg1 <> " " <> show arg2)
LamApp f -> case args' of
(x : xs) -> case defaultSelector x of
Just app -> pure (xs, f app)
Nothing -> do
-- Recursively select the app to eval arg `mod app arg` as `mod (app arg)`
-- e.g. LamApp should be applied at the end.
atom' <- lookup x atoms `orDie` (x <> ": unknown lam arg")
(rest, app) <- selectApp atoms xs atom'
pure (rest, f (unRecord app))
[] -> Left "Missing app argument"
defaultConfigPath :: Text
defaultConfigPath = "~/.config/podenv/config.dhall"
-- | The default app
defaultApp :: Application
defaultApp = case Dhall.extract Dhall.auto (Dhall.renote appDefault) of
Success app -> app
Failure v -> error $ "Invalid default application: " <> show v
-- | A type synonym to simplify function annotation.
type DhallParser a = Dhall.Extractor Dhall.Src.Src Void a
-- | A type synonym to simplify function annotation.
type DhallExpr = Dhall.Expr Dhall.Src.Src Void
type DhallExtractor a = Dhall.Extractor Dhall.Src.Src Void a
-- | The `//` dhall record update operation
pref :: Dhall.Expr s a -> Dhall.Expr s a -> Dhall.Expr s a
pref = Dhall.Prefer Nothing Dhall.PreferFromSource
mkRecord :: [(Text, Dhall.Expr s a)] -> Dhall.Expr s a
mkRecord kv = Dhall.RecordLit (Dhall.makeRecordField <$> DM.fromList kv)
recordItems :: DM.Map Text (Dhall.RecordField s a) -> [(Text, Dhall.Expr Void a)]
recordItems kv = fmap (Dhall.denote . Dhall.recordFieldValue) <$> DM.toList kv
-- | A custom Dhall Decoder that can convert a weakly type Application
-- This is done by modifying the 'base' Dhall.Expr with:
-- App.default // (base // { capabilities = Caps.default // base.capabilities })
-- so that the missing fields are automatically added.
--
-- For the runtime value, this convert may convert a tag record to an Union variant
appRecordDecoder :: Dhall.Decoder ApplicationRecord
appRecordDecoder = ApplicationRecord <$> Dhall.Decoder extract expected
where
extract :: Dhall.Expr Dhall.Src.Src Void -> DhallExtractor Application
extract (Dhall.RecordLit kv) = case DM.lookup "runtime" kv of
Just (Dhall.RecordField _ (Dhall.RecordLit kv') _ _) -> extract' kv (runtimeFromRecord kv')
Just (Dhall.RecordField _ v _ _) -> extract' kv (Dhall.denote v)
Nothing -> Dhall.extractError "Application does not have a runtime"
extract _ = Dhall.extractError "Application is not a record"
runtimeFromRecord :: DM.Map Text (Dhall.RecordField s Void) -> Dhall.Expr Void Void
runtimeFromRecord kv = case recordItems kv of
[("image", x)] -> mkRuntime "Image" x
[("nix", x)] -> mkRuntime "Nix" (mkNixRecord x)
[("containerfile", x)] -> mkRuntime "Container" (pref containerBuildDefault (mkRecord [("containerfile", x)]))
_ -> mkRuntime "Container" (pref containerBuildDefault (Dhall.denote (Dhall.RecordLit kv)))
where
mkNixRecord v =
mkRecord
[ ("installables", Dhall.ListLit Nothing $ fromList [v]),
("nixpkgs", Dhall.App Dhall.None Dhall.Text)
]
mkRuntime field v =
Dhall.App (Dhall.Field runtimeType (Dhall.FieldSelection Nothing field Nothing)) v
extract' :: DM.Map Text (Dhall.RecordField s Void) -> Dhall.Expr Void Void -> DhallExtractor Application
extract' kv runtimeExpr =
let capsExpr = case DM.lookup "capabilities" kv of
Just (Dhall.RecordField _ v _ _) -> pref capsDefault (Dhall.denote v)
_ -> capsDefault
-- capabilities and runtime are always added since they are nested schemas
nestedSchemas = [("capabilities", capsExpr), ("runtime", runtimeExpr)]
baseExpr = pref (Dhall.denote $ Dhall.RecordLit kv) (mkRecord nestedSchemas)
expr = pref appDefault baseExpr
-- The generic Application decoder
Dhall.Decoder appDecoder _ = Dhall.genericAuto
in appDecoder (Dhall.renote (Dhall.normalize expr))
expected = Success (Dhall.renote appType)
-- | Parse and tag a DhallExpr with an Atom constructor
loadApp :: DhallExpr -> DhallParser Atom
loadApp expr = case expr of
Dhall.Lam _ fb1 (Dhall.Lam _ fb2 _) -> LamArg2 (getArgName fb1) (getArgName fb2) <$> Dhall.extract Dhall.auto expr
Dhall.Lam _ fb _
| Dhall.denote (Dhall.functionBindingAnnotation fb) == appType ->
LamApp <$> Dhall.extract Dhall.auto expr
| otherwise -> LamArg (getArgName fb) <$> Dhall.extract Dhall.auto expr
_ -> Lit <$> Dhall.extract Dhall.auto expr
where
getArgName = ArgName . Dhall.functionBindingVariable
loadSystem :: IO SystemConfig
loadSystem = do
confDir <- getXdgDirectory XdgConfig "podenv"
let fp = confDir </> "system.dhall"
exist <- doesFileExist fp
if exist
then Dhall.input Dhall.auto (toText fp)
else pure defaultSystemConfig
-- | The default system config
defaultSystemConfig :: SystemConfig
defaultSystemConfig = case Dhall.extract Dhall.auto (Dhall.renote systemConfigDefault) of
Success x -> x
Failure v -> error $ "Invalid default system config: " <> show v