Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
name: gameservers.gameplane.local
spec:
Expand Down Expand Up @@ -2215,6 +2215,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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
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: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c
gameplane.local/crd-bundle-sha256: fc63992190029342d40e8b7cc96ce57b24527f03275df038524b0a3a3e22ae6e
controller-gen.kubebuilder.io/version: v0.20.1
name: restores.gameplane.local
spec:
Expand Down
Loading
Loading