packages feed

keiro-ops-0.22.0.0: src/Keiro/Ops.hs

module Keiro.Ops
  ( main,
    mainWithHooks,
    AppHooks (..),
    AggregateInspectors,
    ProcessManagerInspectors,
    OpsAuditConfig (..),
    emptyAppHooks,
    OpsInvocation (..),
    Command (..),
    opsCommandTree,
    commandParser,
    validateCommand,
    isMutation,
    runCommand,
    runCommandWithHeaderCache,
    runCommandWithReadEncoding,
    runOpsInvocation,
  )
where

import Control.Exception (SomeException, displayException, fromException, try)
import Data.Aeson.Encoding (Encoding)
import Data.ByteString (ByteString)
import Data.Foldable (traverse_)
import Data.Maybe (isJust)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Hasql.Connection.Settings qualified as Settings
import Keiro.Migrations.SchemaCheck (renderSchemaDrift, verifyExpectedSchema)
import Keiro.Ops.Aggregate qualified as Aggregate
import Keiro.Ops.Embed
import Keiro.Ops.Env
import Keiro.Ops.Inbox qualified as Inbox
import Keiro.Ops.Outbox qualified as Outbox
import Keiro.Ops.Pgmq qualified as Pgmq
import Keiro.Ops.ProcessManager qualified as ProcessManager
import Keiro.Ops.Projection qualified as Projection
import Keiro.Ops.Rebuild qualified as Rebuild
import Keiro.Ops.Render
import Keiro.Ops.ReplayAudit qualified as ReplayAudit
import Keiro.Ops.Shard qualified as Shard
import Keiro.Ops.Snapshot qualified as Snapshot
import Keiro.Ops.Stream qualified as Stream
import Keiro.Ops.Timer qualified as Timer
import Keiro.Ops.Workflow qualified as Workflow
import Keiro.Ops.WorkflowHeaderCache (WorkflowHeaderCache, runHeaderCached)
import Kiroku.Store.Connection (defaultConnectionSettings, withStore)
import Options.Applicative
import System.Exit qualified as Exit
import System.IO (stderr)

main :: IO ()
main = mainWithHooks emptyAppHooks

mainWithHooks :: AppHooks -> IO ()
mainWithHooks hooks = do
  invocation <- customExecParser (prefs subparserInline) (opsCommandTree hooks)
  exitCode <- runOpsInvocation hooks invocation
  Exit.exitWith exitCode

runOpsInvocation :: AppHooks -> OpsInvocation -> IO Exit.ExitCode
runOpsInvocation hooks invocation = do
  result <- try (runInvocation hooks invocation)
  case result of
    Left exception ->
      case fromException exception :: Maybe Exit.ExitCode of
        Just exitCode -> pure exitCode
        Nothing -> operationalFailure (Text.pack (displayException (exception :: SomeException)))
    Right exitCode -> pure exitCode

data OpsInvocation = OpsInvocation
  { globalOptions :: !GlobalOptions,
    opsCommand :: !Command
  }

-- | Programmatic front-end boundary. Alternative front ends must verify the
-- live schema, use JSON output mode, and respect 'isMutation'.
data Command
  = ProcessManager ProcessManager.Command
  | Aggregate Aggregate.Command
  | Workflow Workflow.Command
  | Timer Timer.Command
  | Outbox Outbox.Command
  | Inbox Inbox.Command
  | Pgmq Pgmq.Command
  | Projection Projection.Command
  | Shard Shard.Command
  | Snapshot Snapshot.Command
  | Stream Stream.Command
  | ReplayAudit ReplayAudit.Command
  | Rebuild Rebuild.Command

opsCommandTree :: AppHooks -> ParserInfo OpsInvocation
opsCommandTree hooks =
  info
    (invocationParser hooks <**> helper)
    ( fullDesc
        <> progDesc "Inspect and operate a Keiro deployment"
        <> failureCode 2
    )

invocationParser :: AppHooks -> Parser OpsInvocation
invocationParser hooks = OpsInvocation <$> globalOptionsParser <*> commandParser hooks

