seihou-core-0.10.0.0: src/Seihou/Manifest/Upgrade.hs
-- | The ordered, adjacent schema steps that take a raw
-- @.seihou/manifest.json@ document from any supported schema to a newer one.
--
-- Every semantic manifest change advances the schema and adds exactly one
-- @N -> N + 1@ step here
-- (docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md).
-- A path is planned one adjacent step at a time, so a missing step is an
-- error rather than a silent jump, and a document's @version@ only ever
-- names the last step that actually ran.
--
-- Steps work on 'Aeson.Value' rather than 'Manifest' so that keys this build
-- does not model survive. Each step is classified: a lossless step is a
-- deterministic transform implemented here and may be staged by any command
-- that needs it; an inference-bearing step consults machine-local state and
-- is implemented only by the explicit @seihou manifest upgrade@ command in
-- @seihou-cli@ (docs/adr/0005-legacy-manifests-convert-through-an-explicit-command.md).
module Seihou.Manifest.Upgrade
( UpgradeStepAction (..),
UpgradeStepKind (..),
ManifestUpgradeStep (..),
ManifestUpgradeError (..),
oldestUpgradableManifestVersion,
manifestUpgradeSteps,
upgradeStepKind,
planManifestUpgrade,
planUpgradeWith,
documentSchemaVersion,
setDocumentSchemaVersion,
applyLosslessUpgradeStep,
upgradeDocumentLosslessly,
makeSharedWriteEvidenceExplicit,
renderManifestUpgradeError,
)
where
import Control.Monad (foldM, unless)
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Generics.Labels ()
import Data.Text qualified as T
import Seihou.Core.Types
import Seihou.Manifest.Types (currentManifestVersion, sharedWriteModeToText)
import Seihou.Prelude
-- | What a step does to the document. The kind of each action is fixed by
-- 'upgradeStepKind', so a step cannot be declared lossless while doing
-- inference.
data UpgradeStepAction
= -- | Only the version changes: every field the newer schema added decodes
-- from its absence to the same default the older reader assumed.
StampVersion
| -- | Schema 5 to 6: machine-local @source@ and @targetSource@ paths
-- become portable artifact origins. Implemented by the CLI.
ConvertMachineLocalPaths
| -- | Schema 6 to 7: the optional @additiveOnly@ Boolean becomes a
-- required @sharedWriteMode@ on every file record.
MakeSharedWriteEvidenceExplicit
| AddEmptySeedReceipts
| AddEmptyCheckoutOutputs
deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)
-- | Whether a step may be staged automatically.
data UpgradeStepKind
= -- | Deterministic and loses nothing; may be staged by a command that
-- requires the newer schema and committed atomically with its result.
LosslessUpgrade
| -- | Consults machine-local state or chooses among plausible answers;
-- only the explicit, reviewable @seihou manifest upgrade@ runs it.
InferenceBearingUpgrade
deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)
-- | One adjacent schema step.
data ManifestUpgradeStep = ManifestUpgradeStep
{ fromVersion :: !ManifestSchemaVersion,
toVersion :: !ManifestSchemaVersion,
action :: !UpgradeStepAction,
-- | A short description for upgrade reports.
summary :: !Text
}
deriving stock (Eq, Show, Generic)
data ManifestUpgradeError
= -- | The document has no integer @version@, or is not an object.
UnreadableSchemaVersion Text
| -- | Older than any schema this build knows how to upgrade.
SourceVersionUnsupported ManifestSchemaVersion
| -- | Written by a newer seihou than this one.
SourceNewerThanBinary ManifestSchemaVersion
| -- | Asked for a schema this build does not know.
TargetAboveCurrent ManifestSchemaVersion
| -- | Asked to go backwards: source, then requested target.
TargetBelowSource ManifestSchemaVersion ManifestSchemaVersion
| -- | No step starts at this version.
MissingUpgradeStep ManifestSchemaVersion
| -- | A lossless run reached a step only the explicit command may run.
StepRequiresInference ManifestUpgradeStep
| -- | A step was applied to a document at a different version.
StepVersionMismatch ManifestUpgradeStep ManifestSchemaVersion
| -- | The document did not have the shape its version promises.
MalformedDocument ManifestSchemaVersion Text
deriving stock (Eq, Show, Generic)
-- | The oldest schema any step starts from.
oldestUpgradableManifestVersion :: ManifestSchemaVersion
oldestUpgradableManifestVersion = ManifestSchemaVersion 1
-- | Every adjacent step, oldest first. Adding a schema version means adding
-- exactly one entry here; the test suite plans a path from every supported
-- version to 'currentManifestVersion', so a forgotten step fails it.
manifestUpgradeSteps :: [ManifestUpgradeStep]
manifestUpgradeSteps =
[ step 1 StampVersion "per-instance parent variables",
step 2 StampVersion "applied blueprint record",
step 3 StampVersion "reproducible applications and generated baselines",
step 4 StampVersion "blueprint migration receipts",
step 5 ConvertMachineLocalPaths "portable artifact origins",
step 6 MakeSharedWriteEvidenceExplicit "explicit shared-write evidence",
step 7 AddEmptySeedReceipts "seed receipts",
step 8 AddEmptyCheckoutOutputs "checkout outputs"
]
where
step from stepAction =
ManifestUpgradeStep (ManifestSchemaVersion from) (ManifestSchemaVersion (from + 1)) stepAction
upgradeStepKind :: UpgradeStepAction -> UpgradeStepKind
upgradeStepKind StampVersion = LosslessUpgrade
upgradeStepKind ConvertMachineLocalPaths = InferenceBearingUpgrade
upgradeStepKind MakeSharedWriteEvidenceExplicit = LosslessUpgrade
upgradeStepKind AddEmptySeedReceipts = LosslessUpgrade
upgradeStepKind AddEmptyCheckoutOutputs = LosslessUpgrade
-- | The contiguous steps from @source@ to @target@. Planning from a version
-- to itself is an empty path.
planManifestUpgrade ::
ManifestSchemaVersion ->
ManifestSchemaVersion ->
Either ManifestUpgradeError [ManifestUpgradeStep]
planManifestUpgrade = planUpgradeWith manifestUpgradeSteps currentManifestVersion
-- | 'planManifestUpgrade' over an explicit step table and current version,
-- so the gap check itself can be tested.
planUpgradeWith ::
[ManifestUpgradeStep] ->
ManifestSchemaVersion ->
ManifestSchemaVersion ->
ManifestSchemaVersion ->
Either ManifestUpgradeError [ManifestUpgradeStep]
planUpgradeWith steps current source target
| source > current = Left (SourceNewerThanBinary source)
| source < oldestUpgradableManifestVersion = Left (SourceVersionUnsupported source)
| target > current = Left (TargetAboveCurrent target)
| target < source = Left (TargetBelowSource source target)
| otherwise = walk source
where
walk version
| version == target = Right []
| otherwise = case [candidate | candidate <- steps, candidate ^. #fromVersion == version] of
[found]
| found ^. #toVersion == nextVersion version -> (found :) <$> walk (nextVersion version)
_ -> Left (MissingUpgradeStep version)
nextVersion (ManifestSchemaVersion v) = ManifestSchemaVersion (v + 1)
-- | Read a raw document's top-level @version@.
documentSchemaVersion :: Aeson.Value -> Either ManifestUpgradeError ManifestSchemaVersion
documentSchemaVersion (Aeson.Object fields) = case KeyMap.lookup "version" fields of
Just value | Aeson.Success v <- Aeson.fromJSON value -> Right (ManifestSchemaVersion v)
Just _ -> Left (UnreadableSchemaVersion "the \"version\" field is not an integer")
Nothing -> Left (UnreadableSchemaVersion "the manifest has no \"version\" field")
documentSchemaVersion _ = Left (UnreadableSchemaVersion "the manifest is not a JSON object")
-- | Replace a raw document's top-level @version@, keeping every other key.
setDocumentSchemaVersion :: ManifestSchemaVersion -> Aeson.Value -> Aeson.Value
setDocumentSchemaVersion version (Aeson.Object fields) =
Aeson.Object (KeyMap.insert "version" (Aeson.toJSON (version ^. #unManifestSchemaVersion)) fields)
setDocumentSchemaVersion _ other = other
-- | Run one lossless step. The document must be at the step's source
-- version; the version is advanced only after the transform succeeds.
applyLosslessUpgradeStep :: ManifestUpgradeStep -> Aeson.Value -> Either ManifestUpgradeError Aeson.Value
applyLosslessUpgradeStep upgradeStep document = do
actual <- documentSchemaVersion document
unless (actual == upgradeStep ^. #fromVersion) $
Left (StepVersionMismatch upgradeStep actual)
transformed <- case upgradeStep ^. #action of
StampVersion -> Right document
AddEmptyCheckoutOutputs -> case document of
Aeson.Object fields -> case KeyMap.lookup "checkoutOutputs" fields of
Nothing -> Right (Aeson.Object (KeyMap.insert "checkoutOutputs" (Aeson.object []) fields))
Just (Aeson.Object _) -> Right document
_ -> Left (MalformedDocument actual "checkoutOutputs must be an object")
_ -> Left (MalformedDocument actual "manifest must be an object")
AddEmptySeedReceipts -> case document of
Aeson.Object fields -> case KeyMap.lookup "seeds" fields of
Nothing -> Right (Aeson.Object (KeyMap.insert "seeds" (Aeson.object []) fields))
Just (Aeson.Object _) -> Right document
_ -> Left (MalformedDocument actual "seeds must be an object")
_ -> Left (MalformedDocument actual "cannot add empty seeds: field already exists or document is not an object")
MakeSharedWriteEvidenceExplicit ->
first (MalformedDocument actual) (makeSharedWriteEvidenceExplicit document)
ConvertMachineLocalPaths -> Left (StepRequiresInference upgradeStep)
pure (setDocumentSchemaVersion (upgradeStep ^. #toVersion) transformed)
-- | Upgrade a raw document to @target@ using lossless steps only. Stops with
-- 'StepRequiresInference' before changing anything if the path crosses an
-- inference-bearing step. Returns the steps that ran with the result.
upgradeDocumentLosslessly ::
ManifestSchemaVersion ->
Aeson.Value ->
Either ManifestUpgradeError ([ManifestUpgradeStep], Aeson.Value)
upgradeDocumentLosslessly target document = do
source <- documentSchemaVersion document
steps <- planManifestUpgrade source target
case [s | s <- steps, upgradeStepKind (s ^. #action) == InferenceBearingUpgrade] of
blocked : _ -> Left (StepRequiresInference blocked)
[] -> (steps,) <$> foldM (flip applyLosslessUpgradeStep) document steps
-- | The schema 6 to 7 transform. Every file record gains a required
-- @sharedWriteMode@: an explicit @additiveOnly: true@ becomes
-- @additive-only@, and anything else becomes @unknown@, because schema 6
-- omitted @false@ and absence therefore proves nothing. The old key is
-- removed; every other member at every level is kept.
makeSharedWriteEvidenceExplicit :: Aeson.Value -> Either Text Aeson.Value
makeSharedWriteEvidenceExplicit (Aeson.Object fields) = case KeyMap.lookup "files" fields of
Just (Aeson.Object records) -> do
converted <- KeyMap.traverseWithKey convertRecord records
Right (Aeson.Object (KeyMap.insert "files" (Aeson.Object converted) fields))
Just _ -> Left "\"files\" is not an object"
Nothing -> Left "the manifest has no \"files\" object"
where
convertRecord path (Aeson.Object record) = do
mode <- case KeyMap.lookup "additiveOnly" record of
Nothing -> Right SharedWriteUnknown
Just (Aeson.Bool True) -> Right SharedWriteAdditiveOnly
Just (Aeson.Bool False) -> Right SharedWriteUnknown
Just _ -> Left ("file record " <> Key.toText path <> ": \"additiveOnly\" is not a Boolean")
Right
( Aeson.Object
( KeyMap.insert "sharedWriteMode" (Aeson.String (sharedWriteModeToText mode)) $
KeyMap.delete "additiveOnly" record
)
)
convertRecord path _ = Left ("file record " <> Key.toText path <> " is not an object")
makeSharedWriteEvidenceExplicit _ = Left "the manifest is not a JSON object"
renderManifestUpgradeError :: ManifestUpgradeError -> Text
renderManifestUpgradeError err = case err of
UnreadableSchemaVersion reason -> "cannot read the manifest schema version: " <> reason
SourceVersionUnsupported v -> "manifest schema " <> showVersion v <> " is older than any schema seihou can upgrade"
SourceNewerThanBinary v ->
"manifest schema "
<> showVersion v
<> " was written by a newer seihou; this build understands up to schema "
<> showVersion currentManifestVersion
TargetAboveCurrent v ->
"schema "
<> showVersion v
<> " is newer than this build supports (up to "
<> showVersion currentManifestVersion
<> ")"
TargetBelowSource source target ->
"cannot upgrade from schema " <> showVersion source <> " to the older schema " <> showVersion target
MissingUpgradeStep v -> "no upgrade step is defined from schema " <> showVersion v
StepRequiresInference s ->
"upgrading from schema "
<> showVersion (s ^. #fromVersion)
<> " to "
<> showVersion (s ^. #toVersion)
<> " ("
<> s ^. #summary
<> ") needs review; run 'seihou manifest upgrade'"
StepVersionMismatch s actual ->
"the "
<> showVersion (s ^. #fromVersion)
<> " -> "
<> showVersion (s ^. #toVersion)
<> " step cannot run on a schema-"
<> showVersion actual
<> " document"
MalformedDocument v reason -> "the schema-" <> showVersion v <> " manifest is malformed: " <> reason
where
showVersion (ManifestSchemaVersion v) = T.pack (show v)