Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
c0e8f40
feat(security): add Restricted workloads and per-server node capture …
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
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
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
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
2cffa4d
ci: require and report every audit E2E bucket
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
95e38d0
fix: carry capture verification fixes into staged rollouts
Baylem Oct 21, 2026
69c73eb
fix: carry operator upgrade migration into staged rollouts
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
4d2bfc6
fix: include restricted upgrade corrections in staged rollouts
Baylem Oct 22, 2026
db5db7c
fix(operator): retain failed restore ownership until startup drains
Baylem Oct 23, 2026
f118220
fix(ci): retain backup and rollout coverage with upstream selection
Baylem Oct 23, 2026
0e0d5d9
test: preserve controller updates in lifecycle fixtures
Baylem Oct 23, 2026
2081831
test: synchronize closed RCON response fixtures
Baylem Oct 23, 2026
575a912
test: verify shared PID survives restricted capture
Baylem Oct 24, 2026
32addfe
fix: allow guarded wipe recovery to drain workloads
Baylem Oct 24, 2026
dd8c9bb
fix: carry reviewed security and chart prerequisites
Baylem Oct 25, 2026
38c69aa
chore: reconcile staged rollouts with upstream and CI lint
Baylem Oct 25, 2026
22494f1
chore: reconcile staged rollouts and refresh CI inputs
Baylem Oct 27, 2026
ecee255
chore: reconcile rollout 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
20 changes: 15 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,8 @@ 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: operator Secret namespace permissions and cache configuration
Expand Down Expand Up @@ -1824,7 +1826,7 @@ 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, management-placement]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, restricted-workloads, management-placement]
arch: [amd64]
include:
- bucket: operator
Expand Down Expand Up @@ -1864,6 +1866,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 @@ -1937,7 +1943,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 @@ -1973,6 +1979,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 @@ -2697,8 +2707,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|management-placement) \//.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) \//.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 @@ -2850,7 +2860,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|management-placement) \//);
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads|management-placement) \//);
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
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
111 changes: 111 additions & 0 deletions api/internal/handlers/capture_node.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package handlers

import (
"encoding/json"
"errors"
"net/http"
"net/url"

"github.com/GameplanePanel/gameplane/api/internal/auth"
"github.com/GameplanePanel/gameplane/api/internal/httperr"
"github.com/GameplanePanel/gameplane/api/internal/kube"
"github.com/GameplanePanel/gameplane/api/internal/scope"
"github.com/go-chi/chi/v5"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)

func (h *captureHandler) captureNodeEnable(w http.ResponseWriter, r *http.Request) {
h.captureNodeOptIn(w, r, true)
}
func (h *captureHandler) captureNodeDisable(w http.ResponseWriter, r *http.Request) {
h.captureNodeOptIn(w, r, false)
}

func (h *captureHandler) captureNodeOptIn(w http.ResponseWriter, req *http.Request, enabled bool) {
k, ok := resolveCluster(w, req, h.reg)
if !ok {
return
}
ns, ok := resolveNS(w, req)
if !ok {
return
}
name := chi.URLParam(req, "name")
u := auth.UserFromContext(req.Context())
if u == nil || (u.Role != "admin" && !u.Can("*", true, scope.RequestedCluster(req), "*")) {
if h.auditWriteOrFail(w, req, req.Method, req.URL.Path, name, "node_diagnostics_admin_required", http.StatusForbidden) {
httperr.WriteCode(w, req, http.StatusForbidden, errors.New("node diagnostic exception requires administrator privileges"))
}
return
}
gs, err := k.GetServer(req.Context(), ns, name)
if err != nil {
httperr.Write(w, req, err)
return
}
if gs.GetDeletionTimestamp() != nil {
httperr.WriteCode(w, req, http.StatusConflict, errors.New("server is terminating"))
return
}
reason := "node_diagnostics_opt_out"
if enabled {
reason = "node_diagnostics_opt_in_host_privileges"
}
// Record intent synchronously before granting host capabilities. A failed
// audit write prevents the exception; Kubernetes status remains separate.
if !h.auditWriteOrFail(w, req, req.Method, req.URL.Path, name+":"+string(gs.GetUID()), reason, http.StatusOK) {
return
}
patch, err := json.Marshal(map[string]any{"metadata": map[string]any{"uid": string(gs.GetUID()), "resourceVersion": gs.GetResourceVersion()}, "spec": map[string]any{"capture": map[string]any{"nodeDiagnostic": enabled}}})
if err != nil {
httperr.Write(w, req, err)
return
}
_, err = k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(ns).Patch(req.Context(), name, types.MergePatchType, patch, metav1.PatchOptions{})
if err != nil {
if h.auditWriteOrFail(w, req, req.Method, req.URL.Path, name, "node_diagnostics_update_failed", http.StatusInternalServerError) {
httperr.Write(w, req, err)
}
return
}
if !enabled {
if err := h.stopActiveCaptures(req.Context(), k, ns, name); err != nil {
httperr.Write(w, req, err)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"nodeDiagnostic": enabled})
}

func (h *captureHandler) proxyNodeCaptureDownload(w http.ResponseWriter, req *http.Request, gs *kube.GameServer, nc *kube.NetworkCapture) int {
bound, err := captureTarget(scope.RequestedCluster(req), gs, nc)
if err != nil {
http.NotFound(w, req)
return http.StatusNotFound
}
name, ns, _, err := kube.NodeCaptureService(nc)
if err != nil {
httperr.Write(w, req, err)
return http.StatusInternalServerError
}
host, ok := newValidatedHost(name, ns)
if !ok || h.tlsClient == nil {
http.Error(w, "capture artifacts unavailable", http.StatusServiceUnavailable)
return http.StatusServiceUnavailable
}
u := url.URL{Scheme: "https", Host: string(host), Path: "/v1/targets/" + bound.UID + "/captures/" + bound.Capture + "/uids/" + bound.CaptureUID + "/file"}
up, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil)
if err != nil {
httperr.Write(w, req, err)
return http.StatusInternalServerError
}
copyProxyHeaders(up.Header, req.Header)
resp, err := h.tlsClient.Do(up)
if err != nil {
return writeUpstreamError(w, req, err)
}
defer func() { _ = resp.Body.Close() }()
return streamCaptureResponse(w, req, resp, nc.Name)
}
Loading
Loading