-- SPDX-FileCopyrightText: 2026 Alexandra de Wit
--
-- SPDX-License-Identifier: MIT

{- | Deciding one mirror job: probe the mirror target, re-run current policy, fetch, verify, and
publish. Every step reports its verdict as a 'JobOutcome' value, which
"Ecluse.Core.Worker.Realise" realises at the queue handle.

The receipt is held for the whole job by the lease controller ("Ecluse.Core.Worker.Lease"), so
nothing here touches the queue. Nothing here acks either: a transient failure simply reports
'Retried', and the un-acked message redelivers.
-}
module Ecluse.Core.Worker.Job (
    JobOutcome (..),
    RetryLeg (..),
    mirrorLatest,
    outcomeOfAdmission,
    outcomeOfFetchFault,
    processJob,
) where

import Data.Map.Strict qualified as Map
import Katip (Severity (DebugS, ErrorS, InfoS), katipAddNamespace, logFM, ls)
import UnliftIO (withRunInIO)

import Ecluse.Core.Ecosystem (ecosystemName)
import Ecluse.Core.Fault (tfCause)
import Ecluse.Core.Package (Artifact (artSize), Hash, pkgEcosystem)
import Ecluse.Core.Package.Admission (
    ArtifactAdmission (
        AdmissionAdmit,
        AdmissionBelowFloor,
        AdmissionDenied,
        AdmissionFileAbsent,
        AdmissionIntegrityMissing,
        AdmissionUndecidable
    ),
    admissionTransience,
    admitArtifact,
 )
import Ecluse.Core.Queue (MirrorJob (jobArtifactFilename, jobArtifactUrl, jobPackage, jobTraceContext, jobVersion))
import Ecluse.Core.Registry (
    BodyOutcome (SuccessBody, UnreadStatus),
    FetchFault (FetchBoundExceeded, FetchTransport, FetchUrlUnformable),
    MirrorArtifact (MirrorArtifact, maFilename, maHashes, maSize),
    ParseError (ParseError),
    PublishFault (PublishFetch, PublishRejected, PublishSourceUnavailable),
    renderUrlFormationError,
 )
import Ecluse.Core.Registry.Adapter.Capability (AdapterArtifact (artifactByUrl))
import Ecluse.Core.Registry.CachedDocument (CachedDoc)
import Ecluse.Core.Registry.Metadata (
    VersionDoc (vdDetails, vdRaw),
    VersionEvaluation (VersionMetadataUnavailable, VersionMissing, VersionPresent),
    versionTransience,
 )
import Ecluse.Core.Registry.Publish (
    MirrorPublish (mpProbeMetadata, mpPublishArtifact),
    PublishPlan (PublishPlan, ppLatest, ppMetadata, ppVersion),
 )
import Ecluse.Core.Rules.Types (Decision (Blocked, Undecidable), Transience (WillResolve, WontResolve), mkEvalContext)
import Ecluse.Core.Security (authorityLabel, hostPortAddress)
import Ecluse.Core.Security.Egress (registryUrlText)
import Ecluse.Core.Server.Path (Filename)
import Ecluse.Core.Telemetry.Record (WorkerMetricsPort (..), timedSeconds)
import Ecluse.Core.Telemetry.Span (JobSpanOutcome (JobSpanOutcome), WorkerTracingPort (..))
import Ecluse.Core.Version (Version, selectLatest)
import Ecluse.Core.Worker.Fetch (fetchArtifactBytes)
import Ecluse.Core.Worker.Integrity (IntegrityResult (..), verifyIntegrity)
import Ecluse.Core.Worker.Types

{- | The terminal outcome of processing one mirror job. It decides whether the worker
acks the message or leaves it to redeliver.
-}
data JobOutcome
    = {- | The publish succeeded or the mirror already held the version.
      The worker acknowledges either result, including idempotent redelivery.
      -}
      Succeeded
    | {- | A __non-retryable__ rejection (a tampered artifact, an unformable request URL).
      Redelivery cannot help, so the job is acked to retire it after alarming.
      -}
      Dropped Text
    | {- | The source's own version object was unavailable, so no mirror write can reflect the
      package. Retired like 'Dropped', but reported apart from a policy deny.
      -}
      SourceUnavailable Text
    | {- | A __terminal__ fault handed to 'Ecluse.Core.Queue.deadLetter' rather than acked,
      because a plain delete would silently discard it on a durable queue.
      -}
      DeadLettered Text
    | {- | A __transient__ fault: a fetch failure, or a registry rejection worth retrying. The
      message is left un-acked so it redelivers, carrying the leg it gave up on.
      -}
      Retried RetryLeg Text
    deriving stock (JobOutcome -> JobOutcome -> Bool
(JobOutcome -> JobOutcome -> Bool)
-> (JobOutcome -> JobOutcome -> Bool) -> Eq JobOutcome
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: JobOutcome -> JobOutcome -> Bool
== :: JobOutcome -> JobOutcome -> Bool
$c/= :: JobOutcome -> JobOutcome -> Bool
/= :: JobOutcome -> JobOutcome -> Bool
Eq, Int -> JobOutcome -> ShowS
[JobOutcome] -> ShowS
JobOutcome -> String
(Int -> JobOutcome -> ShowS)
-> (JobOutcome -> String)
-> ([JobOutcome] -> ShowS)
-> Show JobOutcome
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> JobOutcome -> ShowS
showsPrec :: Int -> JobOutcome -> ShowS
$cshow :: JobOutcome -> String
show :: JobOutcome -> String
$cshowList :: [JobOutcome] -> ShowS
showList :: [JobOutcome] -> ShowS
Show)

