packages feed

heni-0.1.0.0: src/Test/Heni/Resource.hs

{-# LANGUAGE RankNTypes #-}

-- | Consumer-owned resource lifetimes. Trusted callbacks must implement their
-- own external reset semantics; workspace disposal cannot roll back a service.
module Test.Heni.Resource
  ( ResourceScope (..),
    ResourceBinding (..),
    ResourceContext (..),
    ResourceEvent (..),
    ResourceProvider,
    resourceName,
    resourceScope,
    resourceCapacity,
    resourceCommands,
    ResourceLease,
    resourceBinding,
    prepareResource,
    managedResource,
    commandResource,
    withResources,
    validateResources,
    ResourcePhase (..),
    ResourceError (..),
    bindingEnvironment,
  )
where

import Control.Exception
import Control.Monad (unless)
import Data.Char (isAsciiUpper)
import Data.IORef
import Data.List (nub)
import Data.Text (Text)
import Data.Text qualified as Text
import Test.Heni.Case (ConfigError (..))
import Test.Heni.Command
import Test.Heni.Expectation
import Test.Heni.Internal.Files (withFinalizer)
import Test.Heni.Process
import Test.Heni.Workspace

-- | One acquisition for the campaign, each worker, or each claimed case.
data ResourceScope = CampaignScope | WorkerScope | CaseScope deriving stock (Eq, Show)

-- | Nonsecret identities/versions are reportable. Unknown epoch disables reuse;
-- environment values remain in memory and are redacted from portable reports.
data ResourceBinding = ResourceBinding
  {identity :: !Text, version :: !Text, epoch :: !(Maybe Text), environment :: ![(String, String)]}
  deriving stock (Eq, Show)

-- | Owned context for resource hooks and their supervised command artifacts.
data ResourceContext = ResourceContext
  { instanceId :: !Text,
    snapshot :: !SourceSnapshot,
    artifactDirectory :: !FilePath,
    observeResource :: !(ResourceEvent -> IO ())
  }

-- | Facts about completed ownership transitions, with no environment values.
data ResourceEvent = ResourceEvent !Text !Text !ResourcePhase !ResourceBinding | ResourceCommandFinished !Text !Text !ResourcePhase !ProcessReport
  deriving stock (Eq, Show)

-- | Lifecycle failures differ from test assertions.
data ResourcePhase = AcquireResource | ResetResource | ReleaseResource deriving stock (Eq, Show)

-- | Resource name, failed lifecycle phase and original exception.
data ResourceError = ResourceError !Text !ResourcePhase !SomeException
  deriving stock (Show)
  deriving anyclass (Exception)

-- | Opaque lease; only its owner can update the prepared state.
data ResourceLease = ResourceLease !(IO ResourceBinding) !(IO ResourceBinding)

-- | A scoped CPS provider keeps arbitrary consumer values out of JSON/core types.
data ResourceProvider = ResourceProvider
  { -- | Uppercase identity used by claims and environment namespaces.
    resourceName :: !Text,
    -- | Ownership lifetime; each instance is acquired and released once.
    resourceScope :: !ResourceScope,
    -- | Maximum concurrent claims, also bounding worker-scoped acquisition.
    resourceCapacity :: !Int,
    -- | Portable lifecycle commands for preflight; arbitrary providers use [].
    resourceCommands :: ![Command],
    provide :: forall a. ResourceContext -> (ResourceLease -> IO a) -> IO a
  }

-- | Current identity without changing the external fixture.
resourceBinding :: ResourceLease -> IO ResourceBinding
resourceBinding (ResourceLease current _) = current

-- | Reset before both baseline and candidate; a failure invalidates the case.
prepareResource :: ResourceLease -> IO ResourceBinding
prepareResource (ResourceLease _ reset) = reset

-- | Acquire in declaration order and release in reverse order, including partial
-- acquisition, callback failure and cancellation. Cleanup failures retain the
-- primary exception via 'WorkspaceFinalizationError'. No timeout guarantee is
-- possible for arbitrary uninterruptible consumer foreign calls.
managedResource :: Text -> ResourceScope -> Int -> (ResourceContext -> IO (value, ResourceBinding)) -> (value -> IO ResourceBinding) -> (value -> IO ()) -> ResourceProvider
managedResource name scope capacity acquire reset release = ResourceProvider name scope capacity [] $ \context@ResourceContext {instanceId, observeResource} action -> mask $ \restore -> do
  (value, initial) <- phase AcquireResource (restore (acquire context))
  current <- newIORef initial
  let announce p b = observeResource (ResourceEvent name instanceId p b)
      check b = either (throwIO . userError . show) pure (validateBinding name b)
      resetLease = mask $ \unmask -> do
        next <- phase ResetResource (unmask (reset value))
        writeIORef current next
        check next
        announce ResetResource next
        pure next
      dispose = do
        latest <- readIORef current
        phase ReleaseResource (release value)
        announce ReleaseResource latest
  (check initial >> announce AcquireResource initial >> restore (action (ResourceLease (readIORef current) resetLease))) `withFinalizer` dispose
  where
    phase :: ResourcePhase -> IO b -> IO b
    phase step work =
      work `catch` \(e :: SomeException) -> case fromException e :: Maybe SomeAsyncException of
        Just _ -> throwIO e
        Nothing -> throwIO (ResourceError name step e)

-- | Portable supervised setup/reset/teardown. Each provider instance owns a
-- private workspace. Commands have independent declared budgets and logs.
commandResource :: Text -> ResourceScope -> Int -> ResourceBinding -> Command -> Command -> Command -> ResourceProvider
commandResource name scope capacity binding setup reset teardown = ResourceProvider name scope capacity [setup, reset, teardown] $ \context@ResourceContext {snapshot, artifactDirectory, instanceId, observeResource} action ->
  withWorkspace snapshot $ \workspace -> do
    counter <- newIORef (0 :: Int)
    let run phase command = do
          index <- atomicModifyIORef' counter (\n -> (n + 1, n))
          let prefix = "resource-" ++ Text.unpack name ++ "-" ++ Text.unpack instanceId ++ "-" ++ show index
          report@ProcessReport {evidence = StageEvidence {outcome}, errors} <-
            runCommand (defaultProcessOptions artifactDirectory prefix) workspace (if phase == ReleaseResource then Teardown else Setup) (addEnvironment command)
              `catch` \ProcessAborted {report, cause} -> throwIO cause `withFinalizer` observeResource (ResourceCommandFinished name instanceId phase report)
          observeResource (ResourceCommandFinished name instanceId phase report)
          unless (outcome == Exited 0 && null errors) (ioError (userError ("resource command " ++ show phase ++ ": " ++ show outcome ++ " " ++ show errors)))
        addEnvironment (Command e a s w env b o) = Command e a s w (environment ++ filter ((`notElem` map fst environment) . fst) env) b o
        ResourceBinding {environment} = binding
        provider = managedResource name scope capacity (\_ -> run AcquireResource setup >> pure ((), binding)) (\() -> run ResetResource reset >> pure binding) (\() -> run ReleaseResource teardown)
    provide provider context action

-- | Compose ownership; the callback receives named opaque leases.
withResources :: [ResourceProvider] -> ResourceContext -> ([(Text, ResourceLease)] -> IO a) -> IO a
withResources [] _ action = action []
withResources (provider : rest) context action = provide provider context $ \lease ->
  withResources rest context (action . ((resourceName provider, lease) :))

-- | Validate names, capacities and namespaces before any acquisition.
validateResources :: [ResourceProvider] -> Either ConfigError ()
validateResources providers = do
  let names = map resourceName providers
  unless (length names == length (nub names)) (Left (ConfigError "resources" "duplicate resource name"))
  mapM_
    ( \ResourceProvider {resourceName, resourceCapacity} -> do
        unless (validName resourceName) (Left (ConfigError "resources.name" "use uppercase ASCII names with digits/underscores"))
        unless (resourceCapacity > 0) (Left (ConfigError "resources.capacity" "capacity must be positive"))
    )
    providers

validName :: Text -> Bool
validName name = case Text.uncons name of Nothing -> False; Just (first, rest) -> isAsciiUpper first && Text.all (\c -> isAsciiUpper c || (c >= '0' && c <= '9') || c == '_') rest

validateBinding :: Text -> ResourceBinding -> Either ConfigError ()
validateBinding name ResourceBinding {identity, version, epoch, environment} = do
  unless (not (Text.null identity || Text.null version) && maybe True (not . Text.null) epoch) (Left (ConfigError "resource.binding" "identity/version/known epoch must be nonempty"))
  let keys = map fst environment
      prefix = "HENI_RESOURCE_" <> name <> "_"
  unless (length keys == length (nub keys) && all (\key -> prefix `Text.isPrefixOf` Text.pack key && not (any (`elem` ['=', '\0']) key)) keys && all (notElem '\0' . snd) environment) (Left (ConfigError "resource.environment" "duplicate or unnamespaced environment binding"))

-- | Combine bindings only after validation; resource namespaces prevent clashes.
bindingEnvironment :: [(Text, ResourceBinding)] -> Either ConfigError [(String, String)]
bindingEnvironment bindings = mapM_ (uncurry validateBinding) bindings >> pure (concatMap (\(_, ResourceBinding {environment}) -> environment) bindings)