feat(restore): 恢复前默认为当前世界做安全快照并串接恢复 Job,快照失败则不恢复;并发恢复另一备份返回 409
This commit is contained in:
33 files changed
+1260
-105
No files matched your search
@@ -20,6 +20,11 @@ const (
|
||||
managedByValue = "felis-restore"
|
||||
componentValue = "world-restore"
|
||||
|
||||
// AnnotationBackupRef records which archive the Job extracts, so a second
|
||||
// restore that collides with it on the name can tell a duplicate of the same
|
||||
// request from a request for a different backup (K8sJobs.CreateRestoreJob).
|
||||
AnnotationBackupRef = "felis.lolicon.best/backup-ref"
|
||||
|
||||
worldVolume = "world"
|
||||
backupVolume = "backup"
|
||||
felisBinaryPath = "/usr/local/bin/felis"
|
||||
@@ -144,9 +149,10 @@ func RestoreJob(p JobParams) (*batchv1.Job, error) {
|
||||
|
||||
job := &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: RestoreJobName(p.Server),
|
||||
Namespace: p.Namespace,
|
||||
Labels: restoreLabels(p),
|
||||
Name: RestoreJobName(p.Server),
|
||||
Namespace: p.Namespace,
|
||||
Labels: restoreLabels(p),
|
||||
Annotations: map[string]string{AnnotationBackupRef: p.BackupRef},
|
||||
},
|
||||
Spec: batchv1.JobSpec{
|
||||
// One shot: a bad archive must not loop. The TTL GCs the finished Job
|
||||
|
||||
@@ -224,6 +224,9 @@ func TestRestoreJobInvokesFelisRestoreWithParams(t *testing.T) {
|
||||
if !argPairPresent(c.Args, "--ref", p.BackupRef) {
|
||||
t.Errorf("args must carry --ref %q, got %v", p.BackupRef, c.Args)
|
||||
}
|
||||
if got := job.Annotations[AnnotationBackupRef]; got != p.BackupRef {
|
||||
t.Errorf("backup-ref annotation = %q, want %q", got, p.BackupRef)
|
||||
}
|
||||
if !argPairPresent(c.Args, "--archive-store", p.ArchiveStore) {
|
||||
t.Errorf("args must carry --archive-store %q, got %v", p.ArchiveStore, c.Args)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"time"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
@@ -37,8 +39,10 @@ func NewK8sJobs(c client.Client) *K8sJobs {
|
||||
// of the same server collides on Create. The collision is answered by the state
|
||||
// of the Job already holding the name:
|
||||
//
|
||||
// - still running (or not yet started): ErrAlreadyExists, which the Restorer
|
||||
// treats as success — the idempotent coalesce.
|
||||
// - still running (or not yet started) on the same archive: ErrAlreadyExists,
|
||||
// which the Restorer treats as success — the idempotent coalesce.
|
||||
// - still running on another archive: ErrOtherRestoreRunning. A Job from
|
||||
// before the ref annotation cannot be compared and coalesces as before.
|
||||
// - finished (succeeded OR failed): the finished Job is deleted and replaced,
|
||||
// so the caller's retry enqueues for real. Without this, the deterministic
|
||||
// name plus the ten-minute TTL would swallow the retry — most importantly
|
||||
@@ -68,9 +72,14 @@ func (k *K8sJobs) CreateRestoreJob(ctx context.Context, p JobParams) error {
|
||||
return getErr
|
||||
}
|
||||
if !restoreJobFinished(&existing) {
|
||||
if ref, ok := existing.Annotations[AnnotationBackupRef]; ok && ref != p.BackupRef {
|
||||
return ErrOtherRestoreRunning
|
||||
}
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
if deleteErr := k.c.Delete(ctx, &existing); deleteErr != nil && !apierrors.IsNotFound(deleteErr) {
|
||||
// Background propagation: a Job deleted with the API's default policy
|
||||
// orphans its pods, which then outlive it for good.
|
||||
if deleteErr := k.c.Delete(ctx, &existing, client.PropagationPolicy(metav1.DeletePropagationBackground)); deleteErr != nil && !apierrors.IsNotFound(deleteErr) {
|
||||
return deleteErr
|
||||
}
|
||||
// The API server keeps the object until its job-tracking finalizer has run,
|
||||
@@ -128,7 +137,21 @@ func (k *K8sJobs) recreate(ctx context.Context, job *batchv1.Job) error {
|
||||
// that is merely created-but-not-started (no active pods yet, no completions)
|
||||
// counts as in flight, not finished, so a duplicate enqueue during startup still
|
||||
// coalesces.
|
||||
//
|
||||
// A terminal condition wins over pods still shutting down: the world-volume lock
|
||||
// (internal/maintenance.JobFinished) already lets the next restore in at that
|
||||
// point, and answering it with the coalesce would be a 202 for a restore that
|
||||
// never runs.
|
||||
func restoreJobFinished(job *batchv1.Job) bool {
|
||||
for _, c := range job.Status.Conditions {
|
||||
if c.Status != corev1.ConditionTrue {
|
||||
continue
|
||||
}
|
||||
switch c.Type {
|
||||
case batchv1.JobComplete, batchv1.JobFailed, batchv1.JobSuccessCriteriaMet, batchv1.JobFailureTarget:
|
||||
return true
|
||||
}
|
||||
}
|
||||
if job.Status.Active > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -103,6 +103,12 @@ func TestRestoreJobFinished(t *testing.T) {
|
||||
Conditions: []batchv1.JobCondition{{Type: batchv1.JobFailed, Status: "True"}},
|
||||
}}, true},
|
||||
{"failed-and-some-active", batchv1.Job{Status: batchv1.JobStatus{Active: 1, Failed: 1}}, false},
|
||||
// The failure is decided while the pod is still being torn down: the
|
||||
// world-volume lock already admits the next restore, so this must too.
|
||||
{"failure-target-pod-terminating", batchv1.Job{Status: batchv1.JobStatus{
|
||||
Active: 1,
|
||||
Conditions: []batchv1.JobCondition{{Type: batchv1.JobFailureTarget, Status: "True"}},
|
||||
}}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := restoreJobFinished(&tc.job); got != tc.want {
|
||||
@@ -110,3 +116,33 @@ func TestRestoreJobFinished(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A running restore of a different archive refuses the request instead of
|
||||
// absorbing it: the caller would otherwise get a 202 naming the backup it chose
|
||||
// while another one is extracted.
|
||||
func TestCreateRestoreJobRefusesAnotherArchiveInFlight(t *testing.T) {
|
||||
inFlight := &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "restore-survival", Namespace: "minecraft",
|
||||
Annotations: map[string]string{AnnotationBackupRef: "/backups/other.tar.gz"},
|
||||
},
|
||||
Status: batchv1.JobStatus{Active: 1},
|
||||
}
|
||||
c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithObjects(inFlight).Build()
|
||||
|
||||
err := NewK8sJobs(c).CreateRestoreJob(context.Background(), testParams())
|
||||
if !errors.Is(err, ErrOtherRestoreRunning) {
|
||||
t.Fatalf("CreateRestoreJob = %v, want ErrOtherRestoreRunning", err)
|
||||
}
|
||||
if err := (&Restorer{Jobs: NewK8sJobs(c), Config: Config{Image: "felis:test", BackupPVC: "felis-backups"}}).
|
||||
Restore(context.Background(), "survival", "/backups/x.tar.gz"); !errors.Is(err, ErrOtherRestoreRunning) {
|
||||
t.Fatalf("Restore = %v, want ErrOtherRestoreRunning", err)
|
||||
}
|
||||
|
||||
// The same archive still coalesces.
|
||||
p := testParams()
|
||||
p.BackupRef = "/backups/other.tar.gz"
|
||||
if err := NewK8sJobs(c).CreateRestoreJob(context.Background(), p); !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Fatalf("same archive: CreateRestoreJob = %v, want ErrAlreadyExists", err)
|
||||
}
|
||||
}
|
||||
+30
-10
@@ -39,6 +39,22 @@ import (
|
||||
// as success — see Restore.
|
||||
var ErrAlreadyExists = errors.New("restore: job already exists")
|
||||
|
||||
// ErrOtherRestoreRunning is returned when a restore of a DIFFERENT backup is
|
||||
// still running on the server. The caller's request did not take effect; the
|
||||
// API answers 409 restore_in_progress rather than a 202 that names the backup
|
||||
// it asked for.
|
||||
var ErrOtherRestoreRunning error = otherRestoreRunning{}
|
||||
|
||||
type otherRestoreRunning struct{}
|
||||
|
||||
func (otherRestoreRunning) Error() string {
|
||||
return "restore: a restore of another backup is still running"
|
||||
}
|
||||
|
||||
// RestoreInProgress marks the error for internal/api, which cannot import this
|
||||
// package and recognises it by the method.
|
||||
func (otherRestoreRunning) RestoreInProgress() bool { return true }
|
||||
|
||||
// Jobs is the cluster-side restore lifecycle the Restorer depends on. It is an
|
||||
// interface so the orchestration is tested against a fake; the controller-runtime
|
||||
// implementation (K8sJobs) is integration-tested only — it requires a live
|
||||
@@ -47,7 +63,9 @@ var ErrAlreadyExists = errors.New("restore: job already exists")
|
||||
// whole contract, exactly matching the asynchronous 202 the handler answers.
|
||||
type Jobs interface {
|
||||
// CreateRestoreJob renders and applies the restore Job for p. It returns
|
||||
// ErrAlreadyExists if a Job of the same (deterministic) name already exists.
|
||||
// ErrAlreadyExists if an unfinished Job of the same (deterministic) name is
|
||||
// already restoring p.BackupRef, and ErrOtherRestoreRunning if it is
|
||||
// restoring another archive.
|
||||
CreateRestoreJob(ctx context.Context, p JobParams) error
|
||||
}
|
||||
|
||||
@@ -162,16 +180,18 @@ type Restorer struct {
|
||||
// serverName's world PVC. It returns once the Job is created — the extraction
|
||||
// runs in the Pod — so the handler's 202 ("restoring") is honest.
|
||||
//
|
||||
// It is idempotent: a duplicate enqueue while a restore Job for this server is
|
||||
// still running is treated as success rather than surfaced as an error.
|
||||
// It is idempotent: a duplicate enqueue of the same archive while a restore Job
|
||||
// for this server is still running is treated as success rather than surfaced
|
||||
// as an error.
|
||||
//
|
||||
// The coalescing key is the Job name (RestoreJobName), which depends only on the
|
||||
// server, NOT on backupRef — so a second request that arrives while one is in
|
||||
// flight is absorbed regardless of the ref it carries, and if the two refs
|
||||
// differ the second is silently dropped (the in-flight restore wins). That is
|
||||
// acceptable here: restore runs only for a Stopped server (handler gate ⑥) and
|
||||
// the handler always passes the latest backup, which for a stopped server does
|
||||
// not change, so concurrent requests carry the same ref in practice.
|
||||
// The Job name (RestoreJobName) depends only on the server, and the handler lets
|
||||
// the caller pick any of the server's backups, so a collision can carry a
|
||||
// different ref. The world-volume lock refuses a second restore while the first
|
||||
// Job runs, which makes this rare (it takes the lock seeing the Job finished
|
||||
// while its pods are still going), but when it happens the running Job's ref
|
||||
// annotation decides: the same archive coalesces, another archive is
|
||||
// ErrOtherRestoreRunning, so no caller is told "restoring" for a backup that is
|
||||
// not the one being extracted.
|
||||
//
|
||||
// A FINISHED Job — succeeded or failed — does not absorb the next request: its
|
||||
// deterministic name is replaced so the retry enqueues for real (see
|
||||
|
||||
Reference in new issue
Block a user