hinagata-cli-0.1.0.0: src/Hinagata/Cli/Config.hs
module Hinagata.Cli.Config
( ConfigFailure (..),
CliSettings (..),
HookSettings (..),
cliConfiguration,
resolveConfig,
validateHook,
revisionDeclarations,
)
where
import Control.Monad (void)
import Data.Text qualified as Text
import Hinagata.Cli.Options (CliOptions (..))
import Hinagata.Cli.Process (parseOverlay)
import Hinagata.Config (HinagataConfig, hinagataConfig, hinagataEnvironmentBindings)
import Hinagata.Hook.Revision qualified as Revision
import Settei qualified
import Settei.Env qualified as Env
import Settei.Optparse qualified as SetteiOptions
import Settei.Yaml qualified as Yaml
import System.Environment (getEnvironment)
import System.FilePath (isAbsolute)
import Text.Read (readMaybe)
data ConfigFailure
= SourceFailure !Text.Text
| ResolutionFailure !Text.Text !Text.Text
deriving stock (Eq, Show)
data HookSettings = HookSettings
{ executable :: !(Maybe Text.Text),
arguments :: ![Text.Text],
workingDirectory :: !(Maybe Text.Text),
environment :: ![Text.Text],
revision :: !(Maybe Text.Text),
revisionExecutable :: !Bool,
revisionInputs :: ![Text.Text],
revisionValues :: ![Text.Text],
deadlineMs :: !Int
}
deriving stock (Eq)
data CliSettings = CliSettings
{ baseFixtures :: ![Text.Text],
migration :: !HookSettings,
verification :: !HookSettings,
clonePreparation :: !HookSettings
}
deriving stock (Eq)
cliConfiguration :: Settei.Config (HinagataConfig, CliSettings)
cliConfiguration = (,) <$> hinagataConfig <*> settings
settings :: Settei.Config CliSettings
settings =
CliSettings
<$> listSetting "baseline.fixtures" "Base fixtures loaded into every sealed template"
<*> hookSettings "migration"
<*> hookSettings "verification"
<*> hookSettings "clone.preparation"
hookSettings :: Text.Text -> Settei.Config HookSettings
hookSettings prefix =
HookSettings
<$> optionalText (prefix <> ".executable") ("Executable to run against the " <> if prefix == "clone.preparation" then "fresh clone" else "baseline database")
<*> secretListSetting (prefix <> ".argv") "Argument vector passed without shell evaluation"
<*> optionalText (prefix <> ".cwd") "Working directory for the hook"
<*> secretListSetting (prefix <> ".environment") "Explicit NAME=VALUE child environment overlay"
<*> (if prefix == "clone.preparation" then pure Nothing else optionalText (prefix <> ".revision") "Declared revision identifying the hook's effects")
<*> ( if prefix == "clone.preparation"
then pure False
else
Settei.withDefault
(Settei.publicShowSetting (settingKey (prefix <> ".revision_executable")) "Select absolute executable bytes for hook identity" Settei.boolDecoder)
(Settei.constantDefault (Settei.RuleName (prefix <> "-revision-executable")) "Executable bytes are not selected" False)
)
<*> (if prefix == "clone.preparation" then pure [] else secretListSetting (prefix <> ".revision_inputs") "Named NAME=PATH content inputs for hook identity")
<*> (if prefix == "clone.preparation" then pure [] else secretListSetting (prefix <> ".revision_values") "Named NAME=VALUE non-secret identity values")
<*> Settei.withDefault
(Settei.publicShowSetting (settingKey (prefix <> ".deadline_ms")) "Hook deadline in milliseconds" deadlineDecoder)
(Settei.constantDefault (Settei.RuleName (prefix <> "-deadline")) "Bound hook execution" (300000 :: Int))
optionalText :: Text.Text -> Text.Text -> Settei.Config (Maybe Text.Text)
optionalText name description = Settei.optional (Settei.publicSetting (settingKey name) description Settei.textDecoder)
listSetting :: Text.Text -> Text.Text -> Settei.Config [Text.Text]
listSetting name description =
Settei.withDefault
(Settei.publicShowSetting (settingKey name) description (Settei.listDecoder Settei.textDecoder))
(Settei.constantDefault (Settei.RuleName (name <> "-empty")) "No entries configured" [])
secretListSetting :: Text.Text -> Text.Text -> Settei.Config [Text.Text]
secretListSetting name description =
Settei.withDefault
(Settei.secretSetting (settingKey name) description (Settei.listDecoder Settei.textDecoder))
(Settei.constantDefault (Settei.RuleName (name <> "-empty")) "No entries configured" [])
settingKey :: Text.Text -> Settei.Key
settingKey name = either (error . show) id (Settei.parseKey name)
deadlineDecoder :: Settei.Decoder Int
deadlineDecoder = Settei.parsedDecoder "a positive integer number of milliseconds" $ \value ->
case readMaybe (Text.unpack value) of
Just milliseconds | milliseconds > 0 -> Right milliseconds
_ -> Left "deadline must be a positive integer"
-- | Files are loaded in command-line order, then environment bindings and
-- repeated overrides. Settei's declaration supplies built-in defaults.
resolveConfig :: CliOptions -> IO (Either ConfigFailure ((HinagataConfig, CliSettings), Settei.ResolveResult (HinagataConfig, CliSettings)))
resolveConfig CliOptions {configPaths, overrides, diagnosticMode} = do
case hinagataEnvironmentBindings of
Left problems -> pure (Left (SourceFailure (Env.renderEnvErrorsText problems)))
Right bindings -> do
let cliBindings =
Env.bindings
[ Env.binding (Env.EnvName ("HINAGATA_" <> Text.toUpper (Text.replace "." "_" name))) (settingKey name)
| name <- ["migration.executable", "migration.cwd", "migration.revision", "migration.deadline_ms", "migration.revision_executable", "verification.executable", "verification.cwd", "verification.revision", "verification.deadline_ms", "verification.revision_executable", "clone.preparation.executable", "clone.preparation.cwd", "clone.preparation.deadline_ms"]
]
case cliBindings of
Left problems -> pure (Left (SourceFailure (Env.renderEnvErrorsText problems)))
Right hookBindings -> resolveSources bindings hookBindings
where
resolveSources bindings hookBindings = do
fileResults <- traverse (\(index, path) -> Yaml.readYamlSource (Yaml.yamlSourceOptions ("config #" <> Text.pack (show index))) path) (zip [1 :: Int ..] configPaths)
case traverse (either (Left . SourceFailure . Yaml.renderYamlErrorsText) Right) fileResults of
Left failure -> pure (Left failure)
Right files -> do
environment <- getEnvironment
let snapshot = Env.envSnapshot [(Text.pack key, Text.pack value) | (key, value) <- environment]
sources = files <> [Env.environmentSource bindings snapshot, Env.environmentSource hookBindings snapshot] <> SetteiOptions.cliSources "arguments" overrides
result@Settei.ResolveResult {answer, report} = Settei.resolve Settei.defaultResolveOptions sources cliConfiguration
pure $ case answer of
Left problems ->
let detail = Settei.renderErrorsText problems
explanation = case diagnosticMode of
SetteiOptions.ExplainText -> Settei.renderResolutionText report
SetteiOptions.ExplainJson -> Settei.renderResolutionJson report <> "\n"
_ -> ""
in Left (ResolutionFailure detail explanation)
Right configuration@(_, cli) -> case traverse (uncurry validateHook) [("migration", migration cli), ("verification", verification cli), ("clone.preparation", clonePreparation cli)] of
Left problem -> Left (ResolutionFailure problem "")
Right _ -> Right (configuration, result)
-- | Semantic checks also run for offline diagnostics, before any database work.
validateHook :: Text.Text -> HookSettings -> Either Text.Text ()
validateHook name hook = do
either (Left . ((name <> ": ") <>)) (const (Right ())) (revisionDeclarations hook)
validateCommand name hook
validateCommand :: Text.Text -> HookSettings -> Either Text.Text ()
validateCommand name HookSettings {executable, arguments, workingDirectory, environment, revision, deadlineMs}
| deadlineMs <= 0 = invalid "hook deadline must be positive"
| otherwise = case executable of
Nothing
| null arguments && workingDirectory == Nothing && null environment && revision == Nothing -> Right ()
| otherwise -> invalid "hook settings require an executable"
Just program
| Text.null program || Text.any (== '\0') program -> invalid "hook executable is empty or contains NUL"
| any (Text.any (== '\0')) arguments -> invalid "hook argv contains NUL"
| maybe False (\path -> not (isAbsolute (Text.unpack path)) || Text.any (== '\0') path) workingDirectory -> invalid "hook cwd must be absolute and without NUL"
| otherwise -> either invalid (const (Right ())) (void (parseOverlay environment))
where
invalid message = Left (name <> ": " <> message)
-- | Parse and validate effective declarations without reading files. Config
-- explanations use sensitive arrays; failures never retain rejected entries.
revisionDeclarations :: HookSettings -> Either Text.Text [Revision.RevisionInput]
revisionDeclarations HookSettings {executable, revision, revisionExecutable, revisionInputs, revisionValues}
| derived && revision /= Nothing = Left "revision conflicts with derived revision selectors"
| derived && executable == Nothing = Left "derived revision requires a hook executable"
| revisionExecutable && maybe True (not . isAbsolute . Text.unpack) executable = Left "revision_executable requires an absolute executable"
| otherwise = do
files <- traverse (entry "revision_inputs" True) revisionInputs
values <- traverse (entry "revision_values" False) revisionValues
selected <-
if revisionExecutable
then case executable of
Just path -> do
name <- logicalName "revision_executable" "executable"
pure [Revision.FileInput name (Text.unpack path)]
Nothing -> Left "revision_executable requires an executable"
else pure []
let inputs = selected <> files <> values
if null inputs
then Right []
else case Revision.validateRevisionInputs inputs of
Left (Revision.DuplicateInput name) -> Left ("revision inputs contain duplicate logical name " <> Revision.inputNameText name)
Left _ -> Left "revision inputs are invalid"
Right () -> Right inputs
where
derived = revisionExecutable || not (null revisionInputs) || not (null revisionValues)
logicalName setting label = either (const (Left (setting <> " requires normalized logical names"))) Right (Revision.mkInputName label)
entry setting isFile raw = do
let (label, suffix) = Text.breakOn "=" raw
if Text.null suffix
then Left (setting <> " entries require NAME=PAYLOAD")
else do
name <- logicalName setting label
let payload = Text.drop 1 suffix
if isFile
then
if Text.null payload || Text.any (== '\0') payload
then Left (setting <> " requires nonempty paths without NUL")
else Right (Revision.FileInput name (Text.unpack payload))
else Right (Revision.ValueInput name payload)