mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 09:35:31 +03:00
fix(batch): pause transient authorization failures with owned retries
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+114
-22
@@ -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,24 +452,47 @@ 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)
|
||||||
plugin := newGlobalAuthZPluginFn()
|
}
|
||||||
policies, err := globalIAMSys.batchLocalPolicies(p, args, plugin == nil)
|
|
||||||
if err != nil {
|
// authorizeArgs uses the same policy snapshot for request-context checks and
|
||||||
return denied
|
// 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)
|
||||||
}
|
}
|
||||||
supplied := req.suppliedConditionKeys()
|
ctx := p.ctx
|
||||||
for _, pol := range policies {
|
if ctx == nil {
|
||||||
for _, st := range pol.Statements {
|
ctx = GlobalContext
|
||||||
if batchStatementNeedsRequest(st, args, supplied) {
|
}
|
||||||
return batchJobAuthzError{reason: denied.reason + ": a policy statement depends on request context that a batch job does not have"}
|
plugin := newGlobalAuthZPluginFn()
|
||||||
|
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 errors.Is(err, errNoSuchPolicy) || errors.Is(err, errAuthentication) || errors.Is(err, errNoSuchUser) || errors.Is(err, errNoSuchGroup) {
|
||||||
|
return denied
|
||||||
|
}
|
||||||
|
return batchUnavailable("IAM policy lookup", err)
|
||||||
|
}
|
||||||
|
if req != nil {
|
||||||
|
supplied := req.suppliedConditionKeys()
|
||||||
|
for _, pol := range policies {
|
||||||
|
for _, st := range pol.Statements {
|
||||||
|
if batchStatementNeedsRequest(st, args, supplied) {
|
||||||
|
return batchJobAuthzError{reason: denied.reason + ": a policy statement depends on request context that a batch job does not have"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
if _, ok := p.claims[iamPolicyClaimNameSA()].(string); !ok {
|
||||||
|
return denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has, allowed := p.sessionPolicyDecision(args); has && !allowed {
|
||||||
return denied
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
+19
-12
@@ -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) {
|
||||||
ri.refuse(err)
|
if isBatchJobUnavailable(err) {
|
||||||
|
ri.pause(err)
|
||||||
|
} else {
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-68
@@ -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,16 +1065,20 @@ 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 (
|
||||||
batchReplName = "batch-replicate.bin"
|
batchReplName = "batch-replicate.bin"
|
||||||
batchReplFormat = 1
|
batchReplFormat = 1
|
||||||
batchReplVersionV1 = 1
|
batchReplVersionV1 = 1
|
||||||
batchReplVersion = batchReplVersionV1
|
batchReplVersion = batchReplVersionV1
|
||||||
batchJobName = "job.bin"
|
batchJobName = "job.bin"
|
||||||
batchJobPrefix = "batch-jobs"
|
// The envelope rejects old decoders before they can ignore Submitter.
|
||||||
batchJobReportsPrefix = batchJobPrefix + "/reports"
|
batchJobEnvelope = "SILO-BATCH\x02"
|
||||||
|
batchAuthorizedReportVersion = 2
|
||||||
|
batchJobPrefix = "batch-jobs"
|
||||||
|
batchJobReportsPrefix = batchJobPrefix + "/reports"
|
||||||
|
|
||||||
batchReplJobAPIVersion = "v1"
|
batchReplJobAPIVersion = "v1"
|
||||||
batchReplJobDefaultRetries = 3
|
batchReplJobDefaultRetries = 3
|
||||||
@@ -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) {
|
|
||||||
batchLogIf(j.ctx, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
j.canceler(job.ID, false)
|
if err != nil {
|
||||||
|
batchLogIf(j.ctx, err)
|
||||||
|
}
|
||||||
|
j.finishJob(job, err)
|
||||||
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 cancel {
|
if owner := j.jobOwners[jobID]; owner != nil {
|
||||||
delete(j.jobCancelers, jobID)
|
if owner.cancel != nil {
|
||||||
|
owner.cancel()
|
||||||
|
}
|
||||||
|
j.jobOwners[jobID] = &batchJobOwner{state: "canceled"}
|
||||||
|
} else {
|
||||||
|
j.jobOwners[jobID] = &batchJobOwner{state: "canceled"}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-6
@@ -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) {
|
||||||
ri.refuse(err)
|
if isBatchJobUnavailable(err) {
|
||||||
|
ri.pause(err)
|
||||||
|
} else {
|
||||||
|
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))
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ func batchTestJobPool(ctx context.Context, t *testing.T, obj ObjectLayer) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
prevPool := globalBatchJobPool
|
prevPool := globalBatchJobPool
|
||||||
globalBatchJobPool = &BatchJobPool{
|
globalBatchJobPool = &BatchJobPool{
|
||||||
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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
return false, err
|
||||||
|
|
||||||
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 resultAllow.Result.Allow, nil
|
|
||||||
}
|
}
|
||||||
|
var allowed *bool
|
||||||
return result.Result, nil
|
if err := json.Unmarshal(result.Result, &allowed); err == nil && allowed != nil {
|
||||||
|
return *allowed, nil
|
||||||
|
}
|
||||||
|
var nested struct {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user