-- | The authoritative command grammar, including only mounted hooks.
commandParser :: AppHooks -> Parser Command
commandParser hooks =
  hsubparser
    ( command
        "wf"
        ( info
            (Workflow <$> Workflow.commandParserWithResume (isJust hooks.workflowResume))
            (progDesc "Inspect and operate durable workflows")
        )
        <> command
          "timer"
          ( info
              (Timer <$> Timer.commandParserWithDrain (isJust hooks.timerFire))
              (progDesc "Inspect and operate durable timers")
          )
        <> command
          "outbox"
          (info (Outbox <$> Outbox.commandParser) (progDesc "Inspect and operate the transactional outbox"))
        <> command
          "inbox"
          (info (Inbox <$> Inbox.commandParser) (progDesc "Inspect and operate the integration-event inbox"))
        <> command
          "pgmq"
          (info (Pgmq <$> Pgmq.commandParser) (progDesc "Inspect and operate Keiro PGMQ queues"))
        <> command
          "projection"
          (info (Projection <$> Projection.commandParser) (progDesc "Inspect and operate projection dedup state"))
        <> command
          "shard"
          (info (Shard <$> Shard.commandParser) (progDesc "Inspect and operate sharded-subscription ownership"))
        <> command
          "snapshot"
          (info (Snapshot <$> Snapshot.commandParser) (progDesc "Inspect and operate advisory snapshots"))
        <> command
          "stream"
          (info (Stream <$> Stream.commandParser) (progDesc "Inspect and operate Kiroku streams"))
        <> replayAuditCommand
        <> rebuildCommand
        <> aggregateCommand
        <> processManagerCommand
    )
  where
    processManagerCommand = case hooks.processManagers of
      Nothing -> mempty
      Just _ -> command "pm" (info (ProcessManager <$> ProcessManager.commandParser) (progDesc "Inspect mounted process managers"))
    aggregateCommand =
      case hooks.aggregates of
        Nothing -> mempty
        Just _ -> command "aggregate" (info (Aggregate <$> Aggregate.commandParser) (progDesc "Inspect mounted aggregates"))
    replayAuditCommand =
      case hooks.replayAudit of
        Nothing -> mempty
        Just _ ->
          command
            "replay-audit"
            (info (ReplayAudit <$> ReplayAudit.commandParser) (progDesc "Audit candidate-code replay against configured targets"))
    rebuildCommand =
      case hooks.projectionCatalog of
        Nothing -> mempty
        Just _ ->
          command
            "rebuild"
            (info (Rebuild <$> Rebuild.commandParser) (progDesc "Inspect and operate the mounted projection catalog"))

runInvocation :: AppHooks -> OpsInvocation -> IO Exit.ExitCode
runInvocation hooks OpsInvocation {globalOptions, opsCommand} = do
  connectionString <- resolveConnectionString globalOptions.databaseUrl
  verified <- verifyExpectedSchema (Settings.connectionString connectionString)
  case verified of
    Left migrationError ->
      operationalFailure ("schema verification failed: " <> Text.pack (show migrationError))
    Right drifts -> do
      let renderedDrifts = map renderSchemaDrift drifts
      traverse_ (Text.IO.hPutStrLn stderr . ("warning: " <>)) renderedDrifts
      if isMutation opsCommand && not (null drifts) && not globalOptions.allowSchemaDrift
        then
          operationalFailure
            "refusing mutation because the live schema differs from this binary; inspect the warnings or pass --allow-schema-drift"
        else withStore (defaultConnectionSettings connectionString) $ \store -> do
          let env =
                OpsEnv
                  { store,
                    outputMode = globalOptions.outputMode,
                    force = globalOptions.force,
                    schemaDrift = renderedDrifts,
                    allowSchemaDrift = globalOptions.allowSchemaDrift
                  }
          runCommand hooks env opsCommand >>= finishOutcome env

-- | Validate supported command arguments before admission or schema work.
validateCommand :: Command -> Either (Text.Text, Text.Text) ()
validateCommand (ProcessManager command) = ProcessManager.validateCommand command
validateCommand (Aggregate command) = Aggregate.validateCommand command
validateCommand (Workflow command) = Workflow.validateCommand command
validateCommand (Timer command) = Timer.validateCommand command
validateCommand (Projection command) = Projection.validateCommand command
validateCommand _ = Right ()

-- | Authoritative read/write classification for every front end.
isMutation :: Command -> Bool
isMutation = \case
  ProcessManager command -> ProcessManager.isMutation command
  Aggregate command -> Aggregate.isMutation command
  Workflow workflowCommand -> Workflow.isMutation workflowCommand
  Timer timerCommand -> Timer.isMutation timerCommand
  Outbox outboxCommand -> Outbox.isMutation outboxCommand
  Inbox inboxCommand -> Inbox.isMutation inboxCommand
  Pgmq pgmqCommand -> Pgmq.isMutation pgmqCommand
  Projection projectionCommand -> Projection.isMutation projectionCommand
  Shard shardCommand -> Shard.isMutation shardCommand
  Snapshot snapshotCommand -> Snapshot.isMutation snapshotCommand
  Stream streamCommand -> Stream.isMutation streamCommand
  ReplayAudit _ -> False
  Rebuild rebuildCommand -> Rebuild.isMutation rebuildCommand

