module Ecluse.Boot (
BootEnv (..),
applySecretFileIndirection,
readConfigDocument,
withBootEnv,
BootAborted (..),
orExit,
logBootWarning,
logBootInfo,
logRuleBootOrder,
buildMirrorQueue,
) where
import Data.ByteString qualified as BS
import Data.List (lookup)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Katip (Environment (Environment), LogEnv, Severity (InfoS, WarningS), logFM, ls)
import Katip.Monadic (runKatipContextT)
import System.Environment (getEnvironment)
import System.IO.Error (ioeGetErrorString, isDoesNotExistError)
import UnliftIO (throwIO, tryIO)
import Ecluse.Composition.MirrorQueue (
MirrorQueuePlan (MemoryBackend, SqsBackend),
memoryQueueDropWarning,
mirrorQueuePlanWarning,
)
import Ecluse.Config (
AppConfig (cfgObservability, cfgRuntime),
Config (configApp),
ObservabilitySettings (obsLogFormat, obsTelemetry),
RuntimeSettings (rtCores, rtMaxHeapBytes),
loadConfig,
mountCollisionWarnings,
renderConfigError,
resolvedKeyProvenance,
)
import Ecluse.Config.Ambient (AmbientAws, ambientAwsFromEnv)
import Ecluse.Config.Resolve (secretEnvSpellings)
import Ecluse.Core.Queue (MirrorQueue)
import Ecluse.Core.Queue.Memory (defaultMemoryQueueConfig, newBoundedInMemoryQueue)
import Ecluse.Core.Rules (renderBootOrder)
import Ecluse.Core.Security.Egress (mkRegistryUrl)
import Ecluse.Core.Server.Context (PackumentDeps (pdRules))
import Ecluse.Rts (EffectiveRuntimePlan, applyRuntimePosture)
import Ecluse.Runtime.Log (moduleField, newLogEnv)
import Ecluse.Runtime.Queue.Sqs (newSqsQueue)
import Ecluse.Runtime.Server (MountBinding (bindingPackumentDeps, bindingPrefix))
import Ecluse.Runtime.Telemetry (Telemetry, TelemetrySwitch (TelemetryOff, TelemetryOn), withTelemetry)
import Ecluse.Runtime.Telemetry.Resolve (prepareTelemetry)
data BootEnv = BootEnv
{ BootEnv -> AppConfig
beConfig :: AppConfig
, BootEnv -> AmbientAws
beAmbient :: AmbientAws
, BootEnv -> LogEnv
beLogEnv :: LogEnv
, BootEnv -> Telemetry
beTelemetry :: Telemetry
, BootEnv -> Config
beConfigFull :: Config
, BootEnv -> EffectiveRuntimePlan
beRuntimePlan :: EffectiveRuntimePlan
}
applySecretFileIndirection :: [(String, String)] -> IO (Either Text [(String, String)])
applySecretFileIndirection :: [(String, String)] -> IO (Either Text [(String, String)])
applySecretFileIndirection [(String, String)]
envVars = do
reads' <- ((String, String) -> IO (Either Text (String, String)))
-> [(String, String)] -> IO [Either Text (String, String)]
forall (t :: * -> *) (f :: * -> *) a b.
(Traversable t, Applicative f) =>
(a -> f b) -> t a -> f (t b)
forall (f :: * -> *) a b.
Applicative f =>
(a -> f b) -> [a] -> f [b]
traverse (String, String) -> IO (Either Text (String, String))
forall {m :: * -> *}.
MonadUnliftIO m =>
(String, String) -> m (Either Text (String, String))
readOne [(String, String)]
fileVars
let (readErrs, resolved) = partitionEithers reads'
pure $ case conflicts <> readErrs of
[] -> [(String, String)] -> Either Text [(String, String)]
forall a b. b -> Either a b
Right (((String, String) -> Bool)
-> [(String, String)] -> [(String, String)]
forall a. (a -> Bool) -> [a] -> [a]
filter (Bool -> Bool
not (Bool -> Bool)
-> ((String, String) -> Bool) -> (String, String) -> Bool
forall b c a. (b -> c) -> (a -> b) -> a -> c
. String -> Bool
isSecretFileVar (String -> Bool)
-> ((String, String) -> String) -> (String, String) -> Bool
forall b c a. (b -> c) -> (a -> b) -> a -> c
. (String, String) -> String
forall a b. (a, b) -> a
fst) [(String, String)]
envVars [(String, String)] -> [(String, String)] -> [(String, String)]
forall a. Semigroup a => a -> a -> a
<> [(String, String)]
resolved)
[Text]
errs -> Text -> Either Text [(String, String)]
forall a b. a -> Either a b
Left ([Text] -> Text
T.unlines [Text]
errs)
where
fileVars :: [(String, String)]
fileVars = ((String, String) -> Bool)
-> [(String, String)] -> [(String, String)]
forall a. (a -> Bool) -> [a] -> [a]
filter (String -> Bool
isSecretFileVar (String -> Bool)
-> ((String, String) -> String) -> (String, String) -> Bool
forall b c a. (b -> c) -> (a -> b) -> a -> c
. (String, String) -> String
forall a b. (a, b) -> a
fst) [(String, String)]
envVars
conflicts :: [Text]
conflicts =
[ String -> Text
T.pack String
base Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" and " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack String
name Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" are both set: supply the secret through exactly one of them"
| (String
name, String
_) <- [(String, String)]
fileVars
, let base :: String
base = String -> String
baseVarOf String
name
, Maybe String -> Bool
forall a. Maybe a -> Bool
isJust (String -> [(String, String)] -> Maybe String
forall a b. Eq a => a -> [(a, b)] -> Maybe b
lookup String
base [(String, String)]
envVars)
]
readOne :: (String, String) -> m (Either Text (String, String))
readOne (String
name, String
path) = do
outcome <- m ByteString -> m (Either IOException ByteString)
forall (m :: * -> *) a.
MonadUnliftIO m =>
m a -> m (Either IOException a)
tryIO (String -> m ByteString
forall (m :: * -> *). MonadIO m => String -> m ByteString
readFileBS String
path)
pure $ case outcome of
Left IOException
err ->
Text -> Either Text (String, String)
forall a b. a -> Either a b
Left (String -> Text
T.pack String
name Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" points at " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack String
path Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
", which cannot be read: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack (IOException -> String
forall e. Exception e => e -> String
displayException IOException
err))
Right ByteString
bytes ->
(String, String) -> Either Text (String, String)
forall a b. b -> Either a b
Right (String -> String
baseVarOf String
name, Text -> String
T.unpack ((Char -> Bool) -> Text -> Text
T.dropWhileEnd (Char -> Char -> Bool
forall a. Eq a => a -> a -> Bool
== Char
'\n') (ByteString -> Text
forall a b. ConvertUtf8 a b => b -> a
decodeUtf8 ByteString
bytes)))
isSecretFileVar :: String -> Bool
isSecretFileVar String
name =
let spelling :: Text
spelling = String -> Text
T.pack String
name
in Text
"ECLUSE_" Text -> Text -> Bool
`T.isPrefixOf` Text
spelling Bool -> Bool -> Bool
&& (Text -> Bool) -> [Text] -> Bool
forall (t :: * -> *) a. Foldable t => (a -> Bool) -> t a -> Bool
any (Text -> Text -> Bool
`T.isSuffixOf` Text
spelling) [Text]
secretFileSuffixes
baseVarOf :: String -> String
baseVarOf String
name = String -> (Text -> String) -> Maybe Text -> String
forall b a. b -> (a -> b) -> Maybe a -> b
maybe String
name Text -> String
T.unpack (Text -> Text -> Maybe Text
T.stripSuffix Text
"_FILE" (String -> Text
T.pack String
name))
secretFileSuffixes :: [Text]
secretFileSuffixes :: [Text]
secretFileSuffixes = (Text -> Text) -> [Text] -> [Text]
forall a b. (a -> b) -> [a] -> [b]
map (Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"_FILE") [Text]
secretEnvSpellings
readConfigDocument :: [(String, String)] -> IO (Either Text (Maybe ByteString, FilePath))
readConfigDocument :: [(String, String)] -> IO (Either Text (Maybe ByteString, String))
readConfigDocument [(String, String)]
envVars = do
let explicitPath :: Maybe String
explicitPath = String -> Maybe String
nonBlankPath (String -> Maybe String) -> Maybe String -> Maybe String
forall (m :: * -> *) a b. Monad m => (a -> m b) -> m a -> m b
=<< String -> [(String, String)] -> Maybe String
forall a b. Eq a => a -> [(a, b)] -> Maybe b
lookup String
"ECLUSE_CONFIG" [(String, String)]
envVars
docPath :: String
docPath = String -> Maybe String -> String
forall a. a -> Maybe a -> a
fromMaybe String
defaultConfigPath Maybe String
explicitPath
mDocBlob <- IO ByteString -> IO (Either IOException ByteString)
forall (m :: * -> *) a.
MonadUnliftIO m =>
m a -> m (Either IOException a)
tryIO (String -> IO ByteString
BS.readFile String
docPath)
pure $ case mDocBlob of
Right ByteString
bytes -> (Maybe ByteString, String)
-> Either Text (Maybe ByteString, String)
forall a b. b -> Either a b
Right (ByteString -> Maybe ByteString
forall a. a -> Maybe a
Just ByteString
bytes, String
docPath)
Left IOException
err
| IOException -> Bool
isDoesNotExistError IOException
err ->
case Maybe String
explicitPath of
Maybe String
Nothing -> (Maybe ByteString, String)
-> Either Text (Maybe ByteString, String)
forall a b. b -> Either a b
Right (Maybe ByteString
forall a. Maybe a
Nothing, String
docPath)
Just String
path ->
Text -> Either Text (Maybe ByteString, String)
forall a b. a -> Either a b
Left
( Text
"ECLUSE_CONFIG points at "
Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack String
path
Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
", but no config document exists there; fix the path, or unset ECLUSE_CONFIG to use "
Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack String
defaultConfigPath
)
| Bool
otherwise ->
Text -> Either Text (Maybe ByteString, String)
forall a b. a -> Either a b
Left
( Text
"config document at "
Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack String
docPath
Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" cannot be read: "
Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack (IOException -> String
ioeGetErrorString IOException
err)
)
defaultConfigPath :: FilePath
defaultConfigPath :: String
defaultConfigPath = String
"/etc/ecluse/config.yaml"
nonBlankPath :: FilePath -> Maybe FilePath
nonBlankPath :: String -> Maybe String
nonBlankPath String
p = if Text -> Bool
T.null (Text -> Text
T.strip (String -> Text
T.pack String
p)) then Maybe String
forall a. Maybe a
Nothing else String -> Maybe String
forall a. a -> Maybe a
Just String
p
withBootEnv :: (BootEnv -> IO ()) -> IO ()
withBootEnv :: (BootEnv -> IO ()) -> IO ()
withBootEnv BootEnv -> IO ()
action = do
rawEnvVars <- IO [(String, String)]
getEnvironment
envVars <- applySecretFileIndirection rawEnvVars >>= orExit id
let ambient = [(String, String)] -> AmbientAws
ambientAwsFromEnv [(String, String)]
envVars
(docBlob, docPath) <- readConfigDocument envVars >>= orExit id
config <- orExit (T.unlines . map renderConfigError) (loadConfig envVars docBlob)
let env = Config -> AppConfig
configApp Config
config
observability = AppConfig -> ObservabilitySettings
cfgObservability AppConfig
env
runtimeSettings = AppConfig -> RuntimeSettings
cfgRuntime AppConfig
env
logEnv <- newLogEnv (obsLogFormat observability) (Environment "production")
runtimePlan <-
applyRuntimePosture (logBootInfo logEnv) (logBootWarning logEnv) (rtCores runtimeSettings) (rtMaxHeapBytes runtimeSettings)
logBootInfo logEnv $ case docBlob of
Just ByteString
_ -> Text
"Config document: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack String
docPath
Maybe ByteString
Nothing -> Text
"Config document: none at " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> String -> Text
T.pack String
docPath Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (defaults and environment only)"
traverse_ (logBootInfo logEnv) (resolvedKeyProvenance envVars docBlob)
traverse_ (logBootWarning logEnv) (mountCollisionWarnings config)
prepareTelemetryBoot (obsTelemetry observability) logEnv
withTelemetry (obsTelemetry observability) logEnv $ \Telemetry
telemetry ->
BootEnv -> IO ()
action
BootEnv
{ beConfig :: AppConfig
beConfig = AppConfig
env
, beAmbient :: AmbientAws
beAmbient = AmbientAws
ambient
, beLogEnv :: LogEnv
beLogEnv = LogEnv
logEnv
, beTelemetry :: Telemetry
beTelemetry = Telemetry
telemetry
, beConfigFull :: Config
beConfigFull = Config
config
, beRuntimePlan :: EffectiveRuntimePlan
beRuntimePlan = EffectiveRuntimePlan
runtimePlan
}
buildMirrorQueue :: LogEnv -> Int -> MirrorQueuePlan -> IO MirrorQueue
buildMirrorQueue :: LogEnv -> Int -> MirrorQueuePlan -> IO MirrorQueue
buildMirrorQueue LogEnv
logEnv Int
memoryDepth MirrorQueuePlan
plan = do
Maybe Text -> (Text -> IO ()) -> IO ()
forall (f :: * -> *) a.
Applicative f =>
Maybe a -> (a -> f ()) -> f ()
whenJust (MirrorQueuePlan -> Maybe Text
mirrorQueuePlanWarning MirrorQueuePlan
plan) (LogEnv -> Text -> IO ()
logBootWarning LogEnv
logEnv)
case MirrorQueuePlan
plan of
SqsBackend SqsConfig
sqsConfig -> LogEnv
-> (Text -> Either Text RegistryUrl) -> SqsConfig -> IO MirrorQueue
newSqsQueue LogEnv
logEnv Text -> Either Text RegistryUrl
mkRegistryUrl SqsConfig
sqsConfig
MirrorQueuePlan
MemoryBackend ->
MemoryQueueConfig -> (Int -> IO ()) -> IO MirrorQueue
newBoundedInMemoryQueue (Int -> MemoryQueueConfig
defaultMemoryQueueConfig Int
memoryDepth) (LogEnv -> Text -> IO ()
logBootWarning LogEnv
logEnv (Text -> IO ()) -> (Int -> Text) -> Int -> IO ()
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Int -> Text
memoryQueueDropWarning)
logBootWarning :: LogEnv -> Text -> IO ()
logBootWarning :: LogEnv -> Text -> IO ()
logBootWarning LogEnv
logEnv Text
message =
LogEnv
-> SimpleLogPayload -> Namespace -> KatipContextT IO () -> IO ()
forall c (m :: * -> *) a.
LogItem c =>
LogEnv -> c -> Namespace -> KatipContextT m a -> m a
runKatipContextT LogEnv
logEnv (Text -> SimpleLogPayload
moduleField Text
"Ecluse") Namespace
forall a. Monoid a => a
mempty (Severity -> LogStr -> KatipContextT IO ()
forall (m :: * -> *).
(Applicative m, KatipContext m) =>
Severity -> LogStr -> m ()
logFM Severity
WarningS (Text -> LogStr
forall a. StringConv a Text => a -> LogStr
ls Text
message))
logBootInfo :: LogEnv -> Text -> IO ()
logBootInfo :: LogEnv -> Text -> IO ()
logBootInfo LogEnv
logEnv Text
message =
LogEnv
-> SimpleLogPayload -> Namespace -> KatipContextT IO () -> IO ()
forall c (m :: * -> *) a.
LogItem c =>
LogEnv -> c -> Namespace -> KatipContextT m a -> m a
runKatipContextT LogEnv
logEnv (Text -> SimpleLogPayload
moduleField Text
"Ecluse") Namespace
forall a. Monoid a => a
mempty (Severity -> LogStr -> KatipContextT IO ()
forall (m :: * -> *).
(Applicative m, KatipContext m) =>
Severity -> LogStr -> m ()
logFM Severity
InfoS (Text -> LogStr
forall a. StringConv a Text => a -> LogStr
ls Text
message))
logRuleBootOrder :: LogEnv -> [MountBinding] -> IO ()
logRuleBootOrder :: LogEnv -> [MountBinding] -> IO ()
logRuleBootOrder LogEnv
logEnv = (MountBinding -> IO ()) -> [MountBinding] -> IO ()
forall (t :: * -> *) (f :: * -> *) a b.
(Foldable t, Applicative f) =>
(a -> f b) -> t a -> f ()
traverse_ MountBinding -> IO ()
logMount
where
logMount :: MountBinding -> IO ()
logMount MountBinding
binding = do
let deps :: PackumentDeps
deps = MountBinding -> PackumentDeps
bindingPackumentDeps MountBinding
binding
let label :: Text
label = Text -> [Text] -> Text
T.intercalate Text
"/" (NonEmpty Text -> [Text]
forall a. NonEmpty a -> [a]
forall (t :: * -> *) a. Foldable t => t a -> [a]
toList (MountBinding -> NonEmpty Text
bindingPrefix MountBinding
binding))
LogEnv -> Text -> IO ()
logBootInfo LogEnv
logEnv (Text
"rule boot order for mount " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
label Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
":")
(Text -> IO ()) -> [Text] -> IO ()
forall (t :: * -> *) (f :: * -> *) a b.
(Foldable t, Applicative f) =>
(a -> f b) -> t a -> f ()
traverse_ (LogEnv -> Text -> IO ()
logBootInfo LogEnv
logEnv) ([PreparedRule] -> [Text]
renderBootOrder (PackumentDeps -> [PreparedRule]
pdRules PackumentDeps
deps))
data BootAborted = BootAborted
deriving stock (BootAborted -> BootAborted -> Bool
(BootAborted -> BootAborted -> Bool)
-> (BootAborted -> BootAborted -> Bool) -> Eq BootAborted
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: BootAborted -> BootAborted -> Bool
== :: BootAborted -> BootAborted -> Bool
$c/= :: BootAborted -> BootAborted -> Bool
/= :: BootAborted -> BootAborted -> Bool
Eq, Int -> BootAborted -> String -> String
[BootAborted] -> String -> String
BootAborted -> String
(Int -> BootAborted -> String -> String)
-> (BootAborted -> String)
-> ([BootAborted] -> String -> String)
-> Show BootAborted
forall a.
(Int -> a -> String -> String)
-> (a -> String) -> ([a] -> String -> String) -> Show a
$cshowsPrec :: Int -> BootAborted -> String -> String
showsPrec :: Int -> BootAborted -> String -> String
$cshow :: BootAborted -> String
show :: BootAborted -> String
$cshowList :: [BootAborted] -> String -> String
showList :: [BootAborted] -> String -> String
Show)
instance Exception BootAborted
orExit :: (e -> Text) -> Either e a -> IO a
orExit :: forall e a. (e -> Text) -> Either e a -> IO a
orExit e -> Text
render = \case
Right a
a -> a -> IO a
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure a
a
Left e
err -> Handle -> Text -> IO ()
TIO.hPutStrLn Handle
stderr (e -> Text
render e
err) IO () -> IO a -> IO a
forall a b. IO a -> IO b -> IO b
forall (m :: * -> *) a b. Monad m => m a -> m b -> m b
>> BootAborted -> IO a
forall (m :: * -> *) e a. (MonadIO m, Exception e) => e -> m a
throwIO BootAborted
BootAborted
prepareTelemetryBoot :: TelemetrySwitch -> LogEnv -> IO ()
prepareTelemetryBoot :: TelemetrySwitch -> LogEnv -> IO ()
prepareTelemetryBoot TelemetrySwitch
switch LogEnv
logEnv = case TelemetrySwitch
switch of
TelemetrySwitch
TelemetryOff -> IO ()
forall (f :: * -> *). Applicative f => f ()
pass
TelemetrySwitch
TelemetryOn -> do
environment <- IO [(String, String)]
getEnvironment
prepareTelemetry logEnv environment