hinagata-cli-0.1.0.0: src/Hinagata/Cli/Output.hs
module Hinagata.Cli.Output
( CliError (..),
bundleError,
baselineError,
leaseError,
loadError,
cleanupError,
emitError,
emitLoad,
emitPlan,
emitCandidate,
emitCleanupPlan,
emitCleanupResults,
emitPreparation,
emitAcquisition,
emitWith,
withExitCode,
ValidationResult (..),
emitValidation,
)
where
import Data.Aeson ((.=))
import Data.Aeson qualified as Json
import Data.ByteString.Lazy qualified as Lazy
import Data.Text qualified as Text
import Data.Text.IO qualified as TextIO
import Hinagata.Cli.Process (ChildFailure (..), ChildReport (..))
import Hinagata.Connection (connectionDatabase, connectionHost, connectionPort, connectionUser, hostText)
import Hinagata.Fixture.Bundle
import Hinagata.Fixture.Types (fixtureNameText)
import Hinagata.Postgres.Baseline (BaselineError (..), FingerprintComparison (..), PreparationKind (..), PreparationReport (..), fingerprintComponents)
import Hinagata.Postgres.Cleanup (CleanupCandidate (..), CleanupDisposition (..), CleanupError (..), CleanupResult (..))
import Hinagata.Postgres.Error (FixtureLoadContext (..), LoadError (..), loadErrorContext)
import Hinagata.Postgres.Lease (LeaseError (..), LeaseInfo (..), LeaseOutcome (..), LeaseTimings (..), PreparationContext (..), PreparationFailure (..), PreparationFailureKind (..))
import Hinagata.Postgres.Load (LoadReport (..))
import Hinagata.Types (DatabaseName, databaseNameText, portNumber)
import System.Exit (ExitCode (..))
import System.IO qualified as IO
data CliError = CliError
{ phase :: !Text.Text,
code :: !Text.Text,
message :: !Text.Text,
fixture :: !(Maybe Text.Text),
stepIndex :: !(Maybe Int),
sqlState :: !(Maybe Text.Text),
target :: !(Maybe Text.Text),
exitCode :: !Int,
errorRunId :: !(Maybe Text.Text),
errorLeaseId :: !(Maybe Text.Text),
loadContext :: !(Maybe FixtureLoadContext),
errorCleanupFailure :: !(Maybe Text.Text)
}
deriving stock (Eq, Show)
data ValidationResult = ValidationResult
{ scenario :: !Text.Text,
result :: !(Either CliError LeaseTimings)
}
bundleError :: BundleError -> CliError
bundleError problem = case problem of
BundleIo reason -> failure "bundle_io" reason Nothing
MissingFixture name -> failure "fixture_missing" "fixture does not exist" (Just (fixtureNameText name))
UnsafePath path -> failure "fixture_unsafe_path" (Text.pack path) Nothing
InvalidFixtureManifest name _ -> failure "fixture_manifest_invalid" "fixture manifest is invalid" (Just (fixtureNameText name))
InvalidFixtureGraph _ -> failure "fixture_graph_invalid" "fixture graph is invalid" Nothing
SourceChanged path -> failure "fixture_source_changed" (Text.pack path) Nothing
SqlStepTooLarge path -> failure "fixture_sql_too_large" (Text.pack path) Nothing
InvalidSql path _ -> failure "fixture_sql_invalid" (Text.pack path) Nothing
where
failure code message fixture = CliError "fixture-plan" code message fixture Nothing Nothing Nothing 1 Nothing Nothing Nothing Nothing
loadError :: LoadError -> CliError
loadError problem@LoadError {phase, targetIdentity, fixture, stepIndex, cause, sqlState} =
attachFixtureContext (Just (loadErrorContext problem)) $ CliError (Text.pack (show phase)) "load_failed" cause (fixtureNameText <$> fixture) stepIndex sqlState targetIdentity 1 Nothing Nothing Nothing Nothing
-- | Preserve lifecycle identity while adding safe fixture details.
baselineError :: BaselineError -> CliError
baselineError BaselineError {cause, sqlState, fixtureLoadContext} =
attachFixtureContext fixtureLoadContext (CliError "baseline-prepare" "baseline_failed" cause Nothing Nothing sqlState Nothing 1 Nothing Nothing Nothing Nothing)
attachFixtureContext :: Maybe FixtureLoadContext -> CliError -> CliError
attachFixtureContext Nothing problem = problem
attachFixtureContext selected@(Just FixtureLoadContext {fixture, stepIndex, sqlState}) problem =
problem {fixture = fixtureNameText <$> fixture, stepIndex, sqlState, loadContext = selected}
cleanupError :: CleanupError -> CliError
cleanupError CleanupError {cause, sqlState} = CliError "cleanup" "cleanup_failed" cause Nothing Nothing sqlState Nothing 1 Nothing Nothing Nothing Nothing
-- | Common acquisition mapping keeps per-lease failures independent of the
-- command's shared cancellation status.
leaseError :: Text.Text -> Maybe Text.Text -> LeaseError -> CliError
leaseError fallback fixture LeaseError {cause, sqlState, cleanupFailure, preparationContext, fixtureLoadContext} =
attachFixtureContext fixtureLoadContext $
let message = cause <> maybe "" ("; cleanup: " <>) cleanupFailure
in case preparationContext of
Nothing -> CliError fallback "lease_failed" message fixture Nothing sqlState Nothing 1 Nothing Nothing Nothing cleanupFailure
Just PreparationContext {preparationFailure = PreparationFailure {failureKind, releaseSafe}, preparationRunId, preparationLeaseId} ->
let code =
if not releaseSafe
then "clone_preparation_cleanup_failed"
else case failureKind of
PreparationTimeout -> "clone_preparation_timeout"
PreparationCancelled _ -> "clone_preparation_cancelled"
_ -> "clone_preparation_failed"
status = case failureKind of PreparationCancelled signal -> 128 + signal; _ -> 1
in CliError "clone-preparation" code message fixture Nothing sqlState Nothing status (Just preparationRunId) (Just preparationLeaseId) Nothing cleanupFailure
emitCandidate :: Bool -> Maybe CleanupCandidate -> IO ()
emitCandidate jsonSelected found =
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= True, "kind" .= ("inspect" :: Text.Text), "candidate" .= fmap candidateValue found])
else case found of
Nothing -> TextIO.putStrLn "lease not found"
Just CleanupCandidate {allocationId, database, state, disposition} ->
TextIO.putStrLn (allocationId <> " " <> databaseNameText database <> " " <> state <> " " <> dispositionText disposition)
emitCleanupPlan :: Bool -> [CleanupCandidate] -> IO ()
emitCleanupPlan jsonSelected candidates =
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= True, "kind" .= ("cleanup-preview" :: Text.Text), "candidates" .= map candidateValue candidates])
else mapM_ (emitCandidate False . Just) candidates
emitCleanupResults :: Bool -> [CleanupResult] -> IO ()
emitCleanupResults jsonSelected results =
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= all successful results, "kind" .= ("cleanup-apply" :: Text.Text), "results" .= map cleanupResultValue results])
else mapM_ (TextIO.putStrLn . resultText) results
where
successful (Released _) = True
successful (AlreadyReleased _) = True
successful (Preserved _) = True
successful _ = False
candidateValue :: CleanupCandidate -> Json.Value
candidateValue CleanupCandidate {allocationId, leaseId, database, state, disposition} =
Json.object ["allocationId" .= allocationId, "leaseId" .= leaseId, "database" .= databaseNameText database, "state" .= state, "disposition" .= dispositionText disposition]
dispositionText :: CleanupDisposition -> Text.Text
dispositionText = Text.pack . show
cleanupResultValue :: CleanupResult -> Json.Value
cleanupResultValue result = Json.object ["id" .= resultId result, "code" .= resultCode result, "outcome" .= resultText result, "reason" .= resultReason result]
resultCode :: CleanupResult -> Text.Text
resultCode = \case
Released _ -> "released"
AlreadyReleased _ -> "already_released"
Preserved _ -> "preserved"
Skipped _ _ -> "skipped"
Refused _ _ -> "refused"
resultReason :: CleanupResult -> Maybe Text.Text
resultReason = \case
Refused _ reason -> Just reason
_ -> Nothing
resultId :: CleanupResult -> Text.Text
resultId = \case
Released identifier -> identifier
AlreadyReleased identifier -> identifier
Preserved identifier -> identifier
Skipped identifier _ -> identifier
Refused identifier _ -> identifier
resultText :: CleanupResult -> Text.Text
resultText = \case
Released identifier -> "released " <> identifier
AlreadyReleased identifier -> "already released " <> identifier
Preserved identifier -> "preserved " <> identifier
Skipped identifier disposition -> "skipped " <> identifier <> " (" <> dispositionText disposition <> ")"
Refused identifier reason -> "refused " <> identifier <> ": " <> reason
emitValue :: Json.Value -> IO ()
emitValue value = Lazy.putStr (Json.encode value <> "\n")
emitValidation :: Bool -> [ValidationResult] -> IO ()
emitValidation jsonSelected results =
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= all passed results, "kind" .= ("fixture-validate" :: Text.Text), "results" .= map resultValue results])
else mapM_ (\ValidationResult {scenario, result} -> TextIO.putStrLn (scenario <> either (\problem -> " failed: " <> errorText problem) (const " passed") result)) results
where
passed ValidationResult {result} = either (const False) (const True) result
resultValue ValidationResult {scenario, result} =
Json.object ["scenario" .= scenario, "ok" .= either (const False) (const True) result, "error" .= either (Just . validationErrorValue) (const Nothing) result, "timingsMs" .= either (const Nothing) (Just . timingValue) result]
timingValue LeaseTimings {queueWaitMs, catalogMs, generationLockMs, cloneMs, scenarioLoadMs, completionMs} =
Json.object ["queueWait" .= queueWaitMs, "catalog" .= catalogMs, "generationLock" .= generationLockMs, "clone" .= cloneMs, "scenarioLoad" .= scenarioLoadMs, "completion" .= completionMs]
validationErrorValue :: CliError -> Json.Value
validationErrorValue CliError {phase, code, message, fixture, stepIndex, sqlState, target, errorRunId, errorLeaseId, loadContext, errorCleanupFailure} =
Json.object ["phase" .= phase, "code" .= code, "message" .= message, "fixture" .= fixture, "stepIndex" .= stepIndex, "sqlState" .= sqlState, "target" .= target, "runId" .= errorRunId, "leaseId" .= errorLeaseId, "loadContext" .= fmap loadContextValue loadContext, "cleanupFailure" .= errorCleanupFailure]
loadContextValue :: FixtureLoadContext -> Json.Value
loadContextValue FixtureLoadContext {phase, fixture, stepIndex, sqlState, cause, rollbackFailure} =
Json.object ["phase" .= Text.pack (show phase), "fixture" .= fmap fixtureNameText fixture, "stepIndex" .= stepIndex, "sqlState" .= sqlState, "cause" .= cause, "rollbackFailure" .= rollbackFailure]
errorText :: CliError -> Text.Text
errorText CliError {phase, code, message, fixture, stepIndex, loadContext} =
phase
<> ": "
<> code
<> ": "
<> message
<> maybe "" ("; fixture " <>) fixture
<> maybe "" (\index -> "; step " <> Text.pack (show index)) stepIndex
<> maybe "" (\FixtureLoadContext {phase = loadPhase} -> "; load " <> Text.pack (show loadPhase)) loadContext
emitPreparation :: Bool -> PreparationReport -> IO ()
emitPreparation jsonSelected PreparationReport {kind, fingerprint, elapsedMs, comparison} =
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= True, "kind" .= ("baseline-prepare" :: Text.Text), "result" .= Text.pack (show kind), "reason" .= preparationReason kind, "fingerprint" .= fingerprint, "fingerprintCategories" .= map (Text.pack . show) fingerprintComponents, "elapsedMs" .= elapsedMs, "comparison" .= fmap comparisonValue comparison])
else TextIO.putStrLn (Text.pack (show kind) <> " baseline " <> fingerprint <> " in " <> Text.pack (show elapsedMs) <> " ms")
preparationReason :: PreparationKind -> Text.Text
preparationReason Built = "no-matching-ready-generation"
preparationReason Reused = "matching-sealed-generation"
comparisonValue :: FingerprintComparison -> Json.Value
comparisonValue FingerprintComparison {previousFingerprint, changedComponents} =
Json.object ["previousFingerprint" .= previousFingerprint, "changedComponents" .= map (Text.pack . show) changedComponents]
emitAcquisition :: Bool -> LeaseInfo -> LeaseTimings -> IO ()
emitAcquisition jsonSelected LeaseInfo {leaseId, runId, applicationTarget} LeaseTimings {queueWaitMs, catalogMs, generationLockMs, cloneMs, scenarioLoadMs, completionMs} =
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= True, "kind" .= ("db-acquire" :: Text.Text), "leaseId" .= leaseId, "runId" .= runId, "database" .= databaseNameText (connectionDatabase applicationTarget), "host" .= hostText (connectionHost applicationTarget), "port" .= portNumber (connectionPort applicationTarget), "user" .= connectionUser applicationTarget, "timingsMs" .= Json.object ["queueWait" .= queueWaitMs, "catalog" .= catalogMs, "generationLock" .= generationLockMs, "clone" .= cloneMs, "scenarioLoad" .= scenarioLoadMs, "completion" .= completionMs]])
else TextIO.putStrLn ("acquired " <> leaseId <> " " <> databaseNameText (connectionDatabase applicationTarget))
withExitCode :: LeaseOutcome (Either ChildFailure ChildReport) -> Int
withExitCode LeaseOutcome {callbackValue, cleanupDiagnostic} =
let childStatus = case callbackValue of
Left _ -> 1
Right ChildReport {cancelledBy = Just signal} -> 128 + signal
Right ChildReport {timedOut = True} -> 1
Right ChildReport {childExit = Just ExitSuccess, cleanupFailure = Nothing} -> 0
Right ChildReport {childExit = Just (ExitFailure status)} -> if status < 0 then 128 - status else status
Right _ -> 1
in if childStatus == 0 && cleanupDiagnostic /= Nothing then 1 else childStatus
emitWith :: Bool -> LeaseOutcome (Either ChildFailure ChildReport) -> IO ()
emitWith jsonSelected outcome@LeaseOutcome {callbackValue, leaseInfo = LeaseInfo {leaseId, runId}, leaseDisposition, cleanupDiagnostic, timings} = do
let status = withExitCode outcome
childValue = case callbackValue of
Left ChildSpawnFailed -> Json.object ["outcome" .= ("spawn-failed" :: Text.Text)]
Left ChildIdentityUnavailable -> Json.object ["outcome" .= ("identity-unavailable" :: Text.Text)]
Right ChildReport {childExit, timedOut, cancelledBy, cleanupFailure} ->
Json.object ["outcome" .= (if timedOut then "timed-out" else if cancelledBy /= Nothing then "cancelled" else "exited" :: Text.Text), "exitCode" .= fmap exitNumber childExit, "signal" .= cancelledBy, "processCleanup" .= cleanupFailure]
LeaseTimings {queueWaitMs, catalogMs, generationLockMs, cloneMs, scenarioLoadMs, completionMs} = timings
cleanupValue = fmap (\LeaseError {cause, sqlState, cleanupFailure} -> Json.object ["message" .= cause, "sqlState" .= sqlState, "detail" .= cleanupFailure]) cleanupDiagnostic
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= (status == 0), "kind" .= ("db-with" :: Text.Text), "leaseId" .= leaseId, "runId" .= runId, "disposition" .= Text.pack (show leaseDisposition), "status" .= status, "child" .= childValue, "databaseCleanup" .= cleanupValue, "timingsMs" .= Json.object ["queueWait" .= queueWaitMs, "catalog" .= catalogMs, "generationLock" .= generationLockMs, "clone" .= cloneMs, "scenarioLoad" .= scenarioLoadMs, "completion" .= completionMs]])
else TextIO.putStrLn ("lease " <> leaseId <> " " <> Text.pack (show leaseDisposition) <> "; command status " <> Text.pack (show status))
if status /= 0 then TextIO.hPutStrLn IO.stderr ("db with: command status " <> Text.pack (show status) <> "; lease " <> leaseId <> " " <> Text.pack (show leaseDisposition)) else pure ()
where
exitNumber ExitSuccess = 0 :: Int
exitNumber (ExitFailure status) = if status < 0 then 128 - status else status
emitError :: Bool -> CliError -> IO ()
emitError jsonSelected failure = do
TextIO.hPutStrLn IO.stderr (errorText failure)
if jsonSelected
then emitValue (Json.object ["formatVersion" .= (1 :: Int), "ok" .= False, "error" .= validationErrorValue failure])
else pure ()
emitLoad :: Bool -> DatabaseName -> LoadReport -> IO ()
emitLoad jsonSelected database LoadReport {fixtureCount, stepCount, bytesSent, verifyMs, setupMs, sqlMs, copyMs, commitMs, elapsedMs} =
if jsonSelected
then
Lazy.putStr
( Json.encode
( Json.object
[ "formatVersion" .= (1 :: Int),
"ok" .= True,
"kind" .= ("fixture-load" :: Text.Text),
"database" .= databaseNameText database,
"fixtureCount" .= fixtureCount,
"stepCount" .= stepCount,
"bytesSent" .= bytesSent,
"timingsMs" .= Json.object ["verify" .= verifyMs, "setup" .= setupMs, "sql" .= sqlMs, "copy" .= copyMs, "commit" .= commitMs, "elapsed" .= elapsedMs]
]
)
<> "\n"
)
else TextIO.putStrLn ("loaded " <> Text.pack (show fixtureCount) <> " fixtures into " <> databaseNameText database)
emitPlan :: Bool -> FixturePlan -> IO ()
emitPlan jsonSelected plan =
if jsonSelected
then Lazy.putStr (Json.encode planValue <> "\n")
else do
TextIO.putStrLn ("plan " <> planDigest plan)
mapM_ (TextIO.putStrLn . ("fixture " <>) . fixtureNameText . name) (planFixtures plan)
where
planValue =
Json.object
[ "formatVersion" .= (1 :: Int),
"ok" .= True,
"kind" .= ("fixture-plan" :: Text.Text),
"digest" .= planDigest plan,
"fixtures" .= map fixtureValue (planFixtures plan)
]
fixtureValue :: CapturedFixture -> Json.Value
fixtureValue CapturedFixture {name, includes, steps, identity} =
Json.object
[ "name" .= fixtureNameText name,
"includes" .= map fixtureNameText includes,
"identity" .= identity,
"steps" .= map stepValue steps
]
stepValue :: CapturedStep -> Json.Value
stepValue CapturedSql {relativePath, contentDigest, byteLength} =
Json.object ["type" .= ("sql" :: Text.Text), "path" .= relativePath, "digest" .= contentDigest, "bytes" .= byteLength]
stepValue CapturedCopy {relativePath, contentDigest, byteLength} =
Json.object ["type" .= ("copy" :: Text.Text), "path" .= relativePath, "digest" .= contentDigest, "bytes" .= byteLength]