Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
ca3f6b0
fix(api): authorize snapshot data sources against live Backup owners
Baylem Oct 21, 2026
6cd31f8
test(api): satisfy snapshot regression lint
Baylem Oct 21, 2026
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
1871b88
feat(operator): reserve auxiliary resources and expose management pla…
Baylem Oct 21, 2026
c0e8f40
feat(security): add Restricted workloads and per-server node capture …
Baylem Oct 21, 2026
94b7fd7
fix: handle wrapped decoder EOF in placement drill
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
3e44ff2
merge: compose reviewed backup and resource prerequisites
Baylem Oct 21, 2026
32300a5
merge: include shared web build prerequisite
Baylem Oct 21, 2026
57f2cab
merge: include shared web build prerequisite
Baylem Oct 21, 2026
13594aa
merge: include shared web build prerequisite
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
2dd81f0
fix(api): reject unfinished and aborted snapshot sources
Baylem Oct 21, 2026
4ef2b27
feat(operator): stage immutable template revisions per server
Baylem Oct 21, 2026
34e8a25
merge: preserve backup preparation and pinned coverage metadata
Baylem Oct 21, 2026
e0d67c6
merge: validate staged revisions against workload security
Baylem Oct 21, 2026
7ee7966
merge: retain configurable resources in staged workload inputs
Baylem Oct 21, 2026
16fdc13
fix: confine capture identity creation to its output directory
Baylem Oct 21, 2026
8434483
fix: include capture output confinement follow-up
Baylem Oct 21, 2026
9e2c2ea
fix: prepare backup workers before quiesce and bound CSI captures
Baylem Oct 21, 2026
fe3b965
docs: describe pod-bound agent operations and clarify test fakes
Baylem Oct 21, 2026
2c16b6e
fix: include backup agent interface CI corrections
Baylem Oct 21, 2026
b029fcd
ci: expand restricted workload checks on both architectures
Baylem Oct 21, 2026
9d2c311
ci: require and report every audit E2E bucket
Baylem Oct 21, 2026
2cffa4d
ci: require and report every audit E2E bucket
Baylem Oct 21, 2026
9be2906
fix: integrate staged templates with prepared backup workers
Baylem Oct 21, 2026
169a364
fix: include final backup interface and security CI corrections
Baylem Oct 21, 2026
10eb9a7
fix: include API authorization for completed snapshot sources
Baylem Oct 21, 2026
6663821
fix: align capture fixtures with isolated credentials
Baylem Oct 21, 2026
7ff8955
test: model backup status writes in revision fixtures
Baylem Oct 21, 2026
cc3cf86
test: exercise backup preparation and asynchronous cleanup
Baylem Oct 21, 2026
95e38d0
fix: carry capture verification fixes into staged rollouts
Baylem Oct 21, 2026
fd5d3d5
fix(chart): migrate operator strategy before applying Recreate
Baylem Oct 21, 2026
69c73eb
fix: carry operator upgrade migration into staged rollouts
Baylem Oct 21, 2026
06009ef
fix: integrate reviewed capture and upgrade corrections
Baylem Oct 21, 2026
9c268d8
test: update envtest references to the Restic worker script
Baylem Oct 21, 2026
3ab227e
fix(e2e): isolate rollout fixtures and observe workload stops
Baylem Oct 22, 2026
0e7282d
fix(operator): preserve condition changes before status comparison
Baylem Oct 22, 2026
d3968bb
fix(security): preserve shared config access through restricted upgrades
Baylem Oct 22, 2026
34efe8d
fix(operator): reject restricted host ports before template admission
Baylem Oct 22, 2026
c62ce58
fix(backups): persist snapshot cleanup condition transitions
Baylem Oct 22, 2026
4d2bfc6
fix: include restricted upgrade corrections in staged rollouts
Baylem Oct 22, 2026
96cd7d8
fix: carry reviewed rollout and security fixes into backup budgets
Baylem Oct 22, 2026
ed8a240
fix(operator): wait for source scheduling before binding backup worker
Baylem Oct 23, 2026
c02531a
fix(operator): retain failed restore ownership until startup drains
Baylem Oct 23, 2026
1575438
fix(ci): retain backup and rollout coverage with upstream selection
Baylem Oct 23, 2026
2bc395d
test: reuse scheduled backup source fixture
Baylem Oct 23, 2026
60af93b
test: preserve controller updates in lifecycle fixtures
Baylem Oct 23, 2026
3b9ab20
test: synchronize closed RCON response fixtures
Baylem Oct 23, 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
37 changes: 30 additions & 7 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,8 @@ jobs:
needs: [changes]
if: (needs.changes.outputs.images == 'true') || contains(fromJSON(needs.changes.outputs.ci-jobs || '[]'), 'build-images')
runs-on: ubuntu-latest
timeout-minutes: 15
# Include all image builds, cache exports, artifact upload and cleanup.
timeout-minutes: 30
permissions:
contents: read
steps:
Expand Down Expand Up @@ -1232,6 +1233,10 @@ jobs:
run: python3 charts/tests/check_gateway.py
- name: standalone panel isolation and persistence chart renders
run: python3 charts/tests/check_standalone.py
- name: workload security chart contract
run: python3 charts/tests/check_workload_security.py
- name: auxiliary resources and management placement chart renders
run: python3 charts/tests/check_workloads.py
- name: sanity-check rendered manifests
run: |
grep -q 'kind: Deployment' /tmp/rendered.yaml
Expand Down Expand Up @@ -1793,13 +1798,21 @@ jobs:
# (operator does zero logins and is wait-dominated, so it runs
# wide; the api buckets stay within the login limiter's budget).
# The arm64 job below uses the same steps and per-bucket settings.
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, restricted-workloads, management-placement, backup-preparation]
arch: [amd64]
include:
- bucket: operator
parallel: 8
test_timeout: 20m
job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite.
- bucket: management-placement
parallel: 1
test_timeout: 15m
job_timeout: 60
- bucket: backup-preparation
parallel: 1
test_timeout: 15m
job_timeout: 60
- bucket: api-auth
parallel: 4
test_timeout: 15m
Expand Down Expand Up @@ -1829,6 +1842,10 @@ jobs:
parallel: 1
test_timeout: 20m # one lifecycle test: helm upgrade, then mcp, syslog, frp tunnel and core checks in order
job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite.
- bucket: restricted-workloads
parallel: 1
test_timeout: 25m # Includes real projected-token rotation and node capture cleanup.
job_timeout: 60
env:
CLUSTER: gameplane-e2e
CTX: kind-gameplane-e2e
Expand Down Expand Up @@ -1856,7 +1873,9 @@ jobs:
arch: ${{ matrix.arch }}
- name: bring up cluster + helm install
if: env.RUN_REGEX != '' || env.TAIL_REGEX != ''
run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e
env:
GAMEPLANE_E2E_PROFILE: ${{ (matrix.bucket == 'management-placement' || matrix.bucket == 'backup-preparation') && 'management' || 'default' }}
run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e "$GAMEPLANE_E2E_PROFILE"
- name: run go e2e (${{ matrix.bucket }})
if: env.RUN_REGEX != ''
run: |
Expand Down Expand Up @@ -1900,7 +1919,7 @@ jobs:
# (operator does zero logins and is wait-dominated, so it runs
# wide; the api buckets stay within the login limiter's budget).
# Mirrors e2e-go's steps and per-bucket settings.
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, restricted-workloads]
arch: [arm64]
include:
- bucket: operator
Expand Down Expand Up @@ -1936,6 +1955,10 @@ jobs:
parallel: 1
test_timeout: 20m # one lifecycle test: helm upgrade, then mcp, syslog, frp tunnel and core checks in order
job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite.
- bucket: restricted-workloads
parallel: 1
test_timeout: 25m # Includes real projected-token rotation and node capture cleanup.
job_timeout: 60
env:
CLUSTER: gameplane-e2e
CTX: kind-gameplane-e2e
Expand Down Expand Up @@ -2660,8 +2683,8 @@ jobs:
'go-e2e-unit': (n) => n === 'go (test/e2e)',
'api-postgres': (n) => n === 'api (postgres)',
'e2e-buckets': (n) => n === 'e2e bucket coverage',
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//.test(n) && n.includes('/ amd64 '),
'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//.test(n) && n.includes('/ arm64 '),
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads|management-placement|backup-preparation) \//.test(n) && n.includes('/ amd64 '),
'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads) \//.test(n) && n.includes('/ arm64 '),
'e2e-multicluster': (n) => n.startsWith('e2e multicluster /') && n.includes('/ amd64 '),
'e2e-multicluster-arm64': (n) => n.startsWith('e2e multicluster /') && n.includes('/ arm64 '),
'e2e-upgrade': (n) => n.startsWith('e2e upgrade /') && n.includes('/ amd64 '),
Expand Down Expand Up @@ -2813,7 +2836,7 @@ jobs:
const bucketSet = new Set();
for (const j of apiJobs) {
const n = j.name || '';
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//);
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads|management-placement|backup-preparation) \//);
if (m) bucketSet.add(m[1]);
if (/^e2e multicluster \//.test(n)) bucketSet.add('multicluster');
if (/^e2e upgrade \//.test(n)) bucketSet.add('upgrade');
Expand Down
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
29 changes: 29 additions & 0 deletions agent/internal/rcon/nuclearoption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1275,18 +1275,47 @@ type pipeFrameCase struct {
forbidErr string // substring the error must NOT contain (e.g. a detail suffix)
}

type responseDeadlineConn struct {
net.Conn
deadlineSet chan<- struct{}
}

func (c *responseDeadlineConn) SetDeadline(deadline time.Time) error {
if err := c.Conn.SetDeadline(deadline); err != nil {
return err
}
select {
case c.deadlineSet <- struct{}{}:
default:
}
return nil
}

// runPipeFrameCases sends each case's raw frame back to the client over net.Pipe
// and checks the body or the error.
func runPipeFrameCases(t *testing.T, tests []pipeFrameCase) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deadlineSet := make(chan struct{}, 1)
client := newPipeClient(t, func(remote net.Conn) {
if _, _, err := readNuclearOptionRequest(remote); err != nil {
return
}
if len(tt.frame) == 0 {
// Exec sets its response deadline after sending the request.
// Closing first can fail SetDeadline instead of the status read.
select {
case <-deadlineSet:
case <-t.Context().Done():
return
}
}
writeFrameSegments(remote, tt.frame, tt.oneByte)
})
if len(tt.frame) == 0 {
client.conn = &responseDeadlineConn{Conn: client.conn, deadlineSet: deadlineSet}
}

body, err := client.Exec("test-command")
if tt.wantErr != "" {
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
13 changes: 13 additions & 0 deletions agent/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@

## Purpose

### Kubernetes token projection (#714)

Game Pods disable automatic credentials. The agent alone mounts a rotating
projected token plus kube-root CA and namespace at client-go's standard path;
`rest.InClusterConfig` preserves `BearerTokenFile` refresh. No token is mounted
into game/config-init. Shared PID remains intentional for process metrics and
is residual in-Pod trust; mount isolation does not make the agent safe from
all processes in its Pod. CI exercises live projection rotation and a heartbeat
after the original short token expires.

The agent is a per-pod HTTP/HTTPS sidecar that runs inside every game pod to expose the game server's operations to the control plane and dashboard. It translates dashboard requests into game-protocol actions (RCON, file I/O, container logs, player queries) and reports liveness, resource usage, and player metrics back to the operator via Kubernetes API patches, decoupling game-specific behavior from the cluster control plane.

## Responsibilities
Expand Down Expand Up @@ -177,6 +187,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 +221,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
6 changes: 6 additions & 0 deletions api/internal/gateway/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ func (h *handler) serveCapture(w http.ResponseWriter, req *http.Request, target
}
defer transport.CloseIdleConnections()
host := target.Name + "-agent." + target.Namespace + ".svc.cluster.local:9091"
if name, namespace, node, err := kube.NodeCaptureService(nc); err != nil {
http.NotFound(w, req)
return
} else if node {
host = name + "-agent." + namespace + ".svc.cluster.local:9091"
}
proxy := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(pr *httputil.ProxyRequest) {
Expand Down
12 changes: 10 additions & 2 deletions api/internal/handlers/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ func MountCapture(r chi.Router, reg *kube.Registry, auditor *audit.Auditor, cfg
}
r.Post("/servers/{name}:capture-enable", h.captureEnable)
r.Post("/servers/{name}:capture-disable", h.captureDisable)
r.Post("/servers/{name}:capture-node-enable", h.captureNodeEnable)
r.Post("/servers/{name}:capture-node-disable", h.captureNodeDisable)
r.Post("/servers/{name}:capture-start", h.captureStart)
r.Post("/servers/{name}:capture-stop", h.captureStop)
r.Get("/servers/{name}:captures", h.captureList)
Expand Down Expand Up @@ -957,7 +959,9 @@ func (h *captureHandler) captureDelete(w http.ResponseWriter, req *http.Request)
}
deleteErr = deleteCaptureWithUID(req.Context(), k, nc)
} else {
h.deleteSidecarCaptureFile(req.Context(), gs.Name, gs.Namespace, captureID)
if nc.Annotations[kube.NodeCaptureNamespaceAnnotation] == "" {
h.deleteSidecarCaptureFile(req.Context(), gs.Name, gs.Namespace, captureID)
}
deleteErr = k.DeleteNetworkCapture(req.Context(), ns, captureID)
}

Expand Down Expand Up @@ -1099,7 +1103,11 @@ func (h *captureHandler) captureDownload(w http.ResponseWriter, req *http.Reques
if isRemoteCluster(req) {
status = h.remoteCaptureDownload(w, req, gs, nc)
} else {
status = h.proxyFileDownload(w, req, host, captureID)
if _, _, node, _ := kube.NodeCaptureService(nc); node {
status = h.proxyNodeCaptureDownload(w, req, gs, nc)
} else {
status = h.proxyFileDownload(w, req, host, captureID)
}
}
if status < http.StatusOK || (status >= http.StatusMultipleChoices && status != http.StatusNotModified) {
// A disconnect must not also erase the corrective audit outcome.
Expand Down
Loading
Loading