packages feed

hinagata-cli-0.1.0.0: src/Hinagata/Cli/Command.hs

-- | CLI assembly of frozen plans and executable hooks; SQL stays in the library.
module Hinagata.Cli.Command
  ( prepareBaseline,
    compileScenario,
    prepareBaselineWithRunner,
  )
where

import Control.Exception (Handler (..), IOException, catches, mask_, throwIO, try)
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Text qualified as Text
import Hinagata.Cli.Config (CliSettings (..), HookSettings (..), validateHook)
import Hinagata.Cli.Output (CliError (..), baselineError, bundleError)
import Hinagata.Cli.Process (CancellationScope, ChildCleanupException (..), ChildFailure (..), ChildReport (..), ChildSpec (..), CommandCancelled (..), checkCancellation, parseOverlay, runChild, withCooperativeCancellation)
import Hinagata.Cli.Revision (captureHookRevision, effectiveRevision, verifyHookRevisions)
import Hinagata.Config (HinagataConfig (..))
import Hinagata.Connection (ConnectionTarget)
import Hinagata.Fixture.Bundle (BundleConfig (..), FixturePlan, compileFixtures)
import Hinagata.Fixture.Types (mkFixtureName)
import Hinagata.Postgres.Baseline (BaselineRef, BaselineSpec (..), PreparationReport, ensureBaseline)
import Hinagata.Postgres.Lease (PreparationFailure (..), PreparationFailureKind (..))
import System.Directory (getCurrentDirectory)
import System.Exit (ExitCode (..))

prepareBaseline :: CancellationScope -> HinagataConfig -> CliSettings -> IO (Either CliError (BaselineRef, PreparationReport))
prepareBaseline = prepareBaselineWithRunner runChild

-- | Internal runner injection for exercising supervision failure at the actual
-- library/CLI boundary without a public configuration bypass.
prepareBaselineWithRunner :: (CancellationScope -> ChildSpec -> IO (Either ChildFailure ChildReport)) -> CancellationScope -> HinagataConfig -> CliSettings -> IO (Either CliError (BaselineRef, PreparationReport))
prepareBaselineWithRunner runner scope configuration settings = do
  checkCancellation scope
  case traverse (uncurry validateHook) [("migration", migration settings), ("verification", verification settings), ("clone.preparation", clonePreparation settings)] of
    Left problem -> pure (Left (CliError "config-resolution" "config_resolution_invalid" problem Nothing Nothing Nothing Nothing 4 Nothing Nothing Nothing Nothing))
    Right _ -> do
      invocation <- getCurrentDirectory
      migrationIdentity <- captureHookRevision invocation "migration" (migration settings)
      verificationIdentity <- captureHookRevision invocation "verification" (verification settings)
      case (migrationIdentity, verificationIdentity) of
        (Left problem, _) -> pure (Left problem)
        (_, Left problem) -> pure (Left problem)
        (Right migrationCaptured, Right verificationCaptured) -> do
          let identities = [migrationCaptured, verificationCaptured]
          revisionFailure <- newIORef Nothing
          let checked hook target = do
                before <- verifyHookRevisions identities
                case before of
                  Left problem -> writeIORef revisionFailure (Just problem) >> pure (Left "hook revision inputs changed")
                  Right () -> do
                    outcome <- hook target
                    -- Preserve an original command or cleanup failure.
                    case outcome of
                      Left _ -> pure outcome
                      Right () -> do
                        after <- verifyHookRevisions identities
                        case after of
                          Left problem -> writeIORef revisionFailure (Just problem) >> pure (Left "hook revision inputs changed")
                          Right () -> pure (Right ())
              migrationHook = checked (hookRunner runner scope "migration" False (migration settings))
              verificationHook = checked (hookRunner runner scope "verification" False (verification settings))
              clonePreparationHook = case clonePreparation settings of
                HookSettings {executable = Nothing} -> Nothing
                _ -> Just (hookRunner runner scope "clone preparation" True (clonePreparation settings))
          compiled <- compileScenario configuration (baseFixtures settings)
          case compiled of
            Left problem -> pure (Left problem)
            Right basePlan -> do
              let specification =
                    BaselineSpec
                      { configuration,
                        basePlan,
                        migrationRevision = effectiveRevision migrationCaptured,
                        verificationRevision = effectiveRevision verificationCaptured,
                        requiredExtensions = [],
                        requiredLocale = Nothing,
                        migrationHook,
                        verificationHook,
                        clonePreparationHook,
                        compareAgainst = Nothing
                      }
              prepared <- ensureBaseline specification
              specific <- readIORef revisionFailure
              case prepared of
                Left problem -> pure (Left (maybe (baselineError problem) id specific))
                Right value -> do
                  final <- verifyHookRevisions identities
                  pure (value <$ final)

