Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c6705ed
fix(operator): serialize backup restore and wipe lifecycle ownership
Baylem Oct 21, 2026
1a8eac0
test(operator): align backup fixtures with durable lifecycle admission
Baylem Oct 21, 2026
77bb5b1
fix: back up stopped servers and bind quiesce to pod identity
Baylem Oct 21, 2026
607f226
feat: record backup consistency and captured volume coverage
Baylem Oct 21, 2026
90f75f1
merge: integrate stopped-source backup consistency
Baylem Oct 21, 2026
e1c0f31
fix: keep bound CSI restores on the snapshot reconcile path
Baylem Oct 21, 2026
3e16560
merge: include CSI restore routing correction
Baylem Oct 21, 2026
adb37ab
merge: include CSI restore routing correction
Baylem Oct 21, 2026
a223a9e
merge: include shared web build prerequisite
Baylem Oct 21, 2026
afb4eaa
merge: include shared web build prerequisite
Baylem Oct 21, 2026
fe3b965
docs: describe pod-bound agent operations and clarify test fakes
Baylem Oct 21, 2026
4e2bfea
fix: include backup agent interface CI corrections
Baylem Oct 21, 2026
8307bb7
fix(operator): preserve condition changes before status comparison
Baylem Oct 22, 2026
5743a42
fix(ci): stabilize event stream checks and image builds
Baylem Oct 23, 2026
00c07a2
fix(operator): drain failed restore startup before releasing ownership
Baylem Oct 23, 2026
aaa93d3
test: preserve controller updates in lifecycle fixtures
Baylem Oct 23, 2026
af2cf91
test: synchronize closed RCON response fixtures
Baylem Oct 23, 2026
230e96b
fix: allow guarded wipe recovery to drain workloads
Baylem Oct 24, 2026
9f99fcb
Merge branch 'master' into fix/audit-backup-metadata
ValgulNecron Oct 25, 2026
a1512a7
test: keep stopped backup fixtures unique in metadata coverage
Baylem Oct 25, 2026
8f11cfe
chore: reconcile backup metadata with upstream and CI lint
Baylem Oct 25, 2026
be6255e
chore: reconcile backup metadata with upstream placement
Baylem Oct 27, 2026
5d56c4c
chore: reconcile backup metadata CRDs with upstream
Baylem Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions agent/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ func main() {
protected.Use(authCheck.Middleware, target.Guard(serverUID))
mountProtected(protected)
})
// Backups bind save-off/save-on to the process they prepared. The service
// may already route to a replacement pod, which must reject the old UID.
r.Route("/v1/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) })
quiesce.Mount(protected, rconClient, gameName, quiesceSpec)
})

srv := &http.Server{
Addr: addr,
Expand Down
28 changes: 28 additions & 0 deletions agent/internal/quiesce/quiesce_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"

"github.com/GameplanePanel/gameplane/agent/internal/caps"
"github.com/GameplanePanel/gameplane/agent/internal/target"
)

