From 435de8581b660c8e0c97fd6c0b4a74851c9ad868 Mon Sep 17 00:00:00 2001 From: Baylem Date: Mon, 9 Nov 2026 15:49:12 -0500 Subject: [PATCH 1/7] feat(operator): add stopped multi-volume Restic recovery Signed-off-by: Baylem --- .../handlers/recovery_policies_test.go | 30 + .../crds/gameplane.local_backups.yaml | 45 +- .../crds/gameplane.local_backupschedules.yaml | 20 +- .../crds/gameplane.local_clusters.yaml | 2 +- .../crds/gameplane.local_gameservers.yaml | 20 +- .../crds/gameplane.local_gametemplates.yaml | 10 +- .../crds/gameplane.local_modules.yaml | 2 +- .../crds/gameplane.local_modulesources.yaml | 2 +- .../crds/gameplane.local_networkcaptures.yaml | 2 +- .../crds/gameplane.local_restores.yaml | 85 +- .../gameplane.local_backups.yaml | 45 +- .../gameplane.local_backupschedules.yaml | 20 +- .../gameplane.local_clusters.yaml | 2 +- .../gameplane.local_gameservers.yaml | 20 +- .../gameplane.local_gametemplates.yaml | 10 +- .../gameplane.local_modules.yaml | 2 +- .../gameplane.local_modulesources.yaml | 2 +- .../gameplane.local_networkcaptures.yaml | 2 +- .../gameplane.local_restores.yaml | 85 +- docs/operator-operations.md | 108 +- docs/template-rollouts.md | 6 +- i18n/locales/en/app.json | 4 +- i18n/locales/fr/.review.json | 1 + i18n/locales/fr/app.json | 4 + operator/api/v1alpha1/backup_types.go | 32 + operator/api/v1alpha1/backupschedule_types.go | 10 + operator/api/v1alpha1/gameserver_types.go | 7 + operator/api/v1alpha1/gametemplate_types.go | 8 +- operator/api/v1alpha1/restore_types.go | 19 + .../api/v1alpha1/zz_generated.deepcopy.go | 27 + .../config/crd/gameplane.local_backups.yaml | 43 + .../crd/gameplane.local_backupschedules.yaml | 18 + .../crd/gameplane.local_gameservers.yaml | 18 +- .../crd/gameplane.local_gametemplates.yaml | 8 +- .../config/crd/gameplane.local_restores.yaml | 83 +- .../internal/controller/backup_controller.go | 16 +- operator/internal/controller/backup_guard.go | 35 + .../internal/controller/backup_manifest.go | 33 +- .../internal/controller/backup_preparation.go | 20 +- operator/internal/controller/backup_stop.go | 210 ++++ operator/internal/controller/backup_worker.go | 27 +- .../controller/backupschedule_controller.go | 10 +- .../controller/gameserver_controller.go | 21 + .../controller/lifecycle_operation.go | 41 +- .../controller/recovery_controller_test.go | 1027 +++++++++++++++++ .../internal/controller/recovery_manifest.go | 211 ++++ .../recovery_policy_envtest_test.go | 30 + operator/internal/controller/recovery_test.go | 89 ++ .../internal/controller/restore_controller.go | 57 +- .../internal/controller/restore_lifecycle.go | 10 +- .../internal/controller/restore_recovery.go | 276 +++++ operator/specs.md | 15 + .../OPEN-DECISIONS.md | 3 + .../contracts/recovery.md | 10 + .../data-model.md | 9 + .../032-stopped-multivolume-recovery/spec.md | 13 + .../032-stopped-multivolume-recovery/tasks.md | 12 + test/e2e/buckets.sh | 1 + test/e2e/recovery_multivolume_e2e_test.go | 189 +++ .../backups/BackupDetailDrawer.test.tsx | 19 + .../components/backups/BackupDetailDrawer.tsx | 5 +- web/src/types.ts | 20 +- 62 files changed, 3097 insertions(+), 114 deletions(-) create mode 100644 api/internal/handlers/recovery_policies_test.go create mode 100644 operator/internal/controller/backup_stop.go create mode 100644 operator/internal/controller/recovery_controller_test.go create mode 100644 operator/internal/controller/recovery_manifest.go create mode 100644 operator/internal/controller/recovery_policy_envtest_test.go create mode 100644 operator/internal/controller/recovery_test.go create mode 100644 operator/internal/controller/restore_recovery.go create mode 100644 specs/032-stopped-multivolume-recovery/OPEN-DECISIONS.md create mode 100644 specs/032-stopped-multivolume-recovery/contracts/recovery.md create mode 100644 specs/032-stopped-multivolume-recovery/data-model.md create mode 100644 specs/032-stopped-multivolume-recovery/spec.md create mode 100644 specs/032-stopped-multivolume-recovery/tasks.md create mode 100644 test/e2e/recovery_multivolume_e2e_test.go diff --git a/api/internal/handlers/recovery_policies_test.go b/api/internal/handlers/recovery_policies_test.go new file mode 100644 index 000000000..110218940 --- /dev/null +++ b/api/internal/handlers/recovery_policies_test.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestRecoveryPoliciesRoundTripThroughGenericAPI(t *testing.T) { + for _, resource := range []struct{ path, kind string }{{"backups", "Backup"}, {"schedules", "BackupSchedule"}} { + t.Run(resource.path, func(t *testing.T) { + router := mountResourcesRouter(fakeKubeClient()) + body := map[string]any{"apiVersion": "gameplane.local/v1alpha1", "kind": resource.kind, "metadata": map[string]any{"name": "recovery"}, "spec": map[string]any{"serverRef": map[string]any{"name": "game"}, "repoRef": map[string]any{"name": "repo", "key": "repo"}, "schedule": "* * * * *", "strategy": "restic-snapshot", "powerPolicy": "stop-copy-restart", "volumePolicy": "all-data"}} + rr := do(t, router, http.MethodPost, "/"+resource.path+"/", body) + if rr.Code != http.StatusCreated { + t.Fatalf("create: %d %s", rr.Code, rr.Body) + } + rr = do(t, router, http.MethodGet, "/"+resource.path+"/recovery", nil) + var got struct { + Spec struct{ PowerPolicy, VolumePolicy string } + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Spec.PowerPolicy != "stop-copy-restart" || got.Spec.VolumePolicy != "all-data" { + t.Fatalf("policies lost: %+v", got.Spec) + } + }) + } +} diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml index e64dc3000..d35c0eb64 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: @@ -58,6 +58,13 @@ spec: spec: description: BackupSpec is the desired state of a one-shot backup. properties: + powerPolicy: + description: PowerPolicy preserves power by default; stop-copy-restart + temporarily stops under lifecycle ownership. + enum: + - preserve + - stop-copy-restart + type: string quiesce: default: true description: |- @@ -113,6 +120,13 @@ spec: items: type: string type: array + volumePolicy: + description: VolumePolicy captures primary-only by default; all-data + includes classified persistent data. + enum: + - primary-only + - all-data + type: string volumeSnapshotClassName: description: |- VolumeSnapshotClassName names the VolumeSnapshotClass to use when @@ -126,6 +140,10 @@ spec: x-kubernetes-validations: - message: repoRef is required for the restic-snapshot strategy rule: self.strategy == 'volume-snapshot' || has(self.repoRef) + - message: stopped and all-data policies require restic-snapshot + rule: self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) + || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || + self.volumePolicy == 'primary-only')) status: description: BackupStatus is the observed state of a Backup. properties: @@ -135,6 +153,13 @@ spec: It describes persistent-volume coverage independently of copy success. Missing on pre-upgrade backups; never reconstruct it from current layout. properties: + formatVersion: + description: FormatVersion 2 uses validated disjoint roots. Absent + means legacy /data. + format: int32 + type: integer + gameVersion: + type: string layoutSource: description: LayoutSource identifies the frozen workload used for mount paths. @@ -159,6 +184,12 @@ spec: type: string strategy: type: string + templateName: + type: string + templateRevision: + type: string + volumePolicy: + type: string volumes: items: description: |- @@ -182,12 +213,24 @@ spec: type: string included: type: boolean + logicalName: + description: LogicalName identifies the primary, declared + extra, or active loader volume. + type: string mountPaths: items: type: string type: array reason: type: string + recoveryPrerequisite: + description: RecoveryPrerequisite explains excluded content + that must be reconstructed. + type: string + snapshotRoot: + description: SnapshotRoot is the explicit disjoint archive + root; never a glob. + type: string required: - claimName - classification diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml index 5c5d95552..080fdc494 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: @@ -72,6 +72,13 @@ spec: - Forbid - Replace type: string + powerPolicy: + description: PowerPolicy preserves power by default; stop-copy-restart + temporarily stops under lifecycle ownership. + enum: + - preserve + - stop-copy-restart + type: string quiesce: default: true description: |- @@ -163,6 +170,13 @@ spec: default: false description: Suspend pauses the schedule without deleting it. type: boolean + volumePolicy: + description: VolumePolicy captures primary-only by default; all-data + includes classified persistent data. + enum: + - primary-only + - all-data + type: string required: - schedule - serverRef @@ -170,6 +184,10 @@ spec: x-kubernetes-validations: - message: repoRef is required for the restic-snapshot strategy rule: self.strategy == 'volume-snapshot' || has(self.repoRef) + - message: stopped and all-data policies require restic-snapshot + rule: self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) + || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || + self.volumePolicy == 'primary-only')) status: description: BackupScheduleStatus is the observed state of a BackupSchedule. properties: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml index 8253ec874..22ab099be 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml index d9dab854d..c2a8b3da9 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: @@ -978,6 +978,11 @@ spec: the operator creates/maintains a BackupSchedule owned by this GameServer. Remove to stop scheduled backups. properties: + powerPolicy: + enum: + - preserve + - stop-copy-restart + type: string repoRef: description: |- RepoRef points at a BackupRepo (cluster-scoped resource, TBD) or @@ -1028,6 +1033,11 @@ spec: type: string suspend: type: boolean + volumePolicy: + enum: + - primary-only + - all-data + type: string required: - repoRef - schedule @@ -2222,10 +2232,10 @@ spec: properties: backupRole: description: |- - BackupRole describes recovery requirements without adding this volume - to a backup. "data" needs a separate copy; "rebuildable" may be recreated. - Unset remains unclassified and not covered. Both backup strategies still - capture only the primary data claim. + BackupRole declares recovery scope. all-data Restic backups include data + extras and report rebuildable extras as excluded reconstruction prerequisites. + Unset is unclassified and rejects all-data admission. Default primary-only + backups and CSI snapshots still capture only the primary data claim. enum: - data - rebuildable diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml index 851a1f2dc..c986beb71 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: @@ -2209,10 +2209,10 @@ spec: properties: backupRole: description: |- - BackupRole describes recovery requirements without adding this volume - to a backup. "data" needs a separate copy; "rebuildable" may be recreated. - Unset remains unclassified and not covered. Both backup strategies still - capture only the primary data claim. + BackupRole declares recovery scope. all-data Restic backups include data + extras and report rebuildable extras as excluded reconstruction prerequisites. + Unset is unclassified and rejects all-data admission. Default primary-only + backups and CSI snapshots still capture only the primary data claim. enum: - data - rebuildable diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml index b1cd8c6e9..f490347e0 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml index be111610f..f710cf22b 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml index cce0df632..f79e0add6 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml index 44ad7190b..b3956ffde 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: @@ -93,7 +93,6 @@ spec: - serverRef type: object status: - description: RestoreStatus is the observed state of a Restore. properties: completionTime: format: date-time @@ -171,6 +170,88 @@ spec: - Succeeded - Failed type: string + recoveryPlan: + description: |- + RestoreStatus is the observed state of a Restore. + RestoreRecoveryPlan pins a validated target mapping before any power mutation. + properties: + admittedGeneration: + format: int64 + type: integer + admittedIdle: + type: string + admittedSuspend: + description: Admitted power intent is informational; cleanup never + overwrites newer user intent. + type: boolean + backupUID: + type: string + gameVersion: + type: string + manifestDigest: + type: string + targetUID: + type: string + templateRevision: + type: string + volumes: + items: + description: |- + BackupVolumeCoverage describes one persistent claim. Extra and loader/mod + claims remain uncovered even when mounted beneath the primary data directory. + properties: + claimName: + type: string + claimUID: + description: ClaimUID is empty if the claim did not yet + exist at admission. + type: string + classification: + description: |- + Classification records the module/server declaration at admission; + it does not prove that the volume can actually be reconstructed. + enum: + - data + - rebuildable + - unclassified + type: string + included: + type: boolean + logicalName: + description: LogicalName identifies the primary, declared + extra, or active loader volume. + type: string + mountPaths: + items: + type: string + type: array + reason: + type: string + recoveryPrerequisite: + description: RecoveryPrerequisite explains excluded content + that must be reconstructed. + type: string + snapshotRoot: + description: SnapshotRoot is the explicit disjoint archive + root; never a glob. + type: string + required: + - claimName + - classification + - included + - reason + type: object + type: array + required: + - admittedGeneration + - admittedSuspend + - backupUID + - gameVersion + - manifestDigest + - targetUID + - templateRevision + - volumes + type: object snapshotID: description: |- SnapshotID is the restic snapshot used. Resolved from the diff --git a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml index e64dc3000..d35c0eb64 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: @@ -58,6 +58,13 @@ spec: spec: description: BackupSpec is the desired state of a one-shot backup. properties: + powerPolicy: + description: PowerPolicy preserves power by default; stop-copy-restart + temporarily stops under lifecycle ownership. + enum: + - preserve + - stop-copy-restart + type: string quiesce: default: true description: |- @@ -113,6 +120,13 @@ spec: items: type: string type: array + volumePolicy: + description: VolumePolicy captures primary-only by default; all-data + includes classified persistent data. + enum: + - primary-only + - all-data + type: string volumeSnapshotClassName: description: |- VolumeSnapshotClassName names the VolumeSnapshotClass to use when @@ -126,6 +140,10 @@ spec: x-kubernetes-validations: - message: repoRef is required for the restic-snapshot strategy rule: self.strategy == 'volume-snapshot' || has(self.repoRef) + - message: stopped and all-data policies require restic-snapshot + rule: self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) + || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || + self.volumePolicy == 'primary-only')) status: description: BackupStatus is the observed state of a Backup. properties: @@ -135,6 +153,13 @@ spec: It describes persistent-volume coverage independently of copy success. Missing on pre-upgrade backups; never reconstruct it from current layout. properties: + formatVersion: + description: FormatVersion 2 uses validated disjoint roots. Absent + means legacy /data. + format: int32 + type: integer + gameVersion: + type: string layoutSource: description: LayoutSource identifies the frozen workload used for mount paths. @@ -159,6 +184,12 @@ spec: type: string strategy: type: string + templateName: + type: string + templateRevision: + type: string + volumePolicy: + type: string volumes: items: description: |- @@ -182,12 +213,24 @@ spec: type: string included: type: boolean + logicalName: + description: LogicalName identifies the primary, declared + extra, or active loader volume. + type: string mountPaths: items: type: string type: array reason: type: string + recoveryPrerequisite: + description: RecoveryPrerequisite explains excluded content + that must be reconstructed. + type: string + snapshotRoot: + description: SnapshotRoot is the explicit disjoint archive + root; never a glob. + type: string required: - claimName - classification diff --git a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml index 5c5d95552..080fdc494 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: @@ -72,6 +72,13 @@ spec: - Forbid - Replace type: string + powerPolicy: + description: PowerPolicy preserves power by default; stop-copy-restart + temporarily stops under lifecycle ownership. + enum: + - preserve + - stop-copy-restart + type: string quiesce: default: true description: |- @@ -163,6 +170,13 @@ spec: default: false description: Suspend pauses the schedule without deleting it. type: boolean + volumePolicy: + description: VolumePolicy captures primary-only by default; all-data + includes classified persistent data. + enum: + - primary-only + - all-data + type: string required: - schedule - serverRef @@ -170,6 +184,10 @@ spec: x-kubernetes-validations: - message: repoRef is required for the restic-snapshot strategy rule: self.strategy == 'volume-snapshot' || has(self.repoRef) + - message: stopped and all-data policies require restic-snapshot + rule: self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) + || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || + self.volumePolicy == 'primary-only')) status: description: BackupScheduleStatus is the observed state of a BackupSchedule. properties: diff --git a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml index 8253ec874..22ab099be 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml index d9dab854d..c2a8b3da9 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: @@ -978,6 +978,11 @@ spec: the operator creates/maintains a BackupSchedule owned by this GameServer. Remove to stop scheduled backups. properties: + powerPolicy: + enum: + - preserve + - stop-copy-restart + type: string repoRef: description: |- RepoRef points at a BackupRepo (cluster-scoped resource, TBD) or @@ -1028,6 +1033,11 @@ spec: type: string suspend: type: boolean + volumePolicy: + enum: + - primary-only + - all-data + type: string required: - repoRef - schedule @@ -2222,10 +2232,10 @@ spec: properties: backupRole: description: |- - BackupRole describes recovery requirements without adding this volume - to a backup. "data" needs a separate copy; "rebuildable" may be recreated. - Unset remains unclassified and not covered. Both backup strategies still - capture only the primary data claim. + BackupRole declares recovery scope. all-data Restic backups include data + extras and report rebuildable extras as excluded reconstruction prerequisites. + Unset is unclassified and rejects all-data admission. Default primary-only + backups and CSI snapshots still capture only the primary data claim. enum: - data - rebuildable diff --git a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml index 851a1f2dc..c986beb71 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: @@ -2209,10 +2209,10 @@ spec: properties: backupRole: description: |- - BackupRole describes recovery requirements without adding this volume - to a backup. "data" needs a separate copy; "rebuildable" may be recreated. - Unset remains unclassified and not covered. Both backup strategies still - capture only the primary data claim. + BackupRole declares recovery scope. all-data Restic backups include data + extras and report rebuildable extras as excluded reconstruction prerequisites. + Unset is unclassified and rejects all-data admission. Default primary-only + backups and CSI snapshots still capture only the primary data claim. enum: - data - rebuildable diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index b1cd8c6e9..f490347e0 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml index be111610f..f710cf22b 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml index cce0df632..f79e0add6 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml index 44ad7190b..b3956ffde 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: bf84afb28a947929dcc56cef655364589a4be2e498da9eafb35363eda630676c + gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: @@ -93,7 +93,6 @@ spec: - serverRef type: object status: - description: RestoreStatus is the observed state of a Restore. properties: completionTime: format: date-time @@ -171,6 +170,88 @@ spec: - Succeeded - Failed type: string + recoveryPlan: + description: |- + RestoreStatus is the observed state of a Restore. + RestoreRecoveryPlan pins a validated target mapping before any power mutation. + properties: + admittedGeneration: + format: int64 + type: integer + admittedIdle: + type: string + admittedSuspend: + description: Admitted power intent is informational; cleanup never + overwrites newer user intent. + type: boolean + backupUID: + type: string + gameVersion: + type: string + manifestDigest: + type: string + targetUID: + type: string + templateRevision: + type: string + volumes: + items: + description: |- + BackupVolumeCoverage describes one persistent claim. Extra and loader/mod + claims remain uncovered even when mounted beneath the primary data directory. + properties: + claimName: + type: string + claimUID: + description: ClaimUID is empty if the claim did not yet + exist at admission. + type: string + classification: + description: |- + Classification records the module/server declaration at admission; + it does not prove that the volume can actually be reconstructed. + enum: + - data + - rebuildable + - unclassified + type: string + included: + type: boolean + logicalName: + description: LogicalName identifies the primary, declared + extra, or active loader volume. + type: string + mountPaths: + items: + type: string + type: array + reason: + type: string + recoveryPrerequisite: + description: RecoveryPrerequisite explains excluded content + that must be reconstructed. + type: string + snapshotRoot: + description: SnapshotRoot is the explicit disjoint archive + root; never a glob. + type: string + required: + - claimName + - classification + - included + - reason + type: object + type: array + required: + - admittedGeneration + - admittedSuspend + - backupUID + - gameVersion + - manifestDigest + - targetUID + - templateRevision + - volumes + type: object snapshotID: description: |- SnapshotID is the restic snapshot used. Resolved from the diff --git a/docs/operator-operations.md b/docs/operator-operations.md index f24810524..d286e022b 100644 --- a/docs/operator-operations.md +++ b/docs/operator-operations.md @@ -2,38 +2,82 @@ ## Backup consistency and volume coverage -A successful copy is not necessarily a complete server recovery point. -`status.conditions[type=Consistency]` reports the achieved result separately -from `Completed`: Quiesced means the agent confirmed the module's quiesce -commands before the completed copy; it does not prove those commands flush all -application state. UnsupportedRawCopy, RawCopy and AgentUnavailableRawCopy -describe restic copies without confirmed quiesce. CSI without confirmed -quiesce reports CrashConsistentSnapshot. StoppedSource means the source was -already stopped and its writers drained before capture; the backup did not stop -it. Failed or unfinished copies, including completed Jobs still awaiting a -snapshot ID, cannot report achieved quiesce or stopped-source consistency. - -`status.captureManifest` is recorded once before copy authorization or CSI -capture, after lifecycle ownership and quiesce preparation. It lists the primary -`-data` claim as included and every other observed persistent claim as -uncovered, including extra volumes, nested mod/loader mounts and retained -server-owned claims. It records claim UIDs, observed mount paths, source layout -and optional `data`/`rebuildable` declarations. Included describes copy scope; -check the Backup's phase and snapshot ID for copy success. A declaration that -an uncovered claim is rebuildable is not a recovery guarantee. - -The API returns this status and the backup details drawer displays consistency -and coverage. Capture metadata is immutable through ordinary reconciliation: -editing a template later never changes the historical list. This release does -not add multi-volume restic, automatic game stopping or a stricter consistency -request policy. - -Pre-upgrade captures have missing metadata or `state: LegacyUnknown`; their -Consistency condition is Unknown. The operator never reconstructs their history -from the current template. Before recovery, inspect the original restic snapshot -or CSI source claim, recover the original module/server configuration and arrange -separate recovery of extra/mod volumes. Use an isolated recovery server to -verify those prerequisites before replacing a live world. +A successful copy is not necessarily a complete recovery point. Defaults remain +`powerPolicy: preserve` and `volumePolicy: primary-only`: only `-data` +is captured, using the existing quiesce behavior. Explicit opt-in is available on +Backup, BackupSchedule and GameServer's inline backupPolicy through the CRD/API: + +```yaml +spec: + serverRef: {name: my-game} + repoRef: {name: restic-credentials, key: repo} + strategy: restic-snapshot + powerPolicy: stop-copy-restart + volumePolicy: all-data + quiesce: true +``` + +The two policies are independent. CSI (`volume-snapshot`) rejects stopped or +all-data policies; it remains primary-only and offers no cross-volume atomicity. +A preserve/all-data Restic copy captures one snapshot but reports +`Consistency=False/LiveMultiVolumeCopy`, even when primary-game quiesce succeeds. +Only a stopped, fully drained source supports consistency across included roots. +Quiesce still depends on the module's commands and cannot prove application state. +`Completed`/phase and snapshot ID report copy success separately from consistency. + +`stop-copy-restart` acquires shared lifecycle ownership, persists original intent +and process identity, requests the existing graceful stop, and waits for zero +replicas, observed workload generation and all selected-claim users to drain. +It never changes `spec.suspend`. The source's planned stop precedes worker binding; +replacements after that binding invalidate the copy. Existing persisted preparation +and copy deadlines remain in force across controller restart. Failed stop/copy or +cancellation revokes authorization and drains workers before release. A grace +backstop may force Kubernetes shutdown; timeout alone is never proof of drainage. +After cleanup, only current allowed power intent resumes: manual suspension, +idle markers and pending restart/wake requests are preserved. A force-deleted +Backup leaves its stop debt on the GameServer; autonomous reconciliation drains +orphan workers and continues the original stop before releasing ownership. +`RecoveryIntervention=True` retains the fence when provenance is missing or a +workload was replaced. Inspect those identities and resolve the conflicting +workload/worker before recovery; deleting an owner marker does not prove safety. + +`all-data` includes primary data, explicitly classified `data` extra volumes and +active separate loader/mod volumes. Every observed or declared required claim +must be classified and have a stable identity. Unknown or retained unmounted +claims reject admission rather than silently reporting a complete backup. +Declare genuinely reconstructible extra volumes `backupRole: rebuildable`; +these are excluded and reported as recovery prerequisites. Optional excluded +claims may be absent on the target; recovery never mounts or overwrites them. +Reconstruction remains an administrator/module responsibility and is not verified +by copy success. Suspend a recovery target until its prerequisites are prepared +if starting the game would otherwise be unsafe. + +A v2 `status.captureManifest` pins template digest/name, selected game version, +claim names/UIDs, logical identities, original paths and exact disjoint +`/volumes/` archive roots. A single Restic invocation receives an +explicit argument per included root, mounted read-only; nested game mounts never +alias one another in the archive. The API and existing coverage rows expose +included/excluded volumes, roots and prerequisites. Captures keep that metadata +across later template changes. + +V2 restore preflights the saved manifest against an existing target before +stopping it or creating a writer. Same-server recovery requires original claim +UIDs; another existing server must match the exact logical layout, template digest +and selected version, and its target claim UIDs are pinned. Missing/replaced +included claims, changed paths, duplicate/traversing roots or missing/downgraded +all-data metadata fail visibly without suspension or destructive work. A prepared +restore Pod mounts only validated included roots; after readiness the operator +revalidates the mapping, claim UIDs and PVC protection before issuing a Pod-bound, +expiring authorization. Retries need their own authorization. Only those explicit +roots receive `--delete`/`--include`; excluded roots are untouched. V2 restore +uses the lifecycle guard to stop without changing user suspend intent. Success +releases the guard and preserves current suspend/idle/restart intent; failed or +cancelled writes leave the target suspended for inspection. + +Legacy primary-only snapshots retain `/data` restore semantics. Missing historical +metadata or `LegacyUnknown` never receives fabricated coverage. All-data archives +cannot downgrade into that legacy path. Inspect original module/server settings +and recover uncovered volumes separately before using an old primary-only copy. ## Shared operation ownership diff --git a/docs/template-rollouts.md b/docs/template-rollouts.md index e7871f0f3..5851a4cf5 100644 --- a/docs/template-rollouts.md +++ b/docs/template-rollouts.md @@ -123,7 +123,11 @@ admitted with the same process. Reverting a runtime does not reverse save-format changes. Restoring data may be necessary, and compatibility is game-specific. Backups remain data backups, not automatic runtime rollback bundles. Record the effective digest, immutable image reference and server configuration with your -backup procedure. Restic restores use the target's current effective layout; CSI +backup procedure. Legacy primary-only Restic restores use the target's current effective layout. +V2 all-data Restic restores pin the capture template digest, selected version and +logical volume paths; an incompatible target fails preflight before any stop or +write. They restore included data/mod roots only and report excluded rebuildable +content as reconstruction prerequisites. CSI restores copy the source server's effective template at restore planning time, not necessarily its template at backup time. Select a compatible Module version and `OnRequest` policy before recovery if automatic adoption would be unsafe. diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index a32980921..a92fd9987 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -1324,8 +1324,8 @@ "description": "Backup details: timestamp of the immutable capture manifest" }, "web.components.backups.backup_detail_drawer.coverage_scope": { - "defaultMessage": "Only the primary data claim is copied. Rebuildable is a declaration, not proof that recovery can reconstruct its contents.", - "description": "Backup details: explains the current single-volume backup scope and rebuildable declarations" + "defaultMessage": "Included claims are copied at the recorded archive roots. Rebuildable volumes are excluded. Check their recovery prerequisites before restarting.", + "description": "Backup details: explains recorded volume coverage and excluded rebuildable recovery prerequisites" }, "web.components.backups.backup_detail_drawer.delete": { "defaultMessage": "Delete", diff --git a/i18n/locales/fr/.review.json b/i18n/locales/fr/.review.json index 6c97ce3e0..b9a51a897 100644 --- a/i18n/locales/fr/.review.json +++ b/i18n/locales/fr/.review.json @@ -283,6 +283,7 @@ "web.components.app_layout.users_rbac": "0c6ed78342e6", "web.components.backups.backup_detail_drawer.backup_details": "85ece27eaabf", "web.components.backups.backup_detail_drawer.completed": "22a970d2e5b1", + "web.components.backups.backup_detail_drawer.coverage_scope": "204e943d70df", "web.components.backups.backup_detail_drawer.delete": "e2d0a54968ea", "web.components.backups.backup_detail_drawer.deleting": "43b5894c3e4e", "web.components.backups.backup_detail_drawer.phase": "46342ec1eec9", diff --git a/i18n/locales/fr/app.json b/i18n/locales/fr/app.json index ea81b4460..2f4258794 100644 --- a/i18n/locales/fr/app.json +++ b/i18n/locales/fr/app.json @@ -1135,6 +1135,10 @@ "defaultMessage": "Terminé", "description": "Backup details drawer: field label above the time the backup completed, shown in relative time" }, + "web.components.backups.backup_detail_drawer.coverage_scope": { + "defaultMessage": "Les volumes inclus sont copiés aux racines d’archive enregistrées. Les volumes reconstructibles sont exclus. Vérifiez leurs prérequis de récupération avant de redémarrer.", + "description": "Backup details: explains recorded volume coverage and excluded rebuildable recovery prerequisites" + }, "web.components.backups.backup_detail_drawer.delete": { "defaultMessage": "Supprimer", "description": "Backup detail drawer: delete button label" diff --git a/operator/api/v1alpha1/backup_types.go b/operator/api/v1alpha1/backup_types.go index c2ec70c00..bddbb5088 100644 --- a/operator/api/v1alpha1/backup_types.go +++ b/operator/api/v1alpha1/backup_types.go @@ -35,7 +35,17 @@ const BackupSnapshotFinalizer = "gameplane.local/backup-snapshot-finalizer" // BackupSpec is the desired state of a one-shot backup. // +kubebuilder:validation:XValidation:rule="self.strategy == 'volume-snapshot' || has(self.repoRef)",message="repoRef is required for the restic-snapshot strategy" +// +kubebuilder:validation:XValidation:rule="self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || self.volumePolicy == 'primary-only'))",message="stopped and all-data policies require restic-snapshot" type BackupSpec struct { + // PowerPolicy preserves power by default; stop-copy-restart temporarily stops under lifecycle ownership. + // +kubebuilder:validation:Enum=preserve;stop-copy-restart + // +optional + PowerPolicy string `json:"powerPolicy,omitempty"` + // VolumePolicy captures primary-only by default; all-data includes classified persistent data. + // +kubebuilder:validation:Enum=primary-only;all-data + // +optional + VolumePolicy string `json:"volumePolicy,omitempty"` + // ServerRef is the GameServer whose data volume should be backed up. ServerRef LocalObjectRef `json:"serverRef"` @@ -137,6 +147,18 @@ type BackupStatus struct { // BackupCaptureManifest pins the layout and quiesce result at capture admission. // Recorded coverage does not imply a successful or complete server backup. type BackupCaptureManifest struct { + // FormatVersion 2 uses validated disjoint roots. Absent means legacy /data. + // +optional + FormatVersion int32 `json:"formatVersion,omitempty"` + // +optional + VolumePolicy string `json:"volumePolicy,omitempty"` + // +optional + TemplateName string `json:"templateName,omitempty"` + // +optional + TemplateRevision string `json:"templateRevision,omitempty"` + // +optional + GameVersion string `json:"gameVersion,omitempty"` + // State is Recorded for new captures, LegacyUnknown for an existing worker // whose original layout was never recorded. // +kubebuilder:validation:Enum=Recorded;LegacyUnknown @@ -161,6 +183,16 @@ type BackupCaptureManifest struct { // BackupVolumeCoverage describes one persistent claim. Extra and loader/mod // claims remain uncovered even when mounted beneath the primary data directory. type BackupVolumeCoverage struct { + // LogicalName identifies the primary, declared extra, or active loader volume. + // +optional + LogicalName string `json:"logicalName,omitempty"` + // SnapshotRoot is the explicit disjoint archive root; never a glob. + // +optional + SnapshotRoot string `json:"snapshotRoot,omitempty"` + // RecoveryPrerequisite explains excluded content that must be reconstructed. + // +optional + RecoveryPrerequisite string `json:"recoveryPrerequisite,omitempty"` + ClaimName string `json:"claimName"` // ClaimUID is empty if the claim did not yet exist at admission. // +optional diff --git a/operator/api/v1alpha1/backupschedule_types.go b/operator/api/v1alpha1/backupschedule_types.go index 617ed14b8..662de66c9 100644 --- a/operator/api/v1alpha1/backupschedule_types.go +++ b/operator/api/v1alpha1/backupschedule_types.go @@ -7,7 +7,17 @@ import ( // BackupScheduleSpec defines a recurring backup policy for a GameServer. // +kubebuilder:validation:XValidation:rule="self.strategy == 'volume-snapshot' || has(self.repoRef)",message="repoRef is required for the restic-snapshot strategy" +// +kubebuilder:validation:XValidation:rule="self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || self.volumePolicy == 'primary-only'))",message="stopped and all-data policies require restic-snapshot" type BackupScheduleSpec struct { + // PowerPolicy preserves power by default; stop-copy-restart temporarily stops under lifecycle ownership. + // +kubebuilder:validation:Enum=preserve;stop-copy-restart + // +optional + PowerPolicy string `json:"powerPolicy,omitempty"` + // VolumePolicy captures primary-only by default; all-data includes classified persistent data. + // +kubebuilder:validation:Enum=primary-only;all-data + // +optional + VolumePolicy string `json:"volumePolicy,omitempty"` + // ServerRef is the GameServer to back up on this schedule. ServerRef LocalObjectRef `json:"serverRef"` diff --git a/operator/api/v1alpha1/gameserver_types.go b/operator/api/v1alpha1/gameserver_types.go index e6377f3cb..a8683597b 100644 --- a/operator/api/v1alpha1/gameserver_types.go +++ b/operator/api/v1alpha1/gameserver_types.go @@ -499,6 +499,13 @@ type PlayitTunnelSpec struct { // configure when enabling backups directly from GameServer. The // operator materializes this into a managed BackupSchedule. type InlineBackupPolicy struct { + // +kubebuilder:validation:Enum=preserve;stop-copy-restart + // +optional + PowerPolicy string `json:"powerPolicy,omitempty"` + // +kubebuilder:validation:Enum=primary-only;all-data + // +optional + VolumePolicy string `json:"volumePolicy,omitempty"` + // Schedule is a standard cron expression; same structural guard as // BackupScheduleSpec.Schedule so typos are rejected at admission // instead of failing inside the reconcile loop of the materialized diff --git a/operator/api/v1alpha1/gametemplate_types.go b/operator/api/v1alpha1/gametemplate_types.go index 503130e14..8c87a75c0 100644 --- a/operator/api/v1alpha1/gametemplate_types.go +++ b/operator/api/v1alpha1/gametemplate_types.go @@ -974,10 +974,10 @@ type GameStorageSpec struct { // primary Storage.MountPath, since these exist precisely for directories // that share no safe common parent with it. type ExtraVolumeSpec struct { - // BackupRole describes recovery requirements without adding this volume - // to a backup. "data" needs a separate copy; "rebuildable" may be recreated. - // Unset remains unclassified and not covered. Both backup strategies still - // capture only the primary data claim. + // BackupRole declares recovery scope. all-data Restic backups include data + // extras and report rebuildable extras as excluded reconstruction prerequisites. + // Unset is unclassified and rejects all-data admission. Default primary-only + // backups and CSI snapshots still capture only the primary data claim. // +kubebuilder:validation:Enum=data;rebuildable // +optional BackupRole string `json:"backupRole,omitempty"` diff --git a/operator/api/v1alpha1/restore_types.go b/operator/api/v1alpha1/restore_types.go index a54e56a4a..69d87a204 100644 --- a/operator/api/v1alpha1/restore_types.go +++ b/operator/api/v1alpha1/restore_types.go @@ -38,7 +38,26 @@ type RestoreSpec struct { } // RestoreStatus is the observed state of a Restore. +// RestoreRecoveryPlan pins a validated target mapping before any power mutation. +type RestoreRecoveryPlan struct { + // Admitted power intent is informational; cleanup never overwrites newer user intent. + AdmittedSuspend bool `json:"admittedSuspend"` + // +optional + AdmittedIdle string `json:"admittedIdle,omitempty"` + AdmittedGeneration int64 `json:"admittedGeneration"` + + BackupUID string `json:"backupUID"` + ManifestDigest string `json:"manifestDigest"` + TargetUID string `json:"targetUID"` + TemplateRevision string `json:"templateRevision"` + GameVersion string `json:"gameVersion"` + Volumes []BackupVolumeCoverage `json:"volumes"` +} + type RestoreStatus struct { + // +optional + RecoveryPlan *RestoreRecoveryPlan `json:"recoveryPlan,omitempty"` + // +optional Phase RestorePhase `json:"phase,omitempty"` diff --git a/operator/api/v1alpha1/zz_generated.deepcopy.go b/operator/api/v1alpha1/zz_generated.deepcopy.go index 89c9b1129..9b349cc91 100644 --- a/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -2495,6 +2495,28 @@ func (in *RestoreList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RestoreRecoveryPlan) DeepCopyInto(out *RestoreRecoveryPlan) { + *out = *in + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]BackupVolumeCoverage, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RestoreRecoveryPlan. +func (in *RestoreRecoveryPlan) DeepCopy() *RestoreRecoveryPlan { + if in == nil { + return nil + } + out := new(RestoreRecoveryPlan) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RestoreSpec) DeepCopyInto(out *RestoreSpec) { *out = *in @@ -2515,6 +2537,11 @@ func (in *RestoreSpec) DeepCopy() *RestoreSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RestoreStatus) DeepCopyInto(out *RestoreStatus) { *out = *in + if in.RecoveryPlan != nil { + in, out := &in.RecoveryPlan, &out.RecoveryPlan + *out = new(RestoreRecoveryPlan) + (*in).DeepCopyInto(*out) + } if in.StartTime != nil { in, out := &in.StartTime, &out.StartTime *out = (*in).DeepCopy() diff --git a/operator/config/crd/gameplane.local_backups.yaml b/operator/config/crd/gameplane.local_backups.yaml index 1a46399b8..67fb20227 100644 --- a/operator/config/crd/gameplane.local_backups.yaml +++ b/operator/config/crd/gameplane.local_backups.yaml @@ -57,6 +57,13 @@ spec: spec: description: BackupSpec is the desired state of a one-shot backup. properties: + powerPolicy: + description: PowerPolicy preserves power by default; stop-copy-restart + temporarily stops under lifecycle ownership. + enum: + - preserve + - stop-copy-restart + type: string quiesce: default: true description: |- @@ -112,6 +119,13 @@ spec: items: type: string type: array + volumePolicy: + description: VolumePolicy captures primary-only by default; all-data + includes classified persistent data. + enum: + - primary-only + - all-data + type: string volumeSnapshotClassName: description: |- VolumeSnapshotClassName names the VolumeSnapshotClass to use when @@ -125,6 +139,10 @@ spec: x-kubernetes-validations: - message: repoRef is required for the restic-snapshot strategy rule: self.strategy == 'volume-snapshot' || has(self.repoRef) + - message: stopped and all-data policies require restic-snapshot + rule: self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) + || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || + self.volumePolicy == 'primary-only')) status: description: BackupStatus is the observed state of a Backup. properties: @@ -134,6 +152,13 @@ spec: It describes persistent-volume coverage independently of copy success. Missing on pre-upgrade backups; never reconstruct it from current layout. properties: + formatVersion: + description: FormatVersion 2 uses validated disjoint roots. Absent + means legacy /data. + format: int32 + type: integer + gameVersion: + type: string layoutSource: description: LayoutSource identifies the frozen workload used for mount paths. @@ -158,6 +183,12 @@ spec: type: string strategy: type: string + templateName: + type: string + templateRevision: + type: string + volumePolicy: + type: string volumes: items: description: |- @@ -181,12 +212,24 @@ spec: type: string included: type: boolean + logicalName: + description: LogicalName identifies the primary, declared + extra, or active loader volume. + type: string mountPaths: items: type: string type: array reason: type: string + recoveryPrerequisite: + description: RecoveryPrerequisite explains excluded content + that must be reconstructed. + type: string + snapshotRoot: + description: SnapshotRoot is the explicit disjoint archive + root; never a glob. + type: string required: - claimName - classification diff --git a/operator/config/crd/gameplane.local_backupschedules.yaml b/operator/config/crd/gameplane.local_backupschedules.yaml index 2011ca4cd..b0e945b84 100644 --- a/operator/config/crd/gameplane.local_backupschedules.yaml +++ b/operator/config/crd/gameplane.local_backupschedules.yaml @@ -71,6 +71,13 @@ spec: - Forbid - Replace type: string + powerPolicy: + description: PowerPolicy preserves power by default; stop-copy-restart + temporarily stops under lifecycle ownership. + enum: + - preserve + - stop-copy-restart + type: string quiesce: default: true description: |- @@ -162,6 +169,13 @@ spec: default: false description: Suspend pauses the schedule without deleting it. type: boolean + volumePolicy: + description: VolumePolicy captures primary-only by default; all-data + includes classified persistent data. + enum: + - primary-only + - all-data + type: string required: - schedule - serverRef @@ -169,6 +183,10 @@ spec: x-kubernetes-validations: - message: repoRef is required for the restic-snapshot strategy rule: self.strategy == 'volume-snapshot' || has(self.repoRef) + - message: stopped and all-data policies require restic-snapshot + rule: self.strategy != 'volume-snapshot' || ((!has(self.powerPolicy) + || self.powerPolicy == 'preserve') && (!has(self.volumePolicy) || + self.volumePolicy == 'primary-only')) status: description: BackupScheduleStatus is the observed state of a BackupSchedule. properties: diff --git a/operator/config/crd/gameplane.local_gameservers.yaml b/operator/config/crd/gameplane.local_gameservers.yaml index 6598e07ff..7bc7f5eeb 100644 --- a/operator/config/crd/gameplane.local_gameservers.yaml +++ b/operator/config/crd/gameplane.local_gameservers.yaml @@ -977,6 +977,11 @@ spec: the operator creates/maintains a BackupSchedule owned by this GameServer. Remove to stop scheduled backups. properties: + powerPolicy: + enum: + - preserve + - stop-copy-restart + type: string repoRef: description: |- RepoRef points at a BackupRepo (cluster-scoped resource, TBD) or @@ -1027,6 +1032,11 @@ spec: type: string suspend: type: boolean + volumePolicy: + enum: + - primary-only + - all-data + type: string required: - repoRef - schedule @@ -2221,10 +2231,10 @@ spec: properties: backupRole: description: |- - BackupRole describes recovery requirements without adding this volume - to a backup. "data" needs a separate copy; "rebuildable" may be recreated. - Unset remains unclassified and not covered. Both backup strategies still - capture only the primary data claim. + BackupRole declares recovery scope. all-data Restic backups include data + extras and report rebuildable extras as excluded reconstruction prerequisites. + Unset is unclassified and rejects all-data admission. Default primary-only + backups and CSI snapshots still capture only the primary data claim. enum: - data - rebuildable diff --git a/operator/config/crd/gameplane.local_gametemplates.yaml b/operator/config/crd/gameplane.local_gametemplates.yaml index c588d074d..cf08d43d1 100644 --- a/operator/config/crd/gameplane.local_gametemplates.yaml +++ b/operator/config/crd/gameplane.local_gametemplates.yaml @@ -2208,10 +2208,10 @@ spec: properties: backupRole: description: |- - BackupRole describes recovery requirements without adding this volume - to a backup. "data" needs a separate copy; "rebuildable" may be recreated. - Unset remains unclassified and not covered. Both backup strategies still - capture only the primary data claim. + BackupRole declares recovery scope. all-data Restic backups include data + extras and report rebuildable extras as excluded reconstruction prerequisites. + Unset is unclassified and rejects all-data admission. Default primary-only + backups and CSI snapshots still capture only the primary data claim. enum: - data - rebuildable diff --git a/operator/config/crd/gameplane.local_restores.yaml b/operator/config/crd/gameplane.local_restores.yaml index 4984f6817..236a904fd 100644 --- a/operator/config/crd/gameplane.local_restores.yaml +++ b/operator/config/crd/gameplane.local_restores.yaml @@ -92,7 +92,6 @@ spec: - serverRef type: object status: - description: RestoreStatus is the observed state of a Restore. properties: completionTime: format: date-time @@ -170,6 +169,88 @@ spec: - Succeeded - Failed type: string + recoveryPlan: + description: |- + RestoreStatus is the observed state of a Restore. + RestoreRecoveryPlan pins a validated target mapping before any power mutation. + properties: + admittedGeneration: + format: int64 + type: integer + admittedIdle: + type: string + admittedSuspend: + description: Admitted power intent is informational; cleanup never + overwrites newer user intent. + type: boolean + backupUID: + type: string + gameVersion: + type: string + manifestDigest: + type: string + targetUID: + type: string + templateRevision: + type: string + volumes: + items: + description: |- + BackupVolumeCoverage describes one persistent claim. Extra and loader/mod + claims remain uncovered even when mounted beneath the primary data directory. + properties: + claimName: + type: string + claimUID: + description: ClaimUID is empty if the claim did not yet + exist at admission. + type: string + classification: + description: |- + Classification records the module/server declaration at admission; + it does not prove that the volume can actually be reconstructed. + enum: + - data + - rebuildable + - unclassified + type: string + included: + type: boolean + logicalName: + description: LogicalName identifies the primary, declared + extra, or active loader volume. + type: string + mountPaths: + items: + type: string + type: array + reason: + type: string + recoveryPrerequisite: + description: RecoveryPrerequisite explains excluded content + that must be reconstructed. + type: string + snapshotRoot: + description: SnapshotRoot is the explicit disjoint archive + root; never a glob. + type: string + required: + - claimName + - classification + - included + - reason + type: object + type: array + required: + - admittedGeneration + - admittedSuspend + - backupUID + - gameVersion + - manifestDigest + - targetUID + - templateRevision + - volumes + type: object snapshotID: description: |- SnapshotID is the restic snapshot used. Resolved from the diff --git a/operator/internal/controller/backup_controller.go b/operator/internal/controller/backup_controller.go index 0dcb5217a..694429e53 100644 --- a/operator/internal/controller/backup_controller.go +++ b/operator/internal/controller/backup_controller.go @@ -310,6 +310,9 @@ func (r *BackupReconciler) reconcile(ctx context.Context, req ctrl.Request) (ctr } return r.runUnquiesce(ctx, &b) } + if b.Spec.Strategy == "volume-snapshot" && (b.Spec.PowerPolicy == "stop-copy-restart" || b.Spec.VolumePolicy == "all-data") { + return r.fail(ctx, &b, "stopped and all-data policies require restic-snapshot") + } // Observe pre-upgrade workers even when multiple legacy quiesced copies // already overlap. They must finish and unquiesce before new admission; // trying to acquire exclusive ownership first would deadlock their cleanup. @@ -1033,7 +1036,7 @@ func (r *BackupReconciler) buildBackupPodSpec(b *gameplanev1alpha1.Backup, tmpl args = append(args, "--tag", t) } - return corev1.PodSpec{ + spec := corev1.PodSpec{ SecurityContext: buildBackupPodSecurityContext(tmpl), InitContainers: []corev1.Container{{ Name: "restic-init", @@ -1077,6 +1080,17 @@ func (r *BackupReconciler) buildBackupPodSpec(b *gameplanev1alpha1.Backup, tmpl }, }, } + if b.Spec.VolumePolicy == "all-data" && b.Status.CaptureManifest != nil { + recoveryWorkerVolumes(&spec, b.Status.CaptureManifest.Volumes, true) + roots := []string{"backup"} + for _, volume := range b.Status.CaptureManifest.Volumes { + if volume.Included { + roots = append(roots, volume.SnapshotRoot) + } + } + spec.Containers[0].Args = append(roots, args[2:]...) + } + return spec } // resticEnv is the container environment shared by every Job that talks to a diff --git a/operator/internal/controller/backup_guard.go b/operator/internal/controller/backup_guard.go index 77832d77c..b28da3f3a 100644 --- a/operator/internal/controller/backup_guard.go +++ b/operator/internal/controller/backup_guard.go @@ -297,6 +297,18 @@ func (r *BackupReconciler) prepareBackupWorkload(ctx context.Context, b *gamepla return ctrl.Result{RequeueAfter: 5 * time.Second}, err } ready := false + if fenced && b.Spec.PowerPolicy == "stop-copy-restart" { + stopped, stopErr := r.prepareStoppedBackup(ctx, b, gs) + if stopErr != nil { + if errors.Is(stopErr, errBackupSourceChanged) { + return r.abortResticCopy(ctx, b, stopErr.Error()) + } + return ctrl.Result{}, stopErr + } + if !stopped { + return ctrl.Result{RequeueAfter: 2 * time.Second}, nil + } + } if fenced { ready, err = r.prepareBackupSource(ctx, b, gs) } else { @@ -384,6 +396,29 @@ func (r *BackupReconciler) releaseBackupTarget(ctx context.Context, b *gameplane if err != nil || gs == nil { return err } + op, _, err := lifecycleOwner(gs) + if err != nil { + return err + } + if op.StoppedBackup != nil { + ready, err := stoppedBackupCleanup(ctx, r.Client, r.backupAPIReader(), gs, op.StoppedBackup) + if err != nil { + _ = recoveryIntervention(ctx, r.Client, gs, err.Error()) + return err + } + if !ready { + return fmt.Errorf("waiting for stopped source and workers to drain before recovery release") + } + delete(gs.Annotations, stopRequestedAtAnnotation) + reason, message := "UserIntentPreserved", "Cleanup completed; current suspend, idle and restart intent is preserved." + if stoppedBackupMayResume(gs, op.StoppedBackup) { + reason, message = "RestartPermitted", "Workers and original source drained; the admitted running intent permits normal startup." + } + b.Status.Conditions = upsertCondition(b.Status.Conditions, metav1.Condition{Type: "Recovery", Status: metav1.ConditionTrue, Reason: reason, Message: message, ObservedGeneration: b.Generation}) + if err := r.Status().Update(ctx, b); err != nil { + return err + } + } if err := clearLifecycleOperation(ctx, r.Client, r.backupAPIReader(), gs, backupLifecycleIdentity(gs, b, false)); err != nil { return err } diff --git a/operator/internal/controller/backup_manifest.go b/operator/internal/controller/backup_manifest.go index 2ba50f7e6..102faae64 100644 --- a/operator/internal/controller/backup_manifest.go +++ b/operator/internal/controller/backup_manifest.go @@ -39,6 +39,18 @@ func (r *BackupReconciler) recordBackupManifest(ctx context.Context, b *gameplan return nil } reader := r.backupAPIReader() + if b.Spec.VolumePolicy == "all-data" { + manifest, err := recoveryLayout(ctx, reader, gs) + if err != nil { + return err + } + now := metav1.Now() + manifest.RecordedAt = &now + manifest.QuiesceOutcome = backupQuiesceOutcome(b) + b.Status.CaptureManifest = manifest + return r.Status().Update(ctx, b) + } + worker, err := r.captureWorker(ctx, b) if err != nil { return err @@ -184,10 +196,12 @@ func backupConsistencyCondition(b *gameplanev1alpha1.Backup) metav1.Condition { condition.Reason, condition.Message = "LegacyMetadataUnknown", "Capture-time metadata is unavailable. Do not infer consistency or volume coverage from the current template." case b.Status.Phase != gameplanev1alpha1.BackupPhaseSucceeded || b.Status.SnapshotID == "": condition.Reason, condition.Message = "CapturePending", "The capture has not completed; its achieved consistency is not yet known." + case b.Status.CaptureManifest.VolumePolicy == "all-data" && b.Status.CaptureManifest.QuiesceOutcome != "already-stopped": + condition.Status, condition.Reason, condition.Message = metav1.ConditionFalse, "LiveMultiVolumeCopy", "Included volumes share one snapshot, but live copies have no atomic cross-volume consistency guarantee, even when the primary game's quiesce succeeds." case b.Status.CaptureManifest.QuiesceOutcome == "confirmed": - condition.Status, condition.Reason, condition.Message = metav1.ConditionTrue, "Quiesced", "The primary volume copy completed after the agent confirmed quiesce. This depends on the module's quiesce commands; other volumes remain outside this backup." + condition.Status, condition.Reason, condition.Message = metav1.ConditionTrue, "Quiesced", "The included volumes were copied after the agent confirmed quiesce. This depends on the module's quiesce commands; consult the manifest for excluded volumes." case b.Status.CaptureManifest.QuiesceOutcome == "already-stopped": - condition.Status, condition.Reason, condition.Message = metav1.ConditionTrue, "StoppedSource", "The primary volume copy completed from a source that was already stopped and drained before capture. Other volumes remain outside this backup." + condition.Status, condition.Reason, condition.Message = metav1.ConditionTrue, "StoppedSource", "The included volumes were copied from a stopped and drained source. Consult the manifest for excluded volumes and recovery prerequisites." case b.Status.CaptureManifest.Strategy == "volume-snapshot": condition.Status, condition.Reason, condition.Message = metav1.ConditionFalse, "CrashConsistentSnapshot", "The primary CSI snapshot is crash-consistent; application quiesce was not confirmed ("+b.Status.CaptureManifest.QuiesceOutcome+")." case b.Status.CaptureManifest.QuiesceOutcome == "unsupported": @@ -208,3 +222,18 @@ func (r *BackupReconciler) syncBackupConsistency(ctx context.Context, b *gamepla b.Status.Conditions = conditions return r.Status().Update(ctx, b) } + +// The all-data layout must precede worker creation, while the quiesce RPC +// deliberately follows worker readiness. Finalize only its outcome before the +// first authorization; the claim selection and provenance never change. +func (r *BackupReconciler) finalizeBackupCaptureOutcome(ctx context.Context, b *gameplanev1alpha1.Backup) error { + if b.Spec.VolumePolicy != "all-data" || b.Status.CaptureManifest == nil || b.Annotations[backupAuthorizedWorkerAnnotation] != "" { + return nil + } + outcome := backupQuiesceOutcome(b) + if b.Status.CaptureManifest.QuiesceOutcome == outcome { + return nil + } + b.Status.CaptureManifest.QuiesceOutcome = outcome + return r.Status().Update(ctx, b) +} diff --git a/operator/internal/controller/backup_preparation.go b/operator/internal/controller/backup_preparation.go index fa1d4a08d..1993d0500 100644 --- a/operator/internal/controller/backup_preparation.go +++ b/operator/internal/controller/backup_preparation.go @@ -120,7 +120,7 @@ func parseBackupPreparation(b *gameplanev1alpha1.Backup) (*backupPreparation, er // Call only after the shared owner and StatefulSet fence are held. Readiness // and desired suspend/idle state are not evidence that writers have exited. func (r *BackupReconciler) prepareBackupSource(ctx context.Context, b *gameplanev1alpha1.Backup, gs *gameplanev1alpha1.GameServer) (bool, error) { - if !b.Spec.Quiesce { + if !b.Spec.Quiesce && b.Spec.PowerPolicy != "stop-copy-restart" { return true, nil } p, err := parseBackupPreparation(b) @@ -136,7 +136,7 @@ func (r *BackupReconciler) prepareBackupSource(ctx context.Context, b *gameplane if p == nil && b.Annotations[annoQuiesceAttempted] != "" && b.Annotations[annoQuiesceAttempted] != "unsupported" { return false, fmt.Errorf("%w: quiesce attempt lacks source preparation", errBackupSourceChanged) } - if p == nil && !agentQuiesceAvailable(r.AgentClient) { + if p == nil && !agentQuiesceAvailable(r.AgentClient) && b.Spec.PowerPolicy != "stop-copy-restart" { return true, nil } // An unsupported attempt did not pause the game and retains raw-copy @@ -242,6 +242,18 @@ func (r *BackupReconciler) backupSourceDrained(ctx context.Context, b *gameplane if err := r.backupAPIReader().List(ctx, &jobs, client.InNamespace(gs.Namespace)); err != nil { return false, err } + var selected []gameplanev1alpha1.BackupVolumeCoverage + if b.Status.CaptureManifest != nil { + selected = b.Status.CaptureManifest.Volumes + } + if b.Status.CaptureManifest == nil { + if op, _, err := lifecycleOwner(gs); err == nil && op.Kind == "Backup" && op.UID == b.UID && op.StoppedBackup != nil { + selected = op.StoppedBackup.Claims + } + } + mounts := func(pod *corev1.Pod) bool { + return podMountsRestoreTarget(pod, gs.Name) || podMountsRecoveryClaims(pod, selected) + } copyJobs := map[types.UID]bool{} for i := range jobs.Items { job := &jobs.Items[i] @@ -249,7 +261,7 @@ func (r *BackupReconciler) backupSourceDrained(ctx context.Context, b *gameplane copyJobs[job.UID] = true continue } - if ((job.Status.Succeeded == 0 && !jobPermanentlyFailed(job)) || job.Status.Active > 0 || !job.DeletionTimestamp.IsZero()) && podMountsRestoreTarget(&corev1.Pod{Spec: job.Spec.Template.Spec}, gs.Name) { + if ((job.Status.Succeeded == 0 && !jobPermanentlyFailed(job)) || job.Status.Active > 0 || !job.DeletionTimestamp.IsZero()) && mounts(&corev1.Pod{Spec: job.Spec.Template.Spec}) { return false, nil } } @@ -266,7 +278,7 @@ func (r *BackupReconciler) backupSourceDrained(ctx context.Context, b *gameplane if owner != nil && owner.Kind == "Job" && copyJobs[owner.UID] { continue } - if metav1.IsControlledBy(pod, ss) || metav1.IsControlledBy(pod, gs) || pod.Name == gs.Name+"-0" || podMountsRestoreTarget(pod, gs.Name) { + if metav1.IsControlledBy(pod, ss) || metav1.IsControlledBy(pod, gs) || pod.Name == gs.Name+"-0" || mounts(pod) { return false, nil } } diff --git a/operator/internal/controller/backup_stop.go b/operator/internal/controller/backup_stop.go new file mode 100644 index 000000000..7ce404a3e --- /dev/null +++ b/operator/internal/controller/backup_stop.go @@ -0,0 +1,210 @@ +package controller + +import ( + "context" + "fmt" + "time" + + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/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" +) + +// This record lives on the arbitration object, including during orphan transfer. +// Admitted is persisted atomically with ownership; Stopping precedes any stop RPC. +type stoppedBackup struct { + Stage string `json:"stage"` + WasRunning bool `json:"wasRunning"` + Generation int64 `json:"generation"` + Suspend bool `json:"suspend"` + Idle string `json:"idle,omitempty"` + Restart string `json:"restart,omitempty"` + Wake string `json:"wake,omitempty"` + WorkloadUID types.UID `json:"workloadUID,omitempty"` + OriginalPodUID types.UID `json:"originalPodUID,omitempty"` + Deadline string `json:"deadline,omitempty"` + Claims []v1.BackupVolumeCoverage `json:"claims,omitempty"` +} + +func newStoppedBackup(gs *v1.GameServer) *stoppedBackup { + return &stoppedBackup{Stage: "Admitted", WasRunning: !gs.Spec.Suspend && gs.Annotations[IdleAsleepSinceAnnotation] == "", Generation: gs.Generation, Suspend: gs.Spec.Suspend, Idle: gs.Annotations[IdleAsleepSinceAnnotation], Restart: gs.Annotations[RestartRequestedAnnotation], Wake: gs.Annotations[IdleWakeRequestedAnnotation]} +} +func stoppedBackupMayResume(gs *v1.GameServer, s *stoppedBackup) bool { + return s != nil && s.WasRunning && !gs.Spec.Suspend && gs.Annotations[IdleAsleepSinceAnnotation] == "" && gs.Generation == s.Generation && gs.Annotations[RestartRequestedAnnotation] == s.Restart && gs.Annotations[IdleWakeRequestedAnnotation] == s.Wake +} + +func (r *BackupReconciler) prepareStoppedBackup(ctx context.Context, b *v1.Backup, gs *v1.GameServer) (bool, error) { + op, held, err := lifecycleOwner(gs) + if err != nil { + return false, err + } + if !held || op.StoppedBackup == nil { + return false, fmt.Errorf("stopped backup lacks durable owner provenance") + } + s := op.StoppedBackup + var ss appsv1.StatefulSet + if err := r.backupAPIReader().Get(ctx, client.ObjectKeyFromObject(gs), &ss); err != nil { + return false, err + } + if !metav1.IsControlledBy(&ss, gs) { + return false, fmt.Errorf("%w: stopped workload owner", errBackupSourceChanged) + } + if s.Stage == "Admitted" { + // Select coverage before the first stop; failures cannot interrupt a running game. + if b.Spec.VolumePolicy == "all-data" { + m, err := recoveryLayout(ctx, r.backupAPIReader(), gs) + if err != nil { + return false, fmt.Errorf("%w: %v", errBackupSourceChanged, err) + } + s.Claims = m.Volumes + } else { + s.Claims = []v1.BackupVolumeCoverage{{ClaimName: gs.Name + "-data", Included: true}} + } + var pod corev1.Pod + err := r.backupAPIReader().Get(ctx, client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-0"}, &pod) + if err != nil && !apierrors.IsNotFound(err) { + return false, err + } + if err == nil { + if !metav1.IsControlledBy(&pod, &ss) { + return false, fmt.Errorf("%w: original pod owner", errBackupSourceChanged) + } + s.OriginalPodUID = pod.UID + } + s.WorkloadUID = ss.UID + s.Deadline = b.Annotations[backupPreparationDeadlineAnnotation] + s.Stage = "Stopping" + gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) + return false, r.Update(ctx, gs) + } + if s.WorkloadUID != ss.UID { + return false, fmt.Errorf("%w: stopped workload replaced", errBackupSourceChanged) + } + if ss.Spec.Replicas == nil || *ss.Spec.Replicas != 0 || ss.Status.Replicas != 0 || ss.Status.ObservedGeneration < ss.Generation { + return false, nil + } + drained, err := r.backupSourceDrained(ctx, b, gs, &ss) + if err != nil || !drained { + return false, err + } + if s.Stage != "Drained" { + s.Stage = "Drained" + gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) + if err := r.Update(ctx, gs); err != nil { + return false, err + } + } + return true, nil +} + +// Cleanup never changes spec or acknowledges a user's pending restart/wake. +func stoppedBackupCleanup(ctx context.Context, c client.Client, reader client.Reader, gs *v1.GameServer, s *stoppedBackup) (bool, error) { + if s == nil || s.Stage == "Admitted" { + return true, nil + } + if s.WorkloadUID == "" || s.Deadline == "" || (s.Stage != "Stopping" && s.Stage != "Drained") { + return false, fmt.Errorf("stopped backup provenance is incomplete; recovery intervention required") + } + if _, err := time.Parse(time.RFC3339Nano, s.Deadline); err != nil { + return false, fmt.Errorf("invalid stopped backup deadline: %w", err) + } + var ss appsv1.StatefulSet + if err := reader.Get(ctx, client.ObjectKeyFromObject(gs), &ss); err != nil { + return false, client.IgnoreNotFound(err) + } + if ss.UID != s.WorkloadUID || !metav1.IsControlledBy(&ss, gs) { + return false, fmt.Errorf("stopped backup workload replaced; recovery intervention required") + } + if ss.Spec.Replicas == nil || *ss.Spec.Replicas != 0 || ss.Status.Replicas != 0 || ss.Status.ObservedGeneration < ss.Generation { + return false, nil + } + gone, err := backupQuiesceProcessGone(ctx, reader, gs.Namespace, gs.Name, s.OriginalPodUID) + if err != nil || !gone { + return false, err + } + fake := &v1.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: gs.Namespace}, Status: v1.BackupStatus{CaptureManifest: &v1.BackupCaptureManifest{Volumes: s.Claims}}} + return (&BackupReconciler{Client: c, APIReader: reader}).backupSourceDrained(ctx, fake, gs, &ss) +} + +func recoveryIntervention(ctx context.Context, c client.Client, gs *v1.GameServer, message string) error { + gs.Status.Conditions = upsertCondition(gs.Status.Conditions, metav1.Condition{Type: "RecoveryIntervention", Status: metav1.ConditionTrue, Reason: "ProvenanceUnavailable", Message: message, ObservedGeneration: gs.Generation}) + return c.Status().Update(ctx, gs) +} + +// Autonomous orphan cleanup runs even if nobody requests a successor operation. +func (r *GameServerReconciler) recoverStoppedBackupOwner(ctx context.Context, gs *v1.GameServer) (bool, error) { + op, held, err := lifecycleOwner(gs) + if err != nil { + return true, recoveryIntervention(ctx, r.Client, gs, err.Error()) + } + if !held || op.Kind != "Backup" || op.StoppedBackup == nil { + return false, nil + } + exists, err := lifecycleOwnerExists(ctx, r.wipeReader(), gs.Namespace, op.lifecycleIdentity) + if err != nil || exists { + return false, err + } + backup := &BackupReconciler{Client: r.Client, APIReader: r.wipeReader(), Scheme: r.Scheme} + busy, err := backup.backupWorkersLive(ctx, gs.Namespace, backupIdentity{Name: op.Name, UID: op.UID}, true) + if err != nil || busy { + return false, err + } + ready, err := recoverLifecycleOperation(ctx, r.Client, r.wipeReader(), gs, op, r.OperationQuiescer) + if err != nil || !ready { + return false, err + } + ready, err = stoppedBackupCleanup(ctx, r.Client, r.wipeReader(), gs, op.StoppedBackup) + if err != nil { + return true, recoveryIntervention(ctx, r.Client, gs, err.Error()) + } + if !ready { + return false, nil + } + if err := clearLifecycleOperation(ctx, r.Client, r.wipeReader(), gs, op.lifecycleIdentity); err != nil { + return false, err + } + delete(gs.Annotations, backupGuardAnnotation) + delete(gs.Annotations, stopRequestedAtAnnotation) + return true, r.Update(ctx, gs) +} + +// Recovery keeps the oldest outstanding graceful stop ahead of a successor. +// Admitted carries no stop side effect; Drained is handled by the zero fence. +func lifecycleStoppingBackup(op lifecycleOperation) *stoppedBackup { + for _, id := range append(append([]lifecycleIdentity{}, op.Recovery...), op.lifecycleIdentity) { + if id.StoppedBackup != nil && id.StoppedBackup.Stage == "Stopping" { + return id.StoppedBackup + } + } + return nil +} +func (r *GameServerReconciler) continueBackupStop(ctx context.Context, gs *v1.GameServer, tmpl *v1.GameTemplate, debt *stoppedBackup) (int32, time.Duration, error) { + deadline, err := time.Parse(time.RFC3339Nano, debt.Deadline) + if err != nil { + return 0, 5 * time.Second, fmt.Errorf("invalid persisted backup stop deadline: %w", err) + } + if !time.Now().Before(deadline) { + return 0, 5 * time.Second, nil + } + // A replacement process must not receive the old process's graceful RPC. + var pod corev1.Pod + err = r.wipeReader().Get(ctx, client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-0"}, &pod) + if apierrors.IsNotFound(err) { + return 0, 5 * time.Second, nil + } + if err != nil { + return 0, 0, err + } + if debt.OriginalPodUID == "" || pod.UID != debt.OriginalPodUID { + return 0, 5 * time.Second, nil + } + replicas, poll, err := r.softStop(ctx, gs, tmpl) + if poll == 0 || poll > time.Until(deadline) { + poll = time.Until(deadline) + } + return replicas, poll, err +} diff --git a/operator/internal/controller/backup_worker.go b/operator/internal/controller/backup_worker.go index 80cf6c98c..14852e727 100644 --- a/operator/internal/controller/backup_worker.go +++ b/operator/internal/controller/backup_worker.go @@ -36,7 +36,7 @@ const ( const resticWorkerScript = `set -eu for utility in timeout date cat touch sleep restic; do command -v "$utility" >/dev/null; done timeout -s TERM -k 5 1 /bin/sh -c ':' -test -d /data && test -r /data && test -x /data +test -d /tmp && test -w /tmp restic snapshots --json --retry-lock 10m >/dev/null touch /tmp/prepared while :; do @@ -251,6 +251,20 @@ func (r *BackupReconciler) reconcilePreparedRestic(ctx context.Context, b *gamep if bound == nil { return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } + if b.Spec.VolumePolicy == "all-data" { + if b.Annotations[captureFormatAnnotation] != "all-data-v2" { + patchBackupAnnotations(b, map[string]string{captureFormatAnnotation: "all-data-v2"}) + if err := r.Update(ctx, b); err != nil { + return ctrl.Result{}, err + } + } + if err := r.recordBackupManifest(ctx, b, bound); err != nil { + return r.abortResticCopy(ctx, b, err.Error()) + } + if err := r.validateBackupRecoveryLayout(ctx, b, bound); err != nil { + return r.abortResticCopy(ctx, b, err.Error()) + } + } source, err := r.prepareBackupWorkerSource(ctx, b) if err != nil { if errors.Is(err, errBackupSourceChanged) { @@ -359,12 +373,23 @@ func (r *BackupReconciler) reconcilePreparedRestic(ctx context.Context, b *gamep if !time.Now().Before(deadline) { return r.abortResticCopy(ctx, b, "maximum quiesce duration exceeded before start authorization") } + if err := r.finalizeBackupCaptureOutcome(ctx, b); err != nil { + return ctrl.Result{}, err + } if b.Annotations[backupAuthorizedWorkerAnnotation] != string(worker.UID) { patchBackupAnnotations(b, map[string]string{backupAuthorizedWorkerAnnotation: string(worker.UID)}) if err := r.Update(ctx, b); err != nil { return ctrl.Result{}, err } } + if err := r.validateBackupRecoveryLayout(ctx, b, bound); err != nil { + return r.abortResticCopy(ctx, b, err.Error()) + } + if b.Spec.VolumePolicy == "all-data" { + if err := validateMountedRecoveryClaims(ctx, r.backupAPIReader(), b.Namespace, b.Status.CaptureManifest.Volumes); err != nil { + return r.abortResticCopy(ctx, b, err.Error()) + } + } token := fmt.Sprintf("v1 %s %s %s %d\n", b.UID, worker.UID, backupWorkerSourceID(b), deadline.Unix()) if gate.Data[backupStartTokenKey] != token { gate.Data[backupStartTokenKey] = token diff --git a/operator/internal/controller/backupschedule_controller.go b/operator/internal/controller/backupschedule_controller.go index 5d53b7318..a584525bf 100644 --- a/operator/internal/controller/backupschedule_controller.go +++ b/operator/internal/controller/backupschedule_controller.go @@ -178,10 +178,12 @@ func (r *BackupScheduleReconciler) fire( Labels: map[string]string{"gameplane.local/backup-schedule": sched.Name}, }, Spec: gameplanev1alpha1.BackupSpec{ - ServerRef: sched.Spec.ServerRef, - RepoRef: sched.Spec.RepoRef, - Strategy: sched.Spec.Strategy, - Quiesce: sched.Spec.Quiesce, + ServerRef: sched.Spec.ServerRef, + RepoRef: sched.Spec.RepoRef, + Strategy: sched.Spec.Strategy, + PowerPolicy: sched.Spec.PowerPolicy, + VolumePolicy: sched.Spec.VolumePolicy, + Quiesce: sched.Spec.Quiesce, }, } return r.Create(ctx, b) diff --git a/operator/internal/controller/gameserver_controller.go b/operator/internal/controller/gameserver_controller.go index 05f548573..c2a5d1998 100644 --- a/operator/internal/controller/gameserver_controller.go +++ b/operator/internal/controller/gameserver_controller.go @@ -268,6 +268,11 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) } else if changed { return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } + if changed, err := r.recoverStoppedBackupOwner(ctx, &gs); err != nil { + return requeueOnConflict(ctrl.Result{}, err) + } else if changed { + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } // Exposure compatibility depends only on the server. Reject it before // resolving/adopting a template, which may otherwise initiate a rollout. if r.RestrictedWorkloads && gs.Spec.Networking.Expose == "Hostport" { @@ -1276,6 +1281,10 @@ func (r *GameServerReconciler) desiredReplicas( poll = restartDrainPoll } return replicas, poll, err + } else if held && lifecycleStoppingBackup(op) != nil { + return r.continueBackupStop(ctx, &live, tmpl, lifecycleStoppingBackup(op)) + } else if held && op.Kind == "Backup" && op.StoppedBackup != nil && op.StoppedBackup.Stage == "Drained" { + return 0, 5 * time.Second, nil } else if held && op.InitialStartup && len(op.Recovery) == 0 { if live.Spec.Suspend { return 0, 5 * time.Second, nil @@ -1623,6 +1632,16 @@ func (r *GameServerReconciler) reconcileStatefulSet( // Orphaned restores may leave a running initial workload. // Stop it while preserving its complete pod template. Other // protected operations also preserve the replica count. + if (op.StoppedBackup != nil && op.StoppedBackup.Stage != "Admitted") || lifecycleStoppingBackup(op) != nil { + // A replica decision may predate admission. Never raise a stopped + // workload with that stale decision, including during a copy. + if op.StoppedBackup != nil && op.StoppedBackup.Stage == "Drained" { + zero := int32(0) + ss.Spec.Replicas = &zero + } else if ss.Spec.Replicas != nil && replicas < *ss.Spec.Replicas { + ss.Spec.Replicas = &replicas + } + } if lifecycleRecoveryNeedsStop(op) { zero := int32(0) ss.Spec.Replicas = &zero @@ -2627,6 +2646,8 @@ func (r *GameServerReconciler) reconcileBackupSchedule( _, err := controllerutil.CreateOrUpdate(ctx, r.Client, bs, func() error { bs.Spec.ServerRef = gameplanev1alpha1.LocalObjectRef{Name: gs.Name} + bs.Spec.PowerPolicy = gs.Spec.BackupPolicy.PowerPolicy + bs.Spec.VolumePolicy = gs.Spec.BackupPolicy.VolumePolicy bs.Spec.Schedule = gs.Spec.BackupPolicy.Schedule bs.Spec.RepoRef = &gs.Spec.BackupPolicy.RepoRef bs.Spec.Retention = gs.Spec.BackupPolicy.Retention diff --git a/operator/internal/controller/lifecycle_operation.go b/operator/internal/controller/lifecycle_operation.go index 6445aa927..20aff9bbb 100644 --- a/operator/internal/controller/lifecycle_operation.go +++ b/operator/internal/controller/lifecycle_operation.go @@ -24,14 +24,18 @@ var errLifecycleGenerationChanged = errors.New("lifecycle operation spec changed // request token and GameServer UID identify the operation. Generation is pinned // at admission; subsequent power requests must not change that identity. type lifecycleIdentity struct { - Kind string `json:"kind"` - Name string `json:"name"` - UID types.UID `json:"uid"` - TargetUID types.UID `json:"targetUID"` - Generation int64 `json:"generation"` - Token string `json:"token,omitempty"` - NeedsUnquiesce bool `json:"needsUnquiesce,omitempty"` - QuiescePodUID types.UID `json:"quiescePodUID,omitempty"` + // PreservePower allows v2 restores to stop with the guard, without altering user suspend intent. + PreservePower bool `json:"preservePower,omitempty"` + + StoppedBackup *stoppedBackup `json:"stoppedBackup,omitempty"` + Kind string `json:"kind"` + Name string `json:"name"` + UID types.UID `json:"uid"` + TargetUID types.UID `json:"targetUID"` + Generation int64 `json:"generation"` + Token string `json:"token,omitempty"` + NeedsUnquiesce bool `json:"needsUnquiesce,omitempty"` + QuiescePodUID types.UID `json:"quiescePodUID,omitempty"` // InitialStartup permits only the first workload of a CSI restore's new // target (including WaitForFirstConsumer PVCs), while other operations wait. InitialStartup bool `json:"initialStartup,omitempty"` @@ -69,11 +73,15 @@ func parseLifecycleOperation(value string) (lifecycleOperation, error) { } func backupLifecycleIdentity(gs *gameplanev1alpha1.GameServer, b *gameplanev1alpha1.Backup, quiesce bool) lifecycleIdentity { - return lifecycleIdentity{Kind: "Backup", Name: b.Name, UID: b.UID, TargetUID: gs.UID, Generation: b.Generation, NeedsUnquiesce: quiesce} + id := lifecycleIdentity{Kind: "Backup", Name: b.Name, UID: b.UID, TargetUID: gs.UID, Generation: b.Generation, NeedsUnquiesce: quiesce} + if b.Spec.PowerPolicy == "stop-copy-restart" { + id.StoppedBackup = newStoppedBackup(gs) + } + return id } func restoreLifecycleIdentity(gs *gameplanev1alpha1.GameServer, rs *gameplanev1alpha1.Restore) lifecycleIdentity { - return lifecycleIdentity{Kind: "Restore", Name: rs.Name, UID: rs.UID, TargetUID: gs.UID, Generation: rs.Generation} + return lifecycleIdentity{Kind: "Restore", Name: rs.Name, UID: rs.UID, TargetUID: gs.UID, Generation: rs.Generation, PreservePower: rs.Status.RecoveryPlan != nil} } func wipeLifecycleIdentity(gs *gameplanev1alpha1.GameServer, token string) lifecycleIdentity { @@ -168,6 +176,9 @@ func claimLifecycleOperation(ctx context.Context, c client.Client, reader client return false, errLifecycleGenerationChanged } *gs = live + if want.StoppedBackup != nil { + want.StoppedBackup = newStoppedBackup(gs) + } op, occupied, err := lifecycleOwner(gs) if err != nil { return false, err @@ -225,7 +236,7 @@ func claimLifecycleOperation(ctx context.Context, c client.Client, reader client gs.Annotations[backupGuardAnnotation] = backupIdentityJSON(want.Name, want.UID) case "Restore": gs.Annotations[restoreGuardAnnotation] = restoreIdentityJSON(want.Name, want.UID) - if !op.InitialStartup { + if !op.InitialStartup && !op.PreservePower { gs.Spec.Suspend = true } case "Wipe": @@ -270,7 +281,7 @@ func legacyLifecycleCopiesLive(ctx context.Context, c client.Client, reader clie func lifecycleRecoveryNeedsStop(op lifecycleOperation) bool { for _, predecessor := range op.Recovery { - if predecessor.Kind == "Restore" { + if predecessor.Kind == "Restore" || (predecessor.StoppedBackup != nil && predecessor.StoppedBackup.Stage == "Drained") { return true } } @@ -303,6 +314,12 @@ func recoverLifecycleOperation(ctx context.Context, c client.Client, reader clie } } for _, predecessor := range op.Recovery { + if predecessor.StoppedBackup != nil { + ready, err := stoppedBackupCleanup(ctx, c, reader, gs, predecessor.StoppedBackup) + if err != nil || !ready { + return false, err + } + } if !predecessor.NeedsUnquiesce { continue } diff --git a/operator/internal/controller/recovery_controller_test.go b/operator/internal/controller/recovery_controller_test.go new file mode 100644 index 000000000..9cbef3ec5 --- /dev/null +++ b/operator/internal/controller/recovery_controller_test.go @@ -0,0 +1,1027 @@ +package controller + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func recoveryFixture(t *testing.T) (client.Client, *v1.GameServer, *v1.Backup, *v1.Restore) { + t.Helper() + scheme := runtime.NewScheme() + _ = v1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = batchv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + gs := &v1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "game", Namespace: "games", UID: "game-uid", Generation: 3, Annotations: map[string]string{}}, Spec: v1.GameServerSpec{TemplateRef: v1.GameTemplateRef{Name: "template"}, Version: "v1"}} + tmpl := &v1.GameTemplate{ObjectMeta: metav1.ObjectMeta{Name: "template"}, Spec: v1.GameTemplateSpec{Storage: v1.GameStorageSpec{MountPath: "/data", Extra: []v1.ExtraVolumeSpec{{Name: "world", MountPath: "/world", BackupRole: "data"}, {Name: "cache", MountPath: "/cache", BackupRole: "rebuildable"}}}, Versions: []v1.GameVersion{{ID: "v1", Loader: "paper", Image: "busybox"}}, Capabilities: &v1.CapabilitiesSpec{Mods: &v1.ModsSpec{Loaders: map[string]v1.ModLoaderSpec{"paper": {Path: "mods"}}}}}} + one := int32(1) + ss := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: gs.Name, Namespace: gs.Namespace, UID: "workload", Generation: 1, OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(gs, v1.GroupVersion.WithKind("GameServer"))}}, Spec: appsv1.StatefulSetSpec{Replicas: &one, Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "game"}}}}}, Status: appsv1.StatefulSetStatus{ObservedGeneration: 1}} + objects := []client.Object{gs, tmpl, ss} + for i, pair := range []struct{ name, mount string }{{"data", "/data"}, {"extra-world", "/world"}, {"extra-cache", "/cache"}, {"mods-v1", "/data/mods"}} { + name := gs.Name + "-" + pair.name + ss.Spec.Template.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{Name: pair.name, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: name}}}) + ss.Spec.Template.Spec.Containers[0].VolumeMounts = append(ss.Spec.Template.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{Name: pair.name, MountPath: pair.mount}) + objects = append(objects, &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: gs.Namespace, UID: types.UID(name + "-uid"), Finalizers: []string{"kubernetes.io/pvc-protection"}}, Spec: corev1.PersistentVolumeClaimSpec{VolumeName: []string{"pv0", "pv1", "pv2", "pv3"}[i]}, Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}}) + } + b := &v1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "backup", Namespace: gs.Namespace, UID: "backup-uid", Generation: 1, Annotations: map[string]string{captureFormatAnnotation: "all-data-v2"}}, Spec: v1.BackupSpec{ServerRef: v1.LocalObjectRef{Name: gs.Name}, RepoRef: &v1.SecretKeySelector{Name: "repo"}, VolumePolicy: "all-data", PowerPolicy: "stop-copy-restart"}, Status: v1.BackupStatus{Phase: v1.BackupPhaseSucceeded, SnapshotID: "snapshot"}} + rs := &v1.Restore{ObjectMeta: metav1.ObjectMeta{Name: "restore", Namespace: gs.Namespace, UID: "restore-uid", Annotations: map[string]string{restoreFormatAnnotation: "all-data-v2"}}, Spec: v1.RestoreSpec{ServerRef: v1.LocalObjectRef{Name: gs.Name}, BackupRef: v1.LocalObjectRef{Name: b.Name}}, Status: v1.RestoreStatus{SnapshotID: "snapshot", Phase: v1.RestorePhaseSuspending}} + objects = append(objects, b, rs) + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(gs, b, rs).WithObjects(objects...).Build() + m, err := recoveryLayout(t.Context(), c, gs) + if err != nil { + t.Fatal(err) + } + b.Status.CaptureManifest = m + if err := c.Status().Update(t.Context(), b); err != nil { + t.Fatal(err) + } + return c, gs, b, rs +} + +func TestRecoveryLayoutIncludesDataAndSeparateMods(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + included, excluded := 0, 0 + for _, v := range b.Status.CaptureManifest.Volumes { + if v.Included { + included++ + } else { + excluded++ + if v.RecoveryPrerequisite == "" { + t.Fatal("missing rebuild prerequisite") + } + } + } + if included != 3 || excluded != 1 { + t.Fatalf("coverage: %d/%d", included, excluded) + } + var tmpl v1.GameTemplate + if err := c.Get(t.Context(), client.ObjectKey{Name: "template"}, &tmpl); err != nil { + t.Fatal(err) + } + tmpl.Spec.Storage.Extra[0].BackupRole = "" + if err := c.Update(t.Context(), &tmpl); err != nil { + t.Fatal(err) + } + if _, err := recoveryLayout(t.Context(), c, gs); err == nil { + t.Fatal("unclassified required claim admitted") + } +} + +func TestRecoveryPreflightFailsBeforeSuspension(t *testing.T) { + for _, mutation := range []string{"missing", "replaced", "path", "traversal", "duplicate", "downgrade"} { + t.Run(mutation, func(t *testing.T) { + c, gs, b, rs := recoveryFixture(t) + switch mutation { + case "missing", "replaced": + var pvc corev1.PersistentVolumeClaim + key := client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-extra-world"} + if err := c.Get(t.Context(), key, &pvc); err != nil { + t.Fatal(err) + } + pvc.Finalizers = nil + if err := c.Update(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + if err := c.Delete(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + if mutation == "replaced" { + pvc.ResourceVersion = "" + pvc.UID = "replacement" + if err := c.Create(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + } + case "path": + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + ss.Spec.Template.Spec.Containers[0].VolumeMounts[1].MountPath = "/redirect" + if err := c.Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + case "traversal": + b.Status.CaptureManifest.Volumes[0].SnapshotRoot = "/volumes/../data" + case "duplicate": + b.Status.CaptureManifest.Volumes = append(b.Status.CaptureManifest.Volumes, b.Status.CaptureManifest.Volumes[0]) + case "downgrade": + b.Status.CaptureManifest = nil + } + if err := c.Status().Update(t.Context(), b); err != nil { + t.Fatal(err) + } + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(rs)}); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Spec.Suspend || gs.Annotations[restoreGuardAnnotation] != "" { + t.Fatal("invalid restore mutated target power/ownership") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(rs), rs); err != nil { + t.Fatal(err) + } + if rs.Status.Phase != v1.RestorePhaseFailed { + t.Fatalf("expected visible failure: %+v", rs.Status) + } + var jobs batchv1.JobList + if err := c.List(t.Context(), &jobs); err != nil { + t.Fatal(err) + } + if len(jobs.Items) != 0 { + t.Fatal("invalid restore created destructive job") + } + }) + } +} +func TestRecoveryPlanPinsTargetAgainstReplacement(t *testing.T) { + c, gs, b, rs := recoveryFixture(t) + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if changed, err := r.preflightRecovery(t.Context(), rs, b, gs); err != nil || !changed { + t.Fatalf("preflight: %v %v", changed, err) + } + before := rs.Status.RecoveryPlan.DeepCopy() + var pvc corev1.PersistentVolumeClaim + key := client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-mods-v1"} + if err := c.Get(t.Context(), key, &pvc); err != nil { + t.Fatal(err) + } + pvc.UID = "replacement" + if err := c.Update(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + if _, err := r.preflightRecovery(t.Context(), rs, b, gs); err == nil { + t.Fatal("replacement admitted") + } + if before.Volumes[0].ClaimUID != rs.Status.RecoveryPlan.Volumes[0].ClaimUID { + t.Fatal("mapping rewritten") + } +} +func TestRecoveryMountedClaimsRequireProtection(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + if err := validateMountedRecoveryClaims(t.Context(), c, gs.Namespace, b.Status.CaptureManifest.Volumes); err != nil { + t.Fatal(err) + } + var pvc corev1.PersistentVolumeClaim + if err := c.Get(t.Context(), client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-data"}, &pvc); err != nil { + t.Fatal(err) + } + pvc.Finalizers = nil + if err := c.Update(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + if err := validateMountedRecoveryClaims(t.Context(), c, gs.Namespace, b.Status.CaptureManifest.Volumes); err == nil { + t.Fatal("unprotected mounted claim admitted") + } +} +func TestRecoveryAntiDowngradeAndConsistency(t *testing.T) { + _, _, b, _ := recoveryFixture(t) + b.Status.CaptureManifest.QuiesceOutcome = "confirmed" + if backupConsistencyCondition(b).Status != metav1.ConditionFalse { + t.Fatal("live all-data overclaimed atomic consistency") + } + b.Status.CaptureManifest.QuiesceOutcome = "already-stopped" + if backupConsistencyCondition(b).Status != metav1.ConditionTrue { + t.Fatal("drained all-data not recognized") + } + for _, state := range []string{"LegacyUnknown", "Unknown"} { + copy := b.DeepCopy() + copy.Status.CaptureManifest.State = state + if _, err := recoveryCaptureMode(copy); err == nil { + t.Fatal("unknown manifest downgraded") + } + } + legacy := &v1.Backup{} + if all, err := recoveryCaptureMode(legacy); err != nil || all { + t.Fatal("legacy primary restore rejected") + } +} +func TestStoppedOwnerSurvivesRestartAndForceDeletion(t *testing.T) { + for _, stage := range []string{"Admitted", "Stopping", "Drained"} { + t.Run(stage, func(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + debt := newStoppedBackup(gs) + debt.Stage = stage + debt.WorkloadUID = "workload" + debt.Deadline = time.Now().Add(time.Minute).Format(time.RFC3339Nano) + debt.Claims = b.Status.CaptureManifest.Volumes + op := lifecycleOperation{lifecycleIdentity: backupLifecycleIdentity(gs, b, false)} + op.StoppedBackup = debt + gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) + gs.Annotations[backupGuardAnnotation] = backupIdentityJSON(b.Name, b.UID) + gs.Spec.Suspend = true // A later user stop must survive recovery. + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + if err := c.Delete(t.Context(), b); err != nil { + t.Fatal(err) + } + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + zero := int32(0) + ss.Spec.Replicas = &zero + if err := c.Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + r := &GameServerReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if changed, err := r.recoverStoppedBackupOwner(t.Context(), gs); err != nil || !changed { + t.Fatalf("orphan recovery: %v %v", changed, err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Annotations[lifecycleOperationAnnotation] != "" || !gs.Spec.Suspend { + t.Fatal("orphan recovery lost cleanup or user suspend") + } + }) + } +} +func TestStoppedOwnerRejectsAmbiguousWorkload(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + debt := newStoppedBackup(gs) + debt.Stage = "Drained" + debt.WorkloadUID = "replacement" + debt.Deadline = time.Now().Format(time.RFC3339Nano) + debt.Claims = b.Status.CaptureManifest.Volumes + if ready, err := stoppedBackupCleanup(t.Context(), c, c, gs, debt); ready || err == nil || !strings.Contains(err.Error(), "intervention") { + t.Fatalf("ambiguous provenance: %v %v", ready, err) + } +} + +func TestStoppedPredecessorClaimsSurviveSuccessorAdmission(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + debt := newStoppedBackup(gs) + debt.Stage = "Drained" + debt.WorkloadUID = "workload" + debt.Deadline = time.Now().Add(time.Minute).Format(time.RFC3339Nano) + debt.Claims = b.Status.CaptureManifest.Volumes + predecessor := backupLifecycleIdentity(gs, b, false) + predecessor.StoppedBackup = debt + successor := b.DeepCopy() + successor.Name = "successor" + successor.UID = "successor-uid" + op := lifecycleOperation{lifecycleIdentity: backupLifecycleIdentity(gs, successor, false), Recovery: []lifecycleIdentity{predecessor}} + gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + zero := int32(0) + ss.Spec.Replicas = &zero + if err := c.Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + writer := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "extra-writer", Namespace: gs.Namespace}, Spec: corev1.PodSpec{Volumes: []corev1.Volume{{Name: "extra", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: gs.Name + "-extra-world"}}}}}} + if err := c.Create(t.Context(), writer); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if ready, err := stoppedBackupCleanup(t.Context(), c, c, gs, debt); err != nil || ready { + t.Fatalf("predecessor writer ignored: %v %v", ready, err) + } + op.Recovery = append(op.Recovery, op.lifecycleIdentity) + op.lifecycleIdentity.UID = types.UID("another-successor") + gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + } + if err := c.Delete(t.Context(), writer); err != nil { + t.Fatal(err) + } + if ready, err := stoppedBackupCleanup(t.Context(), c, c, gs, debt); err != nil || !ready { + t.Fatalf("drained predecessor retained: %v %v", ready, err) + } +} +func TestStoppedPredecessorStageControlsGracefulRecovery(t *testing.T) { + for _, stage := range []string{"Admitted", "Stopping", "Drained"} { + op := lifecycleOperation{Recovery: []lifecycleIdentity{{Kind: "Backup", StoppedBackup: &stoppedBackup{Stage: stage}}}} + if (lifecycleStoppingBackup(op) != nil) != (stage == "Stopping") { + t.Fatalf("stage %s lost graceful debt", stage) + } + if lifecycleRecoveryNeedsStop(op) != (stage == "Drained") { + t.Fatalf("stage %s incorrectly forced zero", stage) + } + } +} +func TestStaleRunningReplicaDecisionCannotRestartStoppedCapture(t *testing.T) { + for _, stage := range []string{"Stopping", "Drained"} { + t.Run(stage, func(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + stale := gs.DeepCopy() + op := lifecycleOperation{lifecycleIdentity: backupLifecycleIdentity(gs, b, false)} + op.StoppedBackup.Stage = stage + gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + zero := int32(0) + ss.Spec.Replicas = &zero + if err := c.Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + tmpl, err := effectiveGameTemplate(t.Context(), c, gs) + if err != nil { + t.Fatal(err) + } + r := &GameServerReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if err := r.reconcileStatefulSet(t.Context(), stale, tmpl, nil, &materializedConfig{}, 1); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + if ss.Spec.Replicas == nil || *ss.Spec.Replicas != 0 { + t.Fatal("stale reconcile restarted captured workload") + } + }) + } +} +func TestRecoveryRestorePreservesPowerDuringAdmissionAndSuccess(t *testing.T) { + for _, mode := range []string{"running", "suspended", "idle"} { + t.Run(mode, func(t *testing.T) { + c, gs, b, rs := recoveryFixture(t) + gs.Spec.Suspend = mode == "suspended" + if mode == "idle" { + gs.Annotations[IdleAsleepSinceAnnotation] = "2026-01-01T00:00:00Z" + } + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.preflightRecovery(t.Context(), rs, b, gs); err != nil { + t.Fatal(err) + } + if _, err := r.bindRestoreTarget(t.Context(), rs, gs); err != nil { + t.Fatal(err) + } + if claimed, err := r.claimRestoreTarget(t.Context(), rs, gs); err != nil || !claimed { + t.Fatalf("claim: %v %v", claimed, err) + } + if gs.Spec.Suspend != (mode == "suspended") { + t.Fatal("admission overwrote user power intent") + } + if mode == "idle" && gs.Annotations[IdleAsleepSinceAnnotation] == "" { + t.Fatal("admission cleared idle intent") + } + rs.Status.Phase = v1.RestorePhaseSucceeded + if err := c.Status().Update(t.Context(), rs); err != nil { + t.Fatal(err) + } + if _, err := r.cleanupRestore(t.Context(), rs); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Spec.Suspend != (mode == "suspended") { + t.Fatal("successful recovery overwrote power intent") + } + if mode == "idle" && gs.Annotations[IdleAsleepSinceAnnotation] == "" { + t.Fatal("successful recovery woke idle game") + } + }) + } +} +func TestRecoveryMissingRebuildableIsExplicitPrerequisite(t *testing.T) { + c, gs, b, rs := recoveryFixture(t) + var pvc corev1.PersistentVolumeClaim + if err := c.Get(t.Context(), client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-extra-cache"}, &pvc); err != nil { + t.Fatal(err) + } + pvc.Finalizers = nil + if err := c.Update(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + if err := c.Delete(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.preflightRecovery(t.Context(), rs, b, gs); err != nil { + t.Fatal(err) + } + for _, volume := range rs.Status.RecoveryPlan.Volumes { + if volume.LogicalName == "extra-cache" { + if volume.Included || volume.ClaimUID != "" || volume.RecoveryPrerequisite == "" { + t.Fatal("missing rebuildable silently treated as covered") + } + return + } + } + t.Fatal("missing prerequisite from plan") +} +func TestFailedRecoveryWorkerDoesNotWaitForReadinessDeadline(t *testing.T) { + c, gs, b, rs := recoveryFixture(t) + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.preflightRecovery(t.Context(), rs, b, gs); err != nil { + t.Fatal(err) + } + rs.Annotations[restoreDeadlineAnnotation] = time.Now().Add(time.Hour).Format(time.RFC3339Nano) + job := &batchv1.Job{Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{{Type: batchv1.JobFailed, Status: corev1.ConditionTrue}}}} + if ready, err := r.authorizeRecoveryRestore(t.Context(), rs, b, gs, job); ready || err == nil || !strings.Contains(err.Error(), "job reported Failed") { + t.Fatalf("failed job hidden by readiness: %v %v", ready, err) + } +} + +func TestStoppedCopyFailureAndCancellationWaitForWorkers(t *testing.T) { + for _, reason := range []string{"copy failed", "backup cancelled", "preparation deadline expired"} { + t.Run(reason, func(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + b.Status.Phase = v1.BackupPhaseRunning + b.Status.SnapshotID = "" + if err := c.Status().Update(t.Context(), b); err != nil { + t.Fatal(err) + } + b.Annotations[backupTargetAnnotation] = backupIdentityJSON(gs.Name, gs.UID) + b.Annotations[backupPreparationDeadlineAnnotation] = time.Now().Add(-time.Minute).Format(time.RFC3339Nano) + if err := c.Update(t.Context(), b); err != nil { + t.Fatal(err) + } + debt := newStoppedBackup(gs) + debt.Stage = "Drained" + debt.WorkloadUID = "workload" + debt.Deadline = b.Annotations[backupPreparationDeadlineAnnotation] + debt.Claims = b.Status.CaptureManifest.Volumes + op := lifecycleOperation{lifecycleIdentity: backupLifecycleIdentity(gs, b, false)} + op.StoppedBackup = debt + gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) + gs.Annotations[backupGuardAnnotation] = backupIdentityJSON(b.Name, b.UID) + gs.Spec.Suspend = true + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + zero := int32(0) + ss.Spec.Replicas = &zero + if err := c.Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "copy-worker", Namespace: gs.Namespace, Labels: map[string]string{backupUIDLabel: string(b.UID)}}, Spec: corev1.PodSpec{Volumes: []corev1.Volume{{Name: "data", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: gs.Name + "-data"}}}}}} + if err := c.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } + r := &BackupReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if result, err := r.abortResticCopy(t.Context(), b, reason); err != nil || result.RequeueAfter == 0 { + t.Fatalf("worker drain: %v %v", result, err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Annotations[lifecycleOperationAnnotation] == "" { + t.Fatal("failure released live reader") + } + if err := c.Delete(t.Context(), pod); err != nil { + t.Fatal(err) + } + // A new reconciler instance proves the persisted abort and stop debt are sufficient. + r = &BackupReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(b)}); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Annotations[lifecycleOperationAnnotation] != "" || !gs.Spec.Suspend { + t.Fatal("failure cleanup lost release or newer suspend intent") + } + }) + } +} +func TestRecoveryAuthorizationRejectsPostPreparationClaimReplacement(t *testing.T) { + c, gs, b, rs := recoveryFixture(t) + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.preflightRecovery(t.Context(), rs, b, gs); err != nil { + t.Fatal(err) + } + rs.Annotations[restoreDeadlineAnnotation] = time.Now().Add(time.Hour).Format(time.RFC3339Nano) + if err := c.Update(t.Context(), rs); err != nil { + t.Fatal(err) + } + gate, err := r.restoreRecoveryGate(t.Context(), rs) + if err != nil { + t.Fatal(err) + } + gate.Data[backupStartTokenKey] = "old authorization" + if err := c.Update(t.Context(), gate); err != nil { + t.Fatal(err) + } + var pvc corev1.PersistentVolumeClaim + if err := c.Get(t.Context(), client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-extra-world"}, &pvc); err != nil { + t.Fatal(err) + } + pvc.UID = "replacement" + if err := c.Update(t.Context(), &pvc); err != nil { + t.Fatal(err) + } + if ready, err := r.authorizeRecoveryRestore(t.Context(), rs, b, gs, &batchv1.Job{}); ready || err == nil { + t.Fatal("post-preparation replacement authorized") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gate), gate); err != nil { + t.Fatal(err) + } + if gate.Data[backupStartTokenKey] != "" { + t.Fatal("failed revalidation retained authorization") + } +} + +// The transport records real calls made by desiredReplicas/softStop. Failure +// must retain the same durable grace timestamp across new reconciler instances. +type recoveryStopper struct { + calls int + fail bool +} + +func (s *recoveryStopper) Enabled() bool { return true } +func (s *recoveryStopper) Stop(context.Context, string, string) error { + s.calls++ + if s.fail { + return errors.New("stop transport unavailable") + } + return nil +} + +func TestStoppedBackupGracefulDebtAcrossReconcileAndForceDeletion(t *testing.T) { + for _, deleteAt := range []string{"before-rpc", "after-rpc"} { + t.Run(deleteAt, func(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + var tmpl v1.GameTemplate + if err := c.Get(t.Context(), client.ObjectKey{Name: "template"}, &tmpl); err != nil { + t.Fatal(err) + } + tmpl.Spec.RCON = &v1.RCONSpec{Protocol: "source", Port: 25575} + tmpl.Spec.Capabilities.Lifecycle = &v1.LifecycleSpec{Stop: []string{"stop"}} + if err := c.Update(t.Context(), &tmpl); err != nil { + t.Fatal(err) + } + b.Status = v1.BackupStatus{} + if err := c.Status().Update(t.Context(), b); err != nil { + t.Fatal(err) + } + b.Annotations = map[string]string{} + if err := c.Update(t.Context(), b); err != nil { + t.Fatal(err) + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "repo", Namespace: gs.Namespace}, Data: map[string][]byte{"repo": []byte("s3:https://example.invalid/repo"), "password": []byte("fixture")}} + if err := c.Create(t.Context(), secret); err != nil { + t.Fatal(err) + } + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + ss.Status.ReadyReplicas = 1 + ss.Status.Replicas = 1 + if err := c.Status().Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: gs.Name + "-0", Namespace: gs.Namespace, UID: "original-pod", OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(&ss, appsv1.SchemeGroupVersion.WithKind("StatefulSet"))}}, Spec: ss.Spec.Template.Spec, Status: corev1.PodStatus{Phase: corev1.PodRunning}} + pod.Spec.NodeName = "node" + if err := c.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } + br := &BackupReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := br.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(b)}); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + op, held, err := lifecycleOwner(gs) + if err != nil || !held || op.StoppedBackup == nil || op.StoppedBackup.Stage != "Stopping" { + t.Fatalf("missing admitted stop debt: %+v %v", op, err) + } + deadline := op.StoppedBackup.Deadline + if deadline == "" || op.StoppedBackup.OriginalPodUID != pod.UID || gs.Spec.Suspend { + t.Fatal("admission lost deadline/process or mutated suspend") + } + stopper := &recoveryStopper{fail: true} + gr := &GameServerReconciler{Client: c, APIReader: c, Scheme: c.Scheme(), AgentClient: stopper} + if deleteAt == "after-rpc" { + replicas, _, err := gr.desiredReplicas(t.Context(), gs, &tmpl, idleAwake) + if err != nil || replicas != 1 || stopper.calls != 1 { + t.Fatalf("initial graceful stop: %d %v calls=%d", replicas, err, stopper.calls) + } + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(b), b); err != nil { + t.Fatal(err) + } + b.Finalizers = nil + if err := c.Update(t.Context(), b); err != nil { + t.Fatal(err) + } + if err := c.Delete(t.Context(), b); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if changed, err := gr.recoverStoppedBackupOwner(t.Context(), gs); err != nil || changed { + t.Fatalf("orphan released original process: %v %v", changed, err) + } + replicas, poll, err := gr.desiredReplicas(t.Context(), gs, &tmpl, idleAwake) + if err != nil || replicas != 1 || poll <= 0 || stopper.calls != 1 { + t.Fatalf("orphan skipped/replayed graceful stop: %d %s %v calls=%d", replicas, poll, err, stopper.calls) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + stamp := gs.Annotations[stopRequestedAtAnnotation] + if stamp == "" { + t.Fatal("stop attempt lacks durable timestamp") + } + // Controller restart must neither replay a failed RPC nor renew its deadline. + gr = &GameServerReconciler{Client: c, APIReader: c, Scheme: c.Scheme(), AgentClient: stopper} + if _, _, err := gr.desiredReplicas(t.Context(), gs, &tmpl, idleAwake); err != nil { + t.Fatal(err) + } + if stopper.calls != 1 { + t.Fatal("operator restart replayed stop RPC") + } + op, _, _ = lifecycleOwner(gs) + if op.StoppedBackup.Deadline != deadline || gs.Annotations[stopRequestedAtAnnotation] != stamp { + t.Fatal("operator restart renewed stop budget") + } + gs.Annotations[stopRequestedAtAnnotation] = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + gs.Spec.Suspend = true + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + replicas, _, err = gr.desiredReplicas(t.Context(), gs, &tmpl, idleAwake) + if err != nil || replicas != 0 || stopper.calls != 1 { + t.Fatalf("expired graceful backstop: %d %v", replicas, err) + } + if err := gr.reconcileStatefulSet(t.Context(), gs, &tmpl, nil, &materializedConfig{}, replicas); err != nil { + t.Fatal(err) + } + if changed, err := gr.recoverStoppedBackupOwner(t.Context(), gs); err != nil || changed { + t.Fatalf("timeout mistaken for process exit: %v %v", changed, err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + ss.Status.Replicas = 0 + ss.Status.ReadyReplicas = 0 + ss.Status.ObservedGeneration = ss.Generation + if err := c.Status().Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + if err := c.Delete(t.Context(), pod); err != nil { + t.Fatal(err) + } + if changed, err := gr.recoverStoppedBackupOwner(t.Context(), gs); err != nil || !changed { + t.Fatalf("drained orphan failed release: %v %v", changed, err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if !gs.Spec.Suspend || gs.Annotations[lifecycleOperationAnnotation] != "" { + t.Fatal("orphan release lost user stop intent") + } + }) + } +} + +func TestStoppedBackupWorkerRetryCancellationAcrossReconcile(t *testing.T) { + c, gs, b, _ := recoveryFixture(t) + b.Status = v1.BackupStatus{} + if err := c.Status().Update(t.Context(), b); err != nil { + t.Fatal(err) + } + b.Annotations = map[string]string{} + if err := c.Update(t.Context(), b); err != nil { + t.Fatal(err) + } + if err := c.Create(t.Context(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "repo", Namespace: gs.Namespace}, Data: map[string][]byte{"repo": []byte("s3:https://example.invalid/repo"), "password": []byte("fixture")}}); err != nil { + t.Fatal(err) + } + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + original := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: gs.Name + "-0", Namespace: gs.Namespace, UID: "original", OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(&ss, appsv1.SchemeGroupVersion.WithKind("StatefulSet"))}}, Spec: ss.Spec.Template.Spec, Status: corev1.PodStatus{Phase: corev1.PodRunning}} + original.Spec.NodeName = "node" + if err := c.Create(t.Context(), original); err != nil { + t.Fatal(err) + } + reconcile := func() { + t.Helper() + r := &BackupReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(b)}); err != nil { + t.Fatal(err) + } + } + reconcile() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + tmpl, err := effectiveGameTemplate(t.Context(), c, gs) + if err != nil { + t.Fatal(err) + } + gr := &GameServerReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + replicas, _, err := gr.desiredReplicas(t.Context(), gs, tmpl, idleAwake) + if err != nil || replicas != 0 { + t.Fatalf("stop decision: %d %v", replicas, err) + } + if err := gr.reconcileStatefulSet(t.Context(), gs, tmpl, nil, &materializedConfig{}, replicas); err != nil { + t.Fatal(err) + } + if err := c.Delete(t.Context(), original); err != nil { + t.Fatal(err) + } + reconcile() + var job batchv1.Job + if err := c.Get(t.Context(), client.ObjectKeyFromObject(b), &job); err != nil { + t.Fatal(err) + } + job.UID = "copy-job" + if err := c.Update(t.Context(), &job); err != nil { + t.Fatal(err) + } + readyPod := func(name string, ready bool) *corev1.Pod { + status := corev1.ConditionFalse + if ready { + status = corev1.ConditionTrue + } + return &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: gs.Namespace, UID: types.UID(name), Labels: map[string]string{backupUIDLabel: string(b.UID)}, OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(&job, batchv1.SchemeGroupVersion.WithKind("Job"))}}, Spec: corev1.PodSpec{NodeName: "node", Volumes: job.Spec.Template.Spec.Volumes}, Status: corev1.PodStatus{Phase: corev1.PodRunning, Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: status}}}} + } + first := readyPod("copy-one", true) + if err := c.Create(t.Context(), first); err != nil { + t.Fatal(err) + } + reconcile() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(b), b); err != nil { + t.Fatal(err) + } + deadline := b.Annotations[backupQuiesceDeadlineAnnotation] + var gate corev1.ConfigMap + key := client.ObjectKey{Namespace: gs.Namespace, Name: backupStartConfigMapName(b)} + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + oldToken := gate.Data[backupStartTokenKey] + if !strings.Contains(oldToken, "copy-one") { + t.Fatal("first ready worker was not authorized") + } + first.Status.Phase = corev1.PodFailed + if err := c.Status().Update(t.Context(), first); err != nil { + t.Fatal(err) + } + retry := readyPod("copy-two", false) + retry.Finalizers = []string{"fixture/terminating"} + if err := c.Create(t.Context(), retry); err != nil { + t.Fatal(err) + } + reconcile() + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + if gate.Data[backupStartTokenKey] != "" { + t.Fatal("unprepared retry inherited old token") + } + retry.Status.Conditions[0].Status = corev1.ConditionTrue + if err := c.Status().Update(t.Context(), retry); err != nil { + t.Fatal(err) + } + reconcile() + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + if !strings.Contains(gate.Data[backupStartTokenKey], "copy-two") || gate.Data[backupStartTokenKey] == oldToken { + t.Fatal("retry authorization not Pod-bound") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(b), b); err != nil { + t.Fatal(err) + } + if b.Annotations[backupQuiesceDeadlineAnnotation] != deadline { + t.Fatal("retry reset copy deadline") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + gs.Spec.Suspend = true + gs.Annotations[IdleAsleepSinceAnnotation] = "later-idle" + gs.Annotations[RestartRequestedAnnotation] = "later-restart" + gs.Annotations[IdleWakeRequestedAnnotation] = "later-wake" + if err := c.Update(t.Context(), gs); err != nil { + t.Fatal(err) + } + if err := c.Delete(t.Context(), b); err != nil { + t.Fatal(err) + } + reconcile() + if err := c.Delete(t.Context(), retry); err != nil { + t.Fatal(err) + } + reconcile() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Annotations[lifecycleOperationAnnotation] == "" { + t.Fatal("cancel released terminating reader") + } + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + if gate.Data[backupStartTokenKey] != "" { + t.Fatal("cancel retained authorization") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(retry), retry); err != nil { + t.Fatal(err) + } + retry.Finalizers = nil + if err := c.Update(t.Context(), retry); err != nil { + t.Fatal(err) + } + reconcile() + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Annotations[lifecycleOperationAnnotation] != "" || !gs.Spec.Suspend || gs.Annotations[IdleAsleepSinceAnnotation] != "later-idle" || gs.Annotations[RestartRequestedAnnotation] != "later-restart" || gs.Annotations[IdleWakeRequestedAnnotation] != "later-wake" { + t.Fatal("cancel cleanup lost newer power/admin intent") + } +} + +func TestRecoveryRestoreGateRetryAndCancelledWriter(t *testing.T) { + c, gs, b, rs := recoveryFixture(t) + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if _, err := r.preflightRecovery(t.Context(), rs, b, gs); err != nil { + t.Fatal(err) + } + if _, err := r.bindRestoreTarget(t.Context(), rs, gs); err != nil { + t.Fatal(err) + } + if claimed, err := r.claimRestoreTarget(t.Context(), rs, gs); err != nil || !claimed { + t.Fatalf("claim: %v %v", claimed, err) + } + tmpl, err := effectiveGameTemplate(t.Context(), c, gs) + if err != nil { + t.Fatal(err) + } + spec := r.buildRestorePodSpec(rs, b, tmpl) + if err := r.prepareRecoveryRestorePod(t.Context(), rs, &spec); err != nil { + t.Fatal(err) + } + job := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "restore-" + rs.Name, Namespace: gs.Namespace, UID: "restore-job", OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(rs, v1.GroupVersion.WithKind("Restore"))}}, Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{Spec: spec}}} + if err := c.Create(t.Context(), job); err != nil { + t.Fatal(err) + } + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "restore-one", Namespace: gs.Namespace, UID: "restore-one", Labels: map[string]string{restoreUIDLabel: string(rs.UID)}, OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(job, batchv1.SchemeGroupVersion.WithKind("Job"))}}, Spec: spec, Status: corev1.PodStatus{Phase: corev1.PodRunning, Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}}} + pod.Spec.NodeName = "node" + if err := c.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } + if ready, err := r.authorizeRecoveryRestore(t.Context(), rs, b, gs, job); err != nil || !ready { + t.Fatalf("authorization: %v %v", ready, err) + } + key := client.ObjectKey{Namespace: gs.Namespace, Name: restoreStartName(rs)} + var gate corev1.ConfigMap + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + oldToken := gate.Data[backupStartTokenKey] + deadline := rs.Annotations[restoreDeadlineAnnotation] + pod.Status.Phase = corev1.PodFailed + if err := c.Status().Update(t.Context(), pod); err != nil { + t.Fatal(err) + } + second := pod.DeepCopy() + second.ResourceVersion = "" + second.Name = "restore-two" + second.UID = "restore-two" + second.Status.Phase = corev1.PodRunning + second.Status.Conditions[0].Status = corev1.ConditionFalse + if err := c.Create(t.Context(), second); err != nil { + t.Fatal(err) + } + r = &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(rs), rs); err != nil { + t.Fatal(err) + } + if ready, err := r.authorizeRecoveryRestore(t.Context(), rs, b, gs, job); err != nil || ready { + t.Fatalf("unprepared retry: %v %v", ready, err) + } + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + if gate.Data[backupStartTokenKey] != "" { + t.Fatal("restore retry inherited prior authorization") + } + second.Status.Conditions[0].Status = corev1.ConditionTrue + if err := c.Status().Update(t.Context(), second); err != nil { + t.Fatal(err) + } + if ready, err := r.authorizeRecoveryRestore(t.Context(), rs, b, gs, job); err != nil || !ready { + t.Fatalf("retry ready: %v %v", ready, err) + } + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + if gate.Data[backupStartTokenKey] == oldToken || !strings.Contains(gate.Data[backupStartTokenKey], "restore-two") || rs.Annotations[restoreDeadlineAnnotation] != deadline { + t.Fatal("retry lost Pod binding or reset deadline") + } + if result, err := r.fail(t.Context(), rs, "cancelled during copy"); err != nil || result.RequeueAfter == 0 { + t.Fatalf("cancel failed to drain: %v %v", result, err) + } + if err := c.Get(t.Context(), key, &gate); err != nil { + t.Fatal(err) + } + if gate.Data[backupStartTokenKey] != "" { + t.Fatal("failed restore retained token") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if gs.Annotations[restoreGuardAnnotation] == "" { + t.Fatal("failed restore released live writer") + } + if err := c.Delete(t.Context(), second); err != nil { + t.Fatal(err) + } + var ss appsv1.StatefulSet + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), &ss); err != nil { + t.Fatal(err) + } + zero := int32(0) + ss.Spec.Replicas = &zero + if err := c.Update(t.Context(), &ss); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if _, err := r.cleanupRestore(t.Context(), rs); err != nil { + t.Fatal(err) + } + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(gs), gs); err != nil { + t.Fatal(err) + } + if !gs.Spec.Suspend || gs.Annotations[restoreGuardAnnotation] != "" { + t.Fatal("failed restore did not stay suspended after worker drain") + } +} + +func TestRecoveryQuiesceOutcomeFinalizedOnlyBeforeFirstAuthorization(t *testing.T) { + c, _, b, _ := recoveryFixture(t) + b.Spec.PowerPolicy = "preserve" + b.Spec.Quiesce = true + b.Annotations[annoQuiesceAttempted] = "true" + if err := c.Update(t.Context(), b); err != nil { + t.Fatal(err) + } + before := b.Status.CaptureManifest.DeepCopy() + r := &BackupReconciler{Client: c, APIReader: c} + if err := r.finalizeBackupCaptureOutcome(t.Context(), b); err != nil { + t.Fatal(err) + } + if b.Status.CaptureManifest.QuiesceOutcome != "confirmed" { + t.Fatal("capture retained pre-RPC quiesce outcome") + } + for i, v := range before.Volumes { + if v.ClaimUID != b.Status.CaptureManifest.Volumes[i].ClaimUID || v.SnapshotRoot != b.Status.CaptureManifest.Volumes[i].SnapshotRoot { + t.Fatal("quiesce finalization rewrote coverage") + } + } + b.Annotations[backupAuthorizedWorkerAnnotation] = "worker" + b.Annotations[annoQuiesceAttempted] = "unsupported" + if err := c.Update(t.Context(), b); err != nil { + t.Fatal(err) + } + if err := r.finalizeBackupCaptureOutcome(t.Context(), b); err != nil { + t.Fatal(err) + } + if b.Status.CaptureManifest.QuiesceOutcome != "confirmed" { + t.Fatal("post-authorization outcome rewrote capture history") + } + if backupConsistencyCondition(b).Status != metav1.ConditionFalse { + t.Fatal("live multi-volume copy overclaimed consistency") + } +} diff --git a/operator/internal/controller/recovery_manifest.go b/operator/internal/controller/recovery_manifest.go new file mode 100644 index 000000000..beec4e7c6 --- /dev/null +++ b/operator/internal/controller/recovery_manifest.go @@ -0,0 +1,211 @@ +package controller + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "path" + "reflect" + "regexp" + "sort" + + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var recoveryLogicalName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +func validateRecoveryManifest(m *v1.BackupCaptureManifest) error { + if m == nil || m.State != "Recorded" || m.FormatVersion != 2 || m.VolumePolicy != "all-data" { + return fmt.Errorf("all-data requires a recorded v2 manifest") + } + claims, roots, logical := map[string]bool{}, map[string]bool{}, map[string]bool{} + primary := false + for _, v := range m.Volumes { + if v.ClaimName == "" || !recoveryLogicalName.MatchString(v.LogicalName) || claims[v.ClaimName] || logical[v.LogicalName] { + return fmt.Errorf("ambiguous recovery claim or logical identity %q", v.ClaimName) + } + claims[v.ClaimName], logical[v.LogicalName] = true, true + if len(v.MountPaths) == 0 { + return fmt.Errorf("claim %s has no frozen mount path", v.ClaimName) + } + for _, mount := range v.MountPaths { + if !path.IsAbs(mount) || path.Clean(mount) != mount { + return fmt.Errorf("invalid original mount path") + } + } + if v.Included { + if v.ClaimUID == "" || v.Classification != "data" || v.SnapshotRoot != "/volumes/"+v.LogicalName || roots[v.SnapshotRoot] { + return fmt.Errorf("invalid recovery root for %s", v.ClaimName) + } + roots[v.SnapshotRoot] = true + primary = primary || v.LogicalName == "primary" + } else if v.Classification != "rebuildable" || v.RecoveryPrerequisite == "" || v.SnapshotRoot != "" { + return fmt.Errorf("unclassified or ambiguous excluded claim %s", v.ClaimName) + } + } + if !primary { + return fmt.Errorf("recovery manifest lacks primary data") + } + return nil +} + +// Read the frozen StatefulSet rather than rebuilding a layout from today's catalog. +func recoveryLayout(ctx context.Context, reader client.Reader, gs *v1.GameServer) (*v1.BackupCaptureManifest, error) { + tmpl, err := effectiveGameTemplate(ctx, reader, gs) + if err != nil { + return nil, err + } + _, revision, err := templateSnapshot(tmpl) + if err != nil { + return nil, err + } + ver, err := resolveVersion(gs, tmpl) + if err != nil { + return nil, err + } + var ss appsv1.StatefulSet + if err := reader.Get(ctx, client.ObjectKeyFromObject(gs), &ss); err != nil { + return nil, err + } + if !metav1.IsControlledBy(&ss, gs) { + return nil, fmt.Errorf("recovery workload has foreign owner") + } + m := &v1.BackupCaptureManifest{State: "Recorded", FormatVersion: 2, VolumePolicy: "all-data", ServerUID: string(gs.UID), Strategy: "restic-snapshot", LayoutSource: "StatefulSet", TemplateName: tmpl.Name, TemplateRevision: revision, GameVersion: gs.Spec.Version} + if ver != nil { + m.GameVersion = ver.ID + } + declared := map[string]v1.BackupVolumeCoverage{gs.Name + "-data": {LogicalName: "primary", Classification: "data", MountPaths: []string{effectiveMountPath(tmpl)}}} + for _, extra := range effectiveExtraVolumes(gs, tmpl) { + role := extra.BackupRole + if role == "" { + role = "unclassified" + } + declared[extraPVCName(gs, extra.Name)] = v1.BackupVolumeCoverage{LogicalName: extraVolumePodName(extra.Name), Classification: role, MountPaths: []string{extra.MountPath}} + } + if volume := modVolume(gs, tmpl, ver); volume != nil { + mount := modVolumeMount(tmpl, ver) + declared[volume.PersistentVolumeClaim.ClaimName] = v1.BackupVolumeCoverage{LogicalName: volume.Name, Classification: "data", MountPaths: []string{mount.MountPath}} + } + observed := map[string][]string{} + podNames := map[string]string{} + for _, v := range ss.Spec.Template.Spec.Volumes { + if v.PersistentVolumeClaim != nil { + podNames[v.Name] = v.PersistentVolumeClaim.ClaimName + observed[v.PersistentVolumeClaim.ClaimName] = nil + } + } + for _, c := range ss.Spec.Template.Spec.Containers { + if c.Name != "game" { + continue + } + for _, mount := range c.VolumeMounts { + if claim := podNames[mount.Name]; claim != "" { + if mount.SubPath != "" || mount.SubPathExpr != "" { + return nil, fmt.Errorf("all-data does not support claim subpaths") + } + observed[claim] = append(observed[claim], mount.MountPath) + } + } + } + var claims corev1.PersistentVolumeClaimList + if err := reader.List(ctx, &claims, client.InNamespace(gs.Namespace)); err != nil { + return nil, err + } + uids := map[string]string{} + for _, claim := range claims.Items { + if claim.DeletionTimestamp.IsZero() { + uids[claim.Name] = string(claim.UID) + } + if metav1.IsControlledBy(&claim, gs) { + if _, ok := observed[claim.Name]; !ok { + observed[claim.Name] = nil + } + } + } + for name, mounts := range observed { + v, ok := declared[name] + if !ok { + return nil, fmt.Errorf("unclassified observed claim %s", name) + } + sort.Strings(mounts) + if !reflect.DeepEqual(mounts, v.MountPaths) { + return nil, fmt.Errorf("claim %s frozen mount paths differ from declaration", name) + } + } + for name, v := range declared { + if !reflect.DeepEqual(observed[name], v.MountPaths) { + return nil, fmt.Errorf("required claim %s is absent from frozen workload", name) + } + v.ClaimName, v.ClaimUID = name, uids[name] + if v.Classification == "data" { + v.Included = true + v.SnapshotRoot = "/volumes/" + v.LogicalName + v.CoverageReason = "IncludedData" + } else if v.Classification == "rebuildable" { + v.CoverageReason = "RebuildRequired" + v.RecoveryPrerequisite = "If its contents are missing or incompatible, reconstruct this excluded volume from the declared module and installation sources before starting the recovered game." + } else { + return nil, fmt.Errorf("claim %s must declare backupRole data or rebuildable", name) + } + m.Volumes = append(m.Volumes, v) + } + sort.Slice(m.Volumes, func(i, j int) bool { return m.Volumes[i].LogicalName < m.Volumes[j].LogicalName }) + return m, validateRecoveryManifest(m) +} + +func recoveryDigest(m *v1.BackupCaptureManifest) string { + data, _ := json.Marshal(m) + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + +func sameRecoveryLayout(a, b *v1.BackupCaptureManifest) bool { + return a.TemplateName == b.TemplateName && a.TemplateRevision == b.TemplateRevision && a.GameVersion == b.GameVersion && a.ServerUID == b.ServerUID && reflect.DeepEqual(a.Volumes, b.Volumes) +} +func (r *BackupReconciler) validateBackupRecoveryLayout(ctx context.Context, b *v1.Backup, gs *v1.GameServer) error { + if b.Spec.VolumePolicy != "all-data" { + return nil + } + if err := validateRecoveryManifest(b.Status.CaptureManifest); err != nil { + return err + } + current, err := recoveryLayout(ctx, r.backupAPIReader(), gs) + if err != nil { + return err + } + if !sameRecoveryLayout(b.Status.CaptureManifest, current) { + return fmt.Errorf("capture layout or claim identity changed after admission") + } + return nil +} +func podMountsRecoveryClaims(pod *corev1.Pod, volumes []v1.BackupVolumeCoverage) bool { + for _, v := range pod.Spec.Volumes { + if v.PersistentVolumeClaim == nil { + continue + } + for _, want := range volumes { + if want.Included && want.ClaimName == v.PersistentVolumeClaim.ClaimName { + return true + } + } + } + return false +} + +// Replace only persistent mounts, leaving repository credentials and scratch intact. +func recoveryWorkerVolumes(spec *corev1.PodSpec, volumes []v1.BackupVolumeCoverage, readOnly bool) { + spec.Volumes = []corev1.Volume{{Name: "cache", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}} + spec.Containers[0].VolumeMounts = []corev1.VolumeMount{{Name: "cache", MountPath: "/tmp"}} + for i, v := range volumes { + if !v.Included { + continue + } + name := fmt.Sprintf("root-%d", i) + spec.Volumes = append(spec.Volumes, corev1.Volume{Name: name, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: v.ClaimName, ReadOnly: readOnly}}}) + spec.Containers[0].VolumeMounts = append(spec.Containers[0].VolumeMounts, corev1.VolumeMount{Name: name, MountPath: v.SnapshotRoot, ReadOnly: readOnly}) + } +} diff --git a/operator/internal/controller/recovery_policy_envtest_test.go b/operator/internal/controller/recovery_policy_envtest_test.go new file mode 100644 index 000000000..5850eb0f5 --- /dev/null +++ b/operator/internal/controller/recovery_policy_envtest_test.go @@ -0,0 +1,30 @@ +//go:build envtest + +package controller + +import ( + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "testing" +) + +func TestCRDValidation_RecoveryPoliciesRejectCSI(t *testing.T) { + ns := newNamespace(t) + for _, tc := range []struct{ name, power, volume string }{{"stopped", "stop-copy-restart", "primary-only"}, {"all-data", "preserve", "all-data"}} { + t.Run(tc.name, func(t *testing.T) { + b := &v1.Backup{ObjectMeta: metav1.ObjectMeta{Name: tc.name, Namespace: ns}, Spec: v1.BackupSpec{ServerRef: v1.LocalObjectRef{Name: "game"}, Strategy: "volume-snapshot", PowerPolicy: tc.power, VolumePolicy: tc.volume}} + if err := k8sClient.Create(t.Context(), b); !apierrors.IsInvalid(err) { + t.Fatalf("CSI policy accepted: %v", err) + } + schedule := &v1.BackupSchedule{ObjectMeta: metav1.ObjectMeta{Name: tc.name, Namespace: ns}, Spec: v1.BackupScheduleSpec{ServerRef: v1.LocalObjectRef{Name: "game"}, Strategy: "volume-snapshot", Schedule: "* * * * *", PowerPolicy: tc.power, VolumePolicy: tc.volume}} + if err := k8sClient.Create(t.Context(), schedule); !apierrors.IsInvalid(err) { + t.Fatalf("CSI schedule accepted: %v", err) + } + }) + } + b := &v1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "restic-all", Namespace: ns}, Spec: v1.BackupSpec{ServerRef: v1.LocalObjectRef{Name: "game"}, RepoRef: &v1.SecretKeySelector{Name: "repo", Key: "repo"}, PowerPolicy: "stop-copy-restart", VolumePolicy: "all-data"}} + if err := k8sClient.Create(t.Context(), b); err != nil { + t.Fatalf("valid recovery policy rejected: %v", err) + } +} diff --git a/operator/internal/controller/recovery_test.go b/operator/internal/controller/recovery_test.go new file mode 100644 index 000000000..70c12a84f --- /dev/null +++ b/operator/internal/controller/recovery_test.go @@ -0,0 +1,89 @@ +package controller + +import ( + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "strings" + "testing" +) + +// Runtime execution is CI-only; these regression sources precede implementation. +func TestRecoveryRejectsUnsafeRoots(t *testing.T) { + for _, root := range []string{"/", "/data", "/volumes/../data", "/volumes/a/b", "/volumes/*", "/volumes/extra;echo"} { + t.Run(root, func(t *testing.T) { + m := &v1.BackupCaptureManifest{State: "Recorded", FormatVersion: 2, VolumePolicy: "all-data", Volumes: []v1.BackupVolumeCoverage{{ClaimName: "s-data", ClaimUID: "uid", LogicalName: "primary", SnapshotRoot: root, Included: true, Classification: "data", MountPaths: []string{"/data"}}}} + if err := validateRecoveryManifest(m); err == nil { + t.Fatal("unsafe root admitted") + } + }) + } +} +func TestRecoveryRejectsDuplicateClaims(t *testing.T) { + m := &v1.BackupCaptureManifest{State: "Recorded", FormatVersion: 2, VolumePolicy: "all-data", Volumes: []v1.BackupVolumeCoverage{ + {ClaimName: "s-data", ClaimUID: "uid", LogicalName: "primary", SnapshotRoot: "/volumes/primary", Included: true, Classification: "data", MountPaths: []string{"/data"}}, + {ClaimName: "s-data", ClaimUID: "uid", LogicalName: "extra-world", SnapshotRoot: "/volumes/extra-world", Included: true, Classification: "data", MountPaths: []string{"/world"}}, + }} + if err := validateRecoveryManifest(m); err == nil { + t.Fatal("duplicate claim admitted") + } +} +func TestRecoveryWorkersUseDisjointExplicitRoots(t *testing.T) { + b := &v1.Backup{Spec: v1.BackupSpec{RepoRef: &v1.SecretKeySelector{Name: "repo"}, VolumePolicy: "all-data"}, Status: v1.BackupStatus{CaptureManifest: &v1.BackupCaptureManifest{Volumes: []v1.BackupVolumeCoverage{ + {ClaimName: "world", LogicalName: "primary", SnapshotRoot: "/volumes/primary", Included: true}, + {ClaimName: "mods", LogicalName: "mods-v1", SnapshotRoot: "/volumes/mods-v1", Included: true}, + {ClaimName: "cache", LogicalName: "extra-cache", RecoveryPrerequisite: "rebuild", Included: false}, + }}}} + spec := (&BackupReconciler{}).buildBackupPodSpec(b, &v1.GameTemplate{}) + if strings.Contains(strings.Join(spec.Containers[0].Args, " "), "*") { + t.Fatal("wildcard capture") + } + seen := map[string]bool{} + for _, m := range spec.Containers[0].VolumeMounts { + if m.Name != "cache" { + if !m.ReadOnly { + t.Fatal("writable capture") + } + seen[m.MountPath] = true + } + } + if !seen["/volumes/primary"] || !seen["/volumes/mods-v1"] || seen["/data"] { + t.Fatalf("unexpected mounts: %v", seen) + } + for _, v := range spec.Volumes { + if v.PersistentVolumeClaim != nil && v.PersistentVolumeClaim.ClaimName == "cache" { + t.Fatal("rebuildable mounted") + } + } +} +func TestStoppedBackupPowerBaseline(t *testing.T) { + for _, tc := range []struct { + name string + suspend bool + idle string + running bool + }{{"running", false, "", true}, {"suspended", true, "", false}, {"idle", false, "2026-01-01", false}} { + t.Run(tc.name, func(t *testing.T) { + gs := &v1.GameServer{ObjectMeta: metav1.ObjectMeta{Generation: 4, Annotations: map[string]string{IdleAsleepSinceAnnotation: tc.idle}}, Spec: v1.GameServerSpec{Suspend: tc.suspend}} + debt := newStoppedBackup(gs) + if debt.WasRunning != tc.running { + t.Fatal("incorrect admitted power") + } + gs.Spec.Suspend = true + if stoppedBackupMayResume(gs, debt) { + t.Fatal("overrode user suspension") + } + gs.Spec.Suspend = false + gs.Annotations[IdleAsleepSinceAnnotation] = "later" + if stoppedBackupMayResume(gs, debt) { + t.Fatal("overrode idle intent") + } + }) + } +} +func TestSelectedClaimDrainMatchesForeignClaims(t *testing.T) { + pod := &corev1.Pod{Spec: corev1.PodSpec{Volumes: []corev1.Volume{{Name: "foreign", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: "external-world"}}}}}} + if !podMountsRecoveryClaims(pod, []v1.BackupVolumeCoverage{{ClaimName: "external-world", Included: true}}) { + t.Fatal("foreign claim writer ignored") + } +} diff --git a/operator/internal/controller/restore_controller.go b/operator/internal/controller/restore_controller.go index 41839e2a6..7d78330da 100644 --- a/operator/internal/controller/restore_controller.go +++ b/operator/internal/controller/restore_controller.go @@ -104,6 +104,21 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct } return ctrl.Result{RequeueAfter: 15 * time.Second}, nil } + all, modeErr := recoveryCaptureMode(&src) + if modeErr != nil { + return r.fail(ctx, &rs, modeErr.Error()) + } + if rs.Annotations == nil { + rs.Annotations = map[string]string{} + } + mode := "legacy-primary" + if all { + mode = "all-data-v2" + } + rs.Annotations[restoreFormatAnnotation] = mode + if err := r.Update(ctx, &rs); err != nil { + return ctrl.Result{}, err + } rs.Status.SnapshotID = src.Status.SnapshotID if src.Spec.Strategy == "volume-snapshot" { // Volume-snapshot restores stand up a brand-new server rather @@ -134,6 +149,12 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Binding the target does not change the restore strategy. CSI's own // preparation path checks that binding before awaiting startup, repairing // references and enforcing the deadline on every subsequent reconcile. + if _, err := recoveryCaptureMode(&src); err != nil { + return r.fail(ctx, &rs, err.Error()) + } + if rs.Annotations[restoreFormatAnnotation] == "all-data-v2" && src.Spec.Strategy == "volume-snapshot" { + return r.fail(ctx, &rs, "recovery mode changed") + } if src.Spec.Strategy == "volume-snapshot" { if src.Annotations[backupSnapshotAbortAnnotation] == "true" || src.Status.Phase != gameplanev1alpha1.BackupPhaseSucceeded || src.Status.SnapshotID != rs.Status.SnapshotID { return r.fail(ctx, &rs, "source CSI capture is no longer valid") @@ -152,6 +173,11 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if !gs.DeletionTimestamp.IsZero() { return r.fail(ctx, &rs, "target server is being deleted") } + if changed, err := r.preflightRecovery(ctx, &rs, &src, &gs); err != nil { + return r.fail(ctx, &rs, err.Error()) + } else if changed { + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil + } changed, err := r.bindRestoreTarget(ctx, &rs, &gs) if err != nil { return ctrl.Result{}, err @@ -192,7 +218,7 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct tmpl := *effective if rs.Status.Phase == gameplanev1alpha1.RestorePhaseSuspending { - if gs.Status.Phase != gameplanev1alpha1.GameServerPhaseSuspended && + if rs.Status.RecoveryPlan == nil && gs.Status.Phase != gameplanev1alpha1.GameServerPhaseSuspended && gs.Status.Phase != gameplanev1alpha1.GameServerPhaseStopped { return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } @@ -234,11 +260,22 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if src.Spec.RepoRef == nil { return r.fail(ctx, &rs, "source backup has no restic repository") } + if _, err := r.preflightRecovery(ctx, &rs, &src, &gs); err != nil { + return r.fail(ctx, &rs, err.Error()) + } + if rs.Status.RecoveryPlan != nil { + if err := r.recoveryRestoreWritersDrained(ctx, &rs, &gs); err != nil { + return r.fail(ctx, &rs, err.Error()) + } + } job.Spec.BackoffLimit = &backoff job.Spec.ActiveDeadlineSeconds = &deadline job.Annotations = map[string]string{restoreTargetAnnotation: rs.Annotations[restoreTargetAnnotation]} job.Spec.Template.Labels = map[string]string{backupRestoreJobLabel: backupRestoreJobValue, restoreUIDLabel: string(rs.UID)} job.Spec.Template.Spec = r.buildRestorePodSpec(&rs, &src, &tmpl) + if err := r.prepareRecoveryRestorePod(ctx, &rs, &job.Spec.Template.Spec); err != nil { + return r.fail(ctx, &rs, err.Error()) + } job.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyNever if err := controllerutil.SetControllerReference(&rs, job, r.Scheme); err != nil { return ctrl.Result{}, err @@ -257,6 +294,11 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return r.fail(ctx, &rs, "restore Job does not belong to this Restore and target") } + if ready, err := r.authorizeRecoveryRestore(ctx, &rs, &src, &gs, job); err != nil { + return r.fail(ctx, &rs, err.Error()) + } else if !ready { + return ctrl.Result{RequeueAfter: 2 * time.Second}, nil + } switch { case job.Status.Succeeded > 0: busy, err := r.restoreWorkersLive(ctx, rs.Namespace, restoreIdentity{Name: rs.Name, UID: rs.UID}, false) @@ -340,7 +382,7 @@ func (r *RestoreReconciler) buildRestorePodSpec( roRootFS := true noPrivEsc := false uid := int64(65532) - return corev1.PodSpec{ + spec := corev1.PodSpec{ SecurityContext: buildBackupPodSecurityContext(tmpl), Containers: []corev1.Container{{ Name: "restic", @@ -406,4 +448,15 @@ func (r *RestoreReconciler) buildRestorePodSpec( }, }, } + if rs.Status.RecoveryPlan != nil { + recoveryWorkerVolumes(&spec, rs.Status.RecoveryPlan.Volumes, false) + args := []string{"restore", rs.Status.SnapshotID, "--target", "/", "--delete", "--retry-lock", "10m"} + for _, v := range rs.Status.RecoveryPlan.Volumes { + if v.Included { + args = append(args, "--include", v.SnapshotRoot) + } + } + spec.Containers[0].Args = args + } + return spec } diff --git a/operator/internal/controller/restore_lifecycle.go b/operator/internal/controller/restore_lifecycle.go index 6e392cbc2..78e4bf462 100644 --- a/operator/internal/controller/restore_lifecycle.go +++ b/operator/internal/controller/restore_lifecycle.go @@ -191,6 +191,12 @@ func (r *RestoreReconciler) restoreTargetStopped(ctx context.Context, gs *gamepl // available for their logs. Cancellation uses foreground deletion and UID // preconditions, retaining the guard until both Jobs and live pods have drained. func (r *RestoreReconciler) restoreWorkersLive(ctx context.Context, namespace string, identity restoreIdentity, cancel bool) (bool, error) { + if cancel { + if err := r.revokeRecoveryRestore(ctx, &gameplanev1alpha1.Restore{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: identity.Name, UID: identity.UID}}); err != nil { + return true, err + } + } + var jobs batchv1.JobList if err := r.apiReader().List(ctx, &jobs, client.InNamespace(namespace)); err != nil { return false, err @@ -338,7 +344,9 @@ func (r *RestoreReconciler) cleanupRestore(ctx context.Context, rs *gameplanev1a } delete(gs.Annotations, restoreGuardAnnotation) // Partial or canceled restores stay suspended for investigation. - gs.Spec.Suspend = !success + if !success || rs.Status.RecoveryPlan == nil { + gs.Spec.Suspend = !success + } if err := r.Update(ctx, &gs); err != nil { return ctrl.Result{}, err } diff --git a/operator/internal/controller/restore_recovery.go b/operator/internal/controller/restore_recovery.go new file mode 100644 index 000000000..05fc4b96c --- /dev/null +++ b/operator/internal/controller/restore_recovery.go @@ -0,0 +1,276 @@ +package controller + +import ( + "context" + "crypto/sha256" + "fmt" + "reflect" + "strconv" + "time" + + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const captureFormatAnnotation = "backup.gameplane.local/capture-format" +const restoreFormatAnnotation = "restore.gameplane.local/capture-format" +const restoreDeadlineAnnotation = "restore.gameplane.local/copy-deadline" +const restoreAuthorizedAnnotation = "restore.gameplane.local/authorized-worker" + +func recoveryCaptureMode(src *v1.Backup) (bool, error) { + m := src.Status.CaptureManifest + all := src.Spec.VolumePolicy == "all-data" || src.Annotations[captureFormatAnnotation] == "all-data-v2" || (m != nil && (m.FormatVersion != 0 || m.VolumePolicy == "all-data")) + if all { + if src.Spec.Strategy == "volume-snapshot" { + return false, fmt.Errorf("CSI cannot restore an all-data archive") + } + if err := validateRecoveryManifest(m); err != nil { + return false, err + } + if m.TemplateName == "" || m.TemplateRevision == "" || m.ServerUID == "" { + return false, fmt.Errorf("recovery manifest lacks provenance") + } + return true, nil + } + if m != nil { + for _, v := range m.Volumes { + if v.SnapshotRoot != "" || v.LogicalName != "" || (v.Included && v.CoverageReason != "PrimaryDataClaim") { + return false, fmt.Errorf("contradictory legacy manifest") + } + } + } + return false, nil +} + +func (r *RestoreReconciler) preflightRecovery(ctx context.Context, rs *v1.Restore, src *v1.Backup, gs *v1.GameServer) (bool, error) { + all, err := recoveryCaptureMode(src) + if err != nil { + return false, err + } + if rs.Annotations[restoreFormatAnnotation] == "all-data-v2" && !all { + return false, fmt.Errorf("source recovery manifest was removed or downgraded") + } + if !all { + if rs.Status.RecoveryPlan != nil { + return false, fmt.Errorf("recovery plan cannot downgrade to legacy") + } + return false, nil + } + if src.Status.SnapshotID != rs.Status.SnapshotID || src.Status.Phase != v1.BackupPhaseSucceeded { + return false, fmt.Errorf("source snapshot changed or is invalid") + } + current, err := recoveryLayout(ctx, r.apiReader(), gs) + if err != nil { + return false, err + } + saved := src.Status.CaptureManifest + if current.TemplateName != saved.TemplateName || current.TemplateRevision != saved.TemplateRevision || current.GameVersion != saved.GameVersion { + return false, fmt.Errorf("target template or version differs from capture provenance") + } + sourceByLogical := map[string]v1.BackupVolumeCoverage{} + for _, v := range saved.Volumes { + sourceByLogical[v.LogicalName] = v + } + if len(current.Volumes) != len(saved.Volumes) { + return false, fmt.Errorf("target recovery volume set differs") + } + for _, target := range current.Volumes { + original, ok := sourceByLogical[target.LogicalName] + if !ok || target.Included != original.Included || target.Classification != original.Classification || !reflect.DeepEqual(target.MountPaths, original.MountPaths) || target.SnapshotRoot != original.SnapshotRoot { + return false, fmt.Errorf("target logical layout differs for %s", target.LogicalName) + } + if target.Included && saved.ServerUID == string(gs.UID) && (target.ClaimName != original.ClaimName || target.ClaimUID != original.ClaimUID) { + return false, fmt.Errorf("original claim %s was replaced", target.LogicalName) + } + } + // Excluded rebuildable claims may be created or reconstructed during recovery; + // they have no writable target mapping and must not invalidate included roots. + for i := range current.Volumes { + if !current.Volumes[i].Included { + current.Volumes[i].ClaimUID = "" + } + } + candidate := &v1.RestoreRecoveryPlan{AdmittedSuspend: gs.Spec.Suspend, AdmittedIdle: gs.Annotations[IdleAsleepSinceAnnotation], AdmittedGeneration: gs.Generation, BackupUID: string(src.UID), ManifestDigest: recoveryDigest(saved), TargetUID: string(gs.UID), TemplateRevision: current.TemplateRevision, GameVersion: current.GameVersion, Volumes: current.Volumes} + if rs.Status.RecoveryPlan != nil { + candidate.AdmittedSuspend = rs.Status.RecoveryPlan.AdmittedSuspend + candidate.AdmittedIdle = rs.Status.RecoveryPlan.AdmittedIdle + candidate.AdmittedGeneration = rs.Status.RecoveryPlan.AdmittedGeneration + if !reflect.DeepEqual(candidate, rs.Status.RecoveryPlan) { + return false, fmt.Errorf("pinned recovery mapping or source manifest changed") + } + return false, nil + } + rs.Status.RecoveryPlan = candidate + return true, r.Status().Update(ctx, rs) +} + +// A ready Pod proves the claims are mounted. PVC protection prevents deletion +// and name reuse until all Pods mounting that claim are gone. UID revalidation +// after readiness closes the pre-scheduling replacement gap. +func validateMountedRecoveryClaims(ctx context.Context, reader client.Reader, namespace string, volumes []v1.BackupVolumeCoverage) error { + for _, v := range volumes { + if !v.Included { + continue + } + var pvc corev1.PersistentVolumeClaim + if err := reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: v.ClaimName}, &pvc); err != nil { + return err + } + protected := false + for _, finalizer := range pvc.Finalizers { + protected = protected || finalizer == "kubernetes.io/pvc-protection" + } + if string(pvc.UID) != v.ClaimUID || !pvc.DeletionTimestamp.IsZero() || pvc.Status.Phase != corev1.ClaimBound || pvc.Spec.VolumeName == "" || !protected { + return fmt.Errorf("mounted claim %s is replaced, unbound, deleting or lacks PVC protection", v.ClaimName) + } + } + return nil +} +func restoreStartName(rs *v1.Restore) string { + return fmt.Sprintf("restore-start-%x", sha256.Sum256([]byte(rs.Name+"/"+string(rs.UID))))[:61] +} +func (r *RestoreReconciler) restoreRecoveryGate(ctx context.Context, rs *v1.Restore) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: restoreStartName(rs), Namespace: rs.Namespace}} + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, cm, func() error { + if !cm.CreationTimestamp.IsZero() && !metav1.IsControlledBy(cm, rs) { + return fmt.Errorf("foreign restore gate") + } + if cm.Data == nil { + cm.Data = map[string]string{backupStartTokenKey: ""} + } + return controllerutil.SetControllerReference(rs, cm, r.Scheme) + }) + return cm, err +} +func (r *RestoreReconciler) revokeRecoveryRestore(ctx context.Context, rs *v1.Restore) error { + var cm corev1.ConfigMap + if err := r.apiReader().Get(ctx, client.ObjectKey{Namespace: rs.Namespace, Name: restoreStartName(rs)}, &cm); err != nil { + return client.IgnoreNotFound(err) + } + if !metav1.IsControlledBy(&cm, rs) || cm.Data[backupStartTokenKey] == "" { + return nil + } + cm.Data[backupStartTokenKey] = "" + return r.Update(ctx, &cm) +} +func (r *RestoreReconciler) prepareRecoveryRestorePod(ctx context.Context, rs *v1.Restore, spec *corev1.PodSpec) error { + if rs.Status.RecoveryPlan == nil { + return nil + } + if rs.Annotations[restoreDeadlineAnnotation] == "" { + _, seconds := resolveJobLimits(r.JobBackoffLimit, r.JobActiveDeadlineSeconds) + if rs.Annotations == nil { + rs.Annotations = map[string]string{} + } + rs.Annotations[restoreDeadlineAnnotation] = time.Now().Add(time.Duration(seconds) * time.Second).UTC().Format(time.RFC3339Nano) + if err := r.Update(ctx, rs); err != nil { + return err + } + } + deadline, err := time.Parse(time.RFC3339Nano, rs.Annotations[restoreDeadlineAnnotation]) + if err != nil { + return err + } + if _, err := r.restoreRecoveryGate(ctx, rs); err != nil { + return err + } + worker := &spec.Containers[0] + worker.Command = []string{"/bin/sh", "-c"} + worker.Args = append([]string{resticWorkerScript, "restore-worker"}, worker.Args...) + worker.Env = append(worker.Env, corev1.EnvVar{Name: "BACKUP_UID", Value: string(rs.UID)}, corev1.EnvVar{Name: "POD_UID", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.uid"}}}, corev1.EnvVar{Name: "SOURCE_ID", Value: rs.Status.RecoveryPlan.ManifestDigest}, corev1.EnvVar{Name: "PREPARATION_EXPIRES", Value: strconv.FormatInt(deadline.Unix(), 10)}) + worker.VolumeMounts = append(worker.VolumeMounts, corev1.VolumeMount{Name: "start", MountPath: "/start", ReadOnly: true}) + worker.ReadinessProbe = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{Exec: &corev1.ExecAction{Command: []string{"/bin/sh", "-c", "test -f /tmp/prepared"}}}, PeriodSeconds: 2, FailureThreshold: 1} + spec.Volumes = append(spec.Volumes, corev1.Volume{Name: "start", VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: restoreStartName(rs)}}}}) + noToken := false + spec.AutomountServiceAccountToken = &noToken + return nil +} +func (r *RestoreReconciler) authorizeRecoveryRestore(ctx context.Context, rs *v1.Restore, src *v1.Backup, gs *v1.GameServer, job *batchv1.Job) (bool, error) { + if rs.Status.RecoveryPlan == nil { + return true, nil + } + if err := r.revokeRecoveryRestore(ctx, rs); err != nil { + return false, err + } + if _, err := r.preflightRecovery(ctx, rs, src, gs); err != nil { + return false, err + } + deadline, err := time.Parse(time.RFC3339Nano, rs.Annotations[restoreDeadlineAnnotation]) + if err != nil { + return false, err + } + if job.Status.Succeeded > 0 { + if rs.Annotations[restoreAuthorizedAnnotation] == "" { + return false, fmt.Errorf("restore completed without authorization") + } + return true, nil + } + if jobPermanentlyFailed(job) { + return false, fmt.Errorf("restore job reported Failed") + } + if !time.Now().Before(deadline) { + return false, fmt.Errorf("restore copy deadline expired") + } + worker, err := (&BackupReconciler{Client: r.Client, APIReader: r.apiReader()}).preparedBackupWorker(ctx, &v1.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: rs.Namespace}}, job) + if err != nil || worker == nil { + return false, err + } + if err := validateMountedRecoveryClaims(ctx, r.apiReader(), rs.Namespace, rs.Status.RecoveryPlan.Volumes); err != nil { + return false, err + } + // Recheck other writers, excluding only this exact Restore's worker. + if err := r.recoveryRestoreWritersDrained(ctx, rs, gs); err != nil { + return false, err + } + if _, err := r.preflightRecovery(ctx, rs, src, gs); err != nil { + return false, err + } + rs.Annotations[restoreAuthorizedAnnotation] = string(worker.UID) + if err := r.Update(ctx, rs); err != nil { + return false, err + } + cm, err := r.restoreRecoveryGate(ctx, rs) + if err != nil { + return false, err + } + cm.Data[backupStartTokenKey] = fmt.Sprintf("v1 %s %s %s %d\n", rs.UID, worker.UID, rs.Status.RecoveryPlan.ManifestDigest, deadline.Unix()) + return true, r.Update(ctx, cm) +} +func (r *RestoreReconciler) recoveryRestoreWritersDrained(ctx context.Context, rs *v1.Restore, gs *v1.GameServer) error { + var pods corev1.PodList + var jobs batchv1.JobList + if err := r.apiReader().List(ctx, &pods, client.InNamespace(gs.Namespace)); err != nil { + return err + } + if err := r.apiReader().List(ctx, &jobs, client.InNamespace(gs.Namespace)); err != nil { + return err + } + own := map[string]bool{} + for i := range jobs.Items { + job := &jobs.Items[i] + if metav1.IsControlledBy(job, rs) { + own[string(job.UID)] = true + continue + } + if (job.Status.Succeeded == 0 && !jobPermanentlyFailed(job)) || job.Status.Active > 0 || !job.DeletionTimestamp.IsZero() { + if podMountsRecoveryClaims(&corev1.Pod{Spec: job.Spec.Template.Spec}, rs.Status.RecoveryPlan.Volumes) { + return fmt.Errorf("another Job still uses recovery claims") + } + } + } + for i := range pods.Items { + pod := &pods.Items[i] + owner := metav1.GetControllerOf(pod) + if owner != nil && own[string(owner.UID)] { + continue + } + if liveRestorePod(pod) && podMountsRecoveryClaims(pod, rs.Status.RecoveryPlan.Volumes) { + return fmt.Errorf("another Pod still uses recovery claims") + } + } + return nil +} diff --git a/operator/specs.md b/operator/specs.md index 4f0e5c168..74350a614 100644 --- a/operator/specs.md +++ b/operator/specs.md @@ -804,3 +804,18 @@ CI run: `make cover` generates merged profile (unit + envtest), `make cover-ratc - **CRD YAML:** `operator/config/crd/gameplane.local_*.yaml` (generated from types). - **RBAC YAML:** `operator/config/rbac/*.yaml` (generated, defines operator ServiceAccount + permissions). - **Helm integration:** `charts/gameplane/charts/gameplane-crds/crds/` (native CRDs) + `charts/gameplane/crd-manifests/` and `charts/gameplane/templates/crd-apply-hook.yaml` (pre-upgrade CRD sync). + +## Stopped multi-volume recovery + +Backup/BackupSchedule powerPolicy defaults to preserve; stop-copy-restart uses +durable lifecycle ownership and graceful stopping without spec.suspend mutation. +volumePolicy defaults to primary-only; all-data Restic includes classified data +extras and active separate mod claims, excluding rebuildable prerequisites. +CSI rejects the opt-in policies. All-data manifests and restore plans pin logical +roots, paths, claim UIDs and template/version provenance. Prepared restore workers +receive Pod-bound authorization only after mounted claims and mapping revalidate. +V2 restores stop through lifecycle guards and preserve current power intent on +success. Failed/cancelled writes stay suspended. Orphan stopped backups recover +autonomously through the GameServer owner; unknown provenance remains fenced. +See [the recovery contract](../specs/032-stopped-multivolume-recovery/contracts/recovery.md) +and [operator operations](../docs/operator-operations.md) for defaults and failures. diff --git a/specs/032-stopped-multivolume-recovery/OPEN-DECISIONS.md b/specs/032-stopped-multivolume-recovery/OPEN-DECISIONS.md new file mode 100644 index 000000000..6ea8c2197 --- /dev/null +++ b/specs/032-stopped-multivolume-recovery/OPEN-DECISIONS.md @@ -0,0 +1,3 @@ +# Open decisions + +None for implementation. Policy names follow the issue proposal and make defaults explicit. New dashboard forms are deferred; CRD/API opt-in and existing coverage presentation are sufficient. CI runtime results remain pending and must not be represented as successful before execution. diff --git a/specs/032-stopped-multivolume-recovery/contracts/recovery.md b/specs/032-stopped-multivolume-recovery/contracts/recovery.md new file mode 100644 index 000000000..393c23e99 --- /dev/null +++ b/specs/032-stopped-multivolume-recovery/contracts/recovery.md @@ -0,0 +1,10 @@ +# Recovery contract + +1. Acquire shared lifecycle owner. Persist original intent before graceful stop. No spec.suspend mutation by backup. +2. Freeze workload template; graceful stop uses existing deadline. Confirm zero replicas, observed generation, original Pod gone, all selected PVC writers drained. Never interpret timeout as drainage. +3. Persist exact coverage before creating immutable worker mounts. Reject unavailable, unclassified or ambiguous all-data layouts. Recheck claim UIDs/layout before authorizing worker token. +4. Capture one Restic snapshot with explicit argument roots. No glob or shell expansion from manifest text. Copy readers are read-only. +5. Revoke, drain and resolve original-process debt before release on success, failure or deletion. Original running intent allows normal desired-state reconciliation; newer user power requests remain untouched. Unknown debt retains ownership and intervention condition. +6. Restore preflight precedes suspension. Persist mapping and digest; compare again immediately before Job creation and before authorization of the ready mounted worker. Writable mounts and --delete scopes are exactly validated included roots. Legacy captures explicitly retain historical primary-only restore. + +CI acceptance includes running/suspended/idle backup, stop/copy failure, cancellation and owner loss, claim replacement, changed paths, missing required extras, rebuildable exclusion, malicious roots, legacy and CSI compatibility; Kind round-trip covers primary + extra + separate mod volume. diff --git a/specs/032-stopped-multivolume-recovery/data-model.md b/specs/032-stopped-multivolume-recovery/data-model.md new file mode 100644 index 000000000..1cf631885 --- /dev/null +++ b/specs/032-stopped-multivolume-recovery/data-model.md @@ -0,0 +1,9 @@ +# Data model + +BackupSpec and BackupScheduleSpec: powerPolicy enum preserve|stop-copy-restart; volumePolicy enum primary-only|all-data. Omitted fields retain existing behavior; CSI only permits preserve/primary-only. + +BackupCaptureManifest: formatVersion, volumePolicy, templateName, templateRevision, gameVersion. Each BackupVolumeCoverage adds logicalName, snapshotRoot, recoveryPrerequisite. all-data logical roots are primary, extra-, mods-; one root per included claim. + +GameServer lifecycle identity: optional stoppedBackup record with initial running intent, GameServer generation, suspend/idle/restart/wake baselines, original Pod UID and durable stage. Record survives Backup force deletion through recovery lineage. Status Recovery condition exposes stop/copy/release/intervention. + +RestoreStatus: recoveryPlan containing immutable source Backup UID/snapshot/manifest digest, target GameServer UID/template/version and mapped volume records with pinned target claim UID and names. Reconciliation compares the live layout and PVCs before destructive worker creation. diff --git a/specs/032-stopped-multivolume-recovery/spec.md b/specs/032-stopped-multivolume-recovery/spec.md new file mode 100644 index 000000000..1039c35f4 --- /dev/null +++ b/specs/032-stopped-multivolume-recovery/spec.md @@ -0,0 +1,13 @@ +# Stopped and multi-volume recovery (#801) + +Status: implementation approved; runtime verification belongs to CI. + +Existing backups default to powerPolicy=preserve and volumePolicy=primary-only. Opt-in stop-copy-restart uses the existing shared lifecycle owner and graceful stop sequence, never writes spec.suspend, and requires observed zero replicas and all source writers drained. all-data captures primary data, declared data extras, and active separate loader/mod claims in one Restic snapshot. Rebuildable extras are reported prerequisites; unclassified claims reject admission. CSI rejects either new policy and remains a single primary volume snapshot with no cross-volume atomicity. + +The owner on GameServer durably records admitted power intent, original pod and stop stage before stopping. Existing persisted preparation/copy budgets bound waiting and copying. Stop failure, copy failure and cancellation revoke and drain workers before releasing the fence. An orphan owner preserves stop debt through recovery; malformed provenance fails closed with visible RecoveryIntervention. Release never overwrites current suspend, idle or restart intent. Automatic resumption requires admitted running intent, unchanged power intent and completed drain; user changes take precedence after cleanup. + +Each new all-data manifest pins template/version provenance, claim names and UIDs, logical identities, original mount paths and disjoint /volumes/ roots. Claim mounts in the worker are read-only. Primary-only keeps /data and older manifests remain legacy, without invented metadata. + +Restore validates a manifest against an existing target before claiming ownership (which suspends), provisioning or creating a writer. It persists target claim UIDs/layout and revalidates both before Job creation and after the worker mounts become ready, immediately before Pod-bound authorization. PVC protection plus the final live UID check prevents name replacement while the mounted worker remains live. Missing/replaced required claims, changed mount paths/provenance, duplicate/traversing roots and ambiguous mappings fail visibly. A same-server restore requires original claim UIDs; a different existing server maps by exact logical identity and compatible template/version, pinning target UIDs. Excluded rebuildable claims may be absent: their logical declarations remain pinned, and the manifest reports reconstruction prerequisites. Restore never mounts or writes them. Only validated included roots receive --include and writable mounts. V2 restore uses lifecycle ownership to stop without changing spec.suspend; successful cleanup preserves current power intent, while failed or cancelled writes retain the established suspended safety behavior. Legacy primary-only restore retains /data behavior. + +The existing coverage rows expose included/excluded claims and prerequisites; API types gain policy and manifest fields. No new dashboard controls or layout are introduced. diff --git a/specs/032-stopped-multivolume-recovery/tasks.md b/specs/032-stopped-multivolume-recovery/tasks.md new file mode 100644 index 000000000..054c0a2d6 --- /dev/null +++ b/specs/032-stopped-multivolume-recovery/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +- [x] Specify defaults, durable lifecycle and restore mapping. +- [x] Add regression sources before implementation (execution deferred to CI). +- [x] Implement policies and generated schema. +- [x] Implement stopped lifecycle and orphan recovery. +- [x] Implement all-data manifest and worker. +- [x] Implement restore preflight and pinned mapping. +- [x] Expose metadata and update operations documentation. +- [x] Add CI Kind multi-volume round-trip fixture. +- [x] Generate CRDs/deepcopy and compile production. +- [ ] Independent review and CI verification (root orchestrator). diff --git a/test/e2e/buckets.sh b/test/e2e/buckets.sh index fff502f9a..c4a7afdec 100755 --- a/test/e2e/buckets.sh +++ b/test/e2e/buckets.sh @@ -86,6 +86,7 @@ TestModule_ScaffoldAndPackage TestModule_SteamcmdScaffoldKeepsNonRootSecurity TestBackup_OperatorMaterializesJob TestBackup_StoppedCopiesRemainRestorable +TestBackup_MultiVolumeRecoveryRoundTrip TestBackup_FailsOnMissingPVC TestBackup_FailsOnBadCredentials TestBackup_DeleteForgetsSnapshot diff --git a/test/e2e/recovery_multivolume_e2e_test.go b/test/e2e/recovery_multivolume_e2e_test.go new file mode 100644 index 000000000..f0d8e2686 --- /dev/null +++ b/test/e2e/recovery_multivolume_e2e_test.go @@ -0,0 +1,189 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +// Real Restic/PVC fixture: one world, a declared extra, a separate loader +// claim and a rebuildable cache. No API logins; CI operator bucket owns execution. +func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { + t.Parallel() + ctx := context.Background() + ns := "gameplane-games" + suffix := time.Now().UnixNano() + gs := fmt.Sprintf("recovery-%d", suffix) + tmpl := gs + "-template" + backup := gs + "-backup" + ensureResticRepo(t) + applyBusyboxTemplate(t, tmpl) + patch := map[string]any{"spec": map[string]any{ + "storage": map[string]any{"size": "1Gi", "mountPath": "/data", "extra": []any{map[string]any{"name": "world", "mountPath": "/world", "size": "1Gi", "backupRole": "data"}, map[string]any{"name": "cache", "mountPath": "/rebuild", "size": "1Gi", "backupRole": "rebuildable"}}}, + "versions": []any{map[string]any{"id": "v1", "displayName": "Test loader", "image": "busybox:1.36", "loader": "paper", "default": true}}, + "capabilities": map[string]any{"mods": map[string]any{"loaders": map[string]any{"paper": map[string]any{"path": "mods"}}}}, + }} + data, _ := json.Marshal(patch) + if _, err := envInstance.Dyn.Resource(gameTemplateGVR).Patch(ctx, tmpl, types.MergePatchType, data, metav1.PatchOptions{}); err != nil { + t.Fatal(err) + } + applyBusyboxGameServer(t, ns, gs, tmpl) + ready := func(name string) { + t.Helper() + envInstance.Eventually(t, 3*time.Minute, func() (bool, string) { + pod, err := envInstance.K8s.CoreV1().Pods(ns).Get(ctx, name+"-0", metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + for _, s := range pod.Status.ContainerStatuses { + if s.Name == "game" && s.Ready { + return true, "" + } + } + return false, "game not ready" + }) + } + exec := func(script string) { + t.Helper() + if out, err := envInstance.KubectlExec(t, ns, "pod/"+gs+"-0", "sh", "-c", "set -eu; "+script); err != nil { + t.Fatalf("fixture command: %v %s", err, out) + } + } + ready(gs) + exec("echo primary-before > /data/marker; echo extra-before > /world/marker; echo mods-before > /data/mods/marker; echo cache-before > /rebuild/marker; sync") + capture := func(name string) { + t.Helper() + obj := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "gameplane.local/v1alpha1", "kind": "Backup", "metadata": map[string]any{"name": name, "namespace": ns}, "spec": map[string]any{"serverRef": map[string]any{"name": gs}, "repoRef": map[string]any{"name": "e2e-restic-creds", "key": "repo"}, "quiesce": false, "powerPolicy": "stop-copy-restart", "volumePolicy": "all-data"}}} + if _, err := envInstance.Dyn.Resource(backupGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { deleteBackupCR(t, ns, name) }) + waitBackupSucceeded(t, ns, name, 6*time.Minute) + } + capture(backup) + ready(gs) + got, err := envInstance.Dyn.Resource(backupGVR).Namespace(ns).Get(ctx, backup, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + volumes, _, _ := unstructured.NestedSlice(got.Object, "status", "captureManifest", "volumes") + included, excluded := 0, 0 + for _, raw := range volumes { + v := raw.(map[string]any) + if v["included"] == true { + included++ + if !strings.HasPrefix(v["snapshotRoot"].(string), "/volumes/") { + t.Fatal("non-disjoint capture root") + } + } else { + excluded++ + if v["recoveryPrerequisite"] == "" { + t.Fatal("missing recovery prerequisite") + } + } + } + if included != 3 || excluded != 1 { + t.Fatalf("wrong coverage: %d/%d", included, excluded) + } + exec("echo changed > /data/marker; echo changed > /world/marker; echo changed > /data/mods/marker; echo cache-after > /rebuild/marker; touch /data/stale /world/stale /data/mods/stale; sync") + restore := gs + "-restore" + createRestore(t, ns, restore, gs, backup) + waitRestoreSucceeded(t, ns, restore, 6*time.Minute) + ready(gs) + exec("test $(cat /data/marker) = primary-before; test $(cat /world/marker) = extra-before; test $(cat /data/mods/marker) = mods-before; test $(cat /rebuild/marker) = cache-after; test ! -e /data/stale; test ! -e /world/stale; test ! -e /data/mods/stale") + + // Existing explicit suspend and idle intent survive both stopped capture and + // a successful v2 restore. Verify Pod absence after owner cleanup. + for _, mode := range []string{"suspended", "idle"} { + t.Run(mode, func(t *testing.T) { + body := map[string]any{"spec": map[string]any{"suspend": mode == "suspended", "idle": map[string]any{"enabled": mode == "idle"}}} + asleep := time.Now().UTC().Format(time.RFC3339) + if mode == "idle" { + body["metadata"] = map[string]any{"annotations": map[string]any{"gameplane.local/idle-asleep-since": asleep}} + } + data, _ := json.Marshal(body) + if _, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Patch(ctx, gs, types.MergePatchType, data, metav1.PatchOptions{}); err != nil { + t.Fatal(err) + } + envInstance.Eventually(t, 3*time.Minute, func() (bool, string) { + _, err := envInstance.K8s.CoreV1().Pods(ns).Get(ctx, gs+"-0", metav1.GetOptions{}) + return apierrors.IsNotFound(err), fmt.Sprint(err) + }) + capture(gs + "-" + mode) + name := gs + "-restore-" + mode + createRestore(t, ns, name, gs, backup) + waitRestoreSucceeded(t, ns, name, 6*time.Minute) + envInstance.Eventually(t, time.Minute, func() (bool, string) { + obj, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Get(ctx, gs, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + if obj.GetAnnotations()["operations.gameplane.local/owner"] != "" { + return false, "cleanup pending" + } + suspended, _, _ := unstructured.NestedBool(obj.Object, "spec", "suspend") + if suspended != (mode == "suspended") { + t.Fatal("restore changed suspend intent") + } + if mode == "idle" && obj.GetAnnotations()["gameplane.local/idle-asleep-since"] != asleep { + t.Fatal("restore changed idle intent") + } + _, err = envInstance.K8s.CoreV1().Pods(ns).Get(ctx, gs+"-0", metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("recovery woke parked game: %v", err) + } + return true, "" + }) + }) + } + + // An incompatible existing target must fail before power mutation or a Job. + for _, mode := range []string{"missing", "changed-path"} { + t.Run(mode, func(t *testing.T) { + target := gs + "-" + mode + extra := []any{map[string]any{"name": "cache", "mountPath": "/rebuild", "size": "1Gi", "backupRole": "rebuildable"}} + if mode == "changed-path" { + extra = append(extra, map[string]any{"name": "world", "mountPath": "/redirected-world", "size": "1Gi", "backupRole": "data"}) + } + obj := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "gameplane.local/v1alpha1", "kind": "GameServer", "metadata": map[string]any{"name": target, "namespace": ns}, "spec": map[string]any{"templateRef": map[string]any{"name": tmpl}, "storage": map[string]any{"extra": extra}}}} + if _, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Delete(ctx, target, metav1.DeleteOptions{}) + }) + ready(target) + name := target + "-restore" + createRestore(t, ns, name, target, backup) + envInstance.Eventually(t, time.Minute, func() (bool, string) { + rs, err := envInstance.Dyn.Resource(restoreGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + phase, _, _ := unstructured.NestedString(rs.Object, "status", "phase") + return phase == "Failed", phase + }) + targetObj, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Get(ctx, target, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + suspended, _, _ := unstructured.NestedBool(targetObj.Object, "spec", "suspend") + if suspended || targetObj.GetAnnotations()["restore.gameplane.local/owner"] != "" { + t.Fatal("preflight failure mutated power or ownership") + } + if _, err := envInstance.K8s.BatchV1().Jobs(ns).Get(ctx, "restore-"+name, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("preflight created writer: %v", err) + } + }) + } +} diff --git a/web/src/components/backups/BackupDetailDrawer.test.tsx b/web/src/components/backups/BackupDetailDrawer.test.tsx index 835470dd7..f74a26fb9 100644 --- a/web/src/components/backups/BackupDetailDrawer.test.tsx +++ b/web/src/components/backups/BackupDetailDrawer.test.tsx @@ -29,6 +29,25 @@ describe("BackupDetailDrawer", () => { expect(screen.getByText("Rebuildable")).toBeInTheDocument(); }); + it("shows named archive roots and the saved prerequisites for missing and intact excluded claims", async () => { + server.use(http.get("/backups/recovery", () => HttpResponse.json(makeBackup({ + metadata: { name: "recovery" }, + status: { phase: "Succeeded", snapshotID: "snapshot", captureManifest: { + state: "Recorded", formatVersion: 2, volumePolicy: "all-data", volumes: [ + { claimName: "world", claimUID: "world-uid", logicalName: "primary", snapshotRoot: "/volumes/primary", included: true, classification: "data", reason: "IncludedData" }, + { claimName: "missing-install", included: false, classification: "rebuildable", reason: "RebuildRequired", recoveryPrerequisite: "Missing install files must be reconstructed from the module." }, + { claimName: "intact-cache", claimUID: "cache-uid", included: false, classification: "rebuildable", reason: "RebuildRequired", recoveryPrerequisite: "Keep compatible existing cache content; reconstruct only if needed." }, + ], + } }, + })))); + renderWithQuery( {}} onRestore={() => {}} />); + expect(await screen.findByText("/volumes/primary")).toBeInTheDocument(); + expect(screen.getByText("Missing install files must be reconstructed from the module.")).toBeInTheDocument(); + expect(screen.getByText("Keep compatible existing cache content; reconstruct only if needed.")).toBeInTheDocument(); + expect(screen.getAllByText("Not covered")).toHaveLength(2); + expect(screen.queryByText(/must be reconstructed before recovery can run/)).not.toBeInTheDocument(); + }); + it("shows legacy coverage as unknown without inventing a current layout", async () => { server.use(http.get("/backups/legacy", () => HttpResponse.json(makeBackup({ metadata: { name: "legacy" }, status: { phase: "Succeeded" } })))); renderWithQuery( {}} onRestore={() => {}} />); diff --git a/web/src/components/backups/BackupDetailDrawer.tsx b/web/src/components/backups/BackupDetailDrawer.tsx index 37acbe02a..a4ecce8cd 100644 --- a/web/src/components/backups/BackupDetailDrawer.tsx +++ b/web/src/components/backups/BackupDetailDrawer.tsx @@ -89,7 +89,7 @@ export function BackupDetailDrawer({ name, ns, onClose, onRestore, target: expli {manifest?.state === "Recorded" ? (
{manifest.recordedAt &&
{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.coverage_recorded", defaultMessage: "Recorded: {time}", description: "Backup details: timestamp of the immutable capture manifest" }, { time: manifest.recordedAt })}
} -

{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.coverage_scope", defaultMessage: "Only the primary data claim is copied. Rebuildable is a declaration, not proof that recovery can reconstruct its contents.", description: "Backup details: explains the current single-volume backup scope and rebuildable declarations" })}

+

{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.coverage_scope", defaultMessage: "Included claims are copied at the recorded archive roots. Rebuildable volumes are excluded. Check their recovery prerequisites before restarting.", description: "Backup details: explains recorded volume coverage and excluded rebuildable recovery prerequisites" })}

    {manifest.volumes?.map((volume) => (
  • @@ -98,7 +98,8 @@ export function BackupDetailDrawer({ name, ns, onClose, onRestore, target: expli ? intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.included", defaultMessage: "Included", description: "Backup volume: this claim is selected for copying; success is reported separately" }) : intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.not_covered", defaultMessage: "Not covered", description: "Backup volume: contents of this claim are not included" })}
{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.classification", defaultMessage: "{role, select, data {Data} rebuildable {Rebuildable} other {Unclassified}}", description: "Backup volume: optional declaration from the module or server at capture time" }, { role: volume.classification })}
- {volume.mountPaths?.map((path) =>
{path}
)} + {[...(volume.mountPaths ?? []), ...(volume.snapshotRoot ? [volume.snapshotRoot] : [])].map((path) =>
{path}
)} + {volume.recoveryPrerequisite &&
{volume.recoveryPrerequisite}
} {volume.reason === "RetainedUnmountedClaim" &&
{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.retained_unmounted", defaultMessage: "Retained claim; not mounted in the captured workload.", description: "Backup volume: an older server-owned claim was retained but was not part of the observed pod layout" })}
} ))} diff --git a/web/src/types.ts b/web/src/types.ts index 84b0f1f6d..2091c1f42 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -662,6 +662,11 @@ export interface ServerEvent { } export interface BackupCaptureManifest { + formatVersion?: number; + volumePolicy?: "primary-only" | "all-data"; + templateName?: string; + templateRevision?: string; + gameVersion?: string; state: "Recorded" | "LegacyUnknown"; recordedAt?: string; serverUID?: string; @@ -669,6 +674,9 @@ export interface BackupCaptureManifest { layoutSource?: string; quiesceOutcome?: string; volumes?: Array<{ + logicalName?: string; + snapshotRoot?: string; + recoveryPrerequisite?: string; claimName: string; claimUID?: string; mountPaths?: string[]; @@ -682,7 +690,7 @@ export interface Backup { metadata: ObjectMeta; // strategy: restic-snapshot writes to a restic repo; volume-snapshot takes a // CSI snapshot and is restored by provisioning a NEW server from it. - spec: { serverRef: { name: string }; strategy?: "restic-snapshot" | "volume-snapshot" }; + spec: { serverRef: { name: string }; strategy?: "restic-snapshot" | "volume-snapshot"; powerPolicy?: "preserve" | "stop-copy-restart"; volumePolicy?: "primary-only" | "all-data" }; status?: { phase?: "Pending" | "Running" | "Succeeded" | "Failed"; startTime?: string; @@ -699,6 +707,8 @@ export interface BackupSchedule { spec: { serverRef: { name: string }; schedule: string; + powerPolicy?: "preserve" | "stop-copy-restart"; + volumePolicy?: "primary-only" | "all-data"; suspend?: boolean; retention?: { keepLast?: number; @@ -725,6 +735,14 @@ export interface Restore { serverRef: { name: string }; }; status?: { + recoveryPlan?: { + backupUID: string; + manifestDigest: string; + targetUID: string; + templateRevision: string; + gameVersion: string; + volumes: NonNullable; + }; phase?: RestorePhase; snapshotID?: string; startTime?: string; From 704ab190a179082e35aa1f901a68efac8d3d051a Mon Sep 17 00:00:00 2001 From: Baylem Date: Mon, 9 Nov 2026 16:01:40 -0500 Subject: [PATCH 2/7] fix(recovery): register conditions and satisfy CI checks Signed-off-by: Baylem --- .../crds/gameplane.local_backups.yaml | 2 +- .../crds/gameplane.local_backupschedules.yaml | 2 +- .../crds/gameplane.local_clusters.yaml | 2 +- .../crds/gameplane.local_gameservers.yaml | 2 +- .../crds/gameplane.local_gametemplates.yaml | 2 +- .../crds/gameplane.local_modules.yaml | 2 +- .../crds/gameplane.local_modulesources.yaml | 2 +- .../crds/gameplane.local_networkcaptures.yaml | 2 +- .../crds/gameplane.local_restores.yaml | 8 +++---- .../gameplane.local_backups.yaml | 2 +- .../gameplane.local_backupschedules.yaml | 2 +- .../gameplane.local_clusters.yaml | 2 +- .../gameplane.local_gameservers.yaml | 2 +- .../gameplane.local_gametemplates.yaml | 2 +- .../gameplane.local_modules.yaml | 2 +- .../gameplane.local_modulesources.yaml | 2 +- .../gameplane.local_networkcaptures.yaml | 2 +- .../gameplane.local_restores.yaml | 8 +++---- i18n/locales/en/app.json | 12 ++++++++-- i18n/locales/fr/.review.json | 4 ++++ i18n/locales/fr/app.json | 16 ++++++++++++++ operator/api/v1alpha1/restore_types.go | 2 +- .../config/crd/gameplane.local_restores.yaml | 6 ++--- operator/hack/condition-ids/main.go | 7 ++++-- operator/internal/controller/backup_stop.go | 2 +- .../controller/recovery_controller_test.go | 8 +++---- .../internal/controller/recovery_manifest.go | 7 +++--- .../recovery_policy_envtest_test.go | 3 ++- operator/internal/controller/recovery_test.go | 5 +++-- test/e2e/backup_forget_e2e_test.go | 7 +++++- test/e2e/recovery_multivolume_e2e_test.go | 18 +++++++++------ test/e2e/test_helpers_e2e_test.go | 22 +++++++++++++++---- 32 files changed, 112 insertions(+), 55 deletions(-) diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml index d35c0eb64..e579af4f7 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml index 080fdc494..1a47d5a0b 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml index 22ab099be..4ed0507d0 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml index c2a8b3da9..2e12b7d78 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml index c986beb71..2e31837da 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml index f490347e0..a2054ca99 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml index f710cf22b..db10e1c62 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml index f79e0add6..28adf5343 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml index b3956ffde..f7ea09a1f 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: @@ -93,6 +93,7 @@ spec: - serverRef type: object status: + description: RestoreStatus is the observed state of a Restore. properties: completionTime: format: date-time @@ -171,9 +172,8 @@ spec: - Failed type: string recoveryPlan: - description: |- - RestoreStatus is the observed state of a Restore. - RestoreRecoveryPlan pins a validated target mapping before any power mutation. + description: RestoreRecoveryPlan pins a validated target mapping before + any power mutation. properties: admittedGeneration: format: int64 diff --git a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml index d35c0eb64..e579af4f7 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml index 080fdc494..1a47d5a0b 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml index 22ab099be..4ed0507d0 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml index c2a8b3da9..2e12b7d78 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml index c986beb71..2e31837da 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index f490347e0..a2054ca99 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml index f710cf22b..db10e1c62 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml index f79e0add6..28adf5343 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml index b3956ffde..f7ea09a1f 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 87025215c484916aa3580ec6a5cca2f6a9f48fd5548b2288875e004bfa064628 + gameplane.local/crd-bundle-sha256: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: @@ -93,6 +93,7 @@ spec: - serverRef type: object status: + description: RestoreStatus is the observed state of a Restore. properties: completionTime: format: date-time @@ -171,9 +172,8 @@ spec: - Failed type: string recoveryPlan: - description: |- - RestoreStatus is the observed state of a Restore. - RestoreRecoveryPlan pins a validated target mapping before any power mutation. + description: RestoreRecoveryPlan pins a validated target mapping before + any power mutation. properties: admittedGeneration: format: int64 diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index a92fd9987..d321cd8db 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -211,8 +211,12 @@ "defaultMessage": "Capture-time metadata is unavailable. Do not infer consistency or volume coverage from the current template.", "description": "Backup achieved consistency: LegacyMetadataUnknown." }, + "condition.consistency.livemultivolumecopy": { + "defaultMessage": "Included volumes share one snapshot, but live copies have no atomic cross-volume consistency guarantee, even when the primary game's quiesce succeeds.", + "description": "Backup achieved consistency: LiveMultiVolumeCopy." + }, "condition.consistency.quiesced": { - "defaultMessage": "The primary volume copy completed after the agent confirmed quiesce. This depends on the module's quiesce commands; other volumes remain outside this backup.", + "defaultMessage": "The included volumes were copied after the agent confirmed quiesce. This depends on the module's quiesce commands; consult the manifest for excluded volumes.", "description": "Backup achieved consistency: Quiesced." }, "condition.consistency.rawcopy": { @@ -220,7 +224,7 @@ "description": "Backup achieved consistency: RawCopy." }, "condition.consistency.stoppedsource": { - "defaultMessage": "The primary volume copy completed from a source that was already stopped and drained before capture. Other volumes remain outside this backup.", + "defaultMessage": "The included volumes were copied from a stopped and drained source. Consult the manifest for excluded volumes and recovery prerequisites.", "description": "Backup achieved consistency: StoppedSource." }, "condition.consistency.unsupportedrawcopy": { @@ -619,6 +623,10 @@ "defaultMessage": "Waiting for the module source to index this module.", "description": "Module condition while the source has no catalog entry yet." }, + "condition.recoveryintervention.provenanceunavailable": { + "defaultMessage": "Recovery cannot continue safely because the original operation metadata is unavailable. An administrator must inspect the server and its active workers.", + "description": "Recovery requires intervention when the original operation provenance cannot be established." + }, "condition.retentionstopfailed.sidecar_stop_failed": { "defaultMessage": "Stopping the capture sidecar at the end of retention failed.", "description": "Condition when the retention-expiry stop failed." diff --git a/i18n/locales/fr/.review.json b/i18n/locales/fr/.review.json index b9a51a897..5c208d0f3 100644 --- a/i18n/locales/fr/.review.json +++ b/i18n/locales/fr/.review.json @@ -43,6 +43,9 @@ "condition.completed.pending": "fcd4ee5f73b3", "condition.completed.running": "6045c2e3f230", "condition.completed.succeeded": "ed54690bd16d", + "condition.consistency.livemultivolumecopy": "8acea8f3b27e", + "condition.consistency.quiesced": "876727991b14", + "condition.consistency.stoppedsource": "f1233d8fcfd7", "condition.datawipe.jobfailed": "f1c100d69216", "condition.failed.capture_already_in_progress": "8c6c3fb2a7bb", "condition.failed.failed": "a2f668070978", @@ -127,6 +130,7 @@ "condition.ready.verifyconfig": "3d84bdf1c513", "condition.ready.versionunavailable": "a4b327ee30da", "condition.ready.waitingforcatalog": "4618394ce7f4", + "condition.recoveryintervention.provenanceunavailable": "8b888cfbc3ae", "condition.retentionstopfailed.sidecar_stop_failed": "1bf2ca9bc80f", "condition.retentiontrimmed.trimfailed": "2fa1a4345931", "condition.retentiontrimmed.trimsucceeded": "c389ba94d579", diff --git a/i18n/locales/fr/app.json b/i18n/locales/fr/app.json index 2f4258794..338154c6c 100644 --- a/i18n/locales/fr/app.json +++ b/i18n/locales/fr/app.json @@ -175,6 +175,18 @@ "defaultMessage": "Le backup ou le restore s'est terminé avec succès.", "description": "Completed condition on a Backup or Restore that finished." }, + "condition.consistency.livemultivolumecopy": { + "defaultMessage": "Les volumes inclus partagent un même instantané, mais leur copie à chaud ne garantit pas une cohérence atomique entre les volumes, même si la mise au repos du jeu principal réussit.", + "description": "Backup achieved consistency: LiveMultiVolumeCopy." + }, + "condition.consistency.quiesced": { + "defaultMessage": "Les volumes inclus ont été copiés après confirmation de la mise au repos par l’agent. Cela dépend des commandes de mise au repos du module ; consultez le manifeste pour connaître les volumes exclus.", + "description": "Backup achieved consistency: Quiesced." + }, + "condition.consistency.stoppedsource": { + "defaultMessage": "Les volumes inclus ont été copiés depuis une source arrêtée, sans processus utilisant ces volumes. Consultez le manifeste pour connaître les volumes exclus et les prérequis de récupération.", + "description": "Backup achieved consistency: StoppedSource." + }, "condition.datawipe.jobfailed": { "defaultMessage": "La tâche d'effacement des données ne s'est pas terminée. Le volume n'est peut-être pas entièrement vidé.", "description": "DataWipe condition when the wipe job fails." @@ -511,6 +523,10 @@ "defaultMessage": "En attente de l'indexation de ce module par la source de modules.", "description": "Module condition while the source has no catalog entry yet." }, + "condition.recoveryintervention.provenanceunavailable": { + "defaultMessage": "La récupération ne peut pas continuer en toute sécurité, car les métadonnées de l’opération d’origine sont indisponibles. Un administrateur doit examiner le serveur et ses tâches actives.", + "description": "Recovery requires intervention when the original operation provenance cannot be established." + }, "condition.retentionstopfailed.sidecar_stop_failed": { "defaultMessage": "L'arrêt du sidecar de capture à la fin de la rétention a échoué.", "description": "Condition when the retention-expiry stop failed." diff --git a/operator/api/v1alpha1/restore_types.go b/operator/api/v1alpha1/restore_types.go index 69d87a204..3a42b1542 100644 --- a/operator/api/v1alpha1/restore_types.go +++ b/operator/api/v1alpha1/restore_types.go @@ -37,7 +37,6 @@ type RestoreSpec struct { ServerRef LocalObjectRef `json:"serverRef"` } -// RestoreStatus is the observed state of a Restore. // RestoreRecoveryPlan pins a validated target mapping before any power mutation. type RestoreRecoveryPlan struct { // Admitted power intent is informational; cleanup never overwrites newer user intent. @@ -54,6 +53,7 @@ type RestoreRecoveryPlan struct { Volumes []BackupVolumeCoverage `json:"volumes"` } +// RestoreStatus is the observed state of a Restore. type RestoreStatus struct { // +optional RecoveryPlan *RestoreRecoveryPlan `json:"recoveryPlan,omitempty"` diff --git a/operator/config/crd/gameplane.local_restores.yaml b/operator/config/crd/gameplane.local_restores.yaml index 236a904fd..f7d1af468 100644 --- a/operator/config/crd/gameplane.local_restores.yaml +++ b/operator/config/crd/gameplane.local_restores.yaml @@ -92,6 +92,7 @@ spec: - serverRef type: object status: + description: RestoreStatus is the observed state of a Restore. properties: completionTime: format: date-time @@ -170,9 +171,8 @@ spec: - Failed type: string recoveryPlan: - description: |- - RestoreStatus is the observed state of a Restore. - RestoreRecoveryPlan pins a validated target mapping before any power mutation. + description: RestoreRecoveryPlan pins a validated target mapping before + any power mutation. properties: admittedGeneration: format: int64 diff --git a/operator/hack/condition-ids/main.go b/operator/hack/condition-ids/main.go index ab8e3e5be..b22008df3 100644 --- a/operator/hack/condition-ids/main.go +++ b/operator/hack/condition-ids/main.go @@ -177,12 +177,15 @@ var rows = []row{ {types: []string{"SnapshotForgotten"}, reason: "Forgotten", english: "The snapshot was removed from the backup repository.", description: "SnapshotForgotten condition after a deleted Backup's snapshot was forgotten."}, {types: []string{"SnapshotForgotten"}, reason: "ForgetAbandoned", english: "The snapshot was not removed from the backup repository when the backup was deleted.", description: "SnapshotForgotten condition when the forget step was abandoned."}, + {types: []string{"RecoveryIntervention"}, reason: "ProvenanceUnavailable", english: "Recovery cannot continue safely because the original operation metadata is unavailable. An administrator must inspect the server and its active workers.", description: "Recovery requires intervention when the original operation provenance cannot be established."}, + {types: []string{"Consistency"}, reason: "LiveMultiVolumeCopy", english: "Included volumes share one snapshot, but live copies have no atomic cross-volume consistency guarantee, even when the primary game's quiesce succeeds.", description: "Backup achieved consistency: LiveMultiVolumeCopy."}, + // Consistency is separate from copy completion and volume coverage. {types: []string{"Consistency"}, reason: "CaptureFailed", english: "The capture failed; no usable consistency guarantee was achieved.", description: "Backup achieved consistency: CaptureFailed."}, {types: []string{"Consistency"}, reason: "LegacyMetadataUnknown", english: "Capture-time metadata is unavailable. Do not infer consistency or volume coverage from the current template.", description: "Backup achieved consistency: LegacyMetadataUnknown."}, {types: []string{"Consistency"}, reason: "CapturePending", english: "The capture has not completed; its achieved consistency is not yet known.", description: "Backup achieved consistency: CapturePending."}, - {types: []string{"Consistency"}, reason: "Quiesced", english: "The primary volume copy completed after the agent confirmed quiesce. This depends on the module's quiesce commands; other volumes remain outside this backup.", description: "Backup achieved consistency: Quiesced."}, - {types: []string{"Consistency"}, reason: "StoppedSource", english: "The primary volume copy completed from a source that was already stopped and drained before capture. Other volumes remain outside this backup.", description: "Backup achieved consistency: StoppedSource."}, + {types: []string{"Consistency"}, reason: "Quiesced", english: "The included volumes were copied after the agent confirmed quiesce. This depends on the module's quiesce commands; consult the manifest for excluded volumes.", description: "Backup achieved consistency: Quiesced."}, + {types: []string{"Consistency"}, reason: "StoppedSource", english: "The included volumes were copied from a stopped and drained source. Consult the manifest for excluded volumes and recovery prerequisites.", description: "Backup achieved consistency: StoppedSource."}, {types: []string{"Consistency"}, reason: "CrashConsistentSnapshot", english: "The primary CSI snapshot is crash-consistent; application quiesce was not confirmed.", description: "Backup achieved consistency: CrashConsistentSnapshot."}, {types: []string{"Consistency"}, reason: "UnsupportedRawCopy", english: "The game does not support quiesce. The primary volume was copied while it could still change.", description: "Backup achieved consistency: UnsupportedRawCopy."}, {types: []string{"Consistency"}, reason: "AgentUnavailableRawCopy", english: "No agent quiesce capability was configured. The primary volume was copied without pausing game writes.", description: "Backup achieved consistency: AgentUnavailableRawCopy."}, diff --git a/operator/internal/controller/backup_stop.go b/operator/internal/controller/backup_stop.go index 7ce404a3e..451d93c0a 100644 --- a/operator/internal/controller/backup_stop.go +++ b/operator/internal/controller/backup_stop.go @@ -58,7 +58,7 @@ func (r *BackupReconciler) prepareStoppedBackup(ctx context.Context, b *v1.Backu if b.Spec.VolumePolicy == "all-data" { m, err := recoveryLayout(ctx, r.backupAPIReader(), gs) if err != nil { - return false, fmt.Errorf("%w: %v", errBackupSourceChanged, err) + return false, fmt.Errorf("%w: %w", errBackupSourceChanged, err) } s.Claims = m.Volumes } else { diff --git a/operator/internal/controller/recovery_controller_test.go b/operator/internal/controller/recovery_controller_test.go index 9cbef3ec5..5b05e7d40 100644 --- a/operator/internal/controller/recovery_controller_test.go +++ b/operator/internal/controller/recovery_controller_test.go @@ -202,9 +202,9 @@ func TestRecoveryAntiDowngradeAndConsistency(t *testing.T) { t.Fatal("drained all-data not recognized") } for _, state := range []string{"LegacyUnknown", "Unknown"} { - copy := b.DeepCopy() - copy.Status.CaptureManifest.State = state - if _, err := recoveryCaptureMode(copy); err == nil { + candidate := b.DeepCopy() + candidate.Status.CaptureManifest.State = state + if _, err := recoveryCaptureMode(candidate); err == nil { t.Fatal("unknown manifest downgraded") } } @@ -302,7 +302,7 @@ func TestStoppedPredecessorClaimsSurviveSuccessorAdmission(t *testing.T) { t.Fatalf("predecessor writer ignored: %v %v", ready, err) } op.Recovery = append(op.Recovery, op.lifecycleIdentity) - op.lifecycleIdentity.UID = types.UID("another-successor") + op.UID = types.UID("another-successor") gs.Annotations[lifecycleOperationAnnotation] = lifecycleOperationJSON(op) if err := c.Update(t.Context(), gs); err != nil { t.Fatal(err) diff --git a/operator/internal/controller/recovery_manifest.go b/operator/internal/controller/recovery_manifest.go index beec4e7c6..1cc14194f 100644 --- a/operator/internal/controller/recovery_manifest.go +++ b/operator/internal/controller/recovery_manifest.go @@ -142,14 +142,15 @@ func recoveryLayout(ctx context.Context, reader client.Reader, gs *v1.GameServer return nil, fmt.Errorf("required claim %s is absent from frozen workload", name) } v.ClaimName, v.ClaimUID = name, uids[name] - if v.Classification == "data" { + switch v.Classification { + case "data": v.Included = true v.SnapshotRoot = "/volumes/" + v.LogicalName v.CoverageReason = "IncludedData" - } else if v.Classification == "rebuildable" { + case "rebuildable": v.CoverageReason = "RebuildRequired" v.RecoveryPrerequisite = "If its contents are missing or incompatible, reconstruct this excluded volume from the declared module and installation sources before starting the recovered game." - } else { + default: return nil, fmt.Errorf("claim %s must declare backupRole data or rebuildable", name) } m.Volumes = append(m.Volumes, v) diff --git a/operator/internal/controller/recovery_policy_envtest_test.go b/operator/internal/controller/recovery_policy_envtest_test.go index 5850eb0f5..fcb253980 100644 --- a/operator/internal/controller/recovery_policy_envtest_test.go +++ b/operator/internal/controller/recovery_policy_envtest_test.go @@ -3,10 +3,11 @@ package controller import ( + "testing" + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "testing" ) func TestCRDValidation_RecoveryPoliciesRejectCSI(t *testing.T) { diff --git a/operator/internal/controller/recovery_test.go b/operator/internal/controller/recovery_test.go index 70c12a84f..982dceccb 100644 --- a/operator/internal/controller/recovery_test.go +++ b/operator/internal/controller/recovery_test.go @@ -1,11 +1,12 @@ package controller import ( + "strings" + "testing" + v1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "strings" - "testing" ) // Runtime execution is CI-only; these regression sources precede implementation. diff --git a/test/e2e/backup_forget_e2e_test.go b/test/e2e/backup_forget_e2e_test.go index 9a5cb307f..1e0e92232 100644 --- a/test/e2e/backup_forget_e2e_test.go +++ b/test/e2e/backup_forget_e2e_test.go @@ -102,9 +102,14 @@ func TestBackup_DeleteForgetsSnapshot(t *testing.T) { // deleteBackupCR deletes a Backup CR, tolerating one that is already gone. func deleteBackupCR(t *testing.T, ns, name string) { + t.Helper() + deleteBackupCRContext(context.Background(), t, ns, name) +} + +func deleteBackupCRContext(ctx context.Context, t *testing.T, ns, name string) { t.Helper() err := envInstance.Dyn.Resource(backupGVR).Namespace(ns). - Delete(context.Background(), name, metav1.DeleteOptions{}) + Delete(ctx, name, metav1.DeleteOptions{}) if err != nil && !apierrors.IsNotFound(err) { t.Fatalf("delete backup %s/%s: %v", ns, name, err) } diff --git a/test/e2e/recovery_multivolume_e2e_test.go b/test/e2e/recovery_multivolume_e2e_test.go index f0d8e2686..3e5a8b532 100644 --- a/test/e2e/recovery_multivolume_e2e_test.go +++ b/test/e2e/recovery_multivolume_e2e_test.go @@ -67,8 +67,12 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { if _, err := envInstance.Dyn.Resource(backupGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{}); err != nil { t.Fatal(err) } - t.Cleanup(func() { deleteBackupCR(t, ns, name) }) - waitBackupSucceeded(t, ns, name, 6*time.Minute) + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + deleteBackupCRContext(cleanupCtx, t, ns, name) + }) + waitBackupSucceededContext(ctx, t, ns, name, 6*time.Minute) } capture(backup) ready(gs) @@ -97,8 +101,8 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { } exec("echo changed > /data/marker; echo changed > /world/marker; echo changed > /data/mods/marker; echo cache-after > /rebuild/marker; touch /data/stale /world/stale /data/mods/stale; sync") restore := gs + "-restore" - createRestore(t, ns, restore, gs, backup) - waitRestoreSucceeded(t, ns, restore, 6*time.Minute) + createRestoreContext(ctx, t, ns, restore, gs, backup) + waitRestoreSucceededContext(ctx, t, ns, restore, 6*time.Minute) ready(gs) exec("test $(cat /data/marker) = primary-before; test $(cat /world/marker) = extra-before; test $(cat /data/mods/marker) = mods-before; test $(cat /rebuild/marker) = cache-after; test ! -e /data/stale; test ! -e /world/stale; test ! -e /data/mods/stale") @@ -121,8 +125,8 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { }) capture(gs + "-" + mode) name := gs + "-restore-" + mode - createRestore(t, ns, name, gs, backup) - waitRestoreSucceeded(t, ns, name, 6*time.Minute) + createRestoreContext(ctx, t, ns, name, gs, backup) + waitRestoreSucceededContext(ctx, t, ns, name, 6*time.Minute) envInstance.Eventually(t, time.Minute, func() (bool, string) { obj, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Get(ctx, gs, metav1.GetOptions{}) if err != nil { @@ -164,7 +168,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { }) ready(target) name := target + "-restore" - createRestore(t, ns, name, target, backup) + createRestoreContext(ctx, t, ns, name, target, backup) envInstance.Eventually(t, time.Minute, func() (bool, string) { rs, err := envInstance.Dyn.Resource(restoreGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { diff --git a/test/e2e/test_helpers_e2e_test.go b/test/e2e/test_helpers_e2e_test.go index 66056840f..2573cf063 100644 --- a/test/e2e/test_helpers_e2e_test.go +++ b/test/e2e/test_helpers_e2e_test.go @@ -164,7 +164,11 @@ func createBackup(t *testing.T, ns, name, gsName, repoSecretName, repoKey string // createRestore applies a Restore CR and registers cleanup on t. func createRestore(t *testing.T, ns, name, gsName, backupName string) *unstructured.Unstructured { t.Helper() - ctx := context.Background() + return createRestoreContext(context.Background(), t, ns, name, gsName, backupName) +} + +func createRestoreContext(ctx context.Context, t *testing.T, ns, name, gsName, backupName string) *unstructured.Unstructured { + t.Helper() rs := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "gameplane.local/v1alpha1", "kind": "Restore", @@ -180,8 +184,10 @@ func createRestore(t *testing.T, ns, name, gsName, backupName string) *unstructu t.Fatalf("create restore %s/%s: %v", ns, name, err) } t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() _ = envInstance.Dyn.Resource(restoreGVR).Namespace(ns). - Delete(context.Background(), name, metav1.DeleteOptions{}) + Delete(cleanupCtx, name, metav1.DeleteOptions{}) }) return created } @@ -191,7 +197,11 @@ func createRestore(t *testing.T, ns, name, gsName, backupName string) *unstructu // terminal Failed phase. func waitBackupSucceeded(t *testing.T, ns, name string, timeout time.Duration) string { t.Helper() - ctx := context.Background() + return waitBackupSucceededContext(context.Background(), t, ns, name, timeout) +} + +func waitBackupSucceededContext(ctx context.Context, t *testing.T, ns, name string, timeout time.Duration) string { + t.Helper() var snapshotID string envInstance.Eventually(t, timeout, func() (bool, string) { got, err := envInstance.Dyn.Resource(backupGVR).Namespace(ns). @@ -222,7 +232,11 @@ func waitBackupSucceeded(t *testing.T, ns, name string, timeout time.Duration) s // Fails the test on timeout or on a terminal Failed phase. func waitRestoreSucceeded(t *testing.T, ns, name string, timeout time.Duration) { t.Helper() - ctx := context.Background() + waitRestoreSucceededContext(context.Background(), t, ns, name, timeout) +} + +func waitRestoreSucceededContext(ctx context.Context, t *testing.T, ns, name string, timeout time.Duration) { + t.Helper() envInstance.Eventually(t, timeout, func() (bool, string) { got, err := envInstance.Dyn.Resource(restoreGVR).Namespace(ns). Get(ctx, name, metav1.GetOptions{}) From 5cd929a305a57892b7b2e700db4fdca2901db6f4 Mon Sep 17 00:00:00 2001 From: Baylem Date: Mon, 9 Nov 2026 16:12:30 -0500 Subject: [PATCH 3/7] docs(design): align backup coverage recovery reference Signed-off-by: Baylem --- assets/design-export/json/bHJy6.json | 2 +- assets/design-export/screenshots/bHJy6.png | Bin 52422 -> 55931 bytes design.pen | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/design-export/json/bHJy6.json b/assets/design-export/json/bHJy6.json index 84d34efaf..61db38266 100644 --- a/assets/design-export/json/bHJy6.json +++ b/assets/design-export/json/bHJy6.json @@ -50,7 +50,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Only the primary data claim is copied. Rebuildable is a declaration, not proof that recovery can reconstruct its contents.", + "content": "Included claims are copied at the recorded archive roots. Rebuildable volumes are excluded. Check their recovery prerequisites before restarting.", "lineHeight": 1.333333, "fontFamily": "$typography/font-sans", "fontSize": 12, diff --git a/assets/design-export/screenshots/bHJy6.png b/assets/design-export/screenshots/bHJy6.png index 4f6308960534f05eb77022e62ea32eadbcde23e8..fac38b334f10d4ce84527e36365c8ed99a7dc6d7 100644 GIT binary patch literal 55931 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 z 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%0D&98rc079tjMkX5e!=M^z6sjY-f4UFXdj> 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 NUC=w9_pfE({{x9?Qt Date: Mon, 9 Nov 2026 16:23:35 -0500 Subject: [PATCH 4/7] fix(restore): scope recovery to the archived volume subtree Signed-off-by: Baylem --- operator/internal/controller/recovery_test.go | 54 +++++++++++++++++++ .../internal/controller/restore_controller.go | 7 ++- .../contracts/recovery.md | 2 + 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/operator/internal/controller/recovery_test.go b/operator/internal/controller/recovery_test.go index 982dceccb..b4d9cc3a1 100644 --- a/operator/internal/controller/recovery_test.go +++ b/operator/internal/controller/recovery_test.go @@ -1,6 +1,7 @@ package controller import ( + "path" "strings" "testing" @@ -88,3 +89,56 @@ func TestSelectedClaimDrainMatchesForeignClaims(t *testing.T) { t.Fatal("foreign claim writer ignored") } } + +// Restic restores metadata on every ancestor of selected files, even when the +// ancestor is not itself included. Selecting the /volumes subtree makes that +// ancestor the metadata-free restore target instead of a read-only image path. +func TestRecoveryRestoreScopesArchiveSubtreeToMountedClaims(t *testing.T) { + volumes := []v1.BackupVolumeCoverage{ + {ClaimName: "world", LogicalName: "primary", SnapshotRoot: "/volumes/primary", Included: true}, + {ClaimName: "mods", LogicalName: "mods-v1", SnapshotRoot: "/volumes/mods-v1", Included: true}, + {ClaimName: "cache", LogicalName: "extra-cache", Included: false}, + } + rs := &v1.Restore{Status: v1.RestoreStatus{SnapshotID: "0123456789abcdef", RecoveryPlan: &v1.RestoreRecoveryPlan{Volumes: volumes}}} + src := &v1.Backup{Spec: v1.BackupSpec{RepoRef: &v1.SecretKeySelector{Name: "repo"}}} + spec := (&RestoreReconciler{}).buildRestorePodSpec(rs, src, &v1.GameTemplate{}) + worker := spec.Containers[0] + if worker.SecurityContext.ReadOnlyRootFilesystem == nil || !*worker.SecurityContext.ReadOnlyRootFilesystem { + t.Fatal("restore weakened container root filesystem protection") + } + if worker.Args[1] != rs.Status.SnapshotID+":/volumes" { + t.Fatalf("restore would apply archive ancestor metadata: %v", worker.Args) + } + target := "" + includes := []string{} + deleteEnabled := false + for i, arg := range worker.Args { + switch arg { + case "--target": + target = worker.Args[i+1] + case "--include": + includes = append(includes, worker.Args[i+1]) + case "--delete": + deleteEnabled = true + } + } + if target != "/volumes" || len(includes) != 2 || !deleteEnabled { + t.Fatalf("incorrect restore scope: %v", worker.Args) + } + mounted := map[string]bool{} + for _, mount := range worker.VolumeMounts { + if mount.MountPath != "/tmp" { + mounted[mount.MountPath] = !mount.ReadOnly + } + } + for _, include := range includes { + if strings.ContainsAny(include, "*?[") || !mounted[path.Join(target, include)] { + t.Fatalf("include %q does not resolve to an exact writable PVC: %v", include, mounted) + } + } + for _, volume := range spec.Volumes { + if volume.PersistentVolumeClaim != nil && volume.PersistentVolumeClaim.ClaimName == "cache" { + t.Fatal("restore mounted excluded rebuildable claim") + } + } +} diff --git a/operator/internal/controller/restore_controller.go b/operator/internal/controller/restore_controller.go index 7d78330da..222219068 100644 --- a/operator/internal/controller/restore_controller.go +++ b/operator/internal/controller/restore_controller.go @@ -450,10 +450,13 @@ func (r *RestoreReconciler) buildRestorePodSpec( } if rs.Status.RecoveryPlan != nil { recoveryWorkerVolumes(&spec, rs.Status.RecoveryPlan.Volumes, false) - args := []string{"restore", rs.Status.SnapshotID, "--target", "/", "--delete", "--retry-lock", "10m"} + // Restore the archive subtree itself so restic never applies the + // archived /volumes ancestor metadata to the read-only container root. + // Includes are relative to that subtree; physical PVC paths stay fixed. + args := []string{"restore", rs.Status.SnapshotID + ":/volumes", "--target", "/volumes", "--delete", "--retry-lock", "10m"} for _, v := range rs.Status.RecoveryPlan.Volumes { if v.Included { - args = append(args, "--include", v.SnapshotRoot) + args = append(args, "--include", "/"+v.LogicalName) } } spec.Containers[0].Args = args diff --git a/specs/032-stopped-multivolume-recovery/contracts/recovery.md b/specs/032-stopped-multivolume-recovery/contracts/recovery.md index 393c23e99..541388673 100644 --- a/specs/032-stopped-multivolume-recovery/contracts/recovery.md +++ b/specs/032-stopped-multivolume-recovery/contracts/recovery.md @@ -8,3 +8,5 @@ 6. Restore preflight precedes suspension. Persist mapping and digest; compare again immediately before Job creation and before authorization of the ready mounted worker. Writable mounts and --delete scopes are exactly validated included roots. Legacy captures explicitly retain historical primary-only restore. CI acceptance includes running/suspended/idle backup, stop/copy failure, cancellation and owner loss, claim replacement, changed paths, missing required extras, rebuildable exclusion, malicious roots, legacy and CSI compatibility; Kind round-trip covers primary + extra + separate mod volume. + +Restic v2 restore selects `:/volumes` with target `/volumes`; each validated root `/volumes/` receives the subtree-relative include `/`. This preserves the exact mounted PVC paths and scoped deletion while preventing restoration of archived ancestor metadata onto the container's read-only `/volumes` directory. The snapshot and persisted manifest retain their original absolute roots. From 9bb761d034bb6a1a1d93c113c0e7cc0067540324 Mon Sep 17 00:00:00 2001 From: Baylem Date: Mon, 9 Nov 2026 16:51:05 -0500 Subject: [PATCH 5/7] fix(operator): preserve safe restore worker failure diagnostics Signed-off-by: Baylem --- .../controller/recovery_controller_test.go | 26 ++++++++++ operator/internal/controller/recovery_test.go | 3 ++ .../internal/controller/restore_controller.go | 48 +++++++++++++++++-- .../internal/controller/restore_recovery.go | 2 +- test/e2e/recovery_multivolume_e2e_test.go | 47 ++++++++++++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/operator/internal/controller/recovery_controller_test.go b/operator/internal/controller/recovery_controller_test.go index 5b05e7d40..2dc21d2b1 100644 --- a/operator/internal/controller/recovery_controller_test.go +++ b/operator/internal/controller/recovery_controller_test.go @@ -1025,3 +1025,29 @@ func TestRecoveryQuiesceOutcomeFinalizedOnlyBeforeFirstAuthorization(t *testing. t.Fatal("live multi-volume copy overclaimed consistency") } } + +func TestRestoreFailurePreservesExactWorkerDiagnosticsBeforeCleanup(t *testing.T) { + c, _, _, rs := recoveryFixture(t) + r := &RestoreReconciler{Client: c, APIReader: c, Scheme: c.Scheme()} + job := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "restore-worker", Namespace: rs.Namespace, UID: "worker-job"}, Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{{Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded", Message: "Job has reached its retry limit"}}}} + controller := true + for _, tc := range []struct{ name, owner, message string }{{"ours", "worker-job", "Fatal: repository https://user:secret@example.invalid failed"}, {"foreign", "replaced-job", "foreign diagnostic must not leak"}} { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: tc.name, Namespace: rs.Namespace, OwnerReferences: []metav1.OwnerReference{{APIVersion: "batch/v1", Kind: "Job", Name: job.Name, UID: types.UID(tc.owner), Controller: &controller}}}, Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{Name: "restic", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1, Reason: "Error", Message: tc.message}}}}}} + if err := c.Create(t.Context(), pod); err != nil { + t.Fatal(err) + } + } + message := r.restoreJobFailure(t.Context(), job).Error() + if !strings.Contains(message, "BackoffLimitExceeded") || !strings.Contains(message, "exited 1 (Error)") || strings.Contains(message, "secret") || strings.Contains(message, "repository") || strings.Contains(message, "foreign diagnostic") { + t.Fatalf("incomplete or foreign failure evidence: %s", message) + } + if _, err := r.fail(t.Context(), rs, message); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(rs), rs); err != nil { + t.Fatal(err) + } + if rs.Status.Phase != v1.RestorePhaseFailed || rs.Status.Message != message { + t.Fatalf("cleanup lost persisted worker failure: %+v", rs.Status) + } +} diff --git a/operator/internal/controller/recovery_test.go b/operator/internal/controller/recovery_test.go index b4d9cc3a1..b5a811a33 100644 --- a/operator/internal/controller/recovery_test.go +++ b/operator/internal/controller/recovery_test.go @@ -103,6 +103,9 @@ func TestRecoveryRestoreScopesArchiveSubtreeToMountedClaims(t *testing.T) { src := &v1.Backup{Spec: v1.BackupSpec{RepoRef: &v1.SecretKeySelector{Name: "repo"}}} spec := (&RestoreReconciler{}).buildRestorePodSpec(rs, src, &v1.GameTemplate{}) worker := spec.Containers[0] + if worker.TerminationMessagePolicy != corev1.TerminationMessageFallbackToLogsOnError { + t.Fatal("restore failure would lose worker stderr before cleanup") + } if worker.SecurityContext.ReadOnlyRootFilesystem == nil || !*worker.SecurityContext.ReadOnlyRootFilesystem { t.Fatal("restore weakened container root filesystem protection") } diff --git a/operator/internal/controller/restore_controller.go b/operator/internal/controller/restore_controller.go index 222219068..68c0eeb47 100644 --- a/operator/internal/controller/restore_controller.go +++ b/operator/internal/controller/restore_controller.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" batchv1 "k8s.io/api/batch/v1" @@ -333,7 +334,7 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct case jobPermanentlyFailed(job): // Leave the server suspended; surface the failure. - return r.fail(ctx, &rs, "restore job reported Failed") + return r.fail(ctx, &rs, r.restoreJobFailure(ctx, job).Error()) default: if rs.Status.StartTime == nil && job.Status.StartTime != nil { @@ -346,6 +347,44 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct } } +// Preserve worker failure evidence in Restore status before cleanup deletes +// the Job and its Pods. Only diagnostics from this exact Job are admitted. +// Raw termination messages can contain repository credentials and remain on Pods. +func (r *RestoreReconciler) restoreJobFailure(ctx context.Context, job *batchv1.Job) error { + details := []string{"restore job reported Failed"} + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobFailed && condition.Status == corev1.ConditionTrue { + details = append(details, condition.Reason) + } + } + if job.UID == "" { + return errors.New(strings.Join(details, "; ")) + } + var pods corev1.PodList + if err := r.apiReader().List(ctx, &pods, client.InNamespace(job.Namespace)); err != nil { + details = append(details, "worker diagnostics unavailable") + return errors.New(strings.Join(details, "; ")) + } + for i := range pods.Items { + pod := &pods.Items[i] + if !metav1.IsControlledBy(pod, job) { + continue + } + statuses := append(append([]corev1.ContainerStatus{}, pod.Status.InitContainerStatuses...), pod.Status.ContainerStatuses...) + for _, status := range statuses { + terminated := status.State.Terminated + if terminated == nil || terminated.ExitCode == 0 { + continue + } + details = append(details, fmt.Sprintf("Pod %s container %s exited %d (%s)", pod.Name, status.Name, terminated.ExitCode, terminated.Reason)) + if len(details) >= 5 { + return errors.New(strings.Join(details, "; ")) + } + } + } + return errors.New(strings.Join(details, "; ")) +} + func (r *RestoreReconciler) fail(ctx context.Context, rs *gameplanev1alpha1.Restore, msg string) (ctrl.Result, error) { now := metav1.Now() rs.Status.Phase = gameplanev1alpha1.RestorePhaseFailed @@ -385,9 +424,10 @@ func (r *RestoreReconciler) buildRestorePodSpec( spec := corev1.PodSpec{ SecurityContext: buildBackupPodSecurityContext(tmpl), Containers: []corev1.Container{{ - Name: "restic", - Image: resticImageOrDefault(r.ResticImage), - Resources: auxiliaryResources(r.BackupResources, "100m", "128Mi"), + Name: "restic", + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, + Image: resticImageOrDefault(r.ResticImage), + Resources: auxiliaryResources(r.BackupResources, "100m", "128Mi"), // --target / restores each snapshot entry at its original // absolute path. The companion backup runs `restic backup // /data`, so the snapshot tree is rooted at /data/...; with diff --git a/operator/internal/controller/restore_recovery.go b/operator/internal/controller/restore_recovery.go index 05fc4b96c..173eb0c27 100644 --- a/operator/internal/controller/restore_recovery.go +++ b/operator/internal/controller/restore_recovery.go @@ -210,7 +210,7 @@ func (r *RestoreReconciler) authorizeRecoveryRestore(ctx context.Context, rs *v1 return true, nil } if jobPermanentlyFailed(job) { - return false, fmt.Errorf("restore job reported Failed") + return false, r.restoreJobFailure(ctx, job) } if !time.Now().Before(deadline) { return false, fmt.Errorf("restore copy deadline expired") diff --git a/test/e2e/recovery_multivolume_e2e_test.go b/test/e2e/recovery_multivolume_e2e_test.go index 3e5a8b532..d409fa2c0 100644 --- a/test/e2e/recovery_multivolume_e2e_test.go +++ b/test/e2e/recovery_multivolume_e2e_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + 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/apis/meta/v1/unstructured" @@ -101,6 +102,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { } exec("echo changed > /data/marker; echo changed > /world/marker; echo changed > /data/mods/marker; echo cache-after > /rebuild/marker; touch /data/stale /world/stale /data/mods/stale; sync") restore := gs + "-restore" + watchRecoveryRestoreWorkers(t, ctx, ns, restore) createRestoreContext(ctx, t, ns, restore, gs, backup) waitRestoreSucceededContext(ctx, t, ns, restore, 6*time.Minute) ready(gs) @@ -125,6 +127,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { }) capture(gs + "-" + mode) name := gs + "-restore-" + mode + watchRecoveryRestoreWorkers(t, ctx, ns, name) createRestoreContext(ctx, t, ns, name, gs, backup) waitRestoreSucceededContext(ctx, t, ns, name, 6*time.Minute) envInstance.Eventually(t, time.Minute, func() (bool, string) { @@ -168,6 +171,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { }) ready(target) name := target + "-restore" + watchRecoveryRestoreWorkers(t, ctx, ns, name) createRestoreContext(ctx, t, ns, name, target, backup) envInstance.Eventually(t, time.Minute, func() (bool, string) { rs, err := envInstance.Dyn.Resource(restoreGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) @@ -191,3 +195,46 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { }) } } + +// Start before creating the Restore so foreground cleanup cannot erase the +// failed worker evidence. This fixture uses only its disposable test repository; +// raw termination messages are deliberately confined to CI diagnostics. +func watchRecoveryRestoreWorkers(t *testing.T, ctx context.Context, ns, restoreName string) { + t.Helper() + watchCtx, cancel := context.WithCancel(ctx) + workerWatch, err := envInstance.K8s.CoreV1().Pods(ns).Watch(watchCtx, metav1.ListOptions{LabelSelector: "job-name=restore-" + restoreName}) + if err != nil { + cancel() + t.Fatalf("watch restore workers: %v", err) + } + done := make(chan struct{}) + go func() { + defer close(done) + for event := range workerWatch.ResultChan() { + pod, ok := event.Object.(*corev1.Pod) + if !ok { + continue + } + for _, status := range pod.Status.ContainerStatuses { + if status.State.Terminated == nil && !status.Ready { + continue + } + state, _ := json.Marshal(status) + t.Logf("restore worker %s event=%s uid=%s phase=%s status=%s", pod.Name, event.Type, pod.UID, pod.Status.Phase, state) + // Capture the persisted gate identity and deadline alongside the + // worker exit to distinguish preparation from authorized copying. + readCtx, cancelRead := context.WithTimeout(watchCtx, 5*time.Second) + rs, err := envInstance.Dyn.Resource(restoreGVR).Namespace(ns).Get(readCtx, restoreName, metav1.GetOptions{}) + cancelRead() + if err == nil { + t.Logf("restore %s authorization=%s deadline=%s", restoreName, rs.GetAnnotations()["restore.gameplane.local/authorized-worker"], rs.GetAnnotations()["restore.gameplane.local/copy-deadline"]) + } + } + } + }() + t.Cleanup(func() { + cancel() + workerWatch.Stop() + <-done + }) +} From e56afce5b5b2ee450faaf8cff27487fadb418344 Mon Sep 17 00:00:00 2001 From: Baylem Date: Mon, 9 Nov 2026 17:17:15 -0500 Subject: [PATCH 6/7] test(e2e): use writable recovery fixture identity Signed-off-by: Baylem --- docs/operator-operations.md | 13 +++++++++++++ .../contracts/recovery.md | 2 ++ test/e2e/recovery_multivolume_e2e_test.go | 14 +++++++++----- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/operator-operations.md b/docs/operator-operations.md index d286e022b..6f654ab6e 100644 --- a/docs/operator-operations.md +++ b/docs/operator-operations.md @@ -74,6 +74,19 @@ uses the lifecycle guard to stop without changing user suspend intent. Success releases the guard and preserves current suspend/idle/restart intent; failed or cancelled writes leave the target suspended for inspection. +Restic backup and restore workers run as UID 65532, with the template's +`security.fsGroup` (65532 by default). Source files must be readable by that +identity; restore requires writable existing files and writable/searchable +parent directories on every included claim. A successful backup does not prove +that an in-place restore can overwrite existing files. Configure ownership, +group permissions or ACLs for the worker before recovery. `fsGroup` relies on the +storage driver applying ownership; hostPath-based provisioners may not do so. +For example, a root-owned mode-0644 file remains unwritable to this worker even +when its parent directory is writable. Game containers may use other UIDs when +their storage permissions provide the required access. Permission failures are +reported as failed restores and leave the target suspended; recovery does not +elevate worker privileges or bypass filesystem permissions. + Legacy primary-only snapshots retain `/data` restore semantics. Missing historical metadata or `LegacyUnknown` never receives fabricated coverage. All-data archives cannot downgrade into that legacy path. Inspect original module/server settings diff --git a/specs/032-stopped-multivolume-recovery/contracts/recovery.md b/specs/032-stopped-multivolume-recovery/contracts/recovery.md index 541388673..e80fd21e7 100644 --- a/specs/032-stopped-multivolume-recovery/contracts/recovery.md +++ b/specs/032-stopped-multivolume-recovery/contracts/recovery.md @@ -10,3 +10,5 @@ CI acceptance includes running/suspended/idle backup, stop/copy failure, cancellation and owner loss, claim replacement, changed paths, missing required extras, rebuildable exclusion, malicious roots, legacy and CSI compatibility; Kind round-trip covers primary + extra + separate mod volume. Restic v2 restore selects `:/volumes` with target `/volumes`; each validated root `/volumes/` receives the subtree-relative include `/`. This preserves the exact mounted PVC paths and scoped deletion while preventing restoration of archived ancestor metadata onto the container's read-only `/volumes` directory. The snapshot and persisted manifest retain their original absolute roots. + +Recovery retains the existing non-root Restic worker identity (UID 65532 with the template fsGroup). Included data must grant it backup read access and restore write access to existing files and parent directories. Storage-driver ownership behavior is a deployment prerequisite; manifest identity validation does not establish filesystem permissions. Failed permission checks during copying keep the target suspended and never trigger privilege escalation. diff --git a/test/e2e/recovery_multivolume_e2e_test.go b/test/e2e/recovery_multivolume_e2e_test.go index d409fa2c0..fdfb3e949 100644 --- a/test/e2e/recovery_multivolume_e2e_test.go +++ b/test/e2e/recovery_multivolume_e2e_test.go @@ -29,7 +29,11 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { backup := gs + "-backup" ensureResticRepo(t) applyBusyboxTemplate(t, tmpl) + // Kind's hostPath provisioner does not apply fsGroup ownership. Run the + // fixture as the existing Restic worker UID so overwriting existing files + // exercises recovery rather than unsupported root-owned file permissions. patch := map[string]any{"spec": map[string]any{ + "security": map[string]any{"runAsUser": int64(65532), "runAsGroup": int64(65532), "fsGroup": int64(65532)}, "storage": map[string]any{"size": "1Gi", "mountPath": "/data", "extra": []any{map[string]any{"name": "world", "mountPath": "/world", "size": "1Gi", "backupRole": "data"}, map[string]any{"name": "cache", "mountPath": "/rebuild", "size": "1Gi", "backupRole": "rebuildable"}}}, "versions": []any{map[string]any{"id": "v1", "displayName": "Test loader", "image": "busybox:1.36", "loader": "paper", "default": true}}, "capabilities": map[string]any{"mods": map[string]any{"loaders": map[string]any{"paper": map[string]any{"path": "mods"}}}}, @@ -61,7 +65,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { } } ready(gs) - exec("echo primary-before > /data/marker; echo extra-before > /world/marker; echo mods-before > /data/mods/marker; echo cache-before > /rebuild/marker; sync") + exec("test $(id -u) = 65532; test $(id -g) = 65532; echo primary-before > /data/marker; echo extra-before > /world/marker; echo mods-before > /data/mods/marker; echo cache-before > /rebuild/marker; sync") capture := func(name string) { t.Helper() obj := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "gameplane.local/v1alpha1", "kind": "Backup", "metadata": map[string]any{"name": name, "namespace": ns}, "spec": map[string]any{"serverRef": map[string]any{"name": gs}, "repoRef": map[string]any{"name": "e2e-restic-creds", "key": "repo"}, "quiesce": false, "powerPolicy": "stop-copy-restart", "volumePolicy": "all-data"}}} @@ -102,7 +106,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { } exec("echo changed > /data/marker; echo changed > /world/marker; echo changed > /data/mods/marker; echo cache-after > /rebuild/marker; touch /data/stale /world/stale /data/mods/stale; sync") restore := gs + "-restore" - watchRecoveryRestoreWorkers(t, ctx, ns, restore) + watchRecoveryRestoreWorkers(ctx, t, ns, restore) createRestoreContext(ctx, t, ns, restore, gs, backup) waitRestoreSucceededContext(ctx, t, ns, restore, 6*time.Minute) ready(gs) @@ -127,7 +131,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { }) capture(gs + "-" + mode) name := gs + "-restore-" + mode - watchRecoveryRestoreWorkers(t, ctx, ns, name) + watchRecoveryRestoreWorkers(ctx, t, ns, name) createRestoreContext(ctx, t, ns, name, gs, backup) waitRestoreSucceededContext(ctx, t, ns, name, 6*time.Minute) envInstance.Eventually(t, time.Minute, func() (bool, string) { @@ -171,7 +175,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { }) ready(target) name := target + "-restore" - watchRecoveryRestoreWorkers(t, ctx, ns, name) + watchRecoveryRestoreWorkers(ctx, t, ns, name) createRestoreContext(ctx, t, ns, name, target, backup) envInstance.Eventually(t, time.Minute, func() (bool, string) { rs, err := envInstance.Dyn.Resource(restoreGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) @@ -199,7 +203,7 @@ func TestBackup_MultiVolumeRecoveryRoundTrip(t *testing.T) { // Start before creating the Restore so foreground cleanup cannot erase the // failed worker evidence. This fixture uses only its disposable test repository; // raw termination messages are deliberately confined to CI diagnostics. -func watchRecoveryRestoreWorkers(t *testing.T, ctx context.Context, ns, restoreName string) { +func watchRecoveryRestoreWorkers(ctx context.Context, t *testing.T, ns, restoreName string) { t.Helper() watchCtx, cancel := context.WithCancel(ctx) workerWatch, err := envInstance.K8s.CoreV1().Pods(ns).Watch(watchCtx, metav1.ListOptions{LabelSelector: "job-name=restore-" + restoreName}) From 9dc4d672b85ed7f4771cba516fdc0dec22a60d2b Mon Sep 17 00:00:00 2001 From: Baylem Date: Mon, 9 Nov 2026 23:48:36 -0500 Subject: [PATCH 7/7] fix(recovery): address stopped backup review feedback Signed-off-by: Baylem --- assets/design-export/json/bHJy6.json | 2 +- assets/design-export/screenshots/bHJy6.png | Bin 55931 -> 92436 bytes design.pen | 2 +- docs/operator-operations.md | 1 + i18n/locales/en/app.json | 4 +- i18n/locales/fr/.review.json | 2 +- i18n/locales/fr/app.json | 4 +- operator/internal/controller/backup_stop.go | 11 +- .../controller/recovery_controller_test.go | 101 ++++++++++++++++++ operator/specs.md | 10 +- .../OPEN-DECISIONS.md | 0 .../contracts/recovery.md | 0 .../data-model.md | 0 .../spec.md | 0 .../tasks.md | 0 test/e2e/recovery_multivolume_e2e_test.go | 2 +- web/specs.md | 1 + .../backups/BackupDetailDrawer.test.tsx | 12 ++- .../components/backups/BackupDetailDrawer.tsx | 2 +- 19 files changed, 137 insertions(+), 17 deletions(-) rename specs/{032-stopped-multivolume-recovery => 033-stopped-multivolume-recovery}/OPEN-DECISIONS.md (100%) rename specs/{032-stopped-multivolume-recovery => 033-stopped-multivolume-recovery}/contracts/recovery.md (100%) rename specs/{032-stopped-multivolume-recovery => 033-stopped-multivolume-recovery}/data-model.md (100%) rename specs/{032-stopped-multivolume-recovery => 033-stopped-multivolume-recovery}/spec.md (100%) rename specs/{032-stopped-multivolume-recovery => 033-stopped-multivolume-recovery}/tasks.md (100%) diff --git a/assets/design-export/json/bHJy6.json b/assets/design-export/json/bHJy6.json index 61db38266..1a32b2e8f 100644 --- a/assets/design-export/json/bHJy6.json +++ b/assets/design-export/json/bHJy6.json @@ -50,7 +50,7 @@ "fill": "$muted", "textGrowth": "fixed-width", "width": "fill_container", - "content": "Included claims are copied at the recorded archive roots. Rebuildable volumes are excluded. Check their recovery prerequisites before restarting.", + "content": "Included claims are copied at the recorded archive roots. Primary-only backups exclude extra data volumes and separate mod claims. Rebuildable volumes are excluded. Check recovery prerequisites before restarting.", "lineHeight": 1.333333, "fontFamily": "$typography/font-sans", "fontSize": 12, diff --git a/assets/design-export/screenshots/bHJy6.png b/assets/design-export/screenshots/bHJy6.png index fac38b334f10d4ce84527e36365c8ed99a7dc6d7..b62cbd0ecfa6f6df9e3291ba2e4178650c2ce48d 100644 GIT binary patch literal 92436 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 z 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 { - it("shows captured and excluded claims independently of copy success", async () => { + it.each([ + { policy: "legacy/default primary", volumePolicy: undefined }, + { policy: "explicit primary-only", volumePolicy: "primary-only" as const }, + ])("shows captured and excluded claims for $policy independently of copy success", async ({ volumePolicy }) => { server.use(http.get("/backups/coverage", () => HttpResponse.json(makeBackup({ metadata: { name: "coverage" }, status: { phase: "Succeeded", snapshotID: "copy", conditions: [{ type: "Consistency", status: "False", reason: "UnsupportedRawCopy", message: "The game does not support quiesce." }], - captureManifest: { state: "Recorded", recordedAt: "2026-10-21T12:00:00Z", volumes: [ + captureManifest: { state: "Recorded", volumePolicy, recordedAt: "2026-10-21T12:00:00Z", volumes: [ { claimName: "game-data", claimUID: "world-uid", mountPaths: ["/world"], included: true, classification: "data", reason: "PrimaryDataClaim" }, + { claimName: "game-extra-data", mountPaths: ["/extra-data"], included: false, classification: "data", reason: "NotCovered" }, { claimName: "game-extra-install", mountPaths: ["/serverfiles"], included: false, classification: "rebuildable", reason: "NotCovered" }, { claimName: "game-mods", mountPaths: ["/world/mods"], included: false, classification: "unclassified", reason: "NotCovered" }, ] }, @@ -23,9 +27,11 @@ describe("BackupDetailDrawer", () => { })))); renderWithQuery( {}} onRestore={() => {}} />); expect(await screen.findByText("The game does not support quiesce.")).toBeInTheDocument(); + expect(screen.getByText("Included claims are copied at the recorded archive roots. Primary-only backups exclude extra data volumes and separate mod claims. Rebuildable volumes are excluded. Check recovery prerequisites before restarting.")).toBeInTheDocument(); expect(screen.getByText("game-data")).toBeInTheDocument(); + expect(screen.getByText("game-extra-data")).toBeInTheDocument(); expect(screen.getByText("/world/mods")).toBeInTheDocument(); - expect(screen.getAllByText("Not covered")).toHaveLength(2); + expect(screen.getAllByText("Not covered")).toHaveLength(3); expect(screen.getByText("Rebuildable")).toBeInTheDocument(); }); diff --git a/web/src/components/backups/BackupDetailDrawer.tsx b/web/src/components/backups/BackupDetailDrawer.tsx index a4ecce8cd..d81969f88 100644 --- a/web/src/components/backups/BackupDetailDrawer.tsx +++ b/web/src/components/backups/BackupDetailDrawer.tsx @@ -89,7 +89,7 @@ export function BackupDetailDrawer({ name, ns, onClose, onRestore, target: expli {manifest?.state === "Recorded" ? (
{manifest.recordedAt &&
{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.coverage_recorded", defaultMessage: "Recorded: {time}", description: "Backup details: timestamp of the immutable capture manifest" }, { time: manifest.recordedAt })}
} -

{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.coverage_scope", defaultMessage: "Included claims are copied at the recorded archive roots. Rebuildable volumes are excluded. Check their recovery prerequisites before restarting.", description: "Backup details: explains recorded volume coverage and excluded rebuildable recovery prerequisites" })}

+

{intl.formatMessage({ id: "web.components.backups.backup_detail_drawer.coverage_scope", defaultMessage: "Included claims are copied at the recorded archive roots. Primary-only backups exclude extra data volumes and separate mod claims. Rebuildable volumes are excluded. Check recovery prerequisites before restarting.", description: "Backup details: explains recorded volume coverage, primary-only exclusions, and recovery prerequisites for excluded volumes" })}

    {manifest.volumes?.map((volume) => (