{- | Which leg a transient failure gave up on. The realisation half reads it to decide whether
to reset the message's visibility, so the two legs cannot be conflated at the queue handle.
-}
data RetryLeg
    = {- | The job gave up before it published: the inventory probe, the re-evaluation, or the
      artifact fetch. The message keeps its lease and redelivers when that window lapses.
      -}
      BeforePublish
    | {- | The publish itself failed transiently, after the bytes were fetched and verified.
      The message is released so its redelivery does not wait out the lease.
      -}
      AfterPublish
    deriving stock (RetryLeg -> RetryLeg -> Bool
(RetryLeg -> RetryLeg -> Bool)
-> (RetryLeg -> RetryLeg -> Bool) -> Eq RetryLeg
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: RetryLeg -> RetryLeg -> Bool
== :: RetryLeg -> RetryLeg -> Bool
$c/= :: RetryLeg -> RetryLeg -> Bool
/= :: RetryLeg -> RetryLeg -> Bool
Eq, Int -> RetryLeg -> ShowS
[RetryLeg] -> ShowS
RetryLeg -> String
(Int -> RetryLeg -> ShowS)
-> (RetryLeg -> String) -> ([RetryLeg] -> ShowS) -> Show RetryLeg
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> RetryLeg -> ShowS
showsPrec :: Int -> RetryLeg -> ShowS
$cshow :: RetryLeg -> String
show :: RetryLeg -> String
$cshowList :: [RetryLeg] -> ShowS
showList :: [RetryLeg] -> ShowS
Show)

-- The failure detail marks the span errored, so only a job that did not publish carries one.
jobSpanOutcome :: JobOutcome -> JobSpanOutcome
jobSpanOutcome :: JobOutcome -> JobSpanOutcome
jobSpanOutcome = \case
    JobOutcome
Succeeded -> Text -> Maybe Text -> JobSpanOutcome
JobSpanOutcome Text
"succeeded" Maybe Text
forall a. Maybe a
Nothing
    Dropped Text
reason -> Text -> Maybe Text -> JobSpanOutcome
JobSpanOutcome Text
"dropped" (Text -> Maybe Text
forall a. a -> Maybe a
Just Text
reason)
    SourceUnavailable Text
reason -> Text -> Maybe Text -> JobSpanOutcome
JobSpanOutcome Text
"source-unavailable" (Text -> Maybe Text
forall a. a -> Maybe a
Just Text
reason)
    DeadLettered Text
reason -> Text -> Maybe Text -> JobSpanOutcome
JobSpanOutcome Text
"dead-lettered" (Text -> Maybe Text
forall a. a -> Maybe a
Just Text
reason)
    Retried RetryLeg
_ Text
reason -> Text -> Maybe Text -> JobSpanOutcome
JobSpanOutcome Text
"retried" (Text -> Maybe Text
forall a. a -> Maybe a
Just Text
reason)

{- | Decide one job, re-checking current policy before publishing, because the queue wait is
unbounded and mirrored bytes bypass every later rule.
-}
processJob :: MirrorJob -> WorkerM JobOutcome
processJob :: MirrorJob -> WorkerM JobOutcome
processJob MirrorJob
job = Namespace -> WorkerM JobOutcome -> WorkerM JobOutcome
forall (m :: * -> *) a. KatipContext m => Namespace -> m a -> m a
katipAddNamespace Namespace
"job" (WorkerM JobOutcome -> WorkerM JobOutcome)
-> WorkerM JobOutcome -> WorkerM JobOutcome
forall a b. (a -> b) -> a -> b
$ do
    Severity -> LogStr -> WorkerM ()
forall (m :: * -> *).
(Applicative m, KatipContext m) =>
Severity -> LogStr -> m ()
logFM Severity
DebugS (Text -> LogStr
forall a. StringConv a Text => a -> LogStr
ls (Text
"starting mirror job for " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job))
    tracing <- (WorkerRuntime -> WorkerTracingPort) -> WorkerM WorkerTracingPort
forall r (m :: * -> *) a. MonadReader r m => (r -> a) -> m a
asks WorkerRuntime -> WorkerTracingPort
wrTracing
    runtime <- ask
    withRunInIO $ \forall a. WorkerM a -> IO a
runInIO ->
        WorkerTracingPort
-> forall a.
   PackageName
   -> Version
   -> Maybe RemoteSpanContext
   -> (a -> JobSpanOutcome)
   -> IO a
   -> IO a
wtpMirrorJobSpan WorkerTracingPort
tracing (MirrorJob -> PackageName
jobPackage MirrorJob
job) (MirrorJob -> Version
jobVersion MirrorJob
job) (MirrorJob -> Maybe RemoteSpanContext
jobTraceContext MirrorJob
job) JobOutcome -> JobSpanOutcome
jobSpanOutcome (IO JobOutcome -> IO JobOutcome) -> IO JobOutcome -> IO JobOutcome
forall a b. (a -> b) -> a -> b
$
            WorkerM JobOutcome -> IO JobOutcome