type fakeRcon struct {
Expand Down Expand Up @@ -86,6 +87,33 @@ func newTestRouter(rc Rcon, spec *caps.Quiesce) *chi.Mux {
return r
}

func TestPodBoundRoutesRejectReplacementBeforeRCON(t *testing.T) {
for _, actual := range []string{"original", "replacement", ""} {
t.Run("pod="+actual, func(t *testing.T) {
rc := &fakeRcon{}
r := chi.NewRouter()
r.Route("/v1/pods/{uid}", func(bound chi.Router) {
bound.Use(target.Guard(actual))
Mount(bound, rc, "test", minecraftQuiesceSpec())
})
for _, path := range []string{"quiesce", "unquiesce"} {
response := httptest.NewRecorder()
r.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/pods/original/"+path, nil))
if actual == "original" {
if response.Code != http.StatusOK {
t.Fatalf("matching process rejected: %d", response.Code)
}
} else if response.Code != http.StatusNotFound {
t.Fatalf("wrong process admitted: %d", response.Code)
}
}
if (len(rc.calls()) > 0) != (actual == "original") {
t.Fatalf("commands reached wrong process: %v", rc.calls())
}
})
}
}

func decodeResponse(t *testing.T, body []byte) response {
t.Helper()
var got response
Expand Down
11 changes: 6 additions & 5 deletions agent/internal/target/target.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Package target binds versioned agent routes to an immutable GameServer UID.
// Package target binds versioned agent routes to an immutable resource UID.
package target

import (
Expand All @@ -7,12 +7,13 @@ import (
"github.com/go-chi/chi/v5"
)

// Guard rejects a route for any server other than the one that started this
// agent. Legacy routes stay available for local callers during rolling upgrades.
func Guard(serverUID string) func(http.Handler) http.Handler {
// Guard rejects a route for any resource other than the one that started this
// agent (GameServer or Pod, according to the route). Legacy routes stay available
// for local callers during rolling upgrades.
func Guard(resourceUID string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if serverUID == "" || chi.URLParam(req, "uid") != serverUID {
if resourceUID == "" || chi.URLParam(req, "uid") != resourceUID {
http.NotFound(w, req)
return
}
Expand Down
3 changes: 3 additions & 0 deletions agent/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ Resource usage env vars (set by the operator):
| `/players/banned` | GET | Currently banned players; response: `[ { name, reason?, source? } ]`; 502 if RCON unavailable |
| `/quiesce` | POST | Pause auto-saves before snapshot; response: `{ quiesced, reason? }` (boolean, reason optional); unsupported games return `quiesced: false` with a reason |
| `/unquiesce` | POST | Resume auto-saves after snapshot; response: `{ quiesced, reason? }` |
| `/v1/pods/{uid}/identity` | GET | Operator preflight: 204 only when `{uid}` matches `GAMEPLANE_POD_UID`; never mutates game state |
| `/v1/pods/{uid}/quiesce`, `/v1/pods/{uid}/unquiesce` | POST | Pod-bound backup RPCs with the same response contract as the legacy routes; reject missing/mismatched Pod UID with 404 before RCON |
| `/lifecycle/stop` | POST | Run stop sequence before scale-to-zero; RCON connection drop is the expected outcome |
| `/actions/run` | POST | Execute module-declared operator action; request: `{ id, params }`; response: `{ ok, raw? }` |
| `/status` | GET | Live game metrics (module-declared); response: bare JSON array `[ { id, displayName?, value, unit? } ]` |
Expand Down Expand Up @@ -209,6 +211,7 @@ All endpoints on the `--addr` control mux, except `/healthz`, return `401 Unauth
- **Extracted mod files stay world-readable**: `mods.moduleFileMode` is `0o644`, not a tighter `0o600`, because the mods volume is shared with the game container, which runs as whatever uid its image needs — a different uid than the agent's own. At `0o600` the game process couldn't read its own mods. This is the rationale behind the `.golangci.yml` gosec G302 exclusion scoped to `agent/internal/mods/mods.go`.
- **Resource usage is in-pod**: The `usage` package reads from `/proc` or cgroups; no external metrics pipeline required. Cgroup mode is a fallback for older clusters; proc mode (default in production) requires the operator to set `ShareProcessNamespace: true`.
- **Module capabilities drive behavior**: Every game-specific handler (players, quiesce, lifecycle, status, actions) reads its config from `--capabilities` (JSON unmarshaled into `caps.Spec`). New games require no agent code change.
- **Backup process identity**: The operator injects `GAMEPLANE_POD_UID` from `metadata.uid` through the DownwardAPI. Pod-bound backup routes require the existing mTLS authentication and check this UID at the final network hop, including when the Service resolves to a replacement pod. Missing UID fails closed. Deploy the updated agent image and allow pod rollout when upgrading the operator; older agents return 404 and the operator does not fall back to unbound routes. Legacy endpoints remain available to existing callers during rollout.
- **RCON connection errors are graceful**: A lost RCON connection does not crash the agent. `console`, `players`, `quiesce`, and `lifecycle` handlers catch connection errors and return appropriate HTTP status (e.g., `502 Bad Gateway`).
- **Source RCON sends complete frames**: Each AUTH or command frame is submitted in one socket write, with its existing byte-size limit, request ID, type, body, and two NUL terminators preserved. This avoids separate header-only writes rejected by vanilla Minecraft's RCON reader. A short write is an error and drops the connection without replaying the command; response grace and healthy connection reuse remain unchanged.
- **Log streams are tail-only**: The `logs` package does not support random-access reads. It streams from the current end (live mode) or from file start (backlog mode); clients must handle partial output and reconnection.
Expand Down
4 changes: 3 additions & 1 deletion api/internal/handlers/resources_annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func TestGenericServerWritesCannotInjectLifecycleAnnotations(t *testing.T) {
wipeRequestedAnnotation: "injected",
"gameplane.local/wipe-data-completed": "forged",
"gameplane.local/restore-guard": "forged",
"operations.gameplane.local/owner": "forged",
"gameserver.gameplane.local/stop-requested-at": "forged",
"restore.gameplane.local/created-by": "forged",
ownerIDAnnotation: "999",
Expand All @@ -51,7 +52,7 @@ func TestGenericServerWritesCannotInjectLifecycleAnnotations(t *testing.T) {
t.Fatalf("write failed: %d %s", response.Code, response.Body)
}
got := serverAnnotations(t, k, "alpha")
for _, key := range []string{wipeRequestedAnnotation, "gameplane.local/wipe-data-completed", "gameplane.local/restore-guard", "gameserver.gameplane.local/stop-requested-at", "restore.gameplane.local/created-by"} {
for _, key := range []string{wipeRequestedAnnotation, "gameplane.local/wipe-data-completed", "gameplane.local/restore-guard", "operations.gameplane.local/owner", "gameserver.gameplane.local/stop-requested-at", "restore.gameplane.local/created-by"} {
if _, found := got[key]; found {
t.Errorf("generic write injected %s", key)
}
Expand Down Expand Up @@ -98,6 +99,7 @@ func TestGenericServerUpdatePreservesControllerState(t *testing.T) {
wipeRequestedAnnotation: "pending",
"gameplane.local/wipe-data-completed": "previous",
"gameplane.local/backup-guard": "active-backup",
"operations.gameplane.local/owner": `{"kind":"Backup","name":"copy","uid":"copy-uid","targetUID":"server-uid","generation":1}`,
"gameserver.gameplane.local/stop-requested-at": "original-time",
"restore.gameplane.local/created-by": "original-restore",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: backups.gameplane.local
spec:
Expand Down Expand Up @@ -129,6 +129,75 @@ spec:
status:
description: BackupStatus is the observed state of a Backup.
properties:
captureManifest:
description: |-
CaptureManifest is recorded once, before the first capture worker starts.
It describes persistent-volume coverage independently of copy success.
Missing on pre-upgrade backups; never reconstruct it from current layout.
properties:
layoutSource:
description: LayoutSource identifies the frozen workload used
for mount paths.
type: string
quiesceOutcome:
description: |-
QuiesceOutcome is confirmed, already-stopped, unsupported, not-requested,
agent-unavailable, or unknown, independent of subsequent copy success.
type: string
recordedAt:
format: date-time
type: string
serverUID:
type: string
state:
description: |-
State is Recorded for new captures, LegacyUnknown for an existing worker
whose original layout was never recorded.
enum:
- Recorded
- LegacyUnknown
type: string
strategy:
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
mountPaths:
items:
type: string
type: array
reason:
type: string
required:
- claimName
- classification
- included
- reason
type: object
type: array
required:
- state
type: object
completionTime:
format: date-time
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: backupschedules.gameplane.local
spec:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: clusters.gameplane.local
spec:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: gameservers.gameplane.local
spec:
Expand Down Expand Up @@ -2212,6 +2212,16 @@ spec:
primary Storage.MountPath, since these exist precisely for directories
that share no safe common parent with it.
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.
enum:
- data
- rebuildable
type: string
mountPath:
description: MountPath is the absolute in-container path
this volume is mounted at.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: gametemplates.gameplane.local
spec:
Expand Down Expand Up @@ -2166,6 +2166,16 @@ spec:
primary Storage.MountPath, since these exist precisely for directories
that share no safe common parent with it.
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.
enum:
- data
- rebuildable
type: string
mountPath:
description: MountPath is the absolute in-container path
this volume is mounted at.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: modules.gameplane.local
spec:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: modulesources.gameplane.local
spec:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: networkcaptures.gameplane.local
spec:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
gameplane.local/crd-bundle-sha256: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b
gameplane.local/crd-bundle-sha256: 72656b2cb852f655e5e8b168918915102fcb5616b76dc73ebc8971942126de86
controller-gen.kubebuilder.io/version: v0.22.0
name: restores.gameplane.local
spec:
Expand Down
Loading
Loading