hinagata-core-0.1.0.0: src/Hinagata/Config.hs
-- | Pure, inspectable Settei declarations for Hinagata's shared settings.
-- Sources are supplied explicitly by the caller; this module performs no IO.
module Hinagata.Config
( HinagataConfig (..),
EndpointConfig (..),
AccessConfig (..),
DatabaseGrant (..),
SchemaPrivilege (..),
SchemaGrant (..),
RoleSetting (..),
CloneStrategy (..),
accessTarget,
hinagataConfig,
hinagataEnvironmentBindings,
)
where
import Data.Text qualified as Text
import Hinagata.Connection
import Hinagata.Prelude
import Hinagata.Types
import Settei qualified
import Settei.Env qualified as Env
import System.FilePath (isAbsolute)
-- | Connection transport shared by the three independently declared roles.
data EndpointConfig = EndpointConfig
{ host :: !Host,
port :: !Port
}
deriving stock (Eq, Show)
-- | One explicitly selected role and database. Passwords remain secret-safe.
data AccessConfig = AccessConfig
{ user :: !Text,
database :: !DatabaseName,
password :: !(Maybe Secret)
}
deriving stock (Eq)
instance Show AccessConfig where
show AccessConfig {user, database} =
"AccessConfig {user = " ++ show user ++ ", database = " ++ show database ++ ", password = <redacted>}"
-- | The only database-level privileges that the lifecycle layer may apply.
data DatabaseGrant = GrantConnect | GrantCreate | GrantTemporary
deriving stock (Eq, Ord, Show)
data SchemaPrivilege = SchemaUsage | SchemaCreate
deriving stock (Eq, Ord, Show)
data SchemaGrant = SchemaGrant
{ schema :: !SqlIdentifier,
privilege :: !SchemaPrivilege
}
deriving stock (Eq, Show)
-- | A declared, non-secret database-level role setting. Its value is
-- configuration, never SQL text. Credential values belong in access targets.
data RoleSetting = RoleSetting
{ name :: !SqlIdentifier,
value :: !Text
}
deriving stock (Eq, Show)
data CloneStrategy = WalLog | FileCopy
deriving stock (Eq, Ord, Show)
-- | All limits are local to one suite-scoped manager.
data HinagataConfig = HinagataConfig
{ endpoint :: !EndpointConfig,
project :: !ProjectId,
fixtureRoot :: !FilePath,
bundleRoot :: !FilePath,
maintenanceDatabase :: !DatabaseName,
maintenanceSchema :: !SqlIdentifier,
administration :: !AccessConfig,
setup :: !AccessConfig,
application :: !AccessConfig,
setupGrants :: ![DatabaseGrant],
applicationGrants :: ![DatabaseGrant],
setupSchemaGrants :: ![SchemaGrant],
applicationSchemaGrants :: ![SchemaGrant],
setupSettings :: ![RoleSetting],
applicationSettings :: ![RoleSetting],
cloneStrategy :: !CloneStrategy,
acquisitionDeadlineMs :: !Positive,
setupDeadlineMs :: !Positive,
setupWorkers :: !Positive,
activeLeases :: !Positive,
pendingRequests :: !Positive,
chunkSize :: !Positive,
sqlSizeLimit :: !Positive
}
deriving stock (Eq, Show)
-- | Recheck the target invariant when turning a resolved access description into
-- a libpq target. The resolver's user decoder already guarantees this succeeds.
accessTarget :: EndpointConfig -> AccessConfig -> Either Text ConnectionTarget
accessTarget EndpointConfig {host, port} AccessConfig {user, database, password} =
mkConnectionTarget host port user database password
hinagataConfig :: Settei.Config HinagataConfig
hinagataConfig =
HinagataConfig
<$> endpointConfig
<*> required "project.id" "Project identity used in owned database names" projectDecoder
<*> required "fixture.root" "Absolute fixture source directory" directoryDecoder
<*> required "bundle.root" "Absolute private bundle cache directory" directoryDecoder
<*> required "maintenance.database" "Existing maintenance database" databaseDecoder
<*> defaulted "maintenance.schema" "Maintenance catalog schema" identifierDecoder "maintenance-schema" "Use a dedicated catalog schema" (knownIdentifier "hinagata")
<*> accessConfig "administration"
<*> accessConfig "setup"
<*> accessConfig "application"
<*> defaulted "setup.grants" "Database grants for setup role" grantsDecoder "setup-grants" "Allow setup migration and fixture creation" [GrantConnect, GrantCreate, GrantTemporary]
<*> defaulted "application.grants" "Database grants for application role" grantsDecoder "application-grants" "Application connects without schema creation" [GrantConnect]
<*> defaulted "setup.schema_grants" "Schema grants for setup role" schemaGrantsDecoder "setup-schema-grants" "Permit migrations in public" [SchemaGrant (knownIdentifier "public") SchemaUsage, SchemaGrant (knownIdentifier "public") SchemaCreate]
<*> defaulted "application.schema_grants" "Schema grants for application role" schemaGrantsDecoder "application-schema-grants" "Permit application access to public" [SchemaGrant (knownIdentifier "public") SchemaUsage]
<*> defaulted "setup.settings" "Database-level setup role settings" settingsDecoder "setup-settings" "No extra role settings" []
<*> defaulted "application.settings" "Database-level application role settings" settingsDecoder "application-settings" "No extra role settings" []
<*> defaulted "clone.strategy" "PostgreSQL clone strategy" (Settei.enumDecoder [("WAL_LOG", WalLog), ("FILE_COPY", FileCopy)]) "clone-strategy" "WAL_LOG is the portable default" WalLog
<*> positiveDefault "limits.acquisition_deadline_ms" "Acquisition deadline in milliseconds" 30000
<*> positiveDefault "limits.setup_deadline_ms" "Setup deadline in milliseconds" 300000
<*> positiveDefault "limits.setup_workers" "Manager-local concurrent setup workers" 2
<*> positiveDefault "limits.active_leases" "Manager-local active leases" 8
<*> positiveDefault "limits.pending_requests" "Manager-local queued acquisition requests" 32
<*> positiveDefault "limits.chunk_size" "CSV transfer chunk bytes" 65536
<*> positiveDefault "limits.sql_size_limit" "Maximum SQL step bytes" 1048576
endpointConfig :: Settei.Config EndpointConfig
endpointConfig =
EndpointConfig
<$> required "endpoint.host" "Absolute socket directory or TCP hostname" hostDecoder
<*> required "endpoint.port" "PostgreSQL listener port" portDecoder
accessConfig :: Text -> Settei.Config AccessConfig
accessConfig prefix =
AccessConfig
<$> required (prefix <> ".user") "Explicit database role" userDecoder
<*> required (prefix <> ".database") "Explicit database for this role" databaseDecoder
<*> Settei.optional (Settei.secretSetting (key (prefix <> ".password")) "Optional role password" secretDecoder)
-- | Explicit bindings only. Invalid static names or key collisions are returned
-- to the caller and can be forced in startup tests before reading the environment.
hinagataEnvironmentBindings :: Either (NonEmpty Env.EnvError) Env.Bindings
hinagataEnvironmentBindings =
Env.bindings
[ Env.binding (Env.EnvName ("HINAGATA_" <> Text.toUpper (Text.replace "." "_" name))) (key name)
| name <- settingNames
]
settingNames :: [Text]
settingNames =
[ "endpoint.host",
"endpoint.port",
"project.id",
"fixture.root",
"bundle.root",
"maintenance.database",
"maintenance.schema",
"administration.user",
"administration.database",
"administration.password",
"setup.user",
"setup.database",
"setup.password",
"application.user",
"application.database",
"application.password",
"setup.grants",
"application.grants",
"setup.schema_grants",
"application.schema_grants",
"setup.settings",
"application.settings",
"clone.strategy",
"limits.acquisition_deadline_ms",
"limits.setup_deadline_ms",
"limits.setup_workers",
"limits.active_leases",
"limits.pending_requests",
"limits.chunk_size",
"limits.sql_size_limit"
]
required :: Text -> Text -> Settei.Decoder a -> Settei.Config a
required name description valueDecoder = Settei.required (Settei.publicSetting (key name) description valueDecoder)
defaulted :: (Show a) => Text -> Text -> Settei.Decoder a -> Text -> Text -> a -> Settei.Config a
defaulted name description valueDecoder rule explanation value =
Settei.withDefault
(Settei.publicShowSetting (key name) description valueDecoder)
(Settei.constantDefault (Settei.RuleName rule) explanation value)
positiveDefault :: Text -> Text -> Int -> Settei.Config Positive
positiveDefault name description value =
defaulted name description (positiveDecoder name) (name <> "-default") "Manager-local operational default" (knownPositive value)
key :: Text -> Settei.Key
key name = case Settei.parseKey name of
Right result -> result
Left problem -> error ("Invalid static Hinagata setting key: " ++ show problem)
knownPositive :: Int -> Positive
knownPositive value = case mkPositive "built-in limit" value of
Right result -> result
Left problem -> error (Text.unpack problem)
knownIdentifier :: Text -> SqlIdentifier
knownIdentifier value = case mkSqlIdentifier value of
Right result -> result
Left problem -> error (Text.unpack problem)
hostDecoder :: Settei.Decoder Host
hostDecoder = Settei.parsedDecoder "an absolute socket directory or TCP hostname" $ \value ->
if Text.isPrefixOf "/" value then socketDirectory value else tcpHost value
portDecoder :: Settei.Decoder Port
portDecoder = Settei.decoder $ \settingKey raw -> do
value <- Settei.runDecoder Settei.boundedIntegralDecoder settingKey raw
either (const (Left (Settei.decodeFailure settingKey "port between 1 and 65535"))) Right (mkPort value)
projectDecoder :: Settei.Decoder ProjectId
projectDecoder = Settei.parsedDecoder "a lowercase project ID" mkProjectId
databaseDecoder :: Settei.Decoder DatabaseName
databaseDecoder = Settei.parsedDecoder "a PostgreSQL database name of at most 63 UTF-8 bytes" mkDatabaseName
identifierDecoder :: Settei.Decoder SqlIdentifier
identifierDecoder = Settei.parsedDecoder "a PostgreSQL identifier of at most 63 UTF-8 bytes" mkSqlIdentifier
directoryDecoder :: Settei.Decoder FilePath
directoryDecoder = Settei.parsedDecoder "an absolute directory path without NUL" $ \value ->
if not (Text.null value) && not (Text.any (== '\0') value) && isAbsolute (Text.unpack value)
then Right (Text.unpack value)
else Left "invalid directory"
userDecoder :: Settei.Decoder Text
userDecoder = Settei.parsedDecoder "a nonempty database role without NUL" $ \value ->
if Text.null value || Text.any (== '\0') value then Left "invalid user" else Right value
secretDecoder :: Settei.Decoder Secret
secretDecoder = Settei.parsedDecoder "a password without NUL" mkSecret
positiveDecoder :: Text -> Settei.Decoder Positive
positiveDecoder name = Settei.decoder $ \settingKey raw -> do
value <- Settei.runDecoder Settei.boundedIntegralDecoder settingKey raw
either (const (Left (Settei.decodeFailure settingKey "a positive integer"))) Right (mkPositive name value)
grantsDecoder :: Settei.Decoder [DatabaseGrant]
grantsDecoder = Settei.decoder $ \settingKey raw ->
case scalarList raw of
Nothing -> Left (Settei.decodeFailure settingKey "a comma-separated or array grant list")
Just names -> traverse (parseGrant settingKey) names
parseGrant :: Settei.Key -> Text -> Either Settei.DecodeFailure DatabaseGrant
parseGrant settingKey value = case Text.toUpper (Text.strip value) of
"CONNECT" -> Right GrantConnect
"CREATE" -> Right GrantCreate
"TEMPORARY" -> Right GrantTemporary
_ -> Left (Settei.decodeFailure settingKey "CONNECT, CREATE, or TEMPORARY grants")
schemaGrantsDecoder :: Settei.Decoder [SchemaGrant]
schemaGrantsDecoder = Settei.decoder $ \settingKey raw ->
case scalarList raw of
Nothing -> Left (Settei.decodeFailure settingKey "a comma-separated or array schema grant list")
Just values -> traverse (parseSchemaGrant settingKey) values
parseSchemaGrant :: Settei.Key -> Text -> Either Settei.DecodeFailure SchemaGrant
parseSchemaGrant settingKey value =
case Text.breakOn ":" value of
(schemaName, assigned)
| not (Text.null assigned),
Right schema <- mkSqlIdentifier (Text.strip schemaName) ->
case Text.toUpper (Text.strip (Text.drop 1 assigned)) of
"USAGE" -> Right SchemaGrant {schema, privilege = SchemaUsage}
"CREATE" -> Right SchemaGrant {schema, privilege = SchemaCreate}
_ -> invalid
_ -> invalid
where
invalid = Left (Settei.decodeFailure settingKey "SCHEMA:USAGE or SCHEMA:CREATE grant")
settingsDecoder :: Settei.Decoder [RoleSetting]
settingsDecoder = Settei.decoder $ \settingKey raw ->
case scalarList raw of
Nothing -> Left (Settei.decodeFailure settingKey "a comma-separated or array setting list")
Just values -> traverse (parseRoleSetting settingKey) values
parseRoleSetting :: Settei.Key -> Text -> Either Settei.DecodeFailure RoleSetting
parseRoleSetting settingKey value =
case Text.breakOn "=" value of
(settingName, assigned)
| not (Text.null assigned),
Right name <- mkSqlIdentifier (Text.strip settingName),
let settingValue = Text.strip (Text.drop 1 assigned),
not (Text.any (== '\0') settingValue) ->
Right RoleSetting {name, value = settingValue}
_ -> Left (Settei.decodeFailure settingKey "NAME=VALUE database setting")
scalarList :: Settei.RawValue -> Maybe [Text]
scalarList (Settei.RawText value)
| Text.null (Text.strip value) = Just []
| otherwise = Just (Text.splitOn "," value)
scalarList (Settei.RawArray values) = traverse textValue values
where
textValue (Settei.RawText value) = Just value
textValue _ = Nothing
scalarList _ = Nothing