-- | Reuse only pure header presentation after an exact fresh database check.
-- The optional bytes encode this outcome's successful read result. Other
-- commands use the ordinary authoritative runner without a cache lookup.
runCommandWithHeaderCache :: WorkflowHeaderCache -> AppHooks -> OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString)
runCommandWithHeaderCache cache _ env (Workflow (Workflow.Inspect (Workflow.InspectHeader ref))) = runHeaderCached cache env ref
runCommandWithHeaderCache _ hooks env command = do
  outcome <- runCommand hooks env command
  pure (outcome, Nothing)

-- | Prepare supported direct encodings on demand using the host's bounded
-- encoder. Schema verification and admission remain the front end's job.
-- The callback must return the supplied encoding's exact bytes or refuse size.
-- Existing command/header behavior is delegated without changing its cache.
runCommandWithReadEncoding :: (Encoding -> IO (Maybe ByteString)) -> WorkflowHeaderCache -> AppHooks -> OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString)
runCommandWithReadEncoding encode cache hooks env command = case command of
  ProcessManager ProcessManager.Types | Just catalog <- hooks.processManagers -> do
    let (result, encoding) = ProcessManager.prepareTypes catalog
    encoded <- encode encoding
    pure $ case encoded of
      Nothing -> (InspectionFailed "inspection_result_too_large" "encoded result exceeds the host limit; narrow the read", Nothing)
      Just body -> (Succeeded result, Just body)
  _ -> runCommandWithHeaderCache cache hooks env command

-- | Execute a parsed command after schema verification. Front ends must use
-- JSON mode and enable force only after explicit mutation confirmation.
runCommand :: AppHooks -> OpsEnv -> Command -> IO OpsOutcome
runCommand hooks env = \case
  ProcessManager command -> maybe (pure (Failed "process-manager inspection hook is not mounted")) (\catalog -> ProcessManager.runCommand catalog env command) hooks.processManagers
  Aggregate command -> maybe (pure (Failed "aggregate inspection hook is not mounted")) (\catalog -> Aggregate.runCommand env catalog command) hooks.aggregates
  Workflow workflowCommand -> Workflow.runCommandWithResume hooks.workflowResume env workflowCommand
  Timer timerCommand -> Timer.runCommandWithFire hooks.timerFire env timerCommand
  Outbox outboxCommand -> Outbox.runCommand env outboxCommand
  Inbox inboxCommand -> Inbox.runCommand env inboxCommand
  Pgmq pgmqCommand -> Pgmq.runCommand env pgmqCommand
  Projection projectionCommand -> Projection.runCommand env projectionCommand
  Shard shardCommand -> Shard.runCommand env shardCommand
  Snapshot snapshotCommand -> Snapshot.runCommand env snapshotCommand
  Stream streamCommand -> Stream.runCommand env streamCommand
  ReplayAudit replayAuditCommand ->
    maybe
      (pure (Failed "replay audit hook is not mounted"))
      (\config -> ReplayAudit.runCommand env config replayAuditCommand)
      hooks.replayAudit
  Rebuild rebuildCommand ->
    maybe
      (pure (Failed "projection catalog hook is not mounted"))
      (\operations -> Rebuild.runCommand env operations rebuildCommand)
      hooks.projectionCatalog

finishOutcome :: OpsEnv -> OpsOutcome -> IO Exit.ExitCode
finishOutcome env = \case
  Succeeded result -> renderResult env result >> pure Exit.ExitSuccess
  SucceededWithExit result exitCode -> renderResult env result >> pure exitCode
  PreviewRequired result reinvocation -> do
    renderResult env result
    Text.IO.hPutStrLn stderr ("preview only; re-run with --force: " <> reinvocation)
    pure (Exit.ExitFailure 1)
  Failed message -> operationalFailure message
  InspectionFailed code message -> operationalFailure (code <> ": " <> message)

operationalFailure :: Text.Text -> IO Exit.ExitCode
operationalFailure message = do
  Text.IO.hPutStrLn stderr ("keiro-ops: " <> message)
  pure (Exit.ExitFailure 1)