From 6d11ed83045bcc6b61983b1e2163f9681fcc7f56 Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 00:16:23 -0500 Subject: [PATCH 1/5] fix(backups): distinguish verified freezes from historical hook success Signed-off-by: Baylem --- operator/internal/controller/backup_budgets_test.go | 2 +- operator/internal/controller/backup_controller.go | 2 ++ operator/internal/controller/backup_guard.go | 1 + operator/internal/controller/backup_guard_test.go | 2 +- operator/internal/controller/backup_manifest.go | 13 ++++++++++--- .../internal/controller/backup_manifest_test.go | 6 ++++-- .../internal/controller/recovery_controller_test.go | 4 ++-- 7 files changed, 21 insertions(+), 9 deletions(-) diff --git a/operator/internal/controller/backup_budgets_test.go b/operator/internal/controller/backup_budgets_test.go index 738274e96..1bda03dd8 100644 --- a/operator/internal/controller/backup_budgets_test.go +++ b/operator/internal/controller/backup_budgets_test.go @@ -79,7 +79,7 @@ func TestBackupWorkerPreparesBeforeQuiesceAndPinsAuthorization(t *testing.T) { worker := budgetReadyWorker(t, r, b.Name, "prepared-worker-uid") budgetPass(t, r, b.Name) b = guardGetBackup(t, r, b.Name) - if q.quiesced != 1 || b.Status.CaptureManifest == nil || b.Status.CaptureManifest.State != "Recorded" || b.Status.CaptureManifest.QuiesceOutcome != "confirmed" { + if q.quiesced != 1 || b.Status.CaptureManifest == nil || b.Status.CaptureManifest.State != "Recorded" || b.Status.CaptureManifest.QuiesceOutcome != "verified-freeze" { t.Fatalf("prepared copy not quiesced/recorded: %+v, %+v", q, b.Status) } if volumes := b.Status.CaptureManifest.Volumes; len(volumes) != 1 || volumes[0].ClaimName != "server-data" || !volumes[0].Included || volumes[0].Classification != "data" { diff --git a/operator/internal/controller/backup_controller.go b/operator/internal/controller/backup_controller.go index 9f61106a8..848d4be99 100644 --- a/operator/internal/controller/backup_controller.go +++ b/operator/internal/controller/backup_controller.go @@ -33,6 +33,7 @@ import ( // quiesce state across reconcile passes. const ( annoQuiesceAttempted = "backup.gameplane.local/quiesce-attempted" + annoVerifiedFreeze = "backup.gameplane.local/verified-freeze-pod" annoQuiescedAt = "backup.gameplane.local/quiesced-at" annoUnquiescedAt = "backup.gameplane.local/unquiesced-at" // annoUnquiesceRetryFirstFailedAt records when finalizeDelete's @@ -447,6 +448,7 @@ func (r *BackupReconciler) maybeQuiesce(ctx context.Context, b *gameplanev1alpha patchBackupAnnotations(b, map[string]string{ annoQuiesceAttempted: "true", annoQuiescedAt: now, + annoVerifiedFreeze: string(podUID), }) case errors.Is(err, agent.ErrUnsupported) && state != "pending": // Only a first, explicitly unsupported operation proves that no diff --git a/operator/internal/controller/backup_guard.go b/operator/internal/controller/backup_guard.go index b28da3f3a..09a0d97fe 100644 --- a/operator/internal/controller/backup_guard.go +++ b/operator/internal/controller/backup_guard.go @@ -130,6 +130,7 @@ func (r *BackupReconciler) recoverBackupTarget(ctx context.Context, b *gameplane } delete(b.Annotations, backupRecoveryAnnotation) delete(b.Annotations, annoQuiesceAttempted) + delete(b.Annotations, annoVerifiedFreeze) delete(b.Annotations, annoUnquiescedAt) if err := r.Update(ctx, b); err != nil { return false, err diff --git a/operator/internal/controller/backup_guard_test.go b/operator/internal/controller/backup_guard_test.go index 4aadc7634..491c7da0c 100644 --- a/operator/internal/controller/backup_guard_test.go +++ b/operator/internal/controller/backup_guard_test.go @@ -149,7 +149,7 @@ func TestBackupGuardSerializesAcrossBackupsAndRestart(t *testing.T) { t.Fatalf("overlapping protected copies: jobs=%d, agent=%+v", len(jobs.Items), q) } first := guardGetBackup(t, r, "first") - if first.Status.CaptureManifest == nil || first.Status.CaptureManifest.QuiesceOutcome != "confirmed" { + if first.Status.CaptureManifest == nil || first.Status.CaptureManifest.QuiesceOutcome != "verified-freeze" { t.Fatalf("worker started without confirmed capture-time metadata: %+v", first.Status) } if second := guardGetBackup(t, r, "second"); second.Status.CaptureManifest != nil { diff --git a/operator/internal/controller/backup_manifest.go b/operator/internal/controller/backup_manifest.go index 102faae64..d91fa9971 100644 --- a/operator/internal/controller/backup_manifest.go +++ b/operator/internal/controller/backup_manifest.go @@ -171,7 +171,12 @@ func backupQuiesceOutcome(b *gameplanev1alpha1.Backup) string { } switch b.Annotations[annoQuiesceAttempted] { case "true": - return "confirmed" + // Pre-upgrade hooks recorded success without verifying disabled writes. + preparation, err := parseBackupPreparation(b) + if err == nil && preparation != nil && preparation.Outcome == "live" && preparation.PodUID != "" && string(preparation.PodUID) == b.Annotations[annoVerifiedFreeze] { + return "verified-freeze" + } + return "legacy-unverified" case "unsupported": return "unsupported" case "pending": @@ -198,8 +203,10 @@ func backupConsistencyCondition(b *gameplanev1alpha1.Backup) metav1.Condition { 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 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 == "confirmed" || b.Status.CaptureManifest.QuiesceOutcome == "legacy-unverified": + condition.Reason, condition.Message = "LegacyHookUnverified", "The historical hook reported success without proving that writes stayed paused. The snapshot remains available for restore." + case b.Status.CaptureManifest.QuiesceOutcome == "verified-freeze": + condition.Status, condition.Reason, condition.Message = metav1.ConditionTrue, "Quiesced", "The pod-bound agent verified the game write-freeze command sequence before capture. See the manifest for included volumes." case b.Status.CaptureManifest.QuiesceOutcome == "already-stopped": 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": diff --git a/operator/internal/controller/backup_manifest_test.go b/operator/internal/controller/backup_manifest_test.go index cb63d493e..cbdf65583 100644 --- a/operator/internal/controller/backup_manifest_test.go +++ b/operator/internal/controller/backup_manifest_test.go @@ -187,13 +187,15 @@ func TestBackupConsistencyDescribesAchievedResult(t *testing.T) { strategy, outcome, reason string status metav1.ConditionStatus }{ - {"restic-snapshot", "confirmed", "Quiesced", metav1.ConditionTrue}, + {"restic-snapshot", "confirmed", "LegacyHookUnverified", metav1.ConditionUnknown}, + {"restic-snapshot", "legacy-unverified", "LegacyHookUnverified", metav1.ConditionUnknown}, + {"restic-snapshot", "verified-freeze", "Quiesced", metav1.ConditionTrue}, {"restic-snapshot", "already-stopped", "StoppedSource", metav1.ConditionTrue}, {"restic-snapshot", "unsupported", "UnsupportedRawCopy", metav1.ConditionFalse}, {"restic-snapshot", "not-requested", "RawCopy", metav1.ConditionFalse}, {"restic-snapshot", "agent-unavailable", "AgentUnavailableRawCopy", metav1.ConditionFalse}, {"volume-snapshot", "unsupported", "CrashConsistentSnapshot", metav1.ConditionFalse}, - {"volume-snapshot", "confirmed", "Quiesced", metav1.ConditionTrue}, + {"volume-snapshot", "verified-freeze", "Quiesced", metav1.ConditionTrue}, {"volume-snapshot", "already-stopped", "StoppedSource", metav1.ConditionTrue}, } { t.Run(tc.strategy+"/"+tc.outcome, func(t *testing.T) { diff --git a/operator/internal/controller/recovery_controller_test.go b/operator/internal/controller/recovery_controller_test.go index f8005a616..a94438eb2 100644 --- a/operator/internal/controller/recovery_controller_test.go +++ b/operator/internal/controller/recovery_controller_test.go @@ -1103,7 +1103,7 @@ func TestRecoveryQuiesceOutcomeFinalizedOnlyBeforeFirstAuthorization(t *testing. if err := r.finalizeBackupCaptureOutcome(t.Context(), b); err != nil { t.Fatal(err) } - if b.Status.CaptureManifest.QuiesceOutcome != "confirmed" { + if b.Status.CaptureManifest.QuiesceOutcome != "legacy-unverified" { t.Fatal("capture retained pre-RPC quiesce outcome") } for i, v := range before.Volumes { @@ -1119,7 +1119,7 @@ func TestRecoveryQuiesceOutcomeFinalizedOnlyBeforeFirstAuthorization(t *testing. if err := r.finalizeBackupCaptureOutcome(t.Context(), b); err != nil { t.Fatal(err) } - if b.Status.CaptureManifest.QuiesceOutcome != "confirmed" { + if b.Status.CaptureManifest.QuiesceOutcome != "legacy-unverified" { t.Fatal("post-authorization outcome rewrote capture history") } if backupConsistencyCondition(b).Status != metav1.ConditionFalse { From 53dcae78ce8915a6108a0d4a49b397f9f4883085 Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 00:32:04 -0500 Subject: [PATCH 2/5] fix(backups): classify pod-bound checkpoints and exercise restored world data Signed-off-by: Baylem --- agent/cmd/main.go | 13 ++++- agent/internal/caps/caps.go | 13 ++--- agent/internal/quiesce/quiesce.go | 16 ++++++ agent/internal/quiesce/verified_test.go | 21 ++++++++ agent/openapi.yaml | 24 ++++++++- agent/specs.md | 5 ++ .../crds/gameplane.local_backups.yaml | 7 +-- .../crds/gameplane.local_backupschedules.yaml | 2 +- .../crds/gameplane.local_clusters.yaml | 2 +- .../crds/gameplane.local_gameservers.yaml | 2 +- .../crds/gameplane.local_gametemplates.yaml | 14 +++++- .../crds/gameplane.local_modules.yaml | 2 +- .../crds/gameplane.local_modulesources.yaml | 2 +- .../crds/gameplane.local_networkcaptures.yaml | 2 +- .../crds/gameplane.local_restores.yaml | 2 +- .../gameplane.local_backups.yaml | 7 +-- .../gameplane.local_backupschedules.yaml | 2 +- .../gameplane.local_clusters.yaml | 2 +- .../gameplane.local_gameservers.yaml | 2 +- .../gameplane.local_gametemplates.yaml | 14 +++++- .../gameplane.local_modules.yaml | 2 +- .../gameplane.local_modulesources.yaml | 2 +- .../gameplane.local_networkcaptures.yaml | 2 +- .../gameplane.local_restores.yaml | 2 +- docs/module-authoring.md | 7 +++ docs/operator-operations.md | 8 ++- gp-module/internal/validator/schema.go | 12 +++++ operator/api/v1alpha1/backup_types.go | 5 +- operator/api/v1alpha1/gametemplate_types.go | 7 +++ .../config/crd/gameplane.local_backups.yaml | 5 +- .../crd/gameplane.local_gametemplates.yaml | 12 +++++ operator/internal/agent/client.go | 25 ++++++++-- .../internal/controller/backup_controller.go | 22 ++++++++- operator/internal/controller/backup_guard.go | 1 + .../internal/controller/backup_guard_test.go | 49 +++++++++++++++++++ .../internal/controller/backup_manifest.go | 6 +++ .../controller/backup_manifest_test.go | 1 + operator/specs.md | 6 +++ test/e2e/backup_stopped_e2e_test.go | 24 ++++++++- test/e2e/minecraft_bot_e2e_test.go | 40 +++++++++++++-- web/src/types.ts | 1 + 41 files changed, 345 insertions(+), 48 deletions(-) diff --git a/agent/cmd/main.go b/agent/cmd/main.go index 3900c18f2..37eb1d1f3 100644 --- a/agent/cmd/main.go +++ b/agent/cmd/main.go @@ -166,9 +166,10 @@ func main() { transport = rcon.New(rconHost, rconPort, rcon.PasswordFromFile(rconPassFile)) } + flushOnly := rconEnabled && capSpec != nil && capSpec.BackupFlush == "minecraft-save-all" && gameName == "minecraft-java" && capSpec.Quiesce == nil commands := rcon.NewCommands(transport) rconClient := transport - if quiesceSpec != nil { + if quiesceSpec != nil || flushOnly { rconClient = commands } r := chi.NewRouter() @@ -219,9 +220,17 @@ func main() { for _, version := range []string{"v1", "v2"} { r.Route("/"+version+"/pods/{uid}", func(protected chi.Router) { protected.Use(authCheck.Middleware, target.Guard(envOr("GAMEPLANE_POD_UID", "")), middleware.Timeout(30*time.Second)) - protected.Get("/identity", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + protected.Get("/identity", func(w http.ResponseWriter, _ *http.Request) { + if version == "v2" && flushOnly { + w.Header().Set("Gameplane-Backup-Mode", "flush-only") + } + w.WriteHeader(http.StatusNoContent) + }) if version == "v2" { quiesce.MountVerified(protected, commands, gameName, quiesceSpec) + if flushOnly { + quiesce.MountMinecraftFlush(protected, commands) + } } else { quiesce.Mount(protected, commands, gameName, quiesceSpec) } diff --git a/agent/internal/caps/caps.go b/agent/internal/caps/caps.go index 448501ff6..cf89d253f 100644 --- a/agent/internal/caps/caps.go +++ b/agent/internal/caps/caps.go @@ -12,12 +12,13 @@ import ( // Spec is the root capabilities document. type Spec struct { - Players *PlayerActions `json:"players,omitempty"` - Quiesce *Quiesce `json:"quiesce,omitempty"` - Lifecycle *Lifecycle `json:"lifecycle,omitempty"` - Actions []ServerAction `json:"actions,omitempty"` - Status *Status `json:"status,omitempty"` - Mods *Mods `json:"mods,omitempty"` + BackupFlush string `json:"backupFlush,omitempty"` + Players *PlayerActions `json:"players,omitempty"` + Quiesce *Quiesce `json:"quiesce,omitempty"` + Lifecycle *Lifecycle `json:"lifecycle,omitempty"` + Actions []ServerAction `json:"actions,omitempty"` + Status *Status `json:"status,omitempty"` + Mods *Mods `json:"mods,omitempty"` } // Lifecycle declares command sequences the agent runs over RCON to drive diff --git a/agent/internal/quiesce/quiesce.go b/agent/internal/quiesce/quiesce.go index 13a3f2952..fb6a381ce 100644 --- a/agent/internal/quiesce/quiesce.go +++ b/agent/internal/quiesce/quiesce.go @@ -106,6 +106,22 @@ func mount(r chi.Router, rc *rcon.Commands, game string, q Quiescer, verified bo handle("/unquiesce", "resumed", false, q.Unquiesce) } +// MountMinecraftFlush cannot pause or resume saving. Even a lost response +// leaves the game free to write; the caller can repeat the same checkpoint. +func MountMinecraftFlush(r chi.Router, rc *rcon.Commands) { + r.Post("/flush", func(w http.ResponseWriter, req *http.Request) { + err := rc.Sequence(req.Context(), func(transport rcon.Executor) error { + _, err := (minecraftReplies{transport}).Exec("save-all flush") + return err + }) + if err != nil { + httpjson.Write(w, http.StatusBadGateway, response{Reason: "checkpoint was not acknowledged"}) + return + } + httpjson.Write(w, http.StatusOK, response{Outcome: "flushed"}) + }) +} + type minecraftReplies struct{ Rcon } // Vanilla Java 1.21.4 en_us commands.save.* messages. RCON can concatenate diff --git a/agent/internal/quiesce/verified_test.go b/agent/internal/quiesce/verified_test.go index 66ae039cf..02381ccdd 100644 --- a/agent/internal/quiesce/verified_test.go +++ b/agent/internal/quiesce/verified_test.go @@ -42,6 +42,27 @@ func TestMinecraftPositiveCommandResponses(t *testing.T) { } } +func TestMinecraftCheckpointLeavesSavingEnabled(t *testing.T) { + for _, tc := range []struct { + reply string + code int + outcome string + }{ + {"Saved the game", http.StatusOK, "flushed"}, + {"Saving the game (this may take a moment!)", http.StatusBadGateway, ""}, + } { + rc := &fakeRcon{respond: func(string) (string, error) { return tc.reply, nil }} + router := chi.NewRouter() + MountMinecraftFlush(router, rcon.NewCommands(rc)) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/flush", nil)) + got := decodeResponse(t, w.Body.Bytes()) + if w.Code != tc.code || got.Quiesced || got.Outcome != tc.outcome || !reflect.DeepEqual(rc.calls(), []string{"save-all flush"}) { + t.Fatalf("checkpoint reply %q: HTTP %d, %+v, commands %v", tc.reply, w.Code, got, rc.calls()) + } + } +} + func TestVerifiedSequenceBlocksCleanupUntilRollbackFinishes(t *testing.T) { reached, release := make(chan struct{}), make(chan struct{}) var once sync.Once diff --git a/agent/openapi.yaml b/agent/openapi.yaml index 49b94061e..c0482fe15 100644 --- a/agent/openapi.yaml +++ b/agent/openapi.yaml @@ -387,8 +387,30 @@ paths: get: summary: Verify the pod UID and availability of the verified backup protocol responses: - "204": { description: Matching pod UID } + "204": + description: Matching pod UID + headers: + Gameplane-Backup-Mode: + description: Present for a checkpoint that leaves writes enabled + schema: { type: string, enum: [flush-only] } "404": { description: Missing or mismatched pod UID } + /v2/pods/{uid}/flush: + parameters: [*podUID] + post: + summary: Checkpoint the running game while writes stay enabled + responses: + "200": + description: The game acknowledged its checkpoint + content: + application/json: + schema: + type: object + required: [outcome, quiesced] + properties: + outcome: { type: string, enum: [flushed] } + quiesced: { type: boolean, enum: [false] } + "404": { description: Missing adapter or mismatched pod UID } + "502": { description: The game did not acknowledge the checkpoint } /v2/pods/{uid}/quiesce: parameters: [*podUID] post: diff --git a/agent/specs.md b/agent/specs.md index 40935f565..d9e9ab121 100644 --- a/agent/specs.md +++ b/agent/specs.md @@ -19,6 +19,11 @@ The agent is a per-pod HTTP/HTTPS sidecar that runs inside every game pod to exp ## Responsibilities +The v2 identity response advertises `Gameplane-Backup-Mode: flush-only` +for a configured Minecraft Java checkpoint adapter. Its pod-bound +`POST /v2/pods/{uid}/flush` checks the game's acknowledgment of +`save-all flush` and keeps automatic saving enabled. + - **Request authentication**: Verify incoming requests via mTLS (preferred) or shared-secret bearer token. - **Console & RCON**: Duplex WebSocket forwarding user commands to the game via RCON and echoing responses; supports Valve/Source, Telnet, WebSocket, BattlEye, Satisfactory, and Palworld protocols. - **File I/O**: List, read, download, upload, write, mkdir, delete within the `/data` volume; reject path traversal and any symlink traversal; every operation runs through directory descriptors rooted at the data directory, so it cannot be redirected by a concurrent filesystem change. 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 e579af4f7..78eb0c3a9 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: @@ -166,8 +166,9 @@ spec: type: string quiesceOutcome: description: |- - QuiesceOutcome is confirmed, already-stopped, unsupported, not-requested, - agent-unavailable, or unknown, independent of subsequent copy success. + QuiesceOutcome records verified-freeze, already-stopped, flush-only or + a weaker observation independently of copy success. Historical confirmed + records lack pod-bound proof of a write freeze. type: string recordedAt: format: date-time 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 1a47d5a0b..bb62acad1 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 4ed0507d0..df3188b64 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 2e12b7d78..c279a090e 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 2e31837da..f1df2aa5d 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: @@ -291,6 +291,12 @@ spec: > 0) maxItems: 32 type: array + backupFlush: + description: BackupFlush requests an acknowledged checkpoint without + disabling writes. + enum: + - minecraft-save-all + type: string lifecycle: description: |- Lifecycle configures graceful lifecycle transitions the agent drives @@ -1016,6 +1022,9 @@ spec: type: array type: object type: object + x-kubernetes-validations: + - message: choose a flush-only checkpoint or a write-freeze declaration + rule: '!(has(self.backupFlush) && has(self.quiesce))' categories: description: |- Categories group this game in the dashboard's catalog and Create @@ -2522,6 +2531,9 @@ spec: rule: '!has(self.capabilities) || !has(self.capabilities.mods) || !has(self.capabilities.mods.loaders) || size(self.capabilities.mods.loaders) == 0 || (has(self.versions) && size(self.versions) > 0)' + - message: backupFlush requires the Minecraft Java adapter + rule: '!has(self.capabilities) || !has(self.capabilities.backupFlush) + || self.game == ''minecraft-java''' status: description: |- GameTemplateStatus is intentionally minimal — templates are mostly 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 a2054ca99..89c1fc44b 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 db10e1c62..4ce4a7444 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 28adf5343..a02efb508 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 f7ea09a1f..6f55383d4 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml index e579af4f7..78eb0c3a9 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: @@ -166,8 +166,9 @@ spec: type: string quiesceOutcome: description: |- - QuiesceOutcome is confirmed, already-stopped, unsupported, not-requested, - agent-unavailable, or unknown, independent of subsequent copy success. + QuiesceOutcome records verified-freeze, already-stopped, flush-only or + a weaker observation independently of copy success. Historical confirmed + records lack pod-bound proof of a write freeze. type: string recordedAt: format: date-time diff --git a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml index 1a47d5a0b..bb62acad1 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 4ed0507d0..df3188b64 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 2e12b7d78..c279a090e 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 2e31837da..f1df2aa5d 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: @@ -291,6 +291,12 @@ spec: > 0) maxItems: 32 type: array + backupFlush: + description: BackupFlush requests an acknowledged checkpoint without + disabling writes. + enum: + - minecraft-save-all + type: string lifecycle: description: |- Lifecycle configures graceful lifecycle transitions the agent drives @@ -1016,6 +1022,9 @@ spec: type: array type: object type: object + x-kubernetes-validations: + - message: choose a flush-only checkpoint or a write-freeze declaration + rule: '!(has(self.backupFlush) && has(self.quiesce))' categories: description: |- Categories group this game in the dashboard's catalog and Create @@ -2522,6 +2531,9 @@ spec: rule: '!has(self.capabilities) || !has(self.capabilities.mods) || !has(self.capabilities.mods.loaders) || size(self.capabilities.mods.loaders) == 0 || (has(self.versions) && size(self.versions) > 0)' + - message: backupFlush requires the Minecraft Java adapter + rule: '!has(self.capabilities) || !has(self.capabilities.backupFlush) + || self.game == ''minecraft-java''' status: description: |- GameTemplateStatus is intentionally minimal — templates are mostly diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index a2054ca99..89c1fc44b 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 db10e1c62..4ce4a7444 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 28adf5343..a02efb508 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 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 f7ea09a1f..6f55383d4 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: e900c220cd57442f03e3264da6ad0a2e746450523b88edf38affff441b150072 + gameplane.local/crd-bundle-sha256: cd1d212902c11b75266ed926aa2646b9ad16756530aca94123d66559bf09c711 controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 08a2886ec..476a89704 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -1152,6 +1152,13 @@ template today. ### Capabilities (moderation + backup quiesce) +Minecraft Java authors can opt into a checkpoint while writes stay enabled +with `spec.capabilities.backupFlush: minecraft-save-all`. This calls +`save-all flush`, requires the game's positive reply, and records +`flush-only`. Choose this field or the write-freeze declaration below. +Gameplane reports unsupported engines as live copies. Select +`powerPolicy: stop-copy-restart` and `volumePolicy: all-data` for full recovery. + `spec.capabilities` declares the console commands behind agent features, so a module adds full Players-tab moderation and safe backups without any agent code. All of these commands run over RCON, diff --git a/docs/operator-operations.md b/docs/operator-operations.md index 9cb3f17cf..cd81f5b9d 100644 --- a/docs/operator-operations.md +++ b/docs/operator-operations.md @@ -22,7 +22,13 @@ 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. +`captureManifest.quiesceOutcome` distinguishes a pod-bound `verified-freeze` +from `flush-only` (writes continue), `unsupported`, and historical hook +success without proof of a freeze. An incomplete or negative checkpoint +reply aborts the capture. Use stopped all-data recovery points for unknown +engines, SQLite saves and multi-volume worlds. The verified live adapter is +Minecraft Java's save-off, save-all flush and save-on sequence qualified +against the 1.21.4 vanilla server in the real-client backup/restore job. `Completed`/phase and snapshot ID report copy success separately from consistency. `stop-copy-restart` acquires shared lifecycle ownership, persists original intent diff --git a/gp-module/internal/validator/schema.go b/gp-module/internal/validator/schema.go index 288340092..99e9a5ed9 100644 --- a/gp-module/internal/validator/schema.go +++ b/gp-module/internal/validator/schema.go @@ -421,6 +421,18 @@ func validateTemplateSchema(node *yaml.Node) []Finding { }) } specVerNode := common.FindNode(node, "spec.version") + if flush := common.FindNode(node, "spec.capabilities.backupFlush"); flush != nil { + game := common.FindNode(node, "spec.game") + if flush.Value != "minecraft-save-all" || game == nil || game.Value != "minecraft-java" || + common.FindNode(node, "spec.capabilities.quiesce") != nil { + findings = append(findings, Finding{ + Level: SeverityError, RuleID: RuleTemplateSchemaViolation, File: "template.yaml", + Field: "spec.capabilities.backupFlush", Line: flush.Line, Column: flush.Column, + Message: "backupFlush requires the minecraft-java save-all adapter and cannot be combined with quiesce", + Remediation: "Use minecraft-save-all with minecraft-java, or choose a stopped copy.", + }) + } + } if specVerNode == nil || specVerNode.Value == "" { findings = append(findings, Finding{ Level: SeverityError, diff --git a/operator/api/v1alpha1/backup_types.go b/operator/api/v1alpha1/backup_types.go index bddbb5088..c17b54799 100644 --- a/operator/api/v1alpha1/backup_types.go +++ b/operator/api/v1alpha1/backup_types.go @@ -172,8 +172,9 @@ type BackupCaptureManifest struct { // LayoutSource identifies the frozen workload used for mount paths. // +optional LayoutSource string `json:"layoutSource,omitempty"` - // QuiesceOutcome is confirmed, already-stopped, unsupported, not-requested, - // agent-unavailable, or unknown, independent of subsequent copy success. + // QuiesceOutcome records verified-freeze, already-stopped, flush-only or + // a weaker observation independently of copy success. Historical confirmed + // records lack pod-bound proof of a write freeze. // +optional QuiesceOutcome string `json:"quiesceOutcome,omitempty"` // +optional diff --git a/operator/api/v1alpha1/gametemplate_types.go b/operator/api/v1alpha1/gametemplate_types.go index 8c87a75c0..ca798da04 100644 --- a/operator/api/v1alpha1/gametemplate_types.go +++ b/operator/api/v1alpha1/gametemplate_types.go @@ -11,6 +11,7 @@ import ( // references it. // +kubebuilder:validation:XValidation:rule="!has(self.consoleMode) || self.consoleMode != 'rcon' || (has(self.rcon) && (!has(self.rcon.protocol) || self.rcon.protocol != 'none'))",message="consoleMode 'rcon' requires spec.rcon with a protocol other than 'none'" // +kubebuilder:validation:XValidation:rule="!has(self.capabilities) || !has(self.capabilities.mods) || !has(self.capabilities.mods.loaders) || size(self.capabilities.mods.loaders) == 0 || (has(self.versions) && size(self.versions) > 0)",message="capabilities.mods.loaders requires a non-empty spec.versions catalog (a mod loader is selected by versions[].loader)" +// +kubebuilder:validation:XValidation:rule="!has(self.capabilities) || !has(self.capabilities.backupFlush) || self.game == 'minecraft-java'",message="backupFlush requires the Minecraft Java adapter" type GameTemplateSpec struct { // DisplayName is a human-friendly label shown in the dashboard. // +kubebuilder:validation:MinLength=1 @@ -268,7 +269,13 @@ type GameVersion struct { // CapabilitiesSpec declares the per-game command surface the agent // interprets. +// +kubebuilder:validation:XValidation:rule="!(has(self.backupFlush) && has(self.quiesce))",message="choose a flush-only checkpoint or a write-freeze declaration" type CapabilitiesSpec struct { + // BackupFlush requests an acknowledged checkpoint without disabling writes. + // +optional + // +kubebuilder:validation:Enum=minecraft-save-all + BackupFlush string `json:"backupFlush,omitempty"` + // Players configures the moderation actions on the Players tab. // +optional Players *PlayerActionsSpec `json:"players,omitempty"` diff --git a/operator/config/crd/gameplane.local_backups.yaml b/operator/config/crd/gameplane.local_backups.yaml index 67fb20227..dcfbed519 100644 --- a/operator/config/crd/gameplane.local_backups.yaml +++ b/operator/config/crd/gameplane.local_backups.yaml @@ -165,8 +165,9 @@ spec: type: string quiesceOutcome: description: |- - QuiesceOutcome is confirmed, already-stopped, unsupported, not-requested, - agent-unavailable, or unknown, independent of subsequent copy success. + QuiesceOutcome records verified-freeze, already-stopped, flush-only or + a weaker observation independently of copy success. Historical confirmed + records lack pod-bound proof of a write freeze. type: string recordedAt: format: date-time diff --git a/operator/config/crd/gameplane.local_gametemplates.yaml b/operator/config/crd/gameplane.local_gametemplates.yaml index cf08d43d1..f623d4bda 100644 --- a/operator/config/crd/gameplane.local_gametemplates.yaml +++ b/operator/config/crd/gameplane.local_gametemplates.yaml @@ -290,6 +290,12 @@ spec: > 0) maxItems: 32 type: array + backupFlush: + description: BackupFlush requests an acknowledged checkpoint without + disabling writes. + enum: + - minecraft-save-all + type: string lifecycle: description: |- Lifecycle configures graceful lifecycle transitions the agent drives @@ -1015,6 +1021,9 @@ spec: type: array type: object type: object + x-kubernetes-validations: + - message: choose a flush-only checkpoint or a write-freeze declaration + rule: '!(has(self.backupFlush) && has(self.quiesce))' categories: description: |- Categories group this game in the dashboard's catalog and Create @@ -2521,6 +2530,9 @@ spec: rule: '!has(self.capabilities) || !has(self.capabilities.mods) || !has(self.capabilities.mods.loaders) || size(self.capabilities.mods.loaders) == 0 || (has(self.versions) && size(self.versions) > 0)' + - message: backupFlush requires the Minecraft Java adapter + rule: '!has(self.capabilities) || !has(self.capabilities.backupFlush) + || self.game == ''minecraft-java''' status: description: |- GameTemplateStatus is intentionally minimal — templates are mostly diff --git a/operator/internal/agent/client.go b/operator/internal/agent/client.go index 2857461f0..735a5553e 100644 --- a/operator/internal/agent/client.go +++ b/operator/internal/agent/client.go @@ -128,22 +128,34 @@ func (c *Client) QuiesceEnabled() bool { return c != nil && !c.Disabled } // CheckPod verifies that the responding agent belongs to the selected Pod. // Older agents return 404; callers must not fall back to unbound mutations. func (c *Client) CheckPod(ctx context.Context, namespace, server, podUID string) error { + _, err := c.PodBackupMode(ctx, namespace, server, podUID) + return err +} + +// PodBackupMode also accepts older v2 agents whose identity response has no +// mode header. Those agents only implement the original pause/resume protocol. +func (c *Client) PodBackupMode(ctx context.Context, namespace, server, podUID string) (string, error) { if c.Disabled || podUID == "" { - return fmt.Errorf("agent: pod identity check requires an enabled client and pod UID") + return "", fmt.Errorf("agent: pod identity check requires an enabled client and pod UID") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, agentURL(namespace, server, "/v2/pods/"+url.PathEscape(podUID)+"/identity"), nil) if err != nil { - return err + return "", err } resp, err := c.http.Do(req) if err != nil { - return fmt.Errorf("agent: pod identity check: %w", err) + return "", fmt.Errorf("agent: pod identity check: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("agent: pod identity check: status %d", resp.StatusCode) + return "", fmt.Errorf("agent: pod identity check: status %d", resp.StatusCode) } - return nil + return resp.Header.Get("Gameplane-Backup-Mode"), nil +} + +// FlushPod checkpoints the game without changing its write policy. +func (c *Client) FlushPod(ctx context.Context, namespace, server, podUID string) error { + return c.callPodQuiesce(ctx, namespace, server, podUID, "/flush", false) } // QuiescePod disables saving only on the selected Pod's agent. @@ -208,6 +220,9 @@ func (c *Client) callQuiesce(ctx context.Context, namespace, server, path string return fmt.Errorf("agent: %s: refused or contradictory acknowledgment", path) } want := "resumed" + if strings.HasSuffix(path, "/flush") { + want = "flushed" + } if expectQuiesced { want = "paused" } diff --git a/operator/internal/controller/backup_controller.go b/operator/internal/controller/backup_controller.go index 848d4be99..2e128ad01 100644 --- a/operator/internal/controller/backup_controller.go +++ b/operator/internal/controller/backup_controller.go @@ -33,6 +33,7 @@ import ( // quiesce state across reconcile passes. const ( annoQuiesceAttempted = "backup.gameplane.local/quiesce-attempted" + annoFlushedPod = "backup.gameplane.local/flushed-pod" annoVerifiedFreeze = "backup.gameplane.local/verified-freeze-pod" annoQuiescedAt = "backup.gameplane.local/quiesced-at" annoUnquiescedAt = "backup.gameplane.local/unquiesced-at" @@ -413,8 +414,27 @@ func (r *BackupReconciler) maybeQuiesce(ctx context.Context, b *gameplanev1alpha // Old agents cannot mutate through it and must not pin an owner that // would prevent their own upgrade. The POST still checks the UID again. if preparation != nil && b.Annotations[annoQuiesceAttempted] == "" { + podUID := string(preparation.PodUID) bound, ok := r.AgentClient.(podAgentQuiescer) - if !ok { + if checkpoint, supportsModes := r.AgentClient.(interface { + PodBackupMode(context.Context, string, string, string) (string, error) + FlushPod(context.Context, string, string, string) error + }); supportsModes { + var mode string + mode, err = checkpoint.PodBackupMode(ctx, b.Namespace, b.Spec.ServerRef.Name, podUID) + if err == nil && mode == "flush-only" { + if err := checkpoint.FlushPod(ctx, b.Namespace, b.Spec.ServerRef.Name, podUID); err != nil { + _, ferr := r.fail(ctx, b, fmt.Sprintf("checkpoint failed: %v", err)) + return errors.Join(err, ferr) + } + // Keep older controllers from trying save-on for this checkpoint. + patchBackupAnnotations(b, map[string]string{annoQuiesceAttempted: "unsupported", annoFlushedPod: podUID}) + return r.Update(ctx, b) + } + if err == nil && mode != "" { + err = fmt.Errorf("unsupported agent backup mode %q", mode) + } + } else if !ok { err = fmt.Errorf("agent client does not support pod-bound quiesce") } else { err = bound.CheckPod(ctx, b.Namespace, b.Spec.ServerRef.Name, string(preparation.PodUID)) diff --git a/operator/internal/controller/backup_guard.go b/operator/internal/controller/backup_guard.go index 09a0d97fe..eebbbdf89 100644 --- a/operator/internal/controller/backup_guard.go +++ b/operator/internal/controller/backup_guard.go @@ -131,6 +131,7 @@ func (r *BackupReconciler) recoverBackupTarget(ctx context.Context, b *gameplane delete(b.Annotations, backupRecoveryAnnotation) delete(b.Annotations, annoQuiesceAttempted) delete(b.Annotations, annoVerifiedFreeze) + delete(b.Annotations, annoFlushedPod) delete(b.Annotations, annoUnquiescedAt) if err := r.Update(ctx, b); err != nil { return false, err diff --git a/operator/internal/controller/backup_guard_test.go b/operator/internal/controller/backup_guard_test.go index 491c7da0c..a97e535b6 100644 --- a/operator/internal/controller/backup_guard_test.go +++ b/operator/internal/controller/backup_guard_test.go @@ -53,6 +53,55 @@ func (q *guardQuiescer) UnquiescePod(ctx context.Context, namespace, server, _ s return q.Unquiesce(ctx, namespace, server) } +type checkpointAgent struct { + *guardQuiescer + flushes int + flushErr error +} + +func (q *checkpointAgent) PodBackupMode(context.Context, string, string, string) (string, error) { + return "flush-only", nil +} + +func (q *checkpointAgent) FlushPod(context.Context, string, string, string) error { + q.flushes++ + return q.flushErr +} + +func TestCheckpointDoesNotCreateWriteFreezeDebt(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(fmt.Sprintf("failed=%t", failed), func(t *testing.T) { + r, base := guardFixtureWorkers(t, false, guardBackup("prepared")) + q := &checkpointAgent{guardQuiescer: base} + if failed { + q.flushErr = errors.New("checkpoint rejected") + } + r.AgentClient = q + budgetPass(t, r, "prepared") + budgetReadyWorker(t, r, "prepared", "prepared-worker-uid") + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "ns", Name: "prepared"}} + for range 4 { + if _, err := r.Reconcile(t.Context(), req); err != nil && !errors.Is(err, q.flushErr) { + t.Fatal(err) + } + } + b := guardGetBackup(t, r, "prepared") + if q.flushes != 1 || q.quiesced != 0 || q.unquiesced != 0 || b.Annotations[annoQuiescedAt] != "" { + t.Fatalf("unexpected write-freeze sequence: %+v, %+v", q, b.Annotations) + } + if failed { + if b.Status.Phase != gameplanev1alpha1.BackupPhaseFailed || b.Annotations[backupAuthorizedWorkerAnnotation] != "" { + t.Fatalf("failed checkpoint authorized capture: %+v", b) + } + return + } + if b.Status.CaptureManifest == nil || b.Status.CaptureManifest.QuiesceOutcome != "flush-only" || b.Annotations[annoFlushedPod] != "game-pod-uid" { + t.Fatalf("missing pod-bound checkpoint: %+v", b) + } + }) + } +} + func guardBackup(name string) *gameplanev1alpha1.Backup { return &gameplanev1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ns", UID: types.UID(name + "-uid"), Finalizers: []string{gameplanev1alpha1.BackupFinalizer, gameplanev1alpha1.BackupSnapshotFinalizer}}, Spec: gameplanev1alpha1.BackupSpec{ServerRef: gameplanev1alpha1.LocalObjectRef{Name: "server"}, RepoRef: &gameplanev1alpha1.SecretKeySelector{Name: "repo"}, Quiesce: true}} } diff --git a/operator/internal/controller/backup_manifest.go b/operator/internal/controller/backup_manifest.go index d91fa9971..3879a7306 100644 --- a/operator/internal/controller/backup_manifest.go +++ b/operator/internal/controller/backup_manifest.go @@ -178,6 +178,10 @@ func backupQuiesceOutcome(b *gameplanev1alpha1.Backup) string { } return "legacy-unverified" case "unsupported": + p, err := parseBackupPreparation(b) + if err == nil && p != nil && p.PodUID != "" && string(p.PodUID) == b.Annotations[annoFlushedPod] { + return "flush-only" + } return "unsupported" case "pending": return "unknown" @@ -205,6 +209,8 @@ func backupConsistencyCondition(b *gameplanev1alpha1.Backup) metav1.Condition { 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" || b.Status.CaptureManifest.QuiesceOutcome == "legacy-unverified": condition.Reason, condition.Message = "LegacyHookUnverified", "The historical hook reported success without proving that writes stayed paused. The snapshot remains available for restore." + case b.Status.CaptureManifest.QuiesceOutcome == "flush-only": + condition.Status, condition.Reason, condition.Message = metav1.ConditionFalse, "FlushedLiveCopy", "The game acknowledged a checkpoint. Subsequent writes could change files during this copy." case b.Status.CaptureManifest.QuiesceOutcome == "verified-freeze": condition.Status, condition.Reason, condition.Message = metav1.ConditionTrue, "Quiesced", "The pod-bound agent verified the game write-freeze command sequence before capture. See the manifest for included volumes." case b.Status.CaptureManifest.QuiesceOutcome == "already-stopped": diff --git a/operator/internal/controller/backup_manifest_test.go b/operator/internal/controller/backup_manifest_test.go index cbdf65583..b4d50ece6 100644 --- a/operator/internal/controller/backup_manifest_test.go +++ b/operator/internal/controller/backup_manifest_test.go @@ -189,6 +189,7 @@ func TestBackupConsistencyDescribesAchievedResult(t *testing.T) { }{ {"restic-snapshot", "confirmed", "LegacyHookUnverified", metav1.ConditionUnknown}, {"restic-snapshot", "legacy-unverified", "LegacyHookUnverified", metav1.ConditionUnknown}, + {"restic-snapshot", "flush-only", "FlushedLiveCopy", metav1.ConditionFalse}, {"restic-snapshot", "verified-freeze", "Quiesced", metav1.ConditionTrue}, {"restic-snapshot", "already-stopped", "StoppedSource", metav1.ConditionTrue}, {"restic-snapshot", "unsupported", "UnsupportedRawCopy", metav1.ConditionFalse}, diff --git a/operator/specs.md b/operator/specs.md index 6dcea63c7..a13bd5527 100644 --- a/operator/specs.md +++ b/operator/specs.md @@ -6,6 +6,12 @@ ## Purpose +Backup manifests record `verified-freeze` only after a successful pod-bound +pause. Checkpoint-only and unsupported live copies receive weak consistency +conditions. Historical unqualified hook success remains readable with +unknown consistency. A drained source can establish stopped-copy consistency +for the included claims. + ### Restricted workloads and node capture (#714) `--restricted-workloads` applies nonroot, no escalation, drop ALL and diff --git a/test/e2e/backup_stopped_e2e_test.go b/test/e2e/backup_stopped_e2e_test.go index e02258db2..a284165af 100644 --- a/test/e2e/backup_stopped_e2e_test.go +++ b/test/e2e/backup_stopped_e2e_test.go @@ -6,7 +6,9 @@ import ( "context" "encoding/json" "fmt" + "os/exec" "regexp" + "strings" "testing" "time" @@ -41,7 +43,14 @@ func TestBackup_StoppedCopiesRemainRestorable(t *testing.T) { } return false, "game container not ready" }) - if out, err := envInstance.KubectlExec(t, ns, "pod/"+gs+"-0", "sh", "-c", "echo stopped-backup-marker > /data/marker.txt && sync"); err != nil { + // The CI runner creates an actual SQLite database. BusyBox only transports + // the bytes into the disposable game volume and back out of the Restic job. + createDB := "import base64,sqlite3; db=sqlite3.connect(':memory:'); db.execute('create table player(score integer)'); db.execute('insert into player values(17)'); db.commit(); print(base64.b64encode(db.serialize()).decode())" + fixture, err := exec.CommandContext(t.Context(), "python3", "-c", createDB).Output() + if err != nil { + t.Fatal(err) + } + if out, err := envInstance.KubectlExec(t, ns, "pod/"+gs+"-0", "sh", "-c", `echo stopped-backup-marker > /data/marker.txt && printf %s "$1" | base64 -d > /data/world.db && sync`, "sh", strings.TrimSpace(string(fixture))); err != nil { t.Fatalf("write marker: %v\n%s", err, out) } if _, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Patch(ctx, gs, types.MergePatchType, []byte(`{"spec":{"suspend":true}}`), metav1.PatchOptions{}); err != nil { @@ -102,7 +111,18 @@ func TestBackup_StoppedCopiesRemainRestorable(t *testing.T) { if !regexp.MustCompile(`^[0-9a-f]{8,64}$`).MatchString(snapshotID) { t.Fatalf("unexpected snapshot ID %q", snapshotID) } - runResticJob(t, "restic restore "+snapshotID+" --target /tmp/stopped-restore --retry-lock 5m && test \"$(cat /tmp/stopped-restore/data/marker.txt)\" = stopped-backup-marker") + logs := runResticJob(t, "restic restore "+snapshotID+" --target /tmp/stopped-restore --retry-lock 5m && test \"$(cat /tmp/stopped-restore/data/marker.txt)\" = stopped-backup-marker && echo SQLITE_BEGIN && base64 /tmp/stopped-restore/data/world.db && echo SQLITE_END") + _, encoded, found := strings.Cut(logs, "SQLITE_BEGIN") + encoded, _, ended := strings.Cut(encoded, "SQLITE_END") + if !found || !ended { + t.Fatal("restored database absent from worker output") + } + verifyDB := "import base64,sqlite3,sys; db=sqlite3.connect(':memory:'); db.deserialize(base64.b64decode(sys.stdin.read())); assert db.execute('pragma integrity_check').fetchall()==[('ok',)]; assert db.execute('select score from player').fetchall()==[(17,)]" + verify := exec.CommandContext(t.Context(), "python3", "-c", verifyDB) + verify.Stdin = strings.NewReader(encoded) + if out, err := verify.CombinedOutput(); err != nil { + t.Fatalf("verify restored SQLite integrity and player data: %v\n%s", err, out) + } } got, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Get(ctx, gs, metav1.GetOptions{}) if err != nil { diff --git a/test/e2e/minecraft_bot_e2e_test.go b/test/e2e/minecraft_bot_e2e_test.go index ecd04f105..fc4b53165 100644 --- a/test/e2e/minecraft_bot_e2e_test.go +++ b/test/e2e/minecraft_bot_e2e_test.go @@ -159,6 +159,20 @@ func verifyMinecraftBackupResumesAutosave(t *testing.T, ns, gs string) { t.Helper() ensureResticRepo(t) name := gs + "-autosave" + exec := func(args ...string) (string, error) { + return envInstance.Kubectl(t.Context(), append([]string{"exec", "-n", ns, "pod/" + gs + "-0", "-c", "game", "--"}, args...)...) + } + run := func(command, expected string) { + t.Helper() + out, err := exec("rcon-cli", command) + if err != nil || !strings.Contains(out, expected) { + t.Fatalf("game command %q: %s (%v), want %q", command, out, err, expected) + } + } + worldMarker := fmt.Sprintf("world_%d", time.Now().UnixNano()) + run("data modify storage gpaudit:backup checkpoint set value "+worldMarker, "Modified storage") + run("scoreboard objectives add gp_restore dummy", "gp_restore") + run("scoreboard players set gameplane-bot gp_restore 17", "17") b := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "gameplane.local/v1alpha1", "kind": "Backup", "metadata": map[string]any{"namespace": ns, "name": name}, @@ -182,12 +196,10 @@ func verifyMinecraftBackupResumesAutosave(t *testing.T, ns, gs string) { return false, err.Error() } a := got.GetAnnotations() - return a["backup.gameplane.local/quiesce-attempted"] == "true" && + outcome, _, _ := unstructured.NestedString(got.Object, "status", "captureManifest", "quiesceOutcome") + return outcome == "verified-freeze" && a["backup.gameplane.local/quiesce-attempted"] == "true" && a["backup.gameplane.local/unquiesced-at"] != "" && a["backup.gameplane.local/cleanup-outcome"] != "source-gone", "waiting for pause and resume of the live game" }) - exec := func(args ...string) (string, error) { - return envInstance.Kubectl(t.Context(), append([]string{"exec", "-n", ns, "pod/" + gs + "-0", "-c", "game", "--"}, args...)...) - } // An old command storage file cannot satisfy a new marker. marker := fmt.Sprintf("gameplane_autosave_%d", time.Now().UnixNano()) if out, err := exec("rcon-cli", "data modify storage gpaudit:backup marker set value "+marker); err != nil || !strings.Contains(out, "Modified storage") { @@ -200,4 +212,24 @@ func verifyMinecraftBackupResumesAutosave(t *testing.T, ns, gs string) { out, err := exec("gzip", "-dc", "/data/world/data/command_storage_gpaudit.dat") return err == nil && strings.Contains(out, marker), "waiting for automatic persistence of the post-backup marker" }) + // Overwrite both independent markers. Seeing the original values after a + // real restore proves this join landed on the captured generation. + run("data modify storage gpaudit:backup checkpoint set value after_backup", "Modified storage") + run("scoreboard players set gameplane-bot gp_restore 42", "42") + run("save-all flush", "Saved the game") + restore := gs + "-restore-proof" + createRestore(t, ns, restore, gs, name) + waitRestoreSucceeded(t, ns, restore, 8*time.Minute) + s := minecraftBotSpec() + res := envInstance.RunGameProbe(t, GameProbe{ + GameNS: ns, GSName: gs, JobSuffix: "restored", Game: s.Game, Port: s.ProbePort, + Deadline: s.ProbeDeadline, ExpectDepth: joindepth.JOINED, Args: s.ProbeArgs, + }) + if res.ExitCode != 0 || res.Verdict == nil || res.Verdict.ReachedDepth != joindepth.JOINED { + t.Fatalf("join after restored-world boot: %+v", res) + } + run("data get storage gpaudit:backup checkpoint", worldMarker) + run("scoreboard players get gameplane-bot gp_restore", "17") + run("scoreboard players set gameplane-bot gp_restore 18", "18") + run("save-all flush", "Saved the game") } diff --git a/web/src/types.ts b/web/src/types.ts index 2091c1f42..c2266bb0d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -169,6 +169,7 @@ export interface ModID { // players/quiesce are agent-side and left opaque here. export interface GameCapabilities { players?: unknown; + backupFlush?: 'minecraft-save-all'; quiesce?: unknown; actions?: ServerActionDecl[]; status?: { metrics?: StatusMetricDecl[] }; From da312a55f531a784af953810bc604f766f0818f6 Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 00:48:48 -0500 Subject: [PATCH 3/5] test(backups): reject contradictory checkpoint replies and module declarations Signed-off-by: Baylem --- .../internal/validator/backup_flush_test.go | 35 +++++++++++++++++++ operator/internal/agent/client_test.go | 25 +++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 gp-module/internal/validator/backup_flush_test.go diff --git a/gp-module/internal/validator/backup_flush_test.go b/gp-module/internal/validator/backup_flush_test.go new file mode 100644 index 000000000..d5334db18 --- /dev/null +++ b/gp-module/internal/validator/backup_flush_test.go @@ -0,0 +1,35 @@ +package validator + +import ( + "fmt" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestCheckpointDeclarationMatchesAvailableAdapter(t *testing.T) { + for _, tc := range []struct { + game, adapter, extra string + valid bool + }{ + {"minecraft-java", "minecraft-save-all", "", true}, + {"ark", "minecraft-save-all", "", false}, + {"minecraft-java", "unqualified-command", "", false}, + {"minecraft-java", "minecraft-save-all", ", quiesce: {}", false}, + } { + var document yaml.Node + input := fmt.Sprintf("spec:\n game: %s\n capabilities: {backupFlush: %s%s}\n", tc.game, tc.adapter, tc.extra) + if err := yaml.Unmarshal([]byte(input), &document); err != nil { + t.Fatal(err) + } + valid := true + for _, finding := range validateTemplateSchema(&document) { + if finding.Field == "spec.capabilities.backupFlush" { + valid = false + } + } + if valid != tc.valid { + t.Errorf("checkpoint %s: accepted=%t", input, valid) + } + } +} diff --git a/operator/internal/agent/client_test.go b/operator/internal/agent/client_test.go index 987912bf7..9dbdbfe57 100644 --- a/operator/internal/agent/client_test.go +++ b/operator/internal/agent/client_test.go @@ -143,6 +143,31 @@ func TestPodBoundQuiesceNeverFallsBackToLegacyRoute(t *testing.T) { } } +func TestCheckpointAcceptsOnlyFlushedPodReply(t *testing.T) { + for _, outcome := range []string{"flushed", "resumed", "paused", "unsupported", ""} { + t.Run(outcome, func(t *testing.T) { + c, stop := newMockClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/pods/uid/identity": + w.Header().Set("Gameplane-Backup-Mode", "flush-only") + w.WriteHeader(http.StatusNoContent) + case "/v2/pods/uid/flush": + _, _ = w.Write([]byte(`{"quiesced":false,"outcome":"` + outcome + `"}`)) + default: + t.Errorf("wrong checkpoint destination: %s", r.URL.Path) + } + }) + defer stop() + if mode, err := c.PodBackupMode(t.Context(), "ns", "srv", "uid"); err != nil || mode != "flush-only" { + t.Fatalf("advertised mode %q, err=%v", mode, err) + } + if err := c.FlushPod(t.Context(), "ns", "srv", "uid"); (err == nil) != (outcome == "flushed") { + t.Fatalf("outcome %q: %v", outcome, err) + } + }) + } +} + func TestPodBoundUnquiesceAndDisabledClient(t *testing.T) { c, closeServer := newMockClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v2/pods/original-pod/unquiesce" { From 8fdcd3d376710b623eb75dff5220f46290a072e6 Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 01:01:33 -0500 Subject: [PATCH 4/5] test(backups): mutate live world and player state inside the freeze window Signed-off-by: Baylem --- test/e2e/minecraft_bot_e2e_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/e2e/minecraft_bot_e2e_test.go b/test/e2e/minecraft_bot_e2e_test.go index fc4b53165..5393df5dc 100644 --- a/test/e2e/minecraft_bot_e2e_test.go +++ b/test/e2e/minecraft_bot_e2e_test.go @@ -189,6 +189,26 @@ func verifyMinecraftBackupResumesAutosave(t *testing.T, ns, gs string) { t.Logf("backup worker logs before cleanup: %s (%v)", logs, err) } }() + // Change the live game while its acknowledged save-off protects the + // already-flushed files. Both marker values must still come from the + // earlier generation when the worker's snapshot is restored. + envInstance.Eventually(t, 5*time.Minute, func() (bool, string) { + got, err := backups.Get(t.Context(), name, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + a := got.GetAnnotations() + if a["backup.gameplane.local/unquiesced-at"] != "" { + t.Fatal("copy resumed before the live-writer exercise") + } + return a["backup.gameplane.local/quiesced-at"] != "", "waiting for the live freeze" + }) + run("data modify storage gpaudit:backup checkpoint set value during_copy", "Modified storage") + run("scoreboard players set gameplane-bot gp_restore 23", "23") + observed, err := backups.Get(t.Context(), name, metav1.GetOptions{}) + if err != nil || observed.GetAnnotations()["backup.gameplane.local/unquiesced-at"] != "" { + t.Fatalf("live writes did not complete during the freeze: %v", err) + } waitBackupSucceeded(t, ns, name, 8*time.Minute) envInstance.Eventually(t, time.Minute, func() (bool, string) { got, err := backups.Get(t.Context(), name, metav1.GetOptions{}) From d8087ec70bcc06c457110ee29a647c2b122ff5db Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 02:07:06 -0500 Subject: [PATCH 5/5] fix(i18n): register achieved consistency for checkpoint and historical backups Signed-off-by: Baylem --- i18n/locales/en/app.json | 10 +++++++++- i18n/locales/fr/.review.json | 4 +++- i18n/locales/fr/app.json | 8 +++++++- operator/hack/condition-ids/main.go | 4 +++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index af51c12f9..04772cf3a 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -207,6 +207,14 @@ "defaultMessage": "The primary CSI snapshot is crash-consistent; application quiesce was not confirmed.", "description": "Backup achieved consistency: CrashConsistentSnapshot." }, + "condition.consistency.flushedlivecopy": { + "defaultMessage": "The game acknowledged a checkpoint. Subsequent writes could change files during this copy.", + "description": "Live copy after a checkpoint with ongoing writes." + }, + "condition.consistency.legacyhookunverified": { + "defaultMessage": "The historical hook reported success without proving that writes stayed paused. The snapshot remains available for restore.", + "description": "Historical recovery point with no verified write freeze." + }, "condition.consistency.legacymetadataunknown": { "defaultMessage": "Capture-time metadata is unavailable. Do not infer consistency or volume coverage from the current template.", "description": "Backup achieved consistency: LegacyMetadataUnknown." @@ -216,7 +224,7 @@ "description": "Backup achieved consistency: LiveMultiVolumeCopy." }, "condition.consistency.quiesced": { - "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.", + "defaultMessage": "The pod-bound agent verified the game write-freeze command sequence before capture. See the manifest for included volumes.", "description": "Backup achieved consistency: Quiesced." }, "condition.consistency.rawcopy": { diff --git a/i18n/locales/fr/.review.json b/i18n/locales/fr/.review.json index 3fdd422e1..c967f3b3c 100644 --- a/i18n/locales/fr/.review.json +++ b/i18n/locales/fr/.review.json @@ -43,8 +43,10 @@ "condition.completed.pending": "fcd4ee5f73b3", "condition.completed.running": "6045c2e3f230", "condition.completed.succeeded": "ed54690bd16d", + "condition.consistency.flushedlivecopy": "e0ebbbdf143c", + "condition.consistency.legacyhookunverified": "e307255eda41", "condition.consistency.livemultivolumecopy": "8acea8f3b27e", - "condition.consistency.quiesced": "876727991b14", + "condition.consistency.quiesced": "7bf8d77f8a05", "condition.consistency.stoppedsource": "f1233d8fcfd7", "condition.datawipe.jobfailed": "f1c100d69216", "condition.failed.capture_already_in_progress": "8c6c3fb2a7bb", diff --git a/i18n/locales/fr/app.json b/i18n/locales/fr/app.json index b2cfb4cee..3054a67b6 100644 --- a/i18n/locales/fr/app.json +++ b/i18n/locales/fr/app.json @@ -10480,8 +10480,14 @@ "condition.consistency.legacymetadataunknown": { "defaultMessage": "Les métadonnées au moment de la capture sont indisponibles. Ne déduisez pas la cohérence ou la couverture des volumes à partir du modèle actuel." }, + "condition.consistency.legacyhookunverified": { + "defaultMessage": "L’ancien mécanisme de sauvegarde a signalé un succès sans vérifier que les écritures restaient suspendues. L’instantané reste disponible pour une restauration." + }, + "condition.consistency.flushedlivecopy": { + "defaultMessage": "Le jeu a confirmé l’enregistrement d’un point de sauvegarde. Des écritures ultérieures ont pu modifier les fichiers pendant la copie." + }, "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." + "defaultMessage": "Avant la capture, l’agent du pod ciblé a vérifié les commandes de suspension des écritures du jeu. Consultez le manifeste pour connaître les volumes inclus." }, "condition.consistency.rawcopy": { "defaultMessage": "Le volume principal a été copié sans confirmation de la mise au repos de l'application." diff --git a/operator/hack/condition-ids/main.go b/operator/hack/condition-ids/main.go index b22008df3..ce35a46aa 100644 --- a/operator/hack/condition-ids/main.go +++ b/operator/hack/condition-ids/main.go @@ -184,7 +184,9 @@ var rows = []row{ {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 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: "LegacyHookUnverified", english: "The historical hook reported success without proving that writes stayed paused. The snapshot remains available for restore.", description: "Historical recovery point with no verified write freeze."}, + {types: []string{"Consistency"}, reason: "FlushedLiveCopy", english: "The game acknowledged a checkpoint. Subsequent writes could change files during this copy.", description: "Live copy after a checkpoint with ongoing writes."}, + {types: []string{"Consistency"}, reason: "Quiesced", english: "The pod-bound agent verified the game write-freeze command sequence before capture. See the manifest for included 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."},