fix(batch): pause transient authorization failures with owned retries

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-11-07 00:42:02 +08:00
parent ade15ea5d0
commit bdd17b0f47
14 changed files with 911 additions and 153 deletions
+23 -4
View File
@@ -75,9 +75,8 @@ func (w batchWalkError) Walk(context.Context, string, string, chan<- itemOrErr[O
return w.err return w.err
} }
// A listing that fails before it returns anything fails the run, as a // A listing outage pauses the run, as a streamed listing error does.
// listing error in the results does, and the job is left to a later run. func TestBatchReviewExpireWalkErrorPausesRun(t *testing.T) {
func TestBatchReviewExpireWalkErrorFailsRun(t *testing.T) {
f := newBatchAuthzFixture(t) f := newBatchAuthzFixture(t)
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/")) rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
if job == nil { if job == nil {
@@ -87,7 +86,7 @@ func TestBatchReviewExpireWalkErrorFailsRun(t *testing.T) {
if err := job.Expire.Start(f.ctx, batchWalkError{ObjectLayer: f.obj, err: listErr}, *job); !errors.Is(err, listErr) { if err := job.Expire.Start(f.ctx, batchWalkError{ObjectLayer: f.obj, err: listErr}, *job); !errors.Is(err, listErr) {
t.Fatalf("start returned %v, want %v", err, listErr) t.Fatalf("start returned %v, want %v", err, listErr)
} }
if ri := f.jobInfo(t, job); !ri.Failed || ri.Complete { if ri := f.jobInfo(t, job); ri.Failed || ri.Complete || ri.PausedReason == "" {
t.Fatalf("failed listing: complete=%v failed=%v", ri.Complete, ri.Failed) t.Fatalf("failed listing: complete=%v failed=%v", ri.Complete, ri.Failed)
} }
} }
@@ -251,6 +250,26 @@ func TestBatchReviewRotationLeavesMovingSource(t *testing.T) {
if after.UserDefined[crypto.MetaSealedKeyS3] != beforeInfo.UserDefined[crypto.MetaSealedKeyS3] { if after.UserDefined[crypto.MetaSealedKeyS3] != beforeInfo.UserDefined[crypto.MetaSealedKeyS3] {
t.Fatal("rotation rewrote a draining pool") t.Fatal("rotation rewrote a draining pool")
} }
// The job pauses and resumes in-process once the selected pool stops moving.
job := BatchJobRequest{ID: "rotation-moving-resume", Started: UTCNow(), Submitter: batchRootSubmitter(t), KeyRotate: &r}
batchTestJobPool(t.Context(), t, z)
if err := r.Start(t.Context(), z, job); !isBatchJobUnavailable(err) {
t.Fatalf("moving job did not pause: %v", err)
}
ri := &batchJobInfo{}
mustIAM(t, ri.load(t.Context(), z, job))
if ri.PausedReason == "" || ri.Failed || ri.Complete || ri.Object != "" || ri.ObjectsFailed != 0 {
t.Fatalf("moving job report: %+v", ri)
}
z.poolMetaMutex.Lock()
z.poolMeta.Pools[0].Decommission = nil
z.poolMetaMutex.Unlock()
mustIAM(t, r.Start(t.Context(), z, job))
mustIAM(t, ri.load(t.Context(), z, job))
if !ri.Complete || ri.Failed || ri.PausedReason != "" || ri.Objects != 1 {
t.Fatalf("resumed rotation: %+v", ri)
}
} }
// Once a move copied a version, the rotation leaves it to a later run, so the // Once a move copied a version, the rotation leaves it to a later run, so the
+106 -14
View File
@@ -150,7 +150,7 @@ func newBatchJobSubmitter(cred auth.Credentials) (*batchJobSubmitter, error) {
} }
u, ok, err := globalIAMSys.identityRecord(cred.AccessKey) u, ok, err := globalIAMSys.identityRecord(cred.AccessKey)
if err != nil { if err != nil {
return nil, err return nil, batchUnavailable("IAM identity lookup", err)
} }
// The record must be the one the request was authenticated with. // The record must be the one the request was authenticated with.
if !ok || u.Credentials.AccessKey != cred.AccessKey || u.Credentials.ParentUser != cred.ParentUser || if !ok || u.Credentials.AccessKey != cred.AccessKey || u.Credentials.ParentUser != cred.ParentUser ||
@@ -174,6 +174,7 @@ func newBatchJobSubmitter(cred auth.Credentials) (*batchJobSubmitter, error) {
// batchJobPrincipal is a submitter resolved against current IAM state. // batchJobPrincipal is a submitter resolved against current IAM state.
type batchJobPrincipal struct { type batchJobPrincipal struct {
ctx context.Context
typ string typ string
accessKey string accessKey string
parentUser string parentUser string
@@ -194,6 +195,9 @@ func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, e
if s.Version != batchJobSubmitterVersion { if s.Version != batchJobSubmitterVersion {
return batchJobPrincipal{}, batchJobAuthzError{reason: fmt.Sprintf("unsupported batch job submitter version %d; submit the job again", s.Version)} return batchJobPrincipal{}, batchJobAuthzError{reason: fmt.Sprintf("unsupported batch job submitter version %d; submit the job again", s.Version)}
} }
if !batchIAMReady() {
return batchJobPrincipal{}, batchUnavailable("IAM is not initialized", errServerNotInitialized)
}
root := globalActiveCred root := globalActiveCred
if s.Type == batchSubmitterRoot { if s.Type == batchSubmitterRoot {
if s.AccessKey != root.AccessKey || !hmac.Equal(s.Binding, batchSubmitterBinding(batchSubmitterRoot, root)) { if s.AccessKey != root.AccessKey || !hmac.Equal(s.Binding, batchSubmitterBinding(batchSubmitterRoot, root)) {
@@ -202,7 +206,7 @@ func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, e
if !globalAPIConfig.permitRootAccess() { if !globalAPIConfig.permitRootAccess() {
return batchJobPrincipal{}, errBatchJobInvalid return batchJobPrincipal{}, errBatchJobInvalid
} }
return batchJobPrincipal{typ: batchSubmitterRoot, accessKey: root.AccessKey, owner: true}, nil return batchJobPrincipal{ctx: ctx, typ: batchSubmitterRoot, accessKey: root.AccessKey, owner: true}, nil
} }
switch s.Type { switch s.Type {
case batchSubmitterUser, batchSubmitterTemporary, batchSubmitterService: case batchSubmitterUser, batchSubmitterTemporary, batchSubmitterService:
@@ -214,7 +218,7 @@ func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, e
} }
u, ok, err := globalIAMSys.CheckKey(ctx, s.AccessKey) u, ok, err := globalIAMSys.CheckKey(ctx, s.AccessKey)
if err != nil { if err != nil {
return batchJobPrincipal{}, err return batchJobPrincipal{}, batchUnavailable("IAM credential lookup", err)
} }
if !ok { if !ok {
// Deleted, disabled, expired or revoked. // Deleted, disabled, expired or revoked.
@@ -231,6 +235,7 @@ func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, e
return batchJobPrincipal{}, errBatchJobInvalid return batchJobPrincipal{}, errBatchJobInvalid
} }
p := batchJobPrincipal{ p := batchJobPrincipal{
ctx: ctx,
typ: typ, typ: typ,
accessKey: cred.AccessKey, accessKey: cred.AccessKey,
parentUser: cred.ParentUser, parentUser: cred.ParentUser,
@@ -243,6 +248,9 @@ func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, e
// Verify the token as a request would, with the current signing keys. // Verify the token as a request would, with the current signing keys.
jwtClaims, err := extractJWTClaims(u) jwtClaims, err := extractJWTClaims(u)
if err != nil { if err != nil {
if errors.Is(err, errTokenSigningKeyUnavailable) {
return batchJobPrincipal{}, batchUnavailable("token signing key", err)
}
return batchJobPrincipal{}, errBatchJobInvalid return batchJobPrincipal{}, errBatchJobInvalid
} }
p.claims = jwtClaims.Map() p.claims = jwtClaims.Map()
@@ -444,12 +452,34 @@ func (p batchJobPrincipal) authorize(req batchAuthzRequest) error {
IsOwner: p.owner, IsOwner: p.owner,
Claims: p.claims, Claims: p.claims,
} }
denied := batchJobAuthzError{reason: batchDeniedReason(req)} return p.authorizeArgs(args, &req)
}
// authorizeArgs uses the same policy snapshot for request-context checks and
// the decision. No boolean IAM evaluator re-reads and silently drops policies.
func (p batchJobPrincipal) authorizeArgs(args policy.Args, req *batchAuthzRequest) error {
denied := batchJobAuthzError{reason: "the batch job submitter is not allowed " + string(args.Action)}
if req != nil {
denied.reason = batchDeniedReason(*req)
}
ctx := p.ctx
if ctx == nil {
ctx = GlobalContext
}
plugin := newGlobalAuthZPluginFn() plugin := newGlobalAuthZPluginFn()
policies, err := globalIAMSys.batchLocalPolicies(p, args, plugin == nil) var cache *iamCache
if plugin == nil && !args.IsOwner {
cache = globalIAMSys.store.lock()
defer globalIAMSys.store.unlock()
}
policies, err := globalIAMSys.batchLocalPolicies(ctx, p, args, plugin == nil, cache)
if err != nil { if err != nil {
if errors.Is(err, errNoSuchPolicy) || errors.Is(err, errAuthentication) || errors.Is(err, errNoSuchUser) || errors.Is(err, errNoSuchGroup) {
return denied return denied
} }
return batchUnavailable("IAM policy lookup", err)
}
if req != nil {
supplied := req.suppliedConditionKeys() supplied := req.suppliedConditionKeys()
for _, pol := range policies { for _, pol := range policies {
for _, st := range pol.Statements { for _, st := range pol.Statements {
@@ -458,10 +488,11 @@ func (p batchJobPrincipal) authorize(req batchAuthzRequest) error {
} }
} }
} }
}
if plugin != nil { if plugin != nil {
ok, err := plugin.IsAllowed(args) ok, err := plugin.IsAllowedContext(ctx, args)
if err != nil { if err != nil {
authZLogIf(GlobalContext, err) return batchUnavailable("authorization plugin", err)
} }
if !ok { if !ok {
return denied return denied
@@ -472,9 +503,29 @@ func (p batchJobPrincipal) authorize(req batchAuthzRequest) error {
} }
return nil return nil
} }
if !globalIAMSys.isAllowedLocally(args) { if args.IsOwner {
return nil
}
if p.service {
if p.claims[parentClaim] != p.parentUser {
return denied return denied
} }
if _, ok := p.claims[iamPolicyClaimNameSA()].(string); !ok {
return denied
}
}
if has, allowed := p.sessionPolicyDecision(args); has && !allowed {
return denied
}
ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey
if !ownerDerived {
if p.service {
args.AccountName = p.parentUser
}
if len(policies) == 0 || !policies[0].IsAllowed(args) {
return denied
}
}
return nil return nil
} }
@@ -507,13 +558,23 @@ func (p batchJobPrincipal) sessionPolicyDecision(args policy.Args) (bool, bool)
// batchLocalPolicies returns every local policy that can take part in the // batchLocalPolicies returns every local policy that can take part in the
// decision on args: the mapped policies that IsAllowed would merge when // decision on args: the mapped policies that IsAllowed would merge when
// withMapped is set (no authorization plugin), and the session policy. // withMapped is set (no authorization plugin), and the session policy.
func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, withMapped bool) ([]policy.Policy, error) { func (sys *IAMSys) batchLocalPolicies(ctx context.Context, p batchJobPrincipal, args policy.Args, withMapped bool, cache *iamCache) ([]policy.Policy, error) {
var out []policy.Policy var out []policy.Policy
if withMapped && args.IsOwner { if withMapped && args.IsOwner {
// IsAllowed does not evaluate any policy for the owner. // IsAllowed does not evaluate any policy for the owner.
return nil, nil return nil, nil
} }
if withMapped { if withMapped {
// The caller holds the IAM writer lock through evaluation. Lazy mapping
// loads and selected documents therefore belong to one cache revision.
mappedPolicies := func(name string) ([]string, error) {
names, _, err := cache.policyDBGet(sys.store, name, false, false)
if err != nil {
return nil, err
}
groups, err := cache.policyDBGetGroups(sys.store, len(names) > 0, args.Groups...)
return append(names, groups...), err
}
var names []string var names []string
ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey
switch { switch {
@@ -526,7 +587,7 @@ func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, wit
} }
names = append(names, newMappedPolicy(sys.rolesMap[a]).toSlice()...) names = append(names, newMappedPolicy(sys.rolesMap[a]).toSlice()...)
} else { } else {
mapped, err := sys.PolicyDBGet(p.parentUser, args.Groups...) mapped, err := mappedPolicies(p.parentUser)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -539,15 +600,31 @@ func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, wit
} }
} }
default: default:
mapped, err := sys.PolicyDBGet(p.accessKey, args.Groups...) mapped, err := mappedPolicies(p.accessKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
names = append(names, mapped...) names = append(names, mapped...)
} }
if len(names) > 0 { var selected []policy.Policy
out = append(out, sys.GetCombinedPolicy(names...)) for _, name := range names {
doc, ok := cache.iamPolicyDocsMap[name]
if !ok {
loaded := make(map[string]PolicyDoc)
loadCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := sys.store.loadPolicyDoc(loadCtx, name, loaded)
cancel()
if err != nil {
return nil, err
} }
doc, ok = loaded[name]
if !ok {
return nil, errNoSuchPolicy
}
}
selected = append(selected, doc.Policy)
}
out = append(out, policy.MergePolicies(selected...))
} }
if p.temp || p.service { if p.temp || p.service {
if p.service && p.claims[iamPolicyClaimNameSA()] == inheritedPolicyType { if p.service && p.claims[iamPolicyClaimNameSA()] == inheritedPolicyType {
@@ -710,6 +787,7 @@ func (ri *batchJobInfo) refuse(err error) {
} }
ri.Complete = false ri.Complete = false
ri.Failed = true ri.Failed = true
ri.PausedReason = ""
} }
func (ri *batchJobInfo) refused() bool { func (ri *batchJobInfo) refused() bool {
@@ -729,8 +807,19 @@ func (ri *batchJobInfo) startAuthorized(ctx context.Context, api ObjectLayer, jo
err = errBatchJobSections err = errBatchJobSections
} }
if err == nil { if err == nil {
ri.mu.Lock()
ri.PausedReason = ""
ri.mu.Unlock()
// Persist the report version before acting. Older binaries must not
// resume an authorization-bearing job with server authority.
if err := ri.updateAfter(ctx, api, 0, job); err != nil {
return true, batchUnavailable("saving job progress", err)
}
return false, nil return false, nil
} }
if isBatchJobUnavailable(err) {
return true, ri.savePaused(ctx, api, job, err)
}
if !isBatchJobAuthzError(err) { if !isBatchJobAuthzError(err) {
return true, err return true, err
} }
@@ -738,5 +827,8 @@ func (ri *batchJobInfo) startAuthorized(ctx context.Context, api ObjectLayer, jo
batchLogIf(ctx, fmt.Errorf("batch job %s refused: %w", job.ID, err)) batchLogIf(ctx, fmt.Errorf("batch job %s refused: %w", job.ID, err))
} }
globalBatchJobsMetrics.save(job.ID, ri) globalBatchJobsMetrics.save(job.ID, ri)
return true, ri.updateAfter(ctx, api, 0, job) if err := ri.updateAfter(ctx, api, 0, job); err != nil {
return true, batchUnavailable("saving job refusal", err)
}
return true, nil
} }
+3 -3
View File
@@ -481,8 +481,8 @@ func TestBatchExpirePurgeBudgetIncludesAuthorization(t *testing.T) {
} }
} }
// A listing that failed before any result fails the run without moving the // A listing outage pauses without moving the cursor or refusing the job;
// cursor or refusing the job; a later run completes it, in both modes. // a later run completes it in both modes.
func TestBatchExpireWalkFailureResumes(t *testing.T) { func TestBatchExpireWalkFailureResumes(t *testing.T) {
for _, purgeAll := range []bool{false, true} { for _, purgeAll := range []bool{false, true} {
name := "retain-one" name := "retain-one"
@@ -507,7 +507,7 @@ func TestBatchExpireWalkFailureResumes(t *testing.T) {
t.Fatalf("listing error: %v", err) t.Fatalf("listing error: %v", err)
} }
ri := f.jobInfo(t, job) ri := f.jobInfo(t, job)
if !ri.Failed || ri.Complete || ri.FailureReason != "" || ri.Object != "" || !f.versionExists(t, key, older) { if ri.Failed || ri.Complete || ri.PausedReason == "" || ri.FailureReason != "" || ri.Object != "" || !f.versionExists(t, key, older) {
t.Fatalf("listing failure changed progress or became terminal: failed=%v complete=%v reason=%q cursor=%q", ri.Failed, ri.Complete, ri.FailureReason, ri.Object) t.Fatalf("listing failure changed progress or became terminal: failed=%v complete=%v reason=%q cursor=%q", ri.Failed, ri.Complete, ri.FailureReason, ri.Object)
} }
mustIAM(t, job.Expire.Start(f.ctx, f.obj, *job)) mustIAM(t, job.Expire.Start(f.ctx, f.obj, *job))
+18 -11
View File
@@ -448,7 +448,7 @@ func (r *BatchJobExpire) expireVersions(ctx context.Context, api ObjectLayer, vc
ri.countRemoved(r.Bucket, 1, 0) ri.countRemoved(r.Bucket, 1, 0)
} }
return nil return nil
case isBatchJobAuthzError(err): case isBatchJobStopped(err):
return err return err
case isErrObjectLocked(err): case isErrObjectLocked(err):
// Left in place, not retried and not a failure. // Left in place, not retried and not a failure.
@@ -515,7 +515,7 @@ func (r *BatchJobExpire) expireAllVersions(ctx context.Context, api ObjectLayer,
switch { switch {
case err == nil: case err == nil:
return nil return nil
case isBatchJobAuthzError(err): case isBatchJobStopped(err):
return err return err
case errors.Is(err, errDeleteAllChanged): case errors.Is(err, errDeleteAllChanged):
// Changed since the walk matched it: not expired. // Changed since the walk matched it: not expired.
@@ -616,7 +616,7 @@ func batchObjsForDelete(ctx context.Context, refuse func(error), walkDone func()
} }
if err != nil { if err != nil {
// The batch is not done; a later run does it again. // The batch is not done; a later run does it again.
if isBatchJobAuthzError(err) { if isBatchJobStopped(err) {
refuse(err) refuse(err)
} }
globalBatchJobsMetrics.save(ri.JobID, ri) globalBatchJobsMetrics.save(ri.JobID, ri)
@@ -639,7 +639,7 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
StartTime: job.Started, StartTime: job.Started,
} }
if err := ri.loadOrInit(ctx, api, job); err != nil { if err := ri.loadOrInit(ctx, api, job); err != nil {
return err return batchUnavailable("loading job progress", err)
} }
// A completed job is not run again, as replication and key rotation // A completed job is not run again, as replication and key rotation
// jobs are not: its submitter is not checked again either, so a // jobs are not: its submitter is not checked again either, so a
@@ -671,7 +671,11 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
defer cancelCause(nil) defer cancelCause(nil)
// refuse ends the job for its submitter: record why before stopping. // refuse ends the job for its submitter: record why before stopping.
refuse := func(err error) { refuse := func(err error) {
if isBatchJobUnavailable(err) {
ri.pause(err)
} else {
ri.refuse(err) ri.refuse(err)
}
cancelCause(err) cancelCause(err)
} }
@@ -679,8 +683,6 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
// run, so the cursor cannot move past what was not listed. // run, so the cursor cannot move past what was not listed.
walkCtx, stopWalk := context.WithCancel(ctx) walkCtx, stopWalk := context.WithCancel(ctx)
defer stopWalk() defer stopWalk()
// walkFailed: a listing failed before it returned any result.
var walkFailed atomic.Bool
results := make(chan itemOrErr[ObjectInfo], workerSize) results := make(chan itemOrErr[ObjectInfo], workerSize)
go func() { go func() {
// One ascending walk over disjoint prefixes, which the cursor // One ascending walk over disjoint prefixes, which the cursor
@@ -697,8 +699,7 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
}) })
if err != nil { if err != nil {
if walkCtx.Err() == nil { if walkCtx.Err() == nil {
walkFailed.Store(true) cancelCause(batchListingError(err))
cancelCause(err)
} }
xioutil.SafeClose(results) xioutil.SafeClose(results)
return return
@@ -786,6 +787,7 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
} }
} }
} }
var listingErr error
for { for {
select { select {
case result, ok := <-results: case result, ok := <-results:
@@ -795,6 +797,7 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
} }
if result.Err != nil { if result.Err != nil {
failed = true failed = true
listingErr = batchListingError(result.Err)
batchLogIf(ctx, result.Err) batchLogIf(ctx, result.Err)
stopWalk() stopWalk()
done = true done = true
@@ -889,11 +892,15 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
<-saverDoneCh <-saverDoneCh
refused := ri.refused() refused := ri.refused()
cause := context.Cause(ctx) cause := context.Cause(ctx)
if cause == nil {
cause = listingErr
}
if cause != nil && !refused { if cause != nil && !refused {
if walkFailed.Load() { if isBatchJobUnavailable(cause) {
// This run failed; a later run lists again from the cursor. return ri.savePaused(ctx, api, job, cause)
}
if isErrBucketNotFound(cause) {
ri.mu.Lock() ri.mu.Lock()
ri.Complete = false
ri.Failed = true ri.Failed = true
ri.mu.Unlock() ri.mu.Unlock()
} }
+97 -59
View File
@@ -454,7 +454,7 @@ func (r *BatchJobReplicateV1) StartFromSource(ctx context.Context, api ObjectLay
StartTime: job.Started, StartTime: job.Started,
} }
if err := ri.loadOrInit(ctx, api, job); err != nil { if err := ri.loadOrInit(ctx, api, job); err != nil {
return err return batchUnavailable("loading job progress", err)
} }
if ri.Complete { if ri.Complete {
return nil return nil
@@ -1065,6 +1065,7 @@ type batchJobInfo struct {
// FailureReason is set when the job was refused for its submitter. It // FailureReason is set when the job was refused for its submitter. It
// ends the job: a job that has one does not run again. // ends the job: a job that has one does not run again.
FailureReason string `json:"failureReason,omitempty" msg:"fr"` FailureReason string `json:"failureReason,omitempty" msg:"fr"`
PausedReason string `json:"pausedReason,omitempty" msg:"pr"`
} }
const ( const (
@@ -1073,6 +1074,9 @@ const (
batchReplVersionV1 = 1 batchReplVersionV1 = 1
batchReplVersion = batchReplVersionV1 batchReplVersion = batchReplVersionV1
batchJobName = "job.bin" batchJobName = "job.bin"
// The envelope rejects old decoders before they can ignore Submitter.
batchJobEnvelope = "SILO-BATCH\x02"
batchAuthorizedReportVersion = 2
batchJobPrefix = "batch-jobs" batchJobPrefix = "batch-jobs"
batchJobReportsPrefix = batchJobPrefix + "/reports" batchJobReportsPrefix = batchJobPrefix + "/reports"
@@ -1162,7 +1166,7 @@ func (ri *batchJobInfo) loadByPath(ctx context.Context, api ObjectLayer, path st
return fmt.Errorf("%s: unknown format: %d", ri.JobType, binary.LittleEndian.Uint16(data[0:2])) return fmt.Errorf("%s: unknown format: %d", ri.JobType, binary.LittleEndian.Uint16(data[0:2]))
} }
switch binary.LittleEndian.Uint16(data[2:4]) { switch binary.LittleEndian.Uint16(data[2:4]) {
case version: case version, batchAuthorizedReportVersion:
default: default:
return fmt.Errorf("%s: unknown version: %d", ri.JobType, binary.LittleEndian.Uint16(data[2:4])) return fmt.Errorf("%s: unknown version: %d", ri.JobType, binary.LittleEndian.Uint16(data[2:4]))
} }
@@ -1210,6 +1214,7 @@ func (ri *batchJobInfo) clone() *batchJobInfo {
VersionsSkippedLocked: ri.VersionsSkippedLocked, VersionsSkippedLocked: ri.VersionsSkippedLocked,
KeysRefused: ri.KeysRefused, KeysRefused: ri.KeysRefused,
FailureReason: ri.FailureReason, FailureReason: ri.FailureReason,
PausedReason: ri.PausedReason,
} }
} }
@@ -1263,6 +1268,7 @@ func (ri *batchJobInfo) updateAfter(ctx context.Context, api ObjectLayer, durati
jobTyp = string(job.Type()) jobTyp = string(job.Type())
ri.Version = batchExpireVersionV1 ri.Version = batchExpireVersionV1
default: default:
ri.mu.Unlock()
return errInvalidArgument return errInvalidArgument
} }
if serverDebugLog { if serverDebugLog {
@@ -1270,6 +1276,9 @@ func (ri *batchJobInfo) updateAfter(ctx context.Context, api ObjectLayer, durati
} }
ri.LastUpdate = now ri.LastUpdate = now
if job.Submitter != nil {
version = batchAuthorizedReportVersion
}
data := make([]byte, 4, ri.Msgsize()+4) data := make([]byte, 4, ri.Msgsize()+4)
// Initialize the header. // Initialize the header.
@@ -1336,7 +1345,7 @@ func (r *BatchJobReplicateV1) Start(ctx context.Context, api ObjectLayer, job Ba
StartTime: job.Started, StartTime: job.Started,
} }
if err := ri.loadOrInit(ctx, api, job); err != nil { if err := ri.loadOrInit(ctx, api, job); err != nil {
return err return batchUnavailable("loading job progress", err)
} }
if ri.Complete { if ri.Complete {
return nil return nil
@@ -1880,7 +1889,11 @@ func (j *BatchJobRequest) save(ctx context.Context, api ObjectLayer) error {
return err return err
} }
job, err := j.MarshalMsg(nil) var prefix []byte
if j.Submitter != nil {
prefix = []byte(batchJobEnvelope)
}
job, err := j.MarshalMsg(prefix)
if err != nil { if err != nil {
return err return err
} }
@@ -1901,6 +1914,12 @@ func (j *BatchJobRequest) load(ctx context.Context, api ObjectLayer, name string
return err return err
} }
if bytes.HasPrefix(job, []byte("SILO-BATCH")) {
if !bytes.HasPrefix(job, []byte(batchJobEnvelope)) {
return errors.New("unsupported batch job envelope version")
}
job = job[len(batchJobEnvelope):]
}
_, err = j.UnmarshalMsg(job) _, err = j.UnmarshalMsg(job)
return err return err
} }
@@ -2070,7 +2089,7 @@ func (a adminAPIHandlers) DescribeBatchJob(w http.ResponseWriter, r *http.Reques
func (a adminAPIHandlers) StartBatchJob(w http.ResponseWriter, r *http.Request) { func (a adminAPIHandlers) StartBatchJob(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
objectAPI, creds := validateAdminReq(ctx, w, r, policy.StartBatchJobAction) objectAPI, creds := validateBatchJobAdminReq(ctx, w, r)
if objectAPI == nil { if objectAPI == nil {
return return
} }
@@ -2130,6 +2149,8 @@ func (a adminAPIHandlers) StartBatchJob(w http.ResponseWriter, r *http.Request)
apiErr := toAPIError(ctx, err) apiErr := toAPIError(ctx, err)
if isBatchJobAuthzError(err) { if isBatchJobAuthzError(err) {
apiErr = errorCodes.ToAPIErrWithErr(ErrAccessDenied, err) apiErr = errorCodes.ToAPIErrWithErr(ErrAccessDenied, err)
} else if isBatchJobUnavailable(err) {
apiErr = batchServiceUnavailable(err)
} }
writeErrorResponseJSON(ctx, w, apiErr, r.URL) writeErrorResponseJSON(ctx, w, apiErr, r.URL)
return return
@@ -2151,6 +2172,11 @@ func (a adminAPIHandlers) StartBatchJob(w http.ResponseWriter, r *http.Request)
} }
if err = globalBatchJobPool.queueJob(job); err != nil { if err = globalBatchJobPool.queueJob(job); err != nil {
// Admission failed (normally shutdown). Do not leave an unacknowledged
// manifest that could start after restart.
if deleteErr := job.delete(ctx, objectAPI); deleteErr != nil {
batchLogIf(ctx, deleteErr)
}
writeErrorResponseJSON(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponseJSON(ctx, w, toAPIError(ctx, err), r.URL)
return return
} }
@@ -2197,8 +2223,10 @@ func (a adminAPIHandlers) CancelBatchJob(w http.ResponseWriter, r *http.Request)
ID: jobID, ID: jobID,
} }
j.delete(ctx, objectAPI) if err := j.delete(ctx, objectAPI); err != nil {
writeErrorResponseJSON(ctx, w, toAPIError(ctx, err), r.URL)
return
}
writeSuccessNoContent(w) writeSuccessNoContent(w)
} }
@@ -2208,11 +2236,10 @@ func (a adminAPIHandlers) CancelBatchJob(w http.ResponseWriter, r *http.Request)
type BatchJobPool struct { type BatchJobPool struct {
ctx context.Context ctx context.Context
objLayer ObjectLayer objLayer ObjectLayer
once sync.Once
mu sync.Mutex mu sync.Mutex
jobCh chan *BatchJobRequest jobCh chan *BatchJobRequest
jmu sync.Mutex // protects jobCancelers jmu sync.Mutex // protects jobOwners
jobCancelers map[string]context.CancelFunc jobOwners map[string]*batchJobOwner
workerKillCh chan struct{} workerKillCh chan struct{}
workerSize int workerSize int
} }
@@ -2226,9 +2253,10 @@ func newBatchJobPool(ctx context.Context, o ObjectLayer, workers int) *BatchJobP
objLayer: o, objLayer: o,
jobCh: make(chan *BatchJobRequest, 10000), jobCh: make(chan *BatchJobRequest, 10000),
workerKillCh: make(chan struct{}, workers), workerKillCh: make(chan struct{}, workers),
jobCancelers: make(map[string]context.CancelFunc), jobOwners: make(map[string]*batchJobOwner),
} }
jpool.ResizeWorkers(workers) jpool.ResizeWorkers(workers)
go jpool.retryLoop()
randomWait := func() time.Duration { randomWait := func() time.Duration {
// randomWait depends on the number of nodes to avoid triggering resume and cleanups at the same time. // randomWait depends on the number of nodes to avoid triggering resume and cleanups at the same time.
@@ -2249,14 +2277,14 @@ func (j *BatchJobPool) cleanupReports(randomWait func() time.Duration) {
for { for {
select { select {
case <-GlobalContext.Done(): case <-j.ctx.Done():
return return
case <-t.C: case <-t.C:
results := make(chan itemOrErr[ObjectInfo], 100) results := make(chan itemOrErr[ObjectInfo], 100)
ctx, cancel := context.WithCancel(j.ctx) ctx, cancel := context.WithCancel(j.ctx)
defer cancel()
if err := j.objLayer.Walk(ctx, minioMetaBucket, batchJobReportsPrefix, results, WalkOptions{}); err != nil { if err := j.objLayer.Walk(ctx, minioMetaBucket, batchJobReportsPrefix, results, WalkOptions{}); err != nil {
batchLogIf(j.ctx, err) batchLogIf(j.ctx, err)
cancel()
t.Reset(randomWait()) t.Reset(randomWait())
continue continue
} }
@@ -2283,13 +2311,20 @@ func (j *BatchJobPool) cleanupReports(randomWait func() time.Duration) {
} }
} }
cancel()
t.Reset(randomWait()) t.Reset(randomWait())
} }
} }
} }
func (j *BatchJobPool) resume(randomWait func() time.Duration) { func (j *BatchJobPool) resume(randomWait func() time.Duration) {
time.Sleep(randomWait()) timer := time.NewTimer(randomWait())
defer timer.Stop()
select {
case <-j.ctx.Done():
return
case <-timer.C:
}
results := make(chan itemOrErr[ObjectInfo], 100) results := make(chan itemOrErr[ObjectInfo], 100)
ctx, cancel := context.WithCancel(j.ctx) ctx, cancel := context.WithCancel(j.ctx)
@@ -2340,43 +2375,26 @@ func (j *BatchJobPool) AddWorker() {
if !ok { if !ok {
return return
} }
if !j.beginJob(job) {
continue
}
var err error
switch { switch {
case job.Replicate != nil: case job.Replicate != nil:
if job.Replicate.RemoteToLocal() { if job.Replicate.RemoteToLocal() {
if err := job.Replicate.StartFromSource(job.ctx, j.objLayer, *job); err != nil { err = job.Replicate.StartFromSource(job.ctx, j.objLayer, *job)
if !isErrBucketNotFound(err) {
batchLogIf(j.ctx, err)
j.canceler(job.ID, false)
continue
}
// Bucket not found proceed to delete such a job.
}
} else { } else {
if err := job.Replicate.Start(job.ctx, j.objLayer, *job); err != nil { err = job.Replicate.Start(job.ctx, j.objLayer, *job)
if !isErrBucketNotFound(err) {
batchLogIf(j.ctx, err)
j.canceler(job.ID, false)
continue
}
// Bucket not found proceed to delete such a job.
}
} }
case job.KeyRotate != nil: case job.KeyRotate != nil:
if err := job.KeyRotate.Start(job.ctx, j.objLayer, *job); err != nil { err = job.KeyRotate.Start(job.ctx, j.objLayer, *job)
if !isErrBucketNotFound(err) {
batchLogIf(j.ctx, err)
continue
}
}
case job.Expire != nil: case job.Expire != nil:
if err := job.Expire.Start(job.ctx, j.objLayer, *job); err != nil { err = job.Expire.Start(job.ctx, j.objLayer, *job)
if !isErrBucketNotFound(err) { }
if err != nil {
batchLogIf(j.ctx, err) batchLogIf(j.ctx, err)
continue
} }
} j.finishJob(job, err)
}
j.canceler(job.ID, false)
case <-j.workerKillCh: case <-j.workerKillCh:
return return
} }
@@ -2398,46 +2416,66 @@ func (j *BatchJobPool) ResizeWorkers(n int) {
} }
for j.workerSize > n { for j.workerSize > n {
j.workerSize-- j.workerSize--
go func() { j.workerKillCh <- struct{}{} }() go func() {
select {
case j.workerKillCh <- struct{}{}:
case <-j.ctx.Done():
}
}()
} }
} }
func (j *BatchJobPool) queueJob(req *BatchJobRequest) error { func (j *BatchJobPool) queueJob(req *BatchJobRequest) error {
if j == nil { if j == nil || req == nil || req.ID == "" {
return errInvalidArgument return errInvalidArgument
} }
jctx, jcancel := context.WithCancel(j.ctx)
j.jmu.Lock() j.jmu.Lock()
j.jobCancelers[req.ID] = jcancel defer j.jmu.Unlock()
j.jmu.Unlock() if err := j.ctx.Err(); err != nil {
return err
}
if owner := j.jobOwners[req.ID]; owner != nil {
if owner.state == "canceled" {
return context.Canceled
}
return nil
}
if j.jobOwners == nil {
j.jobOwners = make(map[string]*batchJobOwner)
}
jctx, jcancel := context.WithCancel(j.ctx)
req.ctx = jctx req.ctx = jctx
owner := &batchJobOwner{req: req, cancel: jcancel, state: "queued"}
j.jobOwners[req.ID] = owner
// A full channel retains explicit pending work owned by the scheduler.
// The durable submission still succeeds and cannot become ownerless.
select { select {
case <-j.ctx.Done():
j.once.Do(func() {
xioutil.SafeClose(j.jobCh)
})
case j.jobCh <- req: case j.jobCh <- req:
default: default:
return fmt.Errorf("batch job queue is currently full please try again later (job %s)", req.ID) owner.state = "paused"
} }
return nil return nil
} }
// delete canceler from the map, cancel job if requested
func (j *BatchJobPool) canceler(jobID string, cancel bool) error { func (j *BatchJobPool) canceler(jobID string, cancel bool) error {
if j == nil { if j == nil {
return errInvalidArgument return errInvalidArgument
} }
j.jmu.Lock() j.jmu.Lock()
defer j.jmu.Unlock() defer j.jmu.Unlock()
if canceler, ok := j.jobCancelers[jobID]; ok { if !cancel {
if cancel { return nil
canceler() } // Worker completion uses its generation token.
if j.jobOwners == nil {
j.jobOwners = make(map[string]*batchJobOwner)
} }
if owner := j.jobOwners[jobID]; owner != nil {
if owner.cancel != nil {
owner.cancel()
} }
if cancel { j.jobOwners[jobID] = &batchJobOwner{state: "canceled"}
delete(j.jobCancelers, jobID) } else {
j.jobOwners[jobID] = &batchJobOwner{state: "canceled"}
} }
return nil return nil
} }
+30 -5
View File
@@ -655,6 +655,12 @@ func (z *batchJobInfo) DecodeMsg(dc *msgp.Reader) (err error) {
err = msgp.WrapError(err, "FailureReason") err = msgp.WrapError(err, "FailureReason")
return return
} }
case "pr":
z.PausedReason, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "PausedReason")
return
}
default: default:
err = dc.Skip() err = dc.Skip()
if err != nil { if err != nil {
@@ -668,9 +674,9 @@ func (z *batchJobInfo) DecodeMsg(dc *msgp.Reader) (err error) {
// EncodeMsg implements msgp.Encodable // EncodeMsg implements msgp.Encodable
func (z *batchJobInfo) EncodeMsg(en *msgp.Writer) (err error) { func (z *batchJobInfo) EncodeMsg(en *msgp.Writer) (err error) {
// map header, size 20 // map header, size 21
// write "v" // write "v"
err = en.Append(0xde, 0x0, 0x14, 0xa1, 0x76) err = en.Append(0xde, 0x0, 0x15, 0xa1, 0x76)
if err != nil { if err != nil {
return return
} }
@@ -869,15 +875,25 @@ func (z *batchJobInfo) EncodeMsg(en *msgp.Writer) (err error) {
err = msgp.WrapError(err, "FailureReason") err = msgp.WrapError(err, "FailureReason")
return return
} }
// write "pr"
err = en.Append(0xa2, 0x70, 0x72)
if err != nil {
return
}
err = en.WriteString(z.PausedReason)
if err != nil {
err = msgp.WrapError(err, "PausedReason")
return
}
return return
} }
// MarshalMsg implements msgp.Marshaler // MarshalMsg implements msgp.Marshaler
func (z *batchJobInfo) MarshalMsg(b []byte) (o []byte, err error) { func (z *batchJobInfo) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize()) o = msgp.Require(b, z.Msgsize())
// map header, size 20 // map header, size 21
// string "v" // string "v"
o = append(o, 0xde, 0x0, 0x14, 0xa1, 0x76) o = append(o, 0xde, 0x0, 0x15, 0xa1, 0x76)
o = msgp.AppendInt(o, z.Version) o = msgp.AppendInt(o, z.Version)
// string "jid" // string "jid"
o = append(o, 0xa3, 0x6a, 0x69, 0x64) o = append(o, 0xa3, 0x6a, 0x69, 0x64)
@@ -936,6 +952,9 @@ func (z *batchJobInfo) MarshalMsg(b []byte) (o []byte, err error) {
// string "fr" // string "fr"
o = append(o, 0xa2, 0x66, 0x72) o = append(o, 0xa2, 0x66, 0x72)
o = msgp.AppendString(o, z.FailureReason) o = msgp.AppendString(o, z.FailureReason)
// string "pr"
o = append(o, 0xa2, 0x70, 0x72)
o = msgp.AppendString(o, z.PausedReason)
return return
} }
@@ -1077,6 +1096,12 @@ func (z *batchJobInfo) UnmarshalMsg(bts []byte) (o []byte, err error) {
err = msgp.WrapError(err, "FailureReason") err = msgp.WrapError(err, "FailureReason")
return return
} }
case "pr":
z.PausedReason, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "PausedReason")
return
}
default: default:
bts, err = msgp.Skip(bts) bts, err = msgp.Skip(bts)
if err != nil { if err != nil {
@@ -1091,6 +1116,6 @@ func (z *batchJobInfo) UnmarshalMsg(bts []byte) (o []byte, err error) {
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message // Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *batchJobInfo) Msgsize() (s int) { func (z *batchJobInfo) Msgsize() (s int) {
s = 3 + 2 + msgp.IntSize + 4 + msgp.StringPrefixSize + len(z.JobID) + 3 + msgp.StringPrefixSize + len(z.JobType) + 3 + msgp.TimeSize + 3 + msgp.TimeSize + 3 + msgp.IntSize + 3 + msgp.IntSize + 4 + msgp.BoolSize + 4 + msgp.BoolSize + 5 + msgp.StringPrefixSize + len(z.Bucket) + 5 + msgp.StringPrefixSize + len(z.Object) + 3 + msgp.Int64Size + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Int64Size + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Int64Size + 3 + msgp.StringPrefixSize + len(z.FailureReason) s = 3 + 2 + msgp.IntSize + 4 + msgp.StringPrefixSize + len(z.JobID) + 3 + msgp.StringPrefixSize + len(z.JobType) + 3 + msgp.TimeSize + 3 + msgp.TimeSize + 3 + msgp.IntSize + 3 + msgp.IntSize + 4 + msgp.BoolSize + 4 + msgp.BoolSize + 5 + msgp.StringPrefixSize + len(z.Bucket) + 5 + msgp.StringPrefixSize + len(z.Object) + 3 + msgp.Int64Size + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Int64Size + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Int64Size + 3 + msgp.StringPrefixSize + len(z.FailureReason) + 3 + msgp.StringPrefixSize + len(z.PausedReason)
return return
} }
+2
View File
@@ -173,6 +173,7 @@ type batchJobStatusMetric struct {
VersionsSkippedLocked int64 `json:"versionsSkippedLocked,omitempty"` VersionsSkippedLocked int64 `json:"versionsSkippedLocked,omitempty"`
KeysRefused int64 `json:"keysRefused,omitempty"` KeysRefused int64 `json:"keysRefused,omitempty"`
FailureReason string `json:"failureReason,omitempty"` FailureReason string `json:"failureReason,omitempty"`
PausedReason string `json:"pausedReason,omitempty"`
} }
func (ri *batchJobInfo) statusMetric() batchJobStatusMetric { func (ri *batchJobInfo) statusMetric() batchJobStatusMetric {
@@ -183,5 +184,6 @@ func (ri *batchJobInfo) statusMetric() batchJobStatusMetric {
VersionsSkippedLocked: ri.VersionsSkippedLocked, VersionsSkippedLocked: ri.VersionsSkippedLocked,
KeysRefused: ri.KeysRefused, KeysRefused: ri.KeysRefused,
FailureReason: ri.FailureReason, FailureReason: ri.FailureReason,
PausedReason: ri.PausedReason,
} }
} }
+194
View File
@@ -0,0 +1,194 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"context"
"errors"
"fmt"
"math/rand"
"net/http"
"time"
"github.com/minio/minio/internal/auth"
"github.com/pgsty/silo-pkg/v3/policy"
)
// An unavailable dependency is not an authorization decision. It stops the
// current attempt without turning an outage into either an allow or a denial.
type batchJobUnavailableError struct {
reason string
err error
}
func (e batchJobUnavailableError) Error() string { return fmt.Sprintf("%s: %v", e.reason, e.err) }
func (e batchJobUnavailableError) Unwrap() error { return e.err }
func batchUnavailable(reason string, err error) error {
if err == nil {
return nil
}
if isBatchJobUnavailable(err) {
return err
}
return batchJobUnavailableError{reason: reason, err: err}
}
func isBatchJobUnavailable(err error) bool { var e batchJobUnavailableError; return errors.As(err, &e) }
func isBatchJobStopped(err error) bool {
return isBatchJobAuthzError(err) || isBatchJobUnavailable(err)
}
func batchListingError(err error) error {
if isErrBucketNotFound(err) {
return err
}
return batchUnavailable("object listing unavailable", err)
}
func (ri *batchJobInfo) pause(err error) {
ri.mu.Lock()
defer ri.mu.Unlock()
// A concurrent explicit denial wins over a transient worker error.
if ri.FailureReason != "" {
return
}
ri.Complete, ri.Failed = false, false
ri.PausedReason = err.Error()
}
func (ri *batchJobInfo) savePaused(ctx context.Context, api ObjectLayer, job BatchJobRequest, err error) error {
ri.pause(err)
globalBatchJobsMetrics.save(job.ID, ri)
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
if saveErr := ri.updateAfter(saveCtx, api, 0, job); saveErr != nil {
return batchUnavailable("saving paused job", errors.Join(err, saveErr))
}
return err
}
// Authenticate the HTTP request normally, but keep dependency errors through
// the authorization step so submission returns 503, not a permanent 403.
func validateBatchJobAdminReq(ctx context.Context, w http.ResponseWriter, r *http.Request) (ObjectLayer, auth.Credentials) {
api := newObjectLayerFn()
if api == nil || globalNotificationSys == nil || !batchIAMReady() {
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
return nil, auth.Credentials{}
}
cred, owner, code := validateAdminSignature(ctx, r, "")
if code != ErrNone {
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(code), r.URL)
return nil, cred
}
submitter, err := newBatchJobSubmitter(cred)
if err == nil {
var principal batchJobPrincipal
principal, err = submitter.principal(ctx)
if err == nil {
err = principal.authorizeArgs(policy.Args{AccountName: cred.AccessKey, Groups: cred.Groups,
Action: policy.Action(policy.StartBatchJobAction), ConditionValues: getConditionValues(r, "", cred), IsOwner: owner, Claims: principal.claims}, nil)
}
}
if err != nil {
apiErr := errorCodes.ToAPIErrWithErr(ErrAccessDenied, err)
if isBatchJobUnavailable(err) {
apiErr = batchServiceUnavailable(err)
}
writeErrorResponseJSON(ctx, w, apiErr, r.URL)
return nil, cred
}
return api, cred
}
// One owner record spans queued, running and paused attempts. The request
// pointer is the generation token. Canceled records are tombstones until
// process shutdown, so a manifest loaded before cancellation cannot requeue.
type batchJobOwner struct {
req *BatchJobRequest
cancel context.CancelFunc
state string
retries uint
due time.Time
}
func (j *BatchJobPool) beginJob(job *BatchJobRequest) bool {
j.jmu.Lock()
defer j.jmu.Unlock()
o := j.jobOwners[job.ID]
if o == nil || o.req != job || o.state != "queued" || job.ctx.Err() != nil || j.ctx.Err() != nil {
return false
}
o.state = "running"
return true
}
func (j *BatchJobPool) finishJob(job *BatchJobRequest, err error) {
j.jmu.Lock()
defer j.jmu.Unlock()
o := j.jobOwners[job.ID]
if o == nil || o.req != job || o.state != "running" {
return
}
if isBatchJobUnavailable(err) && job.ctx.Err() == nil && j.ctx.Err() == nil {
delay := 30 * time.Second * time.Duration(1<<min(o.retries, 4))
o.retries++
o.due = time.Now().Add(delay/2 + time.Duration(rand.Float64()*float64(delay)))
o.state = "paused"
return
}
o.cancel()
delete(j.jobOwners, job.ID)
}
// retryDue holds the ownership lock across the nonblocking handoff. A fast
// worker cannot replace a pause while the scheduler removes the old pause.
func (j *BatchJobPool) retryDue(now time.Time) {
j.jmu.Lock()
defer j.jmu.Unlock()
if j.ctx.Err() != nil {
return
}
for _, o := range j.jobOwners {
if o.state != "paused" || now.Before(o.due) || o.req.ctx.Err() != nil {
continue
}
select {
case j.jobCh <- o.req:
o.state = "queued"
default:
}
}
}
func (j *BatchJobPool) retryLoop() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-j.ctx.Done():
return
case now := <-ticker.C:
j.retryDue(now)
}
}
}
func batchServiceUnavailable(err error) APIError {
return APIError{Code: "ServiceUnavailable", Description: err.Error(), HTTPStatusCode: http.StatusServiceUnavailable}
}
// initStore precedes the first successful cache load. During that interval a
// missing identity or policy is not a revocation decision, including for root.
func batchIAMReady() bool {
sys := globalIAMSys
if sys == nil {
return false
}
sys.Lock()
defer sys.Unlock()
if sys.store == nil {
return false
}
select {
case <-sys.configLoaded:
return true
default:
return false
}
}
+312
View File
@@ -0,0 +1,312 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
polplugin "github.com/minio/minio/internal/config/policy/plugin"
xnet "github.com/pgsty/silo-pkg/v3/net"
"github.com/pgsty/silo-pkg/v3/policy"
"github.com/tinylib/msgp/msgp"
)
func waitBatchState(t *testing.T, pool *BatchJobPool, id, state string) {
t.Helper()
timeout := time.NewTimer(10 * time.Second)
defer timeout.Stop()
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for {
pool.jmu.Lock()
owner := pool.jobOwners[id]
ready := owner == nil && state == ""
if owner != nil {
ready = owner.state == state
}
pool.jmu.Unlock()
if ready {
return
}
select {
case <-timeout.C:
t.Fatalf("job did not reach %q", state)
case <-tick.C:
}
}
}
func TestBatchPluginPauseRecoversInProcess(t *testing.T) {
for _, midrun := range []bool{false, true} {
t.Run(map[bool]string{false: "before-start", true: "during-delete"}[midrun], func(t *testing.T) {
f := newBatchAuthzFixture(t)
older := f.putVersions(t, "data/key")
var unavailable atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
Input policy.Args `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&request)
if unavailable.Load() && (!midrun || request.Input.Action == policy.DeleteObjectVersionAction) {
w.WriteHeader(503)
io.WriteString(w, `{"result":true}`)
return
}
io.WriteString(w, `{"result":true}`)
}))
defer server.Close()
url, err := xnet.ParseHTTPURL(server.URL)
mustIAM(t, err)
previous := newGlobalAuthZPluginFn()
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{URL: url, CloseRespFn: func(r io.ReadCloser) { r.Close() }}))
defer setGlobalAuthZPlugin(previous)
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
if job == nil {
t.Fatalf("submit %d: %s", rec.Code, rec.Body.String())
}
pool := globalBatchJobPool
ctx, cancel := context.WithCancel(f.ctx)
pool.ctx = ctx
// Re-admit the fixture's consumed request with a context owned by this worker.
pool.jmu.Lock()
pool.jobOwners[job.ID].cancel()
delete(pool.jobOwners, job.ID)
pool.jmu.Unlock()
mustIAM(t, pool.queueJob(job))
unavailable.Store(true)
done := make(chan struct{})
go func() { defer close(done); pool.AddWorker() }()
defer func() { cancel(); <-done }()
waitBatchState(t, pool, job.ID, "paused")
ri := f.jobInfo(t, job)
if ri.Failed || ri.Complete || ri.FailureReason != "" || ri.PausedReason == "" || ri.Object != "" {
t.Fatalf("paused report: %+v", ri)
}
if !f.versionExists(t, "data/key", older) {
t.Fatal("outage authorized a deletion")
}
unavailable.Store(false)
pool.retryDue(time.Now().Add(time.Hour))
waitBatchState(t, pool, job.ID, "")
ri = f.jobInfo(t, job)
if !ri.Complete || ri.Failed || ri.PausedReason != "" || ri.Objects != 1 || f.versionExists(t, "data/key", older) {
t.Fatalf("resumed report: %+v", ri)
}
})
}
}
func TestBatchSubmissionUnavailableIs503(t *testing.T) {
f := newBatchAuthzFixture(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503); io.WriteString(w, `{"result":true}`) }))
defer server.Close()
url, err := xnet.ParseHTTPURL(server.URL)
mustIAM(t, err)
previous := newGlobalAuthZPluginFn()
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{URL: url, CloseRespFn: func(r io.ReadCloser) { r.Close() }}))
defer setGlobalAuthZPlugin(previous)
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
if rec.Code != 503 || job != nil {
t.Fatalf("outage submission: %d %s", rec.Code, rec.Body.String())
}
}
func TestBatchPoolOwnershipAndCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
pool := &BatchJobPool{ctx: ctx, jobCh: make(chan *BatchJobRequest, 1)}
first, second := &BatchJobRequest{ID: "first"}, &BatchJobRequest{ID: "second"}
mustIAM(t, pool.queueJob(first))
mustIAM(t, pool.queueJob(second))
if pool.jobOwners[second.ID].state != "paused" {
t.Fatal("full queue lost pending owner")
}
original := second.ctx
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
if err := pool.queueJob(&BatchJobRequest{ID: "second"}); err != nil {
t.Error(err)
}
})
}
wg.Wait()
if second.ctx != original || len(pool.jobCh) != 1 {
t.Fatal("duplicate changed ownership")
}
if !pool.beginJob(<-pool.jobCh) {
t.Fatal("first not owned")
}
pool.finishJob(first, nil)
pool.retryDue(time.Now())
next := <-pool.jobCh
if next != second || !pool.beginJob(next) {
t.Fatal("pending handoff lost ownership")
}
for range 2 {
pool.finishJob(second, batchUnavailable("test", errors.New("outage")))
pool.retryDue(time.Now().Add(time.Hour))
if !pool.beginJob(<-pool.jobCh) {
t.Fatal("retry lost owner")
}
}
if pool.jobOwners[second.ID].retries != 2 {
t.Fatal("retry count reset during handoff")
}
mustIAM(t, pool.canceler(second.ID, true))
mustIAM(t, pool.canceler(second.ID, true))
pool.finishJob(second, batchUnavailable("late", errors.New("outage")))
pool.retryDue(time.Now().Add(time.Hour))
if len(pool.jobCh) != 0 || second.ctx.Err() == nil {
t.Fatal("cancel lost to retry")
}
if err := pool.queueJob(&BatchJobRequest{ID: second.ID}); !errors.Is(err, context.Canceled) {
t.Fatalf("stale admission: %v", err)
}
// A cancellation before this node admitted a manifest is a tombstone too.
mustIAM(t, pool.canceler("not-loaded", true))
mustIAM(t, pool.canceler("not-loaded", true))
if err := pool.queueJob(&BatchJobRequest{ID: "not-loaded"}); !errors.Is(err, context.Canceled) {
t.Fatalf("stale resume: %v", err)
}
cancel()
if err := pool.queueJob(&BatchJobRequest{ID: "shutdown"}); !errors.Is(err, context.Canceled) {
t.Fatalf("shutdown admission: %v", err)
}
pool.retryDue(time.Now().Add(time.Hour)) // channel remains open; no send/close race.
}
type batchPolicyOutage struct {
IAMStorageAPI
failed string
err error
}
func (s *batchPolicyOutage) loadPolicyDoc(ctx context.Context, name string, out map[string]PolicyDoc) error {
if name == s.failed {
return s.err
}
return s.IAMStorageAPI.loadPolicyDoc(ctx, name, out)
}
func TestBatchPolicySnapshotKeepsUnavailableDeny(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "retry-allow", batchAuthzFullAccess)
batchAuthzPolicy(f.ctx, t, f.sys, "retry-deny", `{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"]}`)
cred := iamAdminUser(f.ctx, t, f.sys, "retry-user", "retry-allow,retry-deny")
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
store := f.sys.store
original := store.IAMStorageAPI
defer func() { store.IAMStorageAPI = original }()
cache := store.lock()
delete(cache.iamPolicyDocsMap, "retry-deny")
store.unlock()
store.IAMStorageAPI = &batchPolicyOutage{IAMStorageAPI: original, failed: "retry-deny", err: errErasureReadQuorum}
request := batchAuthzRequest{action: policy.DeleteObjectVersionAction, bucket: f.bucket, object: "key", versionID: mustGetUUID()}
if err := sub.authorize(f.ctx, request); !isBatchJobUnavailable(err) {
t.Fatalf("missing Deny must pause, got %v", err)
}
store.IAMStorageAPI = original
if err := sub.authorize(f.ctx, request); !isBatchJobAuthzError(err) {
t.Fatalf("recovered Deny was not enforced: %v", err)
}
// Root also waits for IAM readiness; startup must not bypass the pause gate.
root, err := newBatchJobSubmitter(globalActiveCred)
mustIAM(t, err)
f.sys.Lock()
f.sys.store = nil
f.sys.Unlock()
_, err = root.principal(f.ctx)
f.sys.Lock()
f.sys.store = store
f.sys.Unlock()
if !isBatchJobUnavailable(err) {
t.Fatalf("root before IAM readiness: %v", err)
}
}
func TestBatchPausedReportCompatibility(t *testing.T) {
before := &batchJobInfo{JobID: "paused", Object: "cursor", PausedReason: "IAM unavailable"}
data, err := before.MarshalMsg(nil)
mustIAM(t, err)
var after batchJobInfo
_, err = after.UnmarshalMsg(data)
mustIAM(t, err)
if after.PausedReason != before.PausedReason || after.clone().PausedReason != before.PausedReason || after.statusMetric().PausedReason != before.PausedReason {
t.Fatal("paused reason lost")
}
old := msgp.AppendMapHeader(nil, 2)
old = msgp.AppendString(old, "jid")
old = msgp.AppendString(old, "legacy")
old = msgp.AppendString(old, "cmp")
old = msgp.AppendBool(old, true)
var legacy batchJobInfo
_, err = legacy.UnmarshalMsg(old)
mustIAM(t, err)
if legacy.PausedReason != "" || !legacy.Complete {
t.Fatal("old report decode changed")
}
after.refuse(errBatchJobInvalid)
if after.PausedReason != "" || !after.Failed {
t.Fatal("terminal denial retained pause")
}
}
func TestBatchPersistenceFencesOlderExecutors(t *testing.T) {
f := newBatchAuthzFixture(t)
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
if job == nil {
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
}
raw, err := readConfig(f.ctx, f.obj, getJobPath(*job))
mustIAM(t, err)
if !bytes.HasPrefix(raw, []byte(batchJobEnvelope)) {
t.Fatal("new manifest has no executor version fence")
}
var oldDecoder BatchJobRequest
if _, err := oldDecoder.UnmarshalMsg(raw); err == nil {
t.Fatal("old bare-msgpack executor could decode new manifest")
}
var loaded BatchJobRequest
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
if loaded.Submitter == nil {
t.Fatal("new executor lost submitter")
}
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started}
outage := batchUnavailable("test", errErasureReadQuorum)
if err := ri.savePaused(f.ctx, f.obj, *job, outage); !errors.Is(err, outage) {
t.Fatal(err)
}
reportPath, err := job.getJobReportPath()
mustIAM(t, err)
report, err := readConfig(f.ctx, f.obj, reportPath)
mustIAM(t, err)
if binary.LittleEndian.Uint16(report[2:4]) == batchExpireVersionV1 {
t.Fatal("old executor would accept paused report")
}
mustIAM(t, ri.load(f.ctx, f.obj, *job))
if ri.PausedReason == "" || ri.Failed {
t.Fatal("restart lost pause")
}
// A stale resume loaded the manifest before cancellation.
mustIAM(t, globalBatchJobPool.canceler(job.ID, true))
mustIAM(t, job.delete(f.ctx, f.obj))
if err := globalBatchJobPool.queueJob(&loaded); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled job re-admitted: %v", err)
}
if err := loaded.load(f.ctx, f.obj, getJobPath(*job)); !errors.Is(err, errNoSuchJob) {
t.Fatalf("canceled manifest recreated: %v", err)
}
}
+19 -5
View File
@@ -385,7 +385,7 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
StartTime: job.Started, StartTime: job.Started,
} }
if err := ri.loadOrInit(ctx, api, job); err != nil { if err := ri.loadOrInit(ctx, api, job); err != nil {
return err return batchUnavailable("loading job progress", err)
} }
if ri.Complete { if ri.Complete {
return nil return nil
@@ -490,7 +490,11 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
defer cancelCause(nil) defer cancelCause(nil)
// refuse ends the job for its submitter: record why before stopping. // refuse ends the job for its submitter: record why before stopping.
refuse := func(err error) { refuse := func(err error) {
if isBatchJobUnavailable(err) {
ri.pause(err)
} else {
ri.refuse(err) ri.refuse(err)
}
cancelCause(err) cancelCause(err)
} }
progress := newBatchProgress(ri, r.Bucket) progress := newBatchProgress(ri, r.Bucket)
@@ -500,8 +504,11 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
Marker: lastObject, Marker: lastObject,
Filter: selectObj, Filter: selectObj,
}); err != nil { }); err != nil {
// Do not need to retry if we can't list objects on source. cause := batchListingError(err)
return err if isBatchJobUnavailable(cause) {
return ri.savePaused(ctx, api, job, cause)
}
return cause
} }
failed := false failed := false
for res := range results { for res := range results {
@@ -511,8 +518,9 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
} }
if res.Err != nil { if res.Err != nil {
failed = true failed = true
cancelCause(batchListingError(res.Err))
batchLogIf(ctx, res.Err) batchLogIf(ctx, res.Err)
break continue
} }
result := res.Item result := res.Item
if !batchResumeAfter(lastObject, result.Name) { if !batchResumeAfter(lastObject, result.Name) {
@@ -532,12 +540,15 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
for attempts := 1; attempts <= retryAttempts; attempts++ { for attempts := 1; attempts <= retryAttempts; attempts++ {
stopFn := globalBatchJobsMetrics.trace(batchJobMetricKeyRotation, job.ID, attempts) stopFn := globalBatchJobsMetrics.trace(batchJobMetricKeyRotation, job.ID, attempts)
err := r.KeyRotate(ctx, api, result, job.Submitter) err := r.KeyRotate(ctx, api, result, job.Submitter)
if errors.Is(err, errBatchRotateMoving) {
err = batchUnavailable("object is being moved", err)
}
stopFn(result, err) stopFn(result, err)
if err == nil { if err == nil {
counts.objects = 1 counts.objects = 1
break break
} }
if isBatchJobAuthzError(err) { if isBatchJobStopped(err) {
refuse(err) refuse(err)
return return
} }
@@ -585,6 +596,9 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
progress.finish() progress.finish()
} }
if cause != nil && !ri.refused() { if cause != nil && !ri.refused() {
if isBatchJobUnavailable(cause) {
return ri.savePaused(ctx, api, job, cause)
}
// Stopped before the end: keep the work after the cursor pending. // Stopped before the end: keep the work after the cursor pending.
globalBatchJobsMetrics.save(job.ID, ri) globalBatchJobsMetrics.save(job.ID, ri)
batchLogIf(ctx, ri.updateAfter(GlobalContext, api, 0, job)) batchLogIf(ctx, ri.updateAfter(GlobalContext, api, 0, job))
+1 -1
View File
@@ -49,7 +49,7 @@ func batchTestJobPool(ctx context.Context, t *testing.T, obj ObjectLayer) {
ctx: ctx, ctx: ctx,
objLayer: obj, objLayer: obj,
jobCh: make(chan *BatchJobRequest, 10), jobCh: make(chan *BatchJobRequest, 10),
jobCancelers: make(map[string]context.CancelFunc), jobOwners: make(map[string]*batchJobOwner),
} }
globalBatchJobsMetrics.Lock() globalBatchJobsMetrics.Lock()
globalBatchJobsMetrics.metrics = make(map[string]*batchJobInfo) globalBatchJobsMetrics.metrics = make(map[string]*batchJobInfo)
+3 -1
View File
@@ -3262,6 +3262,8 @@ func (store *IAMStoreSys) LoadUser(ctx context.Context, accessKey string) error
return nil return nil
} }
var errTokenSigningKeyUnavailable = errors.New("token signing key unavailable")
func extractJWTClaims(u UserIdentity) (jwtClaims *jwt.MapClaims, err error) { func extractJWTClaims(u UserIdentity) (jwtClaims *jwt.MapClaims, err error) {
keys := make([]string, 0, 3) keys := make([]string, 0, 3)
@@ -3272,7 +3274,7 @@ func extractJWTClaims(u UserIdentity) (jwtClaims *jwt.MapClaims, err error) {
if globalSiteReplicationSys.isEnabled() { if globalSiteReplicationSys.isEnabled() {
secretKey, err := getTokenSigningKey() secretKey, err := getTokenSigningKey()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("%w: %w", errTokenSigningKeyUnavailable, err)
} }
keys = append(keys, secretKey) keys = append(keys, secretKey)
} }
+31 -26
View File
@@ -19,7 +19,9 @@ package plugin
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"time" "time"
@@ -180,6 +182,14 @@ func New(args Args) *AuthZPlugin {
// IsAllowed - checks given policy args is allowed to continue the REST API. // IsAllowed - checks given policy args is allowed to continue the REST API.
func (o *AuthZPlugin) IsAllowed(args policy.Args) (bool, error) { func (o *AuthZPlugin) IsAllowed(args policy.Args) (bool, error) {
return o.IsAllowedContext(context.Background(), args)
}
// IsAllowedContext requires an explicit decision from a successful response.
// The timeout also bounds a server that accepts a connection but never replies.
func (o *AuthZPlugin) IsAllowedContext(ctx context.Context, args policy.Args) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
if o == nil { if o == nil {
return false, nil return false, nil
} }
@@ -193,7 +203,7 @@ func (o *AuthZPlugin) IsAllowed(args policy.Args) (bool, error) {
return false, err return false, err
} }
req, err := http.NewRequest(http.MethodPost, o.args.URL.String(), bytes.NewReader(inputBytes)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.args.URL.String(), bytes.NewReader(inputBytes))
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -209,37 +219,32 @@ func (o *AuthZPlugin) IsAllowed(args policy.Args) (bool, error) {
} }
defer o.args.CloseRespFn(resp.Body) defer o.args.CloseRespFn(resp.Body)
// Read the body to be saved later. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
opaRespBytes, err := io.ReadAll(resp.Body) return false, fmt.Errorf("authorization plugin returned HTTP %d", resp.StatusCode)
}
// Bound input and reject trailing data as well as absent/null decisions.
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20+1))
if err != nil { if err != nil {
return false, err return false, err
} }
if len(data) > 1<<20 {
// Handle large OPA responses when OPA URL is of return false, fmt.Errorf("authorization plugin response is too large")
// form http://localhost:8181/v1/data/httpapi/authz
type opaResultAllow struct {
Result struct {
Allow bool `json:"allow"`
} `json:"result"`
} }
var result struct {
// Handle simpler OPA responses when OPA URL is of Result json.RawMessage `json:"result"`
// form http://localhost:8181/v1/data/httpapi/authz/allow
type opaResult struct {
Result bool `json:"result"`
} }
if err := json.Unmarshal(data, &result); err != nil {
respBody := bytes.NewReader(opaRespBytes)
var result opaResult
if err = json.NewDecoder(respBody).Decode(&result); err != nil {
respBody.Seek(0, 0)
var resultAllow opaResultAllow
if err = json.NewDecoder(respBody).Decode(&resultAllow); err != nil {
return false, err return false, err
} }
return resultAllow.Result.Allow, nil var allowed *bool
if err := json.Unmarshal(result.Result, &allowed); err == nil && allowed != nil {
return *allowed, nil
} }
var nested struct {
return result.Result, nil Allow *bool `json:"allow"`
}
if err := json.Unmarshal(result.Result, &nested); err == nil && nested.Allow != nil {
return *nested.Allow, nil
}
return false, fmt.Errorf("authorization plugin response has no boolean decision")
} }
@@ -0,0 +1,48 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package plugin
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/pgsty/silo-pkg/v3/net"
"github.com/pgsty/silo-pkg/v3/policy"
)
func TestAuthZPluginExplicitDecision(t *testing.T) {
for _, tc := range []struct {
body string
status int
allow, fail bool
}{
{`{"result":true}`, 200, true, false}, {`{"result":false}`, 200, false, false},
{`{"result":{"allow":true}}`, 200, true, false}, {`{"result":{"allow":false}}`, 200, false, false},
{`{"result":true}`, 503, false, true}, {`{"result":true}`, 403, false, true},
{`{}`, 200, false, true}, {`{"error":"unavailable"}`, 200, false, true}, {`{"result":null}`, 200, false, true},
{`{"result":{}}`, 200, false, true}, {`{"result":{"allow":null}}`, 200, false, true}, {`{"result":"true"}`, 200, false, true},
{`{"result":true} garbage`, 200, false, true},
} {
t.Run(tc.body+http.StatusText(tc.status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status); io.WriteString(w, tc.body) }))
defer server.Close()
u, err := net.ParseHTTPURL(server.URL)
if err != nil {
t.Fatal(err)
}
plugin := New(Args{URL: u, CloseRespFn: func(r io.ReadCloser) { r.Close() }})
allowed, err := plugin.IsAllowedContext(t.Context(), policy.Args{})
if allowed != tc.allow || (err != nil) != tc.fail {
t.Fatalf("allow=%v err=%v", allowed, err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if allowed, err := plugin.IsAllowedContext(ctx, policy.Args{}); allowed || err == nil {
t.Fatal("canceled evaluation did not stop")
}
})
}
}