diff --git a/cmd/batch-expire-completed_test.go b/cmd/batch-expire-completed_test.go new file mode 100644 index 000000000..d53f3feb2 --- /dev/null +++ b/cmd/batch-expire-completed_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 PGSTY +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "testing" + "time" + + "github.com/minio/minio/internal/auth" + "go.yaml.in/yaml/v3" +) + +// A completed expire job is not run again when the server restarts, as +// replication and key rotation jobs are not: in particular it is not checked +// against its submitter again, which could turn it into a failed job. + +// An expire job that completed before the upgrade records no submitter. +func TestBatchExpireCompletedLegacyStaysComplete(t *testing.T) { + f := newBatchAuthzFixture(t) + older := f.putVersions(t, "data/a") + job := &BatchJobRequest{} + mustIAM(t, yaml.Unmarshal([]byte(f.expireYAML("")), job)) + job.ID = "expire-legacydone" + getKeySeparator() + "0" + job.User = globalActiveCred.AccessKey + job.Started = time.Now() + mustIAM(t, job.save(f.ctx, f.obj)) + ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started, Complete: true, Objects: 3} + mustIAM(t, ri.updateAfter(f.ctx, f.obj, 0, *job)) + + loaded := &BatchJobRequest{} + mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job))) + f.run(t, loaded) // resumed after a restart + got := f.jobInfo(t, job) + if !got.Complete || got.Failed || got.FailureReason != "" { + t.Fatalf("complete=%v failed=%v reason=%q, want the completed state kept", got.Complete, got.Failed, got.FailureReason) + } + if !f.versionExists(t, "data/a", older) { + t.Error("a completed job ran again") + } +} + +// A new expire job completed with a temporary credential that has expired +// since. +func TestBatchExpireCompletedAfterCredentialExpiry(t *testing.T) { + f := newBatchAuthzFixture(t) + batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess) + parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full") + cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(3 * time.Second).Unix()}, globalActiveCred.SecretKey) + mustIAM(t, err) + cred.ParentUser = parent.AccessKey + _, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "") + mustIAM(t, err) + older := f.putVersions(t, "data/a") + rec, job := f.submit(t, cred, f.expireYAML("")) + if job == nil { + t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String()) + } + f.run(t, job) + if f.versionExists(t, "data/a", older) { + t.Fatal("the job did not expire the older version") + } + if ri := f.jobInfo(t, job); !ri.Complete { + t.Fatalf("complete=%v failed=%v", ri.Complete, ri.Failed) + } + time.Sleep(time.Until(cred.Expiration) + time.Second) + + loaded := &BatchJobRequest{} + mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job))) + f.run(t, loaded) // resumed after a restart + if ri := f.jobInfo(t, job); !ri.Complete || ri.Failed || ri.FailureReason != "" { + t.Errorf("complete=%v failed=%v reason=%q after a restart, want the completed state kept", ri.Complete, ri.Failed, ri.FailureReason) + } + + // Control: a job that did not complete is still refused once its + // credential has expired. + f.putVersions(t, "data/b") + pending := &BatchJobRequest{} + mustIAM(t, yaml.Unmarshal([]byte(f.expireYAML("")), pending)) + pending.ID = "expire-pending" + getKeySeparator() + "0" + pending.User = cred.AccessKey + pending.Started = time.Now() + pending.Submitter = loaded.Submitter + mustIAM(t, pending.save(f.ctx, f.obj)) + f.run(t, pending) + if ri := f.jobInfo(t, pending); ri.Complete || !ri.Failed || ri.FailureReason == "" { + t.Errorf("pending job with an expired submitter: complete=%v failed=%v reason=%q, want refused", ri.Complete, ri.Failed, ri.FailureReason) + } +} diff --git a/cmd/batch-expire.go b/cmd/batch-expire.go index 77c444dfc..847d7154f 100644 --- a/cmd/batch-expire.go +++ b/cmd/batch-expire.go @@ -641,6 +641,12 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo if err := ri.loadOrInit(ctx, api, job); err != nil { return err } + // A completed job is not run again, as replication and key rotation + // jobs are not: its submitter is not checked again either, so a + // credential that expired since cannot turn it into a failed job. + if ri.Complete { + return nil + } if done, err := ri.startAuthorized(ctx, api, job); done || err != nil { return err }