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: 4 additions & 0 deletions i18n/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,10 @@
"defaultMessage": "The server could not be resumed after the backup. Its world may still have auto-save turned off.",
"description": "Unquiesced condition when the unfreeze failed."
},
"condition.unquiesced.sourceprocessgone": {
"defaultMessage": "The original game process has exited. No resume command was needed.",
"description": "Unquiesced condition when the original source pod no longer runs."
},
"condition.unquiesced.unquiesced": {
"defaultMessage": "The server was resumed after the backup.",
"description": "Unquiesced condition when the world was unfrozen."
Expand Down
1 change: 1 addition & 0 deletions operator/hack/condition-ids/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ var rows = []row{
{types: []string{"Completed"}, reason: "Pending", english: "The backup is waiting to start.", description: "Completed condition on a Backup that has not started (dynamic: reason is the phase).", dynamic: true},
{types: []string{"Completed"}, reason: "Running", english: "The backup is running.", description: "Completed condition on a Backup in progress (dynamic: reason is the phase).", dynamic: true},
{types: []string{"Unquiesced"}, reason: "Unquiesced", english: "The server was resumed after the backup.", description: "Unquiesced condition when the world was unfrozen."},
{types: []string{"Unquiesced"}, reason: "SourceProcessGone", english: "The original game process has exited. No resume command was needed.", description: "Unquiesced condition when the original source pod no longer runs."},
{types: []string{"Unquiesced"}, reason: "Failed", english: "The server could not be resumed after the backup. Its world may still have auto-save turned off.", description: "Unquiesced condition when the unfreeze failed."},
{types: []string{"SnapshotForgotten"}, reason: "Forgotten", english: "The snapshot was removed from the backup repository.", description: "SnapshotForgotten condition after a deleted Backup's snapshot was forgotten."},
{types: []string{"SnapshotForgotten"}, reason: "ForgetAbandoned", english: "The snapshot was not removed from the backup repository when the backup was deleted.", description: "SnapshotForgotten condition when the forget step was abandoned."},
Expand Down
42 changes: 42 additions & 0 deletions operator/internal/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
Expand Down Expand Up @@ -119,6 +120,47 @@ func (c *Client) Unquiesce(ctx context.Context, namespace, server string) error
return c.callQuiesce(ctx, namespace, server, "/unquiesce", false)
}

// QuiesceEnabled reports whether this client can perform quiesce operations.
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 {
if c.Disabled || podUID == "" {
return fmt.Errorf("agent: pod identity check requires an enabled client and pod UID")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, agentURL(namespace, server, "/v1/pods/"+url.PathEscape(podUID)+"/identity"), nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
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 nil
}

// QuiescePod disables saving only on the selected Pod's agent.
func (c *Client) QuiescePod(ctx context.Context, namespace, server, podUID string) error {
return c.callPodQuiesce(ctx, namespace, server, podUID, "/quiesce", true)
}

// UnquiescePod restores saving only on the selected Pod's agent.
func (c *Client) UnquiescePod(ctx context.Context, namespace, server, podUID string) error {
return c.callPodQuiesce(ctx, namespace, server, podUID, "/unquiesce", false)
}

func (c *Client) callPodQuiesce(ctx context.Context, namespace, server, podUID, path string, expectQuiesced bool) error {
if c.Disabled || podUID == "" {
return fmt.Errorf("agent: pod-bound quiesce requires an enabled client and pod UID")
}
return c.callQuiesce(ctx, namespace, server, "/v1/pods/"+url.PathEscape(podUID)+path, expectQuiesced)
}

// expectQuiesced is what the response should report on a successful
// completion: true after /quiesce, false after /unquiesce. A mismatch
// is a protocol bug worth surfacing.
Expand Down
66 changes: 66 additions & 0 deletions operator/internal/agent/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,46 @@ func TestAgentURL(t *testing.T) {
}
}

func TestPodBoundQuiesceNeverFallsBackToLegacyRoute(t *testing.T) {
for _, status := range []int{http.StatusOK, http.StatusNotFound} {
t.Run(http.StatusText(status), func(t *testing.T) {
paths := []string{}
c, closeServer := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"quiesced":true}`))
})
defer closeServer()
err := c.QuiescePod(t.Context(), "ns", "server", "original-pod")
if (err == nil) != (status == http.StatusOK) {
t.Fatalf("status=%d error=%v", status, err)
}
if len(paths) != 1 || paths[0] != "/v1/pods/original-pod/quiesce" {
t.Fatalf("unbound mutation/fallback: %v", paths)
}
})
}
}

func TestPodBoundUnquiesceAndDisabledClient(t *testing.T) {
c, closeServer := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/pods/original-pod/unquiesce" {
t.Errorf("wrong cleanup target: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"quiesced":false}`))
})
defer closeServer()
if err := c.UnquiescePod(t.Context(), "ns", "server", "original-pod"); err != nil {
t.Fatal(err)
}
if err := c.QuiescePod(t.Context(), "ns", "server", ""); err == nil {
t.Fatal("empty pod UID admitted")
}
if err := (&Client{Disabled: true}).QuiescePod(t.Context(), "ns", "server", "pod"); err == nil {
t.Fatal("disabled client reported identity-bound quiesce")
}
}

func TestQuiesce_DisabledNoOp(t *testing.T) {
c := &Client{Disabled: true}
if err := c.Quiesce(context.Background(), "ns", "srv"); err != nil {
Expand Down Expand Up @@ -257,6 +297,32 @@ func TestUnquiesce_HappyPath(t *testing.T) {
}
}

func TestCheckPodRequiresIdentityEndpoint(t *testing.T) {
for _, status := range []int{http.StatusNoContent, http.StatusNotFound, http.StatusOK, http.StatusServiceUnavailable} {
t.Run(http.StatusText(status), func(t *testing.T) {
calls := 0
c, cleanup := newMockClient(t, func(w http.ResponseWriter, req *http.Request) {
calls++
if req.Method != http.MethodGet || req.URL.Path != "/v1/pods/original/identity" {
t.Errorf("unexpected preflight request: %s %s", req.Method, req.URL.Path)
}
w.WriteHeader(status)
})
defer cleanup()
err := c.CheckPod(t.Context(), "ns", "server", "original")
if (err == nil) != (status == http.StatusNoContent) || calls != 1 {
t.Fatalf("identity result = %v, calls = %d", err, calls)
}
})
}
if err := (&Client{Disabled: true}).CheckPod(t.Context(), "ns", "server", "original"); err == nil {
t.Fatal("disabled client confirmed identity")
}
if err := (&Client{}).CheckPod(t.Context(), "ns", "server", ""); err == nil {
t.Fatal("empty pod UID confirmed identity")
}
}

func TestQuiesce_TransportError(t *testing.T) {
// Use a base transport that always errors.
c := &Client{
Expand Down
Loading
Loading