compileScenario :: HinagataConfig -> [Text.Text] -> IO (Either CliError FixturePlan)
compileScenario HinagataConfig {fixtureRoot, bundleRoot, sqlSizeLimit, chunkSize} roots =
  case traverse mkFixtureName roots of
    Left problem -> pure (Left (CliError "options" "fixture_name_invalid" problem Nothing Nothing Nothing Nothing 2 Nothing Nothing Nothing Nothing))
    Right names -> do
      compiled <- compileFixtures (BundleConfig fixtureRoot bundleRoot sqlSizeLimit chunkSize) names
      pure (either (Left . bundleError) Right compiled)

-- Keep the supervision outcome and its release-safety classification masked
-- together. Blocking process waits remain interruptible.
hookRunner :: (CancellationScope -> ChildSpec -> IO (Either ChildFailure ChildReport)) -> CancellationScope -> Text.Text -> Bool -> HookSettings -> ConnectionTarget -> IO (Either Text.Text ())
hookRunner runner scope name preparation HookSettings {executable, arguments, workingDirectory, environment, deadlineMs} target = case executable of
  Nothing -> checkCancellation scope >> pure (Right ())
  Just program -> do
    result <- withCooperativeCancellation scope $ mask_ $ runProgram program
    case result of
      Right () -> checkCancellation scope >> pure (Right ())
      Left problem -> pure (Left problem)
  where
    runProgram program = do
      let failWith kind safe =
            if preparation then throwIO (PreparationFailure kind safe) else pure (Left (name <> " hook failed: " <> Text.pack (show kind)))
          run = case parseOverlay environment of
            Left _ -> failWith PreparationSpawnFailed True
            Right overlay -> do
              attempted <-
                try @IOException $
                  runner
                    scope
                    ChildSpec
                      { executable = Text.unpack program,
                        arguments = map Text.unpack arguments,
                        workingDirectory = Text.unpack <$> workingDirectory,
                        environment = overlay,
                        target,
                        identifiers = Nothing,
                        deadlineMs = Just deadlineMs,
                        stdoutToStderr = True
                      }
              case attempted of
                Left _ -> failWith PreparationSpawnFailed True
                Right result -> case result of
                  Left ChildSpawnFailed -> failWith PreparationSpawnFailed True
                  Left ChildIdentityUnavailable -> failWith PreparationIdentityUnavailable False
                  Right ChildReport {cleanupFailure = Just _, cancelledBy = Just signal} -> failWith (PreparationCancelled signal) False
                  Right ChildReport {cleanupFailure = Just _, timedOut = True} -> failWith PreparationTimeout False
                  Right ChildReport {cleanupFailure = Just _, childExit = Just (ExitFailure status)} -> failWith (PreparationExit status) False
                  Right ChildReport {cleanupFailure = Just _} -> failWith PreparationCleanupFailed False
                  Right ChildReport {cancelledBy = Just signal} -> failWith (PreparationCancelled signal) True
                  Right ChildReport {timedOut = True} -> failWith PreparationTimeout True
                  Right ChildReport {childExit = Just ExitSuccess} -> checkCancellation scope >> pure (Right ())
                  Right ChildReport {childExit = Just (ExitFailure status)} -> failWith (PreparationExit status) True
                  Right _ -> failWith PreparationSpawnFailed True
      run
        `catches` [ Handler (\ChildCleanupException -> failWith PreparationCleanupFailed False),
                    Handler (\(CommandCancelled signal) -> failWith (PreparationCancelled signal) True)
                  ]