packages feed

hercules-ci-cli-0.3.9: src/Hercules/CLI/Effect.hs

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoFieldSelectors #-}

module Hercules.CLI.Effect (commandParser) where

import qualified Data.Aeson as A
import qualified Data.ByteString as BS
import Data.Has (Has)
import qualified Data.Map as M
import qualified Data.Text as T
import qualified Hercules.API.Agent.Evaluate.EvaluateEvent.AttributeEffectEvent as AttributeEffectEvent
import Hercules.API.Attribute (attributePathFromString, attributePathToString)
import Hercules.API.Id (Id (Id, idUUID))
import qualified Hercules.API.Projects as Projects
import qualified Hercules.API.Projects.CreateUserEffectTokenResponse as CreateUserEffectTokenResponse
import Hercules.Agent.NixFile (getHerculesCI, getVirtualValueByPath, homeExprRawValue, loadNixFile, resolveAndInvokeOutputs)
import qualified Hercules.Agent.NixFile.GitSource as GitSource
import qualified Hercules.Agent.NixFile.HerculesCIArgs as HerculesCIArgs
import Hercules.Agent.Sensitive (Sensitive (Sensitive))
import Hercules.CLI.Client (HerculesClientEnv, HerculesClientToken, determineDefaultApiBaseUrl, projectsClient, retryOnFail)
import Hercules.CLI.Common (runAuthenticatedOrDummy)
import Hercules.CLI.Exception (exitMsg)
import Hercules.CLI.Git (getAllBranches, getHypotheticalRefs)
import qualified Hercules.CLI.Git as Git
import Hercules.CLI.JSON (askPasswordWithKey)
import Hercules.CLI.Nix (ciNixAttributeCompleter, computeRef, createHerculesCIArgs, resolveInputs, withNix)
import Hercules.CLI.Options (flatCompleter, mkCommand, subparser)
import Hercules.CLI.Project (ProjectPath, getProjectIdAndPath, getProjectPath, projectOption, projectPathOwner, projectPathProject, projectPathText)
import Hercules.CLI.Secret (getSecretsFilePath)
import Hercules.CNix (Store)
import Hercules.CNix.Expr (EvalState, Match (IsAttrs), RawValue, Value (rtValue), getAttr, getAttrBool, getAttrs, getDrvFile, isDerivation, match, match')
import Hercules.CNix.Expr.Schema (PSObject (value), dictionaryToMap, (#?))
import qualified Hercules.CNix.Std.Vector as Std.Vector
import Hercules.CNix.Store (Derivation, buildPaths, getDerivationInputs, newStorePathWithOutputs)
import qualified Hercules.CNix.Store as CNix
import Hercules.Effect (RunEffectParams (..), parseDrvSecretsMap, runEffect)
import Hercules.Error (escalate)
import qualified Hercules.Secrets as Secret
import Katip (initLogEnv, runKatipContextT)
import Options.Applicative (completer, help, long, metavar, strArgument, strOption)
import qualified Options.Applicative as Optparse
import Protolude hiding (evalState, wait, withAsync)
import RIO (RIO, askUnliftIO)
import UnliftIO.Async (wait, withAsync)
import UnliftIO.Directory (createDirectoryIfMissing, getAppUserDataDirectory)
import UnliftIO.Temporary (withTempDirectory)

commandParser :: Optparse.Parser (IO ())
commandParser =
  subparser
    ( mkCommand
        "run"
        (Optparse.progDesc "Run an effect")
        runParser
        <> mkCommand
          "eval"
          (Optparse.progDesc "Evaluate an effect and print its derivation path")
          evalParser
        <> mkCommand
          "list"
          (Optparse.progDesc "List all effects in the configuration")
          listParser
    )

-- Options for CI context (project, ref, token)
data CIContextOptions = CIContextOptions
  { projectPath :: Maybe ProjectPath,
    ref :: Maybe Text,
    requireToken :: Bool
  }

ciContextOptionsParser :: Optparse.Parser CIContextOptions
ciContextOptionsParser = do
  projectPath' <- optional projectOption
  ref' <- asRefOptions
  requireToken' <- Optparse.flag True False (long "no-token" <> help "Don't get an API token. Disallows access to state files, but can run in untrusted environment or unconfigured repo.")
  pure $ CIContextOptions {projectPath = projectPath', ref = ref', requireToken = requireToken'}

runParser :: Optparse.Parser (IO ())
runParser = do
  attr <- ciAttributeArgument
  opts <- ciContextOptionsParser
  pure $ runAuthenticatedOrDummy opts.requireToken do
    -- Check if it's a direct derivation path
    isDirectPath <- withNix \store _evalState -> do
      storeDir <- liftIO $ CNix.storeDir store
      pure $ decodeUtf8With lenientDecode storeDir `T.isPrefixOf` attr

    if isDirectPath
      then do
        -- Direct derivation path - handle specially
        withNix \store _evalState -> do
          ref <- liftIO $ computeRef opts.ref
          derivation <- liftIO $ do
            -- Support derivation in arbitrary location
            -- Used in hercules-ci-effects test runner
            contents <- BS.readFile $ toS attr
            let stripDrv s = fromMaybe s (T.stripSuffix ".drv" s)
            CNix.getDerivationFromString store (attr & T.takeWhileEnd (/= '/') & stripDrv & encodeUtf8) contents
          prepareDerivation store derivation
          runEffectImpl store derivation ref opts
      else do
        -- Normal effect attribute - use withEffectDerivation
        withEffectDerivation attr opts $ \store _evalState drvPath ref -> do
          -- Get the full derivation from the path
          derivation <- liftIO $ CNix.getDerivation store drvPath
          prepareDerivation store derivation
          runEffectImpl store derivation ref opts

-- Execute an effect derivation
runEffectImpl :: Store -> Derivation -> Text -> CIContextOptions -> RIO (HerculesClientToken, HerculesClientEnv) ()
runEffectImpl _store derivation ref opts = do
  let getProjectInfo =
        case opts.projectPath of
          Just x
            | not opts.requireToken ->
                pure
                  ProjectData
                    { pdProjectPath = Just x,
                      pdProjectId = Nothing,
                      pdToken = Nothing
                    }
          _ -> getProjectEffectData opts.projectPath opts.requireToken
  withAsync getProjectInfo \projectPathAsync -> do
    isDefaultBranch <-
      if opts.requireToken
        then liftIO Git.getIsDefault
        else pure True

    drvEnv <- liftIO $ CNix.getDerivationEnv derivation
    secretsMap <- case parseDrvSecretsMap drvEnv of
      Left e -> exitMsg e
      Right r -> pure r
    serverSecrets <- loadServerSecrets secretsMap

    apiBaseURL <- liftIO determineDefaultApiBaseUrl
    ProjectData {pdProjectPath = projectPath, pdProjectId = projectId, pdToken = token} <- wait projectPathAsync
    secretsJson <- liftIO $ traverse getSecretsFilePath projectPath

    logEnv <- liftIO $ initLogEnv mempty "hci"
    -- withSystemTempDirectory "hci":
    --     ERRO[0000] container_linux.go:370: starting container process caused: process_linux.go:459: container init caused: rootfs_linux.go:59: mounting "/run/user/1000/hci6017/secrets" to rootfs at "/run/user/1000/hci6017/runc-state/rootfs/secrets" caused: operation not permitted
    dataDir <- liftIO $ getAppUserDataDirectory "hercules-ci"
    createDirectoryIfMissing True dataDir
    let secretContextMaybe =
          projectPath <&> \p ->
            Secret.SecretContext
              { ownerName = projectPathOwner p,
                repoName = projectPathProject p,
                isDefaultBranch = isDefaultBranch,
                ref = ref
              }
    exitCode <- withTempDirectory dataDir "tmp-effect-" \workDir -> do
      runKatipContextT logEnv () mempty $
        runEffect
          RunEffectParams
            { runEffectDerivation = derivation,
              runEffectToken = token,
              runEffectSecretsConfigPath = secretsJson,
              runEffectServerSecrets = serverSecrets,
              runEffectApiBaseURL = apiBaseURL,
              runEffectDir = workDir,
              runEffectProjectId = projectId,
              runEffectProjectPath = projectPathText <$> projectPath,
              runEffectSecretContext = secretContextMaybe,
              runEffectUseNixDaemonProxy = False, -- FIXME Enable proxy for ci/dev parity. Requires access to agent binaries. Unified executable?
              runEffectExtraNixOptions = [],
              runEffectFriendly = True,
              runEffectConfiguredMountables = mempty -- FIXME: provide hosts?
            }
    throwIO exitCode

loadServerSecrets :: Map Text AttributeEffectEvent.SecretRef -> RIO r (Sensitive (Map Text (Map Text A.Value)))
loadServerSecrets secrets = secrets & M.traverseMaybeWithKey loadServerSecret <&> Sensitive

loadServerSecret :: Text -> AttributeEffectEvent.SecretRef -> RIO r (Maybe (Map Text A.Value))
loadServerSecret name sr = case sr of
  AttributeEffectEvent.SimpleSecret _ -> pure Nothing
  AttributeEffectEvent.GitToken gitToken -> loadGitToken name gitToken

loadGitToken :: Text -> AttributeEffectEvent.GitToken -> RIO r (Maybe (Map Text A.Value))
loadGitToken name _noDetail = do
  -- TODO: read gh hosts.yaml file?
  token <- liftIO $ askPasswordWithKey (Just name) "token"
  purer $
    M.fromList
      [token <&> A.String]

-- Higher-order function to evaluate effect and pass its path to a callback
withEffectDerivation :: Text -> CIContextOptions -> (Store -> Ptr EvalState -> CNix.StorePath -> Text -> RIO (HerculesClientToken, HerculesClientEnv) a) -> RIO (HerculesClientToken, HerculesClientEnv) a
withEffectDerivation attr opts callback = do
  withNix \store evalState -> do
    ref <- liftIO $ computeRef opts.ref
    storeDir <- liftIO $ CNix.storeDir store
    drvPath <-
      if decodeUtf8With lenientDecode storeDir `T.isPrefixOf` attr
        then do
          -- Support derivation in arbitrary location
          -- Used in hercules-ci-effects test runner
          liftIO $ CNix.parseStorePath store (encodeUtf8 attr)
        else evaluateEffectPath evalState opts.projectPath ref attr
    callback store evalState drvPath ref

evalParser :: Optparse.Parser (IO ())
evalParser = do
  attr <- ciAttributeArgument
  opts <- ciContextOptionsParser
  pure $ runAuthenticatedOrDummy opts.requireToken do
    withEffectDerivation attr opts $ \store _evalState drvPath _ref -> do
      -- For eval, just print the derivation path
      drvPathBS <- liftIO $ CNix.storePathToPath store drvPath
      liftIO $ putStrLn $ decodeUtf8With lenientDecode drvPathBS

listParser :: Optparse.Parser (IO ())
listParser = do
  opts <- ciContextOptionsParser
  pure $ runAuthenticatedOrDummy opts.requireToken do
    withNix \_store evalState -> do
      ref <- liftIO $ computeRef opts.ref
      projectPath <- getProjectPath opts.projectPath
      args <- liftIO $ createHerculesCIArgs (Just ref) (Just projectPath)
      let nixFile = GitSource.outPath $ HerculesCIArgs.primaryRepo args
      homeExprEither <- liftIO $ loadNixFile evalState (toS nixFile) (HerculesCIArgs.primaryRepo args)
      homeExpr <- case homeExprEither of
        Left err -> exitMsg $ toS err
        Right h -> pure h

      uio <- askUnliftIO
      let resolveInputsFn = liftIO . resolveInputs uio evalState (Just projectPath)

      flip runReaderT evalState do
        herculesCIMaybe <- getHerculesCI homeExpr args
        case herculesCIMaybe of
          Nothing -> do
            -- Traditional format: walk the root directly for effects
            effects <- liftIO $ walkEffects evalState [] (homeExprRawValue homeExpr)
            for_ effects \path ->
              liftIO $ putStrLn $ attributePathToString path
          Just hci -> do
            -- Modern format: enumerate jobs using schema types
            -- Effects are only allowed in the "effects" attribute
            let listJobs handlerName handler = do
                  jobs <- dictionaryToMap handler
                  for_ (M.toList jobs) \(jobNameBS, job) -> do
                    let jobName = decodeUtf8With lenientDecode jobNameBS
                        prefix = [handlerName, jobName, "effects"]
                    outputs <- resolveAndInvokeOutputs job resolveInputsFn
                    effectsAttr <- liftIO $ getEffectsAttr evalState (value outputs)
                    for_ effectsAttr \effectsValue -> do
                      effects <- liftIO $ walkEffects evalState prefix effectsValue
                      for_ effects \path ->
                        liftIO $ putStrLn $ attributePathToString path
            hci #? #onPush >>= traverse_ (listJobs "onPush")
            hci #? #onSchedule >>= traverse_ (listJobs "onSchedule")

-- | Get the "effects" attribute from an attrset, if it exists.
getEffectsAttr :: Ptr EvalState -> RawValue -> IO (Maybe RawValue)
getEffectsAttr evalState rawValue = do
  match' evalState rawValue >>= \case
    IsAttrs attrsValue -> getAttr evalState attrsValue "effects"
    _ -> pure Nothing

-- | Recursively walk the attribute tree and collect effect paths.
walkEffects :: Ptr EvalState -> [Text] -> RawValue -> IO [[Text]]
walkEffects evalState prefix rawValue = do
  match' evalState rawValue >>= \case
    IsAttrs attrsValue -> do
      isDeriv <- isDerivation evalState rawValue
      if isDeriv
        then do
          isEff <- getAttrBool evalState attrsValue "isEffect"
          case isEff of
            Right (Just True) -> pure [prefix]
            _ -> pure []
        else do
          attrs <- getAttrs evalState attrsValue
          fmap concat $ for (M.toList attrs) \(name, childValue) ->
            walkEffects evalState (prefix ++ [decodeUtf8With lenientDecode name]) childValue
    _ -> pure []

-- | Evaluate a herculesCI attribute path.
-- Returns the value at the path (if found) and the nix file path for error messages.
evalHerculesCIAttr ::
  (Has HerculesClientToken r, Has HerculesClientEnv r) =>
  Ptr EvalState ->
  Maybe ProjectPath ->
  Text ->
  [Text] ->
  RIO r (Maybe RawValue, Text)
evalHerculesCIAttr evalState projectOptionMaybe ref attrPath = do
  projectPath <- getProjectPath projectOptionMaybe
  args <- liftIO $ createHerculesCIArgs (Just ref) (Just projectPath)
  let nixFile = GitSource.outPath $ HerculesCIArgs.primaryRepo args
  uio <- askUnliftIO
  valMaybe <- liftIO $ getVirtualValueByPath evalState (toS nixFile) args (resolveInputs uio evalState (Just projectPath)) (map encodeUtf8 attrPath)
  pure (valMaybe, nixFile)

-- | Evaluate an effect attribute and get its derivation path.
evaluateEffectPath :: (Has HerculesClientToken r, Has HerculesClientEnv r) => Ptr EvalState -> Maybe ProjectPath -> Text -> Text -> RIO r CNix.StorePath
evaluateEffectPath evalState projectOptionMaybe ref attr = do
  let attrPath = attributePathFromString attr
  (valMaybe, nixFile) <- evalHerculesCIAttr evalState projectOptionMaybe ref attrPath
  attrValue <- case valMaybe of
    Nothing -> exitMsg $ "Could not find an attribute at path " <> show attrPath <> " in " <> nixFile
    Just v -> liftIO (match evalState v) >>= escalate
  effectAttrs <- case attrValue of
    IsAttrs attrs -> pure attrs
    _ -> exitMsg $ "Attribute is not an Effect at path " <> show attrPath <> " in " <> nixFile
  isEffect <- liftIO $ getAttrBool evalState effectAttrs "isEffect" >>= escalate
  when (isEffect /= Just True) do
    exitMsg $ "Attribute is not an Effect at path " <> show attrPath <> " in " <> nixFile
  getDrvFile evalState (rtValue effectAttrs)

prepareDerivation :: (MonadIO m) => Store -> Derivation -> m ()
prepareDerivation store derivation = do
  inputs <- liftIO $ getDerivationInputs store derivation
  storePathsWithOutputs <- liftIO Std.Vector.new
  liftIO $ for_ inputs \(input, outputs) -> do
    swo <- newStorePathWithOutputs input outputs
    Std.Vector.pushBackFP storePathsWithOutputs swo
  liftIO $ buildPaths store storePathsWithOutputs

ciAttributeArgument :: Optparse.Parser Text
ciAttributeArgument =
  strArgument $
    metavar "CI_NIX_ATTRIBUTE"
      <> help "Attribute to run"
      <> completer ciNixAttributeCompleter

asBranchOption :: Optparse.Parser Text
asBranchOption =
  strOption (long "pretend-branch" <> metavar "BRANCH" <> help "Pretend we're on another git branch" <> completer (flatCompleter getAllBranches))
    <|> strOption (long "as-branch" <> metavar "BRANCH" <> help "Alias for --pretend-branch")

asRefOption :: Optparse.Parser Text
asRefOption =
  strOption (long "pretend-ref" <> metavar "REF" <> help "Pretend we're on another git ref" <> completer (flatCompleter getHypotheticalRefs))
    <|> strOption (long "as-ref" <> metavar "REF" <> help "Alias for --pretend-ref")

asRefOptions :: Optparse.Parser (Maybe Text)
asRefOptions = optional (asRefOption <|> (("refs/heads/" <>) <$> asBranchOption))

data ProjectData = ProjectData
  { pdProjectPath :: Maybe ProjectPath,
    pdProjectId :: Maybe (Id "project"),
    pdToken :: Maybe (Sensitive Text)
  }

getProjectEffectData :: (Has HerculesClientToken r, Has HerculesClientEnv r) => Maybe ProjectPath -> Bool -> RIO r ProjectData
getProjectEffectData maybeProjectPathParam requireToken = do
  (projectIdMaybe, path) <- getProjectIdAndPath maybeProjectPathParam
  if requireToken
    then do
      projectId <- case projectIdMaybe of
        Just x -> pure x
        Nothing -> do
          exitMsg $ "Can not access " <> show path <> ". Make sure you have installed Hercules CI on the organization and repository and that you have access to it."
      response <- retryOnFail "create user effect token" (Projects.createUserEffectToken projectsClient projectId)
      let token = Sensitive (CreateUserEffectTokenResponse.token response)
      pure ProjectData {pdProjectPath = Just path, pdProjectId = Just $ Id $ idUUID projectId, pdToken = Just token}
    else
      pure
        ProjectData
          { pdProjectPath = Nothing,
            pdProjectId = Nothing,
            pdToken = Nothing
          }