forall a. WorkerM a -> IO a
runInIO (WorkerM JobOutcome -> IO JobOutcome)
-> WorkerM JobOutcome -> IO JobOutcome
forall a b. (a -> b) -> a -> b
$
                WorkerRuntime
-> forall (m :: * -> *) a.
   (KatipContext m, MonadIO m) =>
   m a -> m a
wrInjectTraceContext WorkerRuntime
runtime (MirrorJob -> WorkerM JobOutcome
reevaluateThenMirror MirrorJob
job)

-- Use one ecosystem bundle throughout so a job cannot consult another ecosystem's policy or registry.
reevaluateThenMirror :: MirrorJob -> WorkerM JobOutcome
reevaluateThenMirror :: MirrorJob -> WorkerM JobOutcome
reevaluateThenMirror MirrorJob
job = do
    policies <- (WorkerRuntime -> WorkerPolicies) -> WorkerM WorkerPolicies
forall r (m :: * -> *) a. MonadReader r m => (r -> a) -> m a
asks WorkerRuntime -> WorkerPolicies
wrPolicies
    case Map.lookup (pkgEcosystem (jobPackage job)) policies of
        -- Structurally unreachable: only an activated ecosystem enqueues jobs, and activation
        -- implies a bundle. Kept as the fail-closed drop.
        Maybe WorkerPolicy
Nothing -> JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (Text -> JobOutcome
Dropped (MirrorJob -> Text
noPolicyReason MirrorJob
job))
        Just WorkerPolicy
policy
            -- An operator can declare the namespace after the enqueue, so the privilege is read
            -- ahead of the mirror probe: the public leg is never entered for a name it owns.
            | WorkerPolicy -> PackageName -> Bool
wpFirstParty WorkerPolicy
policy (MirrorJob -> PackageName
jobPackage MirrorJob
job) -> JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (Text -> JobOutcome
Dropped (MirrorJob -> Text
firstPartyReason MirrorJob
job))
            | Bool
otherwise -> WorkerPolicy -> MirrorJob -> WorkerM JobOutcome
mirrorUnlessPresent WorkerPolicy
policy MirrorJob
job

noPolicyReason :: MirrorJob -> Text
noPolicyReason :: MirrorJob -> Text
noPolicyReason MirrorJob
job =
    Text
"no rule policy is configured for the "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Ecosystem -> Text
ecosystemName (PackageName -> Ecosystem
pkgEcosystem (MirrorJob -> PackageName
jobPackage MirrorJob
job))
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" ecosystem; refusing to mirror "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job

firstPartyReason :: MirrorJob -> Text
firstPartyReason :: MirrorJob -> Text
firstPartyReason MirrorJob
job =
    Text
"this deployment owns the namespace of "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"; refusing to mirror public content under a first-party name"

mirrorUnlessPresent :: WorkerPolicy -> MirrorJob -> WorkerM JobOutcome
mirrorUnlessPresent :: WorkerPolicy -> MirrorJob -> WorkerM JobOutcome
mirrorUnlessPresent WorkerPolicy
policy MirrorJob
job =
    WorkerPolicy -> MirrorJob -> WorkerM (Either JobOutcome [Version])
probeInventory WorkerPolicy
policy MirrorJob
job WorkerM (Either JobOutcome [Version])
-> (Either JobOutcome [Version] -> WorkerM JobOutcome)
-> WorkerM JobOutcome
forall a b. WorkerM a -> (a -> WorkerM b) -> WorkerM b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= \case
        Left JobOutcome
outcome -> JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure JobOutcome
outcome
        Right [Version]
inventory
            | MirrorJob -> Version
jobVersion MirrorJob
job Version -> [Version] -> Bool
forall (f :: * -> *) a.
(Foldable f, DisallowElem f, Eq a) =>
a -> f a -> Bool
`elem` [Version]
inventory -> do
                Severity -> LogStr -> WorkerM ()
forall (m :: * -> *).
(Applicative m, KatipContext m) =>
Severity -> LogStr -> m ()
logFM Severity
InfoS (Text -> LogStr
forall a. StringConv a Text => a -> LogStr
ls (Text
"already present at the mirror target, acking without re-publish: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job))
                JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure JobOutcome
Succeeded
            | Bool
otherwise -> WorkerPolicy -> MirrorJob -> WorkerM (Either JobOutcome Readmitted)
reevaluatePolicy WorkerPolicy
policy MirrorJob
job WorkerM (Either JobOutcome Readmitted)
-> (Either JobOutcome Readmitted -> WorkerM JobOutcome)
-> WorkerM JobOutcome
forall a b. WorkerM a -> (a -> WorkerM b) -> WorkerM b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= (JobOutcome -> WorkerM JobOutcome)
-> (Readmitted -> WorkerM JobOutcome)
-> Either JobOutcome Readmitted
-> WorkerM JobOutcome
forall a c b. (a -> c) -> (b -> c) -> Either a b -> c
either JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (WorkerPolicy
-> MirrorJob -> [Version] -> Readmitted -> WorkerM JobOutcome
publishAdmitted WorkerPolicy
policy MirrorJob
job [Version]
inventory)

{- An unreadable answer is not an empty store, so it reports a fault rather than let the write
declare a tag chosen without the inventory. A 404 is a store holding this package not at all. -}
probeInventory :: WorkerPolicy -> MirrorJob -> WorkerM (Either JobOutcome [Version])
probeInventory :: WorkerPolicy -> MirrorJob -> WorkerM (Either JobOutcome [Version])
probeInventory WorkerPolicy
policy MirrorJob
job = do
    probed <- IO (Either FetchFault (BodyOutcome (Either ParseError [Version])))
-> WorkerM
     (Either FetchFault (BodyOutcome (Either ParseError [Version])))
forall a. IO a -> WorkerM a
forall (m :: * -> *) a. MonadIO m => IO a -> m a
liftIO (MirrorPublish
-> PackageName
-> IO
     (Either FetchFault (BodyOutcome (Either ParseError [Version])))
mpProbeMetadata (WorkerPolicy -> MirrorPublish
wpPublish WorkerPolicy
policy) (MirrorJob -> PackageName
jobPackage MirrorJob
job))
    pure $ case probed of
        Left FetchFault
fault -> JobOutcome -> Either JobOutcome [Version]
forall a b. a -> Either a b
Left (RetryLeg -> (FetchFault -> Text) -> FetchFault -> JobOutcome
outcomeOfFetchFault RetryLeg
BeforePublish (MirrorJob -> FetchFault -> Text
probeFaultReason MirrorJob
job) FetchFault
fault)
        Right (SuccessBody Int
_ (Left (ParseError Text
detail))) -> JobOutcome -> Either JobOutcome [Version]
forall a b. a -> Either a b
Left (RetryLeg -> Text -> JobOutcome
Retried RetryLeg
BeforePublish (MirrorJob -> Text -> Text
probeParseReason MirrorJob
job Text
detail))
        Right (SuccessBody Int
_ (Right [Version]
versions)) -> [Version] -> Either JobOutcome [Version]
forall a b. b -> Either a b
Right [Version]
versions
        Right (UnreadStatus Int
404) -> [Version] -> Either JobOutcome [Version]
forall a b. b -> Either a b
Right []
        Right (UnreadStatus Int
code) -> JobOutcome -> Either JobOutcome [Version]
forall a b. a -> Either a b
Left (RetryLeg -> Text -> JobOutcome
Retried RetryLeg
BeforePublish (MirrorJob -> Int -> Text
probeStatusReason MirrorJob
job Int
code))

probeFaultReason :: MirrorJob -> FetchFault -> Text
probeFaultReason :: MirrorJob -> FetchFault -> Text
probeFaultReason MirrorJob
job = \case
    FetchUrlUnformable UrlFormationError
urlErr -> Text
"unformable mirror probe URL: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> UrlFormationError -> Text
renderUrlFormationError UrlFormationError
urlErr
    FetchBoundExceeded LimitError
limitErr -> Text
"the mirror target's metadata exceeded the response bound: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> LimitError -> Text
forall b a. (Show a, IsString b) => a -> b
show LimitError
limitErr
    FetchTransport TransportFault
fault -> Text
"mirror inventory probe for " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" failed: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> TransportCause -> Text
forall b a. (Show a, IsString b) => a -> b
show (TransportFault -> TransportCause
tfCause TransportFault
fault)

probeStatusReason :: MirrorJob -> Int -> Text
probeStatusReason :: MirrorJob -> Int -> Text
probeStatusReason MirrorJob
job Int
code =
    Text
"the mirror target answered HTTP "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
code
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" for the inventory probe of "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"; refusing to publish a release tag chosen without it"

probeParseReason :: MirrorJob -> Text -> Text
probeParseReason :: MirrorJob -> Text -> Text
probeParseReason MirrorJob
job Text
detail =
    Text
"could not read the mirror target's inventory for "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" ("
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
detail
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"); refusing to publish a release tag chosen without it"

{- What re-evaluation settled for one job: the descriptor the gate re-admitted, the upstream's own
release tag, and the version object current metadata carried, all read from the one fetch. -}
data Readmitted = Readmitted
    { Readmitted -> MirrorArtifact
raArtifact :: MirrorArtifact
    , Readmitted -> Maybe Version
raUpstreamLatest :: Maybe Version
    , Readmitted -> CachedDoc
raMetadata :: CachedDoc
    }

{- Re-check the fetch URL against the mount's tarball-host gate, because the queue payload is a
trust boundary. Then re-run current policy through 'Ecluse.Core.Package.Admission.admitArtifact'. -}
reevaluatePolicy :: WorkerPolicy -> MirrorJob -> WorkerM (Either JobOutcome Readmitted)
reevaluatePolicy :: WorkerPolicy -> MirrorJob -> WorkerM (Either JobOutcome Readmitted)
reevaluatePolicy WorkerPolicy
policy MirrorJob
job
    | Bool -> Bool
not (WorkerPolicy -> Maybe HostPort -> Bool
wpArtifactHostHonoured WorkerPolicy
policy (Text -> Maybe HostPort
hostPortAddress (RegistryUrl -> Text
registryUrlText (MirrorJob -> RegistryUrl
jobArtifactUrl MirrorJob
job)))) =
        Either JobOutcome Readmitted
-> WorkerM (Either JobOutcome Readmitted)
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (JobOutcome -> Either JobOutcome Readmitted
forall a b. a -> Either a b
Left (Text -> JobOutcome
Dropped (MirrorJob -> Text
artifactHostReason MirrorJob
job)))
    | Bool
otherwise =
        IO VersionEvaluation -> WorkerM VersionEvaluation
forall a. IO a -> WorkerM a
forall (m :: * -> *) a. MonadIO m => IO a -> m a
liftIO (WorkerPolicy -> PackageName -> Version -> IO VersionEvaluation
wpResolveVersion WorkerPolicy
policy (MirrorJob -> PackageName
jobPackage MirrorJob
job) (MirrorJob -> Version
jobVersion MirrorJob
job)) WorkerM VersionEvaluation
-> (VersionEvaluation -> WorkerM (Either JobOutcome Readmitted))
-> WorkerM (Either JobOutcome Readmitted)
forall a b. WorkerM a -> (a -> WorkerM b) -> WorkerM b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= WorkerPolicy
-> MirrorJob
-> VersionEvaluation
-> WorkerM (Either JobOutcome Readmitted)
admitEvaluation WorkerPolicy
policy MirrorJob
job

artifactHostReason :: MirrorJob -> Text
artifactHostReason :: MirrorJob -> Text
artifactHostReason MirrorJob
job =
    Text
"the tarball-host policy refuses the artifact host of "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" ("
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
jobArtifactAuthority MirrorJob
job
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"); refusing to fetch or mirror it"

{- A version the upstream no longer offers, or cannot describe, never reaches the rules. A present
version also carries the upstream's own @latest@, read from the same metadata. -}
admitEvaluation :: WorkerPolicy -> MirrorJob -> VersionEvaluation -> WorkerM (Either JobOutcome Readmitted)
admitEvaluation :: WorkerPolicy
-> MirrorJob
-> VersionEvaluation
-> WorkerM (Either JobOutcome Readmitted)
admitEvaluation WorkerPolicy
policy MirrorJob
job VersionEvaluation
evaluation = case VersionEvaluation
evaluation of
    VersionEvaluation
VersionMetadataUnavailable ->
        Either JobOutcome Readmitted
-> WorkerM (Either JobOutcome Readmitted)
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (JobOutcome -> Either JobOutcome Readmitted
forall a b. a -> Either a b
Left (Text -> JobOutcome
unresolved (Text
"could not re-fetch metadata to re-evaluate current policy for " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job)))
    VersionEvaluation
VersionMissing ->
        Either JobOutcome Readmitted
-> WorkerM (Either JobOutcome Readmitted)
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (JobOutcome -> Either JobOutcome Readmitted
forall a b. a -> Either a b
Left (Text -> JobOutcome
unresolved (Text
"the public upstream no longer offers " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"; refusing to mirror a withdrawn version")))
    VersionPresent VersionDoc
doc Maybe Version
upstreamLatest -> do
        -- The back-fill path emits no per-decision audit line, so the audit-only advisory ETag
        -- is not resolved for its context.
        ctx <- IO EvalContext -> WorkerM EvalContext
forall a. IO a -> WorkerM a
forall (m :: * -> *) a. MonadIO m => IO a -> m a
liftIO (IO UTCTime -> IO (Maybe DbEtag) -> IO EvalContext
mkEvalContext (WorkerPolicy -> IO UTCTime
wpNow WorkerPolicy
policy) (Maybe DbEtag -> IO (Maybe DbEtag)
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure Maybe DbEtag
forall a. Maybe a
Nothing))
        admission <- liftIO (admitArtifact ctx (wpRules policy) (wpMinIntegrity policy) (jobArtifactFilename job) (vdDetails doc))
        pure $ do
            artifact <- outcomeOfAdmission job admission
            -- Decided after admission, so a policy deny still reports as one.
            raw <- maybeToRight (SourceUnavailable (sourceUnavailableReason job "the resolver carried none")) (vdRaw doc)
            pure Readmitted{raArtifact = artifact, raUpstreamLatest = upstreamLatest, raMetadata = raw}
  where
    unresolved :: Text -> JobOutcome
unresolved = Maybe Transience -> Text -> JobOutcome
retryOrDrop (VersionEvaluation -> Maybe Transience
versionTransience VersionEvaluation
evaluation)

sourceUnavailableReason :: MirrorJob -> Text -> Text
sourceUnavailableReason :: MirrorJob -> Text -> Text
sourceUnavailableReason MirrorJob
job Text
detail =
    Text
"the source version object for "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" was unavailable ("
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
detail
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"); refusing to mirror a reduced manifest"

{- | Render the shared 'ArtifactAdmission' as the descriptor to publish, or the outcome the queue
realises. 'admissionTransience' alone splits retry from drop, so no path can diverge from the gate.
-}
outcomeOfAdmission :: MirrorJob -> ArtifactAdmission -> Either JobOutcome MirrorArtifact
outcomeOfAdmission :: MirrorJob -> ArtifactAdmission -> Either JobOutcome MirrorArtifact
outcomeOfAdmission MirrorJob
job ArtifactAdmission
admission = case ArtifactAdmission
admission of
    AdmissionAdmit Filename
filename Artifact
artifact NonEmpty Hash
digests -> MirrorArtifact -> Either JobOutcome MirrorArtifact
forall a b. b -> Either a b
Right (Filename -> Artifact -> NonEmpty Hash -> MirrorArtifact
readmittedDescriptor Filename
filename Artifact
artifact NonEmpty Hash
digests)
    AdmissionDenied (Blocked Text
ruleName Maybe DbEtag
_ Text
reason) ->
        Text -> Either JobOutcome MirrorArtifact
refused (Text
"current policy denies " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
": blocked by " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
ruleName Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
reason Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
")")
    AdmissionDenied Decision
_ ->
        Text -> Either JobOutcome MirrorArtifact
refused (Text
"current policy denies " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
": no rule admits it")
    AdmissionUndecidable (Undecidable Transience
_ Text
reason) ->
        Text -> Either JobOutcome MirrorArtifact
refused (Text
"current policy could not be evaluated for " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
": " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
reason)
    AdmissionUndecidable Decision
_ ->
        Text -> Either JobOutcome MirrorArtifact
refused (Text
"current policy could not be evaluated for " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job)
    ArtifactAdmission
AdmissionFileAbsent ->
        Text -> Either JobOutcome MirrorArtifact
refused (Text
"the public upstream no longer offers the admitted artifact file of " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"; refusing to mirror a withdrawn artifact")
    ArtifactAdmission
AdmissionBelowFloor ->
        Text -> Either JobOutcome MirrorArtifact
refused (Text
"current admission policy refuses " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
": its strongest integrity digest is below the configured public floor")
    ArtifactAdmission
AdmissionIntegrityMissing ->
        Text -> Either JobOutcome MirrorArtifact
refused (Text
"current admission policy refuses " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
": it no longer carries any integrity digest")
  where
    refused :: Text -> Either JobOutcome MirrorArtifact
    refused :: Text -> Either JobOutcome MirrorArtifact
refused = JobOutcome -> Either JobOutcome MirrorArtifact
forall a b. a -> Either a b
Left (JobOutcome -> Either JobOutcome MirrorArtifact)
-> (Text -> JobOutcome) -> Text -> Either JobOutcome MirrorArtifact
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Maybe Transience -> Text -> JobOutcome
retryOrDrop (ArtifactAdmission -> Maybe Transience
admissionTransience ArtifactAdmission
admission)

{- The worker's one retry-versus-drop rule, over the shared transience. Only an inability the
evaluator expects to clear redelivers: the rest drop through the terminal path. -}
retryOrDrop :: Maybe Transience -> Text -> JobOutcome
retryOrDrop :: Maybe Transience -> Text -> JobOutcome
retryOrDrop Maybe Transience
transience Text
reason = case Maybe Transience
transience of
    Just (WillResolve Maybe RetryAfter
_) -> RetryLeg -> Text -> JobOutcome
Retried RetryLeg
BeforePublish Text
reason
    Just Transience
WontResolve -> Text -> JobOutcome
Dropped Text
reason
    Maybe Transience
Nothing -> Text -> JobOutcome
Dropped Text
reason

{- Derive the publish descriptor from what the gate settled, so nothing the queue payload asserted
reaches the trusted-tier publish document unchecked. The size is current metadata's. -}
readmittedDescriptor :: Filename -> Artifact -> NonEmpty Hash -> MirrorArtifact
readmittedDescriptor :: Filename -> Artifact -> NonEmpty Hash -> MirrorArtifact
readmittedDescriptor Filename
filename Artifact
artifact NonEmpty Hash
digests =
    MirrorArtifact
        { maFilename :: Filename
maFilename = Filename
filename
        , maHashes :: NonEmpty Hash
maHashes = NonEmpty Hash
digests
        , maSize :: Maybe Int
maSize = Artifact -> Maybe Int
artSize Artifact
artifact
        }

{- | The worker's terminal-versus-transient split over the shared exchange-fault channel. The
artifact fetch and the mirror write read this one table, and each names its own retry leg.
-}
outcomeOfFetchFault :: RetryLeg -> (FetchFault -> Text) -> FetchFault -> JobOutcome
outcomeOfFetchFault :: RetryLeg -> (FetchFault -> Text) -> FetchFault -> JobOutcome
outcomeOfFetchFault RetryLeg
leg FetchFault -> Text
render FetchFault
fault = Text -> JobOutcome
verdict (FetchFault -> Text
render FetchFault
fault)
  where
    verdict :: Text -> JobOutcome
verdict = case FetchFault
fault of
        FetchUrlUnformable UrlFormationError
_ -> Text -> JobOutcome
Dropped
        FetchBoundExceeded LimitError
_ -> Text -> JobOutcome
DeadLettered
        FetchTransport TransportFault
_ -> RetryLeg -> Text -> JobOutcome
Retried RetryLeg
leg

{- | The @latest@ one mirror write declares, over the upstream tag and the post-write inventory.
The published version always survives, so the chosen target is always present at the store.
-}
mirrorLatest :: Maybe Version -> [Version] -> Version -> Version
mirrorLatest :: Maybe Version -> [Version] -> Version -> Version
mirrorLatest Maybe Version
upstreamLatest [Version]
inventory Version
published =
    Version -> Maybe Version -> Version
forall a. a -> Maybe a -> a
fromMaybe Version
published (Maybe Version -> [Version] -> Maybe Version
selectLatest Maybe Version
upstreamLatest (Version
published Version -> [Version] -> [Version]
forall a. a -> [a] -> [a]
: [Version]
inventory))

{- Fix the release tag before the write, over the post-write inventory, so no job makes its own
version latest merely by finishing last. -}
publishAdmitted :: WorkerPolicy -> MirrorJob -> [Version] -> Readmitted -> WorkerM JobOutcome
publishAdmitted :: WorkerPolicy
-> MirrorJob -> [Version] -> Readmitted -> WorkerM JobOutcome
publishAdmitted WorkerPolicy
policy MirrorJob
job [Version]
inventory Readmitted
readmitted =
    WorkerPolicy
-> MirrorJob -> PublishPlan -> MirrorArtifact -> WorkerM JobOutcome
mirrorArtifact WorkerPolicy
policy MirrorJob
job PublishPlan
plan (Readmitted -> MirrorArtifact
raArtifact Readmitted
readmitted)
  where
    plan :: PublishPlan
plan =
        PublishPlan
            { ppVersion :: Version
ppVersion = MirrorJob -> Version
jobVersion MirrorJob
job
            , ppLatest :: Version
ppLatest = Maybe Version -> [Version] -> Version -> Version
mirrorLatest (Readmitted -> Maybe Version
raUpstreamLatest Readmitted
readmitted) [Version]
inventory (MirrorJob -> Version
jobVersion MirrorJob
job)
            , ppMetadata :: CachedDoc
ppMetadata = Readmitted -> CachedDoc
raMetadata Readmitted
readmitted
            }

mirrorArtifact :: WorkerPolicy -> MirrorJob -> PublishPlan -> MirrorArtifact -> WorkerM JobOutcome
mirrorArtifact :: WorkerPolicy
-> MirrorJob -> PublishPlan -> MirrorArtifact -> WorkerM JobOutcome
mirrorArtifact WorkerPolicy
policy MirrorJob
job PublishPlan
plan MirrorArtifact
admitted = do
    Severity -> LogStr -> WorkerM ()
forall (m :: * -> *).
(Applicative m, KatipContext m) =>
Severity -> LogStr -> m ()
logFM Severity
DebugS (Text -> LogStr
forall a. StringConv a Text => a -> LogStr
ls (Text
"fetching artifact bytes from " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
jobArtifactAuthority MirrorJob
job))
    fetched <- Limits
-> (Maybe ClientCredential
    -> Text -> Either UrlFormationError Request)
-> RegistryUrl
-> WorkerM (Either FetchFault ByteString)
fetchArtifactBytes (WorkerPolicy -> Limits
wpArtifactLimits WorkerPolicy
policy) (AdapterArtifact
-> Maybe ClientCredential
-> Text
-> Either UrlFormationError Request
artifactByUrl (WorkerPolicy -> AdapterArtifact
wpArtifact WorkerPolicy
policy)) (MirrorJob -> RegistryUrl
jobArtifactUrl MirrorJob
job)
    case fetched of
        -- 'outcomeOfFetchFault' makes the terminal-versus-transient split, and the realisation
        -- half logs the reason at the queue handle.
        Left FetchFault
fault -> JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (RetryLeg -> (FetchFault -> Text) -> FetchFault -> JobOutcome
outcomeOfFetchFault RetryLeg
BeforePublish (MirrorJob -> FetchFault -> Text
artifactFetchReason MirrorJob
job) FetchFault
fault)
        Right ByteString
bytes -> WorkerPolicy
-> MirrorJob
-> PublishPlan
-> MirrorArtifact
-> ByteString
-> WorkerM JobOutcome
publishIfIntact WorkerPolicy
policy MirrorJob
job PublishPlan
plan MirrorArtifact
admitted ByteString
bytes

-- The client's rendered exception would print the request path, query, and headers, so a
-- transport reason names only the authority and the cause.
artifactFetchReason :: MirrorJob -> FetchFault -> Text
artifactFetchReason :: MirrorJob -> FetchFault -> Text
artifactFetchReason MirrorJob
job = \case
    FetchUrlUnformable UrlFormationError
urlErr -> Text
"unformable artifact URL: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> UrlFormationError -> Text
renderUrlFormationError UrlFormationError
urlErr
    FetchBoundExceeded LimitError
limitErr -> Text
"artifact exceeded the response bound: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> LimitError -> Text
forall b a. (Show a, IsString b) => a -> b
show LimitError
limitErr
    FetchTransport TransportFault
fault -> Text
"artifact fetch from " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
jobArtifactAuthority MirrorJob
job Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" failed: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> TransportCause -> Text
forall b a. (Show a, IsString b) => a -> b
show (TransportFault -> TransportCause
tfCause TransportFault
fault)

-- A tampered artifact must never reach the private upstream, which later serves it without the
-- rules, so the bytes are verified against the re-admitted digests before any publish.
publishIfIntact :: WorkerPolicy -> MirrorJob -> PublishPlan -> MirrorArtifact -> ByteString -> WorkerM JobOutcome
publishIfIntact :: WorkerPolicy
-> MirrorJob
-> PublishPlan
-> MirrorArtifact
-> ByteString
-> WorkerM JobOutcome
publishIfIntact WorkerPolicy
policy MirrorJob
job PublishPlan
plan MirrorArtifact
admitted ByteString
bytes = case NonEmpty Hash -> ByteString -> IntegrityResult
verifyIntegrity (MirrorArtifact -> NonEmpty Hash
maHashes MirrorArtifact
admitted) ByteString
bytes of
    IntegrityMismatch Text
detail -> do
        Severity -> LogStr -> WorkerM ()
forall (m :: * -> *).
(Applicative m, KatipContext m) =>
Severity -> LogStr -> m ()
logFM Severity
ErrorS (Text -> LogStr
forall a. StringConv a Text => a -> LogStr
ls (Text
"artifact integrity mismatch, refusing to publish: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
detail))
        JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (Text -> JobOutcome
Dropped (Text
"integrity mismatch: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
detail))
    IntegrityResult
IntegrityVerified -> WorkerPolicy
-> MirrorJob
-> PublishPlan
-> MirrorArtifact
-> ByteString
-> WorkerM JobOutcome
publishVerified WorkerPolicy
policy MirrorJob
job PublishPlan
plan MirrorArtifact
admitted ByteString
bytes

-- Publish already-verified bytes to the mirror target. The publish document is assembled from the
-- re-admitted descriptor, so no queue-payload text reaches the trusted-tier packument.
publishVerified :: WorkerPolicy -> MirrorJob -> PublishPlan -> MirrorArtifact -> ByteString -> WorkerM JobOutcome
publishVerified :: WorkerPolicy
-> MirrorJob
-> PublishPlan
-> MirrorArtifact
-> ByteString
-> WorkerM JobOutcome
publishVerified WorkerPolicy
policy MirrorJob
job PublishPlan
plan MirrorArtifact
admitted ByteString
bytes = do
    metrics <- (WorkerRuntime -> WorkerMetricsPort) -> WorkerM WorkerMetricsPort
forall r (m :: * -> *) a. MonadReader r m => (r -> a) -> m a
asks WorkerRuntime -> WorkerMetricsPort
wrMetrics
    -- The publish is the long, network-bound step. Time it for the publish-latency
    -- histogram whichever way the registry responds.
    (result, seconds) <- timedSeconds (liftIO (mpPublishArtifact (wpPublish policy) (jobPackage job) plan admitted bytes))
    liftIO (wmpMirrorPublishDuration metrics seconds)
    outcomeOfPublish job result

outcomeOfPublish :: MirrorJob -> Either PublishFault () -> WorkerM JobOutcome
outcomeOfPublish :: MirrorJob -> Either PublishFault () -> WorkerM JobOutcome
outcomeOfPublish MirrorJob
job = \case
    Right () -> JobOutcome
Succeeded JobOutcome -> WorkerM () -> WorkerM JobOutcome
forall a b. a -> WorkerM b -> WorkerM a
forall (f :: * -> *) a b. Functor f => a -> f b -> f a
<$ Severity -> LogStr -> WorkerM ()
forall (m :: * -> *).
(Applicative m, KatipContext m) =>
Severity -> LogStr -> m ()
logFM Severity
InfoS (Text -> LogStr
forall a. StringConv a Text => a -> LogStr
ls (Text
"mirrored artifact published: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> MirrorJob -> Text
renderJob MirrorJob
job))
    Left (PublishRejected PublishError
err) -> JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (RetryLeg -> Text -> JobOutcome
Retried RetryLeg
AfterPublish (Text
"registry rejected publish: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> PublishError -> Text
forall b a. (Show a, IsString b) => a -> b
show PublishError
err))
    Left (PublishSourceUnavailable Text
detail) -> JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (Text -> JobOutcome
SourceUnavailable (MirrorJob -> Text -> Text
sourceUnavailableReason MirrorJob
job Text
detail))
    Left (PublishFetch FetchFault
fault) -> JobOutcome -> WorkerM JobOutcome
forall a. a -> WorkerM a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (RetryLeg -> (FetchFault -> Text) -> FetchFault -> JobOutcome
outcomeOfFetchFault RetryLeg
AfterPublish FetchFault -> Text
publishFaultReason FetchFault
fault)

-- The mirror target is operator-configured, so its rendered transport detail is diagnosable
-- rather than attacker-supplied.
publishFaultReason :: FetchFault -> Text
publishFaultReason :: FetchFault -> Text
publishFaultReason = \case
    FetchUrlUnformable UrlFormationError
urlErr -> Text
"unformable publish URL: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> UrlFormationError -> Text
renderUrlFormationError UrlFormationError
urlErr
    FetchBoundExceeded LimitError
limitErr -> Text
"the publication target's response exceeded the response bound: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> LimitError -> Text
forall b a. (Show a, IsString b) => a -> b
show LimitError
limitErr
    FetchTransport TransportFault
fault -> Text
"publish transport failure: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> TransportFault -> Text
forall b a. (Show a, IsString b) => a -> b
show TransportFault
fault

{- The job's artifact location as a log-safe authority. The queue payload's URL can carry userinfo
or a pre-signed query, so a log line names only the host and port the worker dials. -}
jobArtifactAuthority :: MirrorJob -> Text
jobArtifactAuthority :: MirrorJob -> Text
jobArtifactAuthority = Text -> Text
authorityLabel (Text -> Text) -> (MirrorJob -> Text) -> MirrorJob -> Text
forall b c a. (b -> c) -> (a -> b) -> a -> c
. RegistryUrl -> Text
registryUrlText (RegistryUrl -> Text)
-> (MirrorJob -> RegistryUrl) -> MirrorJob -> Text
forall b c a. (b -> c) -> (a -> b) -> a -> c
. MirrorJob -> RegistryUrl
jobArtifactUrl