diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0a7011aa2..1b38cfce5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,7 @@ "username": "vscode" }, "ghcr.io/devcontainers/features/go:1": { - "version": "1.26" + "version": "1.27.3" }, "ghcr.io/devcontainers/features/node:1": { "version": "24" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index fa606c5b5..c8b911f55 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -52,12 +52,11 @@ if ! command -v oras >/dev/null 2>&1; then fi # ---------- golangci-lint ---------- -# v2 release binaries work with the in-container Go version. The -# .golangci.yml has been migrated from v1 to v2 schema (v2 introduces new -# linters and removes deprecated ones). -GOLANGCI_VERSION="v2.12.2" +# v2.14 includes the importer for Go 1.27.3's export-data version 5. +# Keep this exact version aligned with CI and lint-gate-verify.sh. +GOLANGCI_VERSION="v2.14.0" GOLANGCI_BIN="/go/bin/golangci-lint" -if ! "$GOLANGCI_BIN" --version 2>/dev/null | grep -qE 'version v?2\.'; then +if ! "$GOLANGCI_BIN" --version 2>/dev/null | grep -qF "version ${GOLANGCI_VERSION#v} "; then log "installing golangci-lint ${GOLANGCI_VERSION}" GOBIN=/go/bin go install \ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_VERSION}" diff --git a/.github/actions/go-cache/action.yml b/.github/actions/go-cache/action.yml index 9ea044b46..65116b96f 100644 --- a/.github/actions/go-cache/action.yml +++ b/.github/actions/go-cache/action.yml @@ -11,7 +11,7 @@ inputs: go-version: description: Go version to setup required: false - default: '1.26' + default: '1.27' runs: using: composite @@ -20,7 +20,7 @@ runs: id: go with: go-version: ${{ inputs.go-version }} - # Resolve '1.26' to the newest 1.26.x patch instead of the runner's + # Resolve '1.27' to the newest 1.27.x patch (at least 1.27.3) instead of the runner's # preinstalled one, so stdlib fixes (spec 013 G1, govulncheck) land # without a manual pin bump. check-latest: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26d9ee778..4e51ff11d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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: @@ -801,21 +802,21 @@ jobs: if: matrix.module == 'operator' || matrix.module == 'api' uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.12.2 + version: v2.14.0 working-directory: ${{ matrix.module }} args: --build-tags=envtest - name: lint (test/e2e - e2e build tags) if: matrix.module == 'test/e2e' uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.12.2 + version: v2.14.0 working-directory: ${{ matrix.module }} args: --build-tags=e2e - name: lint (other modules - no build tags) if: matrix.module != 'operator' && matrix.module != 'api' && matrix.module != 'test/e2e' uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.12.2 + version: v2.14.0 working-directory: ${{ matrix.module }} # Merge gate for spec 022: the project default telemetry destination @@ -1169,6 +1170,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 - run: helm lint charts/gameplane + - name: evaluate monitoring rules + run: bash charts/gameplane/tests/monitoring-rules.sh standalone-compose: name: standalone Compose smoke @@ -1232,6 +1235,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: auxiliary resources and management placement chart renders + run: python3 charts/tests/check_workloads.py + - name: strict network profile and sentinel policy selectors + run: python3 charts/tests/check_network_isolation.py - name: sanity-check rendered manifests run: | grep -q 'kind: Deployment' /tmp/rendered.yaml @@ -1316,10 +1323,16 @@ jobs: | grep -q 'app.kubernetes.io/name: gameplane-telemetry-receiver' awk '/kind: Service/,/^---$/' /tmp/telemetry-receiver-scrape.yaml \ | grep -q 'name: dashboard' - # scrapeNamespaceSelector unset (the chart default): neither - # exception renders, matching the pre-fix behavior. + # Missing scrape ingress must fail with actionable guidance. + if helm template gameplane charts/gameplane --set serviceMonitors.enabled=true > /tmp/monitoring-invalid.txt 2>&1; then + echo "::error::monitoring without a scrape selector unexpectedly rendered" + exit 1 + fi + grep -q 'serviceMonitors.scrapeNamespaceSelector' /tmp/monitoring-invalid.txt + # Explicit external policy ownership renders no chart exception. helm template gameplane charts/gameplane \ --set serviceMonitors.enabled=true \ + --set serviceMonitors.externallyManagedPolicy=true \ --show-only templates/networkpolicies.yaml > /tmp/netpol-default.yaml if grep -q 'allow-prometheus-to-agent' /tmp/netpol-default.yaml; then echo "::error::allow-prometheus-to-agent rendered without serviceMonitors.scrapeNamespaceSelector set" @@ -1496,7 +1509,8 @@ jobs: # keeps working without api.telemetry.enabled. helm template gameplane charts/gameplane \ --set api.telemetry.receiver.enabled=true \ - --set serviceMonitors.enabled=true > /tmp/rendered-reuse-values-receiver.yaml + --set serviceMonitors.enabled=true \ + --set serviceMonitors.externallyManagedPolicy=true > /tmp/rendered-reuse-values-receiver.yaml python3 - <<'PY' import yaml docs = [d for d in yaml.safe_load_all(open('/tmp/rendered-reuse-values-receiver.yaml')) if d] @@ -1793,13 +1807,25 @@ 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, management-placement, network-isolation, monitoring] arch: [amd64] include: + - bucket: monitoring + parallel: 1 + test_timeout: 20m + job_timeout: 75 + - bucket: network-isolation + parallel: 1 + test_timeout: 15m + job_timeout: 60 - 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: api-auth parallel: 4 test_timeout: 15m @@ -1827,8 +1853,8 @@ jobs: job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite. - bucket: optional-components 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. + test_timeout: 35m # lifecycle plus two-GameServer TCP/UDP relay identity, outage and recovery checks + job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite. env: CLUSTER: gameplane-e2e CTX: kind-gameplane-e2e @@ -1856,7 +1882,13 @@ 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' && 'management' || 'default' }} + GAMEPLANE_E2E_CNI: ${{ (matrix.bucket == 'network-isolation' || matrix.bucket == 'monitoring') && 'cilium' || 'kindnet' }} + run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e "$GAMEPLANE_E2E_PROFILE" + - name: install real monitoring fixture + if: matrix.bucket == 'monitoring' && env.RUN_REGEX != '' + run: bash deploy/kind/monitoring.sh - name: run go e2e (${{ matrix.bucket }}) if: env.RUN_REGEX != '' run: | @@ -1880,7 +1912,7 @@ jobs: uses: ./.github/actions/dump-cluster-state with: context: ${{ env.CTX }} - namespaces: "gameplane-system,gameplane-games" + namespaces: "${{ matrix.bucket == 'monitoring' && 'gameplane-system,gameplane-games,monitoring' || 'gameplane-system,gameplane-games' }}" include-previous-logs: "true" e2e-go-arm64: @@ -1934,8 +1966,8 @@ jobs: job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite. - bucket: optional-components 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. + test_timeout: 35m # lifecycle plus two-GameServer TCP/UDP relay identity, outage and recovery checks + job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite. env: CLUSTER: gameplane-e2e CTX: kind-gameplane-e2e @@ -2660,7 +2692,7 @@ 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': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|management-placement|network-isolation|monitoring) \//.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-multicluster': (n) => n.startsWith('e2e multicluster /') && n.includes('/ amd64 '), 'e2e-multicluster-arm64': (n) => n.startsWith('e2e multicluster /') && n.includes('/ arm64 '), @@ -2813,7 +2845,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|management-placement|network-isolation|monitoring) \//); if (m) bucketSet.add(m[1]); if (/^e2e multicluster \//.test(n)) bucketSet.add('multicluster'); if (/^e2e upgrade \//.test(n)) bucketSet.add('upgrade'); diff --git a/agent/cmd/main.go b/agent/cmd/main.go index 8ce7f15f8..a27d217ef 100644 --- a/agent/cmd/main.go +++ b/agent/cmd/main.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/prometheus/client_golang/prometheus" "github.com/GameplanePanel/gameplane/agent/internal/actions" "github.com/GameplanePanel/gameplane/agent/internal/auth" @@ -178,6 +179,7 @@ func main() { // and isn't meant to be trusted with the console/files/RCON routes that // cert would also unlock. + statusSource := status.New(rconClient, statusSpec) mountProtected := func(protected chi.Router) { // Console/Logs open long-lived WebSockets and Mods installs can run @@ -198,7 +200,7 @@ func main() { quiesce.Mount(bounded, rconClient, gameName, quiesceSpec) lifecycle.Mount(bounded, rconClient, gameName, lifecycleSpec) actions.Mount(bounded, rconClient, gameName, actionSpecs) - status.Mount(bounded, rconClient, statusSpec) + statusSource.Mount(bounded) }) } r.Group(func(protected chi.Router) { @@ -230,6 +232,8 @@ func main() { // Prometheus metrics for per-server resource usage and player counts. // Registered with the default registry so promhttp.Handler() picks it up. m := metrics.New() + prometheus.MustRegister(statusSource) + go statusSource.Run(ctx) // Resource usage: in proc mode (the operator shares the pod's PID // namespace and sets GAMEPLANE_USAGE_PROC) the agent reports the game diff --git a/agent/internal/caps/caps.go b/agent/internal/caps/caps.go index 495cc2654..448501ff6 100644 --- a/agent/internal/caps/caps.go +++ b/agent/internal/caps/caps.go @@ -83,11 +83,19 @@ type ModInstall struct { // StatusMetric reads one live readout from an RCON command's output via a // named-group regex (group "value"). type StatusMetric struct { - ID string `json:"id"` - DisplayName string `json:"displayName,omitempty"` - Command string `json:"command"` - Regex string `json:"regex"` - Unit string `json:"unit,omitempty"` + ID string `json:"id"` + DisplayName string `json:"displayName,omitempty"` + Command string `json:"command"` + Regex string `json:"regex"` + Unit string `json:"unit,omitempty"` + Performance *PerformanceMetric `json:"performance,omitempty"` +} + +// PerformanceMetric mirrors the optional, explicitly numeric status contract. +type PerformanceMetric struct { + Signal string `json:"signal"` + Unit string `json:"unit"` + DegradedThreshold *string `json:"degradedThreshold,omitempty"` } // PlayerActions maps moderation actions to console command templates diff --git a/agent/internal/rcon/nuclearoption.go b/agent/internal/rcon/nuclearoption.go index 7b8f2b1f2..001135354 100644 --- a/agent/internal/rcon/nuclearoption.go +++ b/agent/internal/rcon/nuclearoption.go @@ -112,14 +112,21 @@ func (c *NuclearOption) Close() error { // If the response has no body (body length = 0), an empty string is returned. // Non-2000 status codes produce an error that includes the status code and, // if present, the error detail from the response body. +// One deadline bounds the complete exchange, including dialing and request writes. func (c *NuclearOption) Exec(cmd string) (string, error) { c.mu.Lock() defer c.mu.Unlock() + d := c.execDeadline + if d <= 0 { + d = defaultNuclearOptionExecDeadline + } + exchangeDeadline := time.Now().Add(d) + // Ensure connection is open. if c.conn == nil { d := net.Dialer{Timeout: c.dialTimeout} - ctx, cancel := context.WithTimeout(context.Background(), c.dialTimeout) + ctx, cancel := context.WithDeadline(context.Background(), exchangeDeadline) defer cancel() conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(c.host, fmt.Sprint(c.port))) if err != nil { @@ -144,6 +151,14 @@ func (c *NuclearOption) Exec(cmd string) (string, error) { return "", fmt.Errorf("nuclearoption rcon: marshal request: %w", err) } + // Bound writes as well as reads. Arm this before sending anything: a + // server that stops reading must not retain the shared client forever. + if err := c.conn.SetDeadline(exchangeDeadline); err != nil { + _ = c.conn.Close() + c.conn = nil + return "", fmt.Errorf("nuclearoption rcon: set deadline: %w", err) + } + // Write request: 4-byte LE length + body. reqLen := len(reqBody) if reqLen < 0 || reqLen > math.MaxUint32 { @@ -162,17 +177,6 @@ func (c *NuclearOption) Exec(cmd string) (string, error) { return "", fmt.Errorf("nuclearoption rcon: write body: %w", err) } - // Set deadline for the entire response read. - d := c.execDeadline - if d <= 0 { - d = defaultNuclearOptionExecDeadline - } - if err := c.conn.SetDeadline(time.Now().Add(d)); err != nil { - _ = c.conn.Close() - c.conn = nil - return "", fmt.Errorf("nuclearoption rcon: set deadline: %w", err) - } - // Read response: 4-byte status + 4-byte body length + body. statusBuf := make([]byte, 4) if _, err := io.ReadFull(c.conn, statusBuf); err != nil { diff --git a/agent/internal/rcon/nuclearoption_deadline_test.go b/agent/internal/rcon/nuclearoption_deadline_test.go new file mode 100644 index 000000000..9cae0e073 --- /dev/null +++ b/agent/internal/rcon/nuclearoption_deadline_test.go @@ -0,0 +1,46 @@ +package rcon + +import ( + "io" + "net" + "strings" + "testing" + "time" +) + +func TestNuclearOptionDeadlineIncludesRequestWrites(t *testing.T) { + t.Parallel() + for _, readHeader := range []bool{false, true} { + stage := "write length" + if readHeader { + stage = "write body" + } + t.Run(stage, func(t *testing.T) { + t.Parallel() + local, remote := net.Pipe() + defer local.Close() + defer remote.Close() + client := NewNuclearOption("ignored", 1, nil) + client.conn = local + client.execDeadline = 100 * time.Millisecond + if readHeader { + go func() { _, _ = io.CopyN(io.Discard, remote, 4) }() + } + done := make(chan error, 1) + go func() { _, err := client.Exec("get-server-id"); done <- err }() + select { + case err := <-done: + if err == nil || !isTimeout(err) || !strings.Contains(err.Error(), stage) { + t.Fatalf("stalled %s must time out: %v", stage, err) + } + case <-time.After(2 * time.Second): + _ = remote.Close() + <-done + t.Fatalf("stalled %s exceeded the command budget", stage) + } + if client.conn != nil { + t.Fatal("timed-out partial request must discard the connection") + } + }) + } +} diff --git a/agent/internal/rcon/nuclearoption_test.go b/agent/internal/rcon/nuclearoption_test.go index 248c6a7fb..d03e76514 100644 --- a/agent/internal/rcon/nuclearoption_test.go +++ b/agent/internal/rcon/nuclearoption_test.go @@ -740,7 +740,7 @@ func TestNuclearOptionWriteBodyError(t *testing.T) { } // TestNuclearOptionSetDeadlineError verifies that a failure setting the -// response read deadline (after the request was fully written) is reported +// exchange deadline (before sending the request) is reported // and the connection is reset. func TestNuclearOptionSetDeadlineError(t *testing.T) { local, remote := net.Pipe() @@ -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 { + // Wait until the deadline is installed before closing so the + // intended error comes from the status read, not SetDeadline. + 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 != "" { diff --git a/agent/internal/rcon/rcon.go b/agent/internal/rcon/rcon.go index bbb0456d2..7dfcaa01e 100644 --- a/agent/internal/rcon/rcon.go +++ b/agent/internal/rcon/rcon.go @@ -56,6 +56,11 @@ const ( // byte length (F-104). minPacketSize = 10 maxPacketSize = 4*4096 + 10 // 16394 + + // sourceMaxResponseBytes permits large administrative listings while + // bounding the aggregate allocation across otherwise valid fragments. + // Status collection applies its separate, smaller 64-KiB acceptance cap. + sourceMaxResponseBytes = 8 * 1024 * 1024 ) // PassFn resolves the RCON password on demand (allowing rotation without restart). @@ -86,6 +91,8 @@ type Client struct { // execDeadline bounds a single command exchange (see Exec). execDeadline time.Duration + // authDeadline separately bounds authentication, including all preamble packets. + authDeadline time.Duration // authFailureCooldown bounds how long ensureLocked refuses to re-dial // after an explicit AUTH_RESPONSE rejection (id=-1). A struct field @@ -114,6 +121,8 @@ type Client struct { // (heartbeat, players, quiesce, console). const defaultExecDeadline = 30 * time.Second +const defaultSourceAuthDeadline = 10 * time.Second + // defaultAuthFailureCooldown bounds how long ensureLocked backs off after // an explicit auth rejection before it will dial again. Mirrors // websocket.go's defaultWebSocketAuthFailureCooldown — same value, same @@ -133,6 +142,7 @@ func New(host string, port int, pw PassFn) *Client { addr: net.JoinHostPort(host, fmt.Sprint(port)), password: pw, execDeadline: defaultExecDeadline, + authDeadline: defaultSourceAuthDeadline, authFailureCooldown: defaultAuthFailureCooldown, } } @@ -154,7 +164,8 @@ func (c *Client) Exec(cmd string) (string, error) { if d <= 0 { d = defaultExecDeadline } - _ = conn.SetDeadline(time.Now().Add(d)) + exchangeDeadline := time.Now().Add(d) + _ = conn.SetDeadline(exchangeDeadline) defer func() { _ = conn.SetDeadline(time.Time{}) }() reqID := c.allocID() @@ -172,12 +183,22 @@ func (c *Client) Exec(cmd string) (string, error) { // reqID) and bound the wait for any trailing fragments with a short grace // window: a single-packet reply returns as soon as the grace // elapses or the server closes; a fragmented Source reply keeps extending - // the window as each fragment arrives. + // the window as each fragment arrives, never beyond exchangeDeadline. var out bytes.Buffer gotResponse := false for { + if !time.Now().Before(exchangeDeadline) { + c.dropLocked() + return "", fmt.Errorf("rcon: command exchange deadline exceeded: %w", os.ErrDeadlineExceeded) + } id, _, body, err := c.readPacket() if err != nil { + if !time.Now().Before(exchangeDeadline) { + // Reaching the hard deadline is not evidence of a complete + // response. Discard its partial text and any queued fragments. + c.dropLocked() + return "", fmt.Errorf("rcon: command exchange deadline exceeded: %w", os.ErrDeadlineExceeded) + } if gotResponse { // We already hold the reply. A timeout means the grace window // elapsed on a still-healthy socket — keep it for reuse. Any @@ -196,9 +217,17 @@ func (c *Client) Exec(cmd string) (string, error) { // Source/Minecraft echo the request id in every reply fragment; ignore // anything else. if id == reqID { + if len(body) > sourceMaxResponseBytes-out.Len() { + c.dropLocked() + return "", fmt.Errorf("rcon: response exceeded limit of %d bytes", sourceMaxResponseBytes) + } out.WriteString(body) gotResponse = true - _ = conn.SetReadDeadline(time.Now().Add(responseGrace)) + readDeadline := time.Now().Add(responseGrace) + if readDeadline.After(exchangeDeadline) { + readDeadline = exchangeDeadline + } + _ = conn.SetReadDeadline(readDeadline) } } } @@ -247,7 +276,12 @@ func (c *Client) ensureLocked() error { return err } c.conn = conn - _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + authBudget := c.authDeadline + if authBudget <= 0 { + authBudget = defaultSourceAuthDeadline + } + authDeadline := time.Now().Add(authBudget) + _ = conn.SetDeadline(authDeadline) authID := c.allocID() if err := c.writePacket(authID, typeAuth, pw); err != nil { @@ -256,6 +290,10 @@ func (c *Client) ensureLocked() error { } // Server MAY send an empty RESPONSE_VALUE before AUTH_RESPONSE. for { + if !time.Now().Before(authDeadline) { + c.dropLocked() + return fmt.Errorf("rcon: authentication deadline exceeded: %w", os.ErrDeadlineExceeded) + } id, kind, _, err := c.readPacket() if err != nil { c.dropLocked() diff --git a/agent/internal/rcon/rcon_bounds_test.go b/agent/internal/rcon/rcon_bounds_test.go new file mode 100644 index 000000000..39038bd81 --- /dev/null +++ b/agent/internal/rcon/rcon_bounds_test.go @@ -0,0 +1,166 @@ +package rcon + +import ( + "net" + "strings" + "testing" + "time" +) + +func TestExecContinuousFragmentsRespectAbsoluteDeadline(t *testing.T) { + t.Parallel() + for _, unrelated := range []bool{false, true} { + name := "matching" + if unrelated { + name = "unrelated-after-response" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + client := &Client{conn: clientConn, execDeadline: 100 * time.Millisecond} + server := &Client{conn: serverConn} + done := make(chan struct{}) + go func() { + defer close(done) + id, _, _, err := server.readPacket() + if err != nil { + return + } + if server.writePacket(id, typeRespValue, "partial") != nil { + return + } + if unrelated { + id++ + } + // Empty fragments cannot trigger the aggregate byte cap. A + // continuously readable socket must still reach its hard deadline. + for { + if server.writePacket(id, typeRespValue, "") != nil { + return + } + } + }() + finished := make(chan struct{}) + var output string + var execErr error + go func() { + output, execErr = client.Exec("status") + close(finished) + }() + select { + case <-finished: + case <-time.After(2 * time.Second): + // Unblock both goroutines even when testing the old read loop. + _ = serverConn.Close() + <-finished + t.Fatal("continuous fragments extended the absolute command deadline") + } + if execErr == nil || !isTimeout(execErr) || output != "" { + t.Fatalf("deadline must discard incomplete output: bytes=%d err=%v", len(output), execErr) + } + if client.conn != nil { + t.Fatal("deadline must drop the connection to discard trailing fragments") + } + <-done + }) + } +} + +func TestExecAggregateResponseBound(t *testing.T) { + t.Parallel() + for _, extra := range []int{0, 1} { + name := "exact-limit" + if extra > 0 { + name = "over-limit" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + client := &Client{conn: clientConn, execDeadline: 10 * time.Second} + server := &Client{conn: serverConn} + done := make(chan struct{}) + go func() { + defer close(done) + defer serverConn.Close() + id, _, _, err := server.readPacket() + if err != nil { + return + } + fragment := strings.Repeat("x", 4000) + remaining := sourceMaxResponseBytes + extra + for remaining > 0 { + length := min(remaining, len(fragment)) + if server.writePacket(id, typeRespValue, fragment[:length]) != nil { + return + } + remaining -= length + } + }() + output, err := client.Exec("large-list") + <-done + if extra == 0 { + if err != nil || len(output) != sourceMaxResponseBytes || strings.Trim(output, "x") != "" { + t.Fatalf("legitimate response at the generous cap must succeed: bytes=%d err=%v", len(output), err) + } + return + } + if err == nil || !strings.Contains(err.Error(), "response exceeded limit") || output != "" { + t.Fatalf("oversized response must fail without a truncated result: bytes=%d err=%v", len(output), err) + } + if client.conn != nil { + t.Fatal("oversized response must drop the connection") + } + }) + } +} + +func TestAuthContinuousPreambleRespectsAbsoluteDeadline(t *testing.T) { + t.Parallel() + lc := &net.ListenConfig{} + listener, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + // Bound the fixture itself even if the client's deadline regresses. + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + server := &Client{conn: conn} + id, _, _, err := server.readPacket() + if err != nil { + return + } + for { + if server.writePacket(id, typeRespValue, "") != nil { + return + } + } + }() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + client := New(host, mustAtoi(port), func() (string, error) { return "pw", nil }) + client.authDeadline = 100 * time.Millisecond + defer client.Close() + started := time.Now() + output, err := client.Exec("status") + if err == nil || !isTimeout(err) || output != "" || time.Since(started) > 2*time.Second { + t.Fatalf("continuous auth preamble must time out without output: bytes=%d err=%v elapsed=%v", len(output), err, time.Since(started)) + } + if client.conn != nil { + t.Fatal("authentication deadline must drop the connection") + } + <-done +} diff --git a/agent/internal/status/performance.go b/agent/internal/status/performance.go new file mode 100644 index 000000000..0dbda1979 --- /dev/null +++ b/agent/internal/status/performance.go @@ -0,0 +1,94 @@ +package status + +import ( + "math" + "regexp" + "strconv" + "time" + + "github.com/GameplanePanel/gameplane/agent/internal/caps" + "github.com/prometheus/client_golang/prometheus" +) + +type performanceSample struct { + value float64 + observed time.Time +} + +var decimalThreshold = regexp.MustCompile(`^(0|[1-9][0-9]{0,8})(\.[0-9]{1,6})?$`) + +func validPerformance(p *caps.PerformanceMetric) bool { + if p == nil { + return false + } + if p.DegradedThreshold != nil && !decimalThreshold.MatchString(*p.DegradedThreshold) { + return false + } + switch p.Signal { + case "tick_rate", "frame_rate": + return p.Unit == "per_second" + case "tick_duration": + return p.Unit == "seconds" || p.Unit == "milliseconds" + default: + return false + } +} + +func finite(n float64) bool { return !math.IsNaN(n) && !math.IsInf(n, 0) } +func baseValue(n float64, p *caps.PerformanceMetric) float64 { + if p.Unit == "milliseconds" { + return n / 1000 + } + return n +} +func parsePerformance(raw string, p *caps.PerformanceMetric) (performanceSample, bool) { + n, err := strconv.ParseFloat(raw, 64) + if err != nil || !finite(n) || n < 0 { + return performanceSample{}, false + } + return performanceSample{value: baseValue(n, p)}, true +} + +var performanceLabels = []string{"metric", "signal", "unit"} +var performanceValue = prometheus.NewDesc("gameplane_agent_performance_value", "Finite game performance measurement in the declared base unit; absent when unknown or stale.", performanceLabels, nil) +var performanceAvailable = prometheus.NewDesc("gameplane_agent_performance_available", "Whether the declared performance signal has a fresh valid sample.", performanceLabels, nil) +var performanceTimestamp = prometheus.NewDesc("gameplane_agent_performance_sample_timestamp_seconds", "Unix collection timestamp for a fresh valid game performance sample.", performanceLabels, nil) +var performanceThreshold = prometheus.NewDesc("gameplane_agent_performance_degraded_threshold", "Module-declared degradation threshold in the same base unit as the measurement.", append(append([]string{}, performanceLabels...), "direction"), nil) + +// Describe publishes the fixed descriptors for explicitly declared performance signals. +func (s *Source) Describe(ch chan<- *prometheus.Desc) { + ch <- performanceValue + ch <- performanceAvailable + ch <- performanceTimestamp + ch <- performanceThreshold +} + +// Collect exports cached performance samples and availability without issuing commands. +func (s *Source) Collect(ch chan<- prometheus.Metric) { + s.mu.Lock() + defer s.mu.Unlock() + for _, m := range s.metrics { + p := m.performance + if p == nil { + continue + } + unit, direction := "hertz", "below" + if p.Signal == "tick_duration" { + unit, direction = "seconds", "above" + } + labels := []string{m.id, p.Signal, unit} + sample, ok := s.samples[m.id] + fresh := !sample.observed.IsZero() && time.Since(sample.observed) < performanceFreshness + available := 0.0 + if fresh && ok { + available = 1 + ch <- prometheus.MustNewConstMetric(performanceValue, prometheus.GaugeValue, sample.value, labels...) + ch <- prometheus.MustNewConstMetric(performanceTimestamp, prometheus.GaugeValue, float64(sample.observed.UnixNano())/1e9, labels...) + } + ch <- prometheus.MustNewConstMetric(performanceAvailable, prometheus.GaugeValue, available, labels...) + if p.DegradedThreshold != nil { + threshold, _ := strconv.ParseFloat(*p.DegradedThreshold, 64) // validated bounded decimal + ch <- prometheus.MustNewConstMetric(performanceThreshold, prometheus.GaugeValue, baseValue(threshold, p), append(labels, direction)...) + } + } +} diff --git a/agent/internal/status/performance_test.go b/agent/internal/status/performance_test.go new file mode 100644 index 000000000..86e934242 --- /dev/null +++ b/agent/internal/status/performance_test.go @@ -0,0 +1,134 @@ +package status + +import ( + "context" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/GameplanePanel/gameplane/agent/internal/caps" + "github.com/go-chi/chi/v5" + "github.com/prometheus/client_golang/prometheus" +) + +func performanceSpec() *caps.Status { + threshold := "18" + return &caps.Status{Metrics: []caps.StatusMetric{{ID: "tps", Command: "stats", Regex: `TPS: (?P\S+)`, Performance: &caps.PerformanceMetric{Signal: "tick_rate", Unit: "per_second", DegradedThreshold: &threshold}}, {ID: "tick", Command: "stats", Regex: `Tick: (?P\S+)`, Performance: &caps.PerformanceMetric{Signal: "tick_duration", Unit: "milliseconds"}}}} +} + +func TestPerformanceCacheZeroFreshnessAndSharedCommand(t *testing.T) { + t.Parallel() + rc := &fakeRcon{respond: func(string) (string, error) { return "TPS: 0 Tick: 50", nil }} + s := New(rc, performanceSpec()) + s.refresh(t.Context()) + if rc.count() != 1 { + t.Fatalf("shared command executed %d times", rc.count()) + } + reg := prometheus.NewRegistry() + reg.MustRegister(s) + router := chi.NewRouter() + router.Route("/legacy", s.Mount) + router.Route("/versioned", s.Mount) + for _, route := range []string{"/legacy/status", "/versioned/status"} { + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), "GET", route, nil)) + if response.Code != 200 { + t.Fatal("shared status cache unavailable") + } + } + for range 20 { + if _, err := reg.Gather(); err != nil { + t.Fatal(err) + } + } + if rc.count() != 1 { + t.Fatal("scrape executed RCON") + } + s.mu.Lock() + if s.samples["tps"].value != 0 || s.samples["tick"].value != .05 { + t.Fatal("zero or unit conversion lost") + } + s.lastFetch = time.Now().Add(-performanceFreshness - time.Second) + for id, sample := range s.samples { + sample.observed = s.lastFetch + s.samples[id] = sample + } + s.mu.Unlock() + families, err := reg.Gather() + if err != nil { + t.Fatal(err) + } + for _, family := range families { + if family.GetName() == "gameplane_agent_performance_value" { + t.Fatal("stale value emitted") + } + } +} + +func TestPerformanceRejectsNonFiniteAndInvalidDeclarations(t *testing.T) { + t.Parallel() + for _, value := range []string{"NaN", "+Inf", "-1", "no-value"} { + s := New(&fakeRcon{respond: func(string) (string, error) { return "TPS: " + value, nil }}, performanceSpec()) + s.refresh(t.Context()) + if len(s.samples) != 0 { + t.Fatalf("invalid %q emitted", value) + } + } + inf := "+Inf" + for _, p := range []*caps.PerformanceMetric{{Signal: "seed", Unit: "per_second"}, {Signal: "tick_rate", Unit: "milliseconds"}, {Signal: "tick_duration", Unit: "seconds", DegradedThreshold: &inf}} { + if validPerformance(p) { + t.Fatalf("accepted %+v", p) + } + } +} + +func TestPerformanceTimeoutBoundsOutstandingWorkAndDiscardsLateResult(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + var calls atomic.Int32 + rc := &fakeRcon{respond: func(string) (string, error) { calls.Add(1); <-release; return "TPS: 20", nil }} + s := New(rc, performanceSpec()) + s.commandTimeout = 10 * time.Millisecond + s.refresh(t.Context()) + for range 10 { + s.refresh(t.Context()) + } + if calls.Load() != 1 { + t.Fatalf("unbounded workers: %d", calls.Load()) + } + close(release) + deadline := time.After(time.Second) + for len(s.execSlot) != 0 { + select { + case <-deadline: + t.Fatal("worker failed to finish") + default: + time.Sleep(time.Millisecond) + } + } + if len(s.samples) != 0 { + t.Fatal("late result became a fresh sample") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + s.Run(ctx) + if calls.Load() != 1 { + t.Fatal("cancelled collector executed a command") + } +} + +func TestPerformanceBoundsDirectCapabilitiesAndDeduplicatesIDs(t *testing.T) { + t.Parallel() + spec := performanceSpec() + for range 30 { + spec.Metrics = append(spec.Metrics, spec.Metrics[0]) + } + if compiled := compile(spec.Metrics); len(compiled) != 2 { + t.Fatalf("duplicate IDs expanded cardinality: %d", len(compiled)) + } + spec.Metrics[0].Command = string(make([]byte, maxCommandBytes+1)) + if len(compile(spec.Metrics[:1])) != 0 { + t.Fatal("oversized command accepted") + } +} diff --git a/agent/internal/status/status.go b/agent/internal/status/status.go index 7ddc82bc8..1304d37ba 100644 --- a/agent/internal/status/status.go +++ b/agent/internal/status/status.go @@ -1,45 +1,45 @@ -// Package status reads module-declared live metrics over RCON for the -// dashboard's Overview tab. -// -// A GameTemplate declares metrics in spec.capabilities.status.metrics[]; -// the operator serializes them into GAMEPLANE_CAPABILITIES and the agent -// interprets them here. Each metric runs an RCON command and extracts a -// value via a named-group regex (group "value"), so modules surface -// game-specific readouts (TPS, world time, …) without an agent change. +// Package status shares module-declared RCON readouts between the dashboard +// and explicitly opted-in numeric performance monitoring. package status import ( + "context" "errors" - "log/slog" "net/http" "regexp" "strings" "sync" "time" - "github.com/go-chi/chi/v5" - "github.com/GameplanePanel/gameplane/agent/internal/caps" "github.com/GameplanePanel/gameplane/agent/internal/httpjson" "github.com/GameplanePanel/gameplane/agent/internal/rcon" + "github.com/go-chi/chi/v5" ) -// Rcon is the slice of *rcon.Client we use. An interface so tests can -// swap in a recording fake. +// Rcon executes module-declared commands through the configured game transport. type Rcon interface { Exec(cmd string) (string, error) } -// metric is one declared readout whose regex compiled at mount time. +const ( + cacheTTL = 5 * time.Second + performanceInterval = 30 * time.Second + performanceFreshness = 75 * time.Second + cycleTimeout = 10 * time.Second + maxCommandBytes = 1024 + maxRegexBytes = 4096 + maxResponseBytes = 64 * 1024 +) + type metric struct { id, displayName, command, unit string re *regexp.Regexp valueIdx int + performance *caps.PerformanceMetric } -// Result is one metric's current value (empty when the command did not -// match this cycle). DisplayName/Unit are echoed so the dashboard needs -// no second lookup. +// Result is one module-declared readout returned by the dashboard status route. type Result struct { ID string `json:"id"` DisplayName string `json:"displayName,omitempty"` @@ -47,85 +47,183 @@ type Result struct { Unit string `json:"unit,omitempty"` } -type handler struct { - rc Rcon - metrics []metric - - mu sync.Mutex - lastFetch time.Time - cached []Result +// Source is shared by both protected route mounts and the metrics collector. +// At most one legacy context-free Exec can remain in flight after a timeout. +type Source struct { + rc Rcon + metrics []metric + automatic bool + mu sync.Mutex + refreshMu sync.Mutex + lastFetch time.Time + cached []Result + samples map[string]performanceSample + execSlot chan struct{} + commandTimeout time.Duration } -const cacheTTL = 5 * time.Second - -// Mount registers GET /status. spec is the module's declared metrics -// (nil when the template declares none — the endpoint then returns []). -func Mount(r chi.Router, rc Rcon, spec *caps.Status) { - h := &handler{rc: rc} +// New validates the declared readouts and creates their shared cache and collector. +// Call Run to collect opted-in performance readings independently of requests. +func New(rc Rcon, spec *caps.Status) *Source { + s := &Source{rc: rc, execSlot: make(chan struct{}, 1), commandTimeout: 3 * time.Second, cached: []Result{}} if spec != nil { - h.metrics = compile(spec.Metrics) + s.metrics = compile(spec.Metrics) + } + for _, m := range s.metrics { + s.automatic = s.automatic || m.performance != nil } - r.Get("/status", h.serve) + return s } -// compile builds the metric list, dropping any whose regex is invalid or -// lacks the required (?P…) group (logged) so one bad declaration -// can't disable the whole panel. +// Mount retains the standalone handler API. Production constructs one Source. +func Mount(r chi.Router, rc Rcon, spec *caps.Status) { New(rc, spec).Mount(r) } + +// Mount registers the status route using this source's shared readout cache. +func (s *Source) Mount(r chi.Router) { r.Get("/status", s.serve) } + +var metricID = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + func compile(specs []caps.StatusMetric) []metric { - out := make([]metric, 0, len(specs)) - for _, s := range specs { - if s.ID == "" || s.Command == "" || s.Regex == "" { + out := make([]metric, 0, min(len(specs), 16)) + seen := map[string]bool{} + for _, s := range specs[:min(len(specs), 16)] { + if seen[s.ID] || len(s.ID) > 63 || !metricID.MatchString(s.ID) || s.Command == "" || len(s.Command) > maxCommandBytes || s.Regex == "" || len(s.Regex) > maxRegexBytes { continue } re, err := regexp.Compile(s.Regex) - if err != nil { - slog.Warn("invalid status metric regex; metric disabled", "metric", s.ID, "err", err) + if err != nil || re.SubexpIndex("value") < 0 { continue } - idx := re.SubexpIndex("value") - if idx < 0 { - slog.Warn("status metric regex has no (?P…) group; metric disabled", "metric", s.ID) - continue + m := metric{id: s.ID, displayName: s.DisplayName, command: s.Command, unit: s.Unit, re: re, valueIdx: re.SubexpIndex("value")} + if validPerformance(s.Performance) { + p := *s.Performance + if p.DegradedThreshold != nil { + value := *p.DegradedThreshold + p.DegradedThreshold = &value + } + m.performance = &p } - out = append(out, metric{ - id: s.ID, displayName: s.DisplayName, command: s.Command, - unit: s.Unit, re: re, valueIdx: idx, - }) + seen[s.ID] = true + out = append(out, m) } return out } -func (h *handler) serve(w http.ResponseWriter, _ *http.Request) { - h.mu.Lock() - fresh := !h.lastFetch.IsZero() && time.Since(h.lastFetch) < cacheTTL - cached := h.cached - h.mu.Unlock() - if fresh { - httpjson.Write(w, http.StatusOK, cached) +// Run refreshes opted-in performance readouts until ctx is canceled. +// Sources without performance declarations retain on-demand collection. +func (s *Source) Run(ctx context.Context) { + if !s.automatic { return } - - results := make([]Result, 0, len(h.metrics)) - for _, m := range h.metrics { - raw, err := h.rc.Exec(m.command) - if errors.Is(err, rcon.ErrDisabled) { - // No RCON: no live metrics. Empty array so the dashboard can - // render the panel uniformly for every game. - httpjson.Write(w, http.StatusOK, []Result{}) + for ctx.Err() == nil { + s.refresh(ctx) + timer := time.NewTimer(performanceInterval) + select { + case <-ctx.Done(): + timer.Stop() return + case <-timer.C: + } + } +} + +type commandResult struct { + raw string + err error + completed time.Time +} + +var errUnavailable = errors.New("status source unavailable") + +func (s *Source) execute(ctx context.Context, command string) commandResult { + if ctx.Err() != nil { + return commandResult{err: ctx.Err()} + } + select { + case s.execSlot <- struct{}{}: + default: + return commandResult{err: errUnavailable} + } + result := make(chan commandResult, 1) + go func() { + raw, err := s.rc.Exec(command) + if len(raw) > maxResponseBytes { + raw, err = "", errUnavailable + } + <-s.execSlot + result <- commandResult{raw: raw, err: err, completed: time.Now()} + }() + queryCtx, cancel := context.WithTimeout(ctx, s.commandTimeout) + defer cancel() + select { + case <-queryCtx.Done(): + return commandResult{err: queryCtx.Err()} + case reply := <-result: + if queryCtx.Err() != nil { + return commandResult{err: queryCtx.Err()} + } + return reply + } +} + +func (s *Source) refresh(ctx context.Context) { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + s.mu.Lock() + fresh := !s.automatic && !s.lastFetch.IsZero() && time.Since(s.lastFetch) < cacheTTL + s.mu.Unlock() + if fresh { + return + } + ctx, cancel := context.WithTimeout(ctx, cycleTimeout) + defer cancel() + results := make([]Result, 0, len(s.metrics)) + samples := map[string]performanceSample{} + commands := map[string]commandResult{} + for _, m := range s.metrics { + reply, ok := commands[m.command] + if !ok { + reply = s.execute(ctx, m.command) + commands[m.command] = reply + } + if errors.Is(reply.err, rcon.ErrDisabled) { + results = []Result{} + samples = map[string]performanceSample{} + break } res := Result{ID: m.id, DisplayName: m.displayName, Unit: m.unit} - if err != nil { - slog.Warn("status metric rcon", "metric", m.id, "err", err) - } else if sub := m.re.FindStringSubmatch(strings.ReplaceAll(raw, "\r", "")); sub != nil { - res.Value = strings.TrimSpace(sub[m.valueIdx]) + if reply.err == nil { + if sub := m.re.FindStringSubmatch(strings.ReplaceAll(reply.raw, "\r", "")); sub != nil { + res.Value = strings.TrimSpace(sub[m.valueIdx]) + } + } + if m.performance != nil { + if sample, valid := parsePerformance(res.Value, m.performance); valid { + sample.observed = reply.completed + samples[m.id] = sample + } } results = append(results, res) } + s.mu.Lock() + s.lastFetch, s.cached, s.samples = time.Now(), results, samples + s.mu.Unlock() +} - h.mu.Lock() - h.lastFetch = time.Now() - h.cached = results - h.mu.Unlock() - httpjson.Write(w, http.StatusOK, results) +func (s *Source) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + fresh := !s.lastFetch.IsZero() && time.Since(s.lastFetch) < cacheTTL + s.mu.Unlock() + if !s.automatic && !fresh { + s.refresh(r.Context()) + } + s.mu.Lock() + cached := append([]Result{}, s.cached...) + if s.automatic && time.Since(s.lastFetch) >= performanceFreshness { + for i := range cached { + cached[i].Value = "" + } + } + s.mu.Unlock() + httpjson.Write(w, http.StatusOK, cached) } diff --git a/agent/specs.md b/agent/specs.md index af1f57529..6806637ae 100644 --- a/agent/specs.md +++ b/agent/specs.md @@ -21,7 +21,7 @@ The agent is a per-pod HTTP/HTTPS sidecar that runs inside every game pod to exp - **Quiesce**: Pause auto-saves and flush in-flight state before snapshots; run module-declared sequences over RCON and handle games that don't support it gracefully. - **Lifecycle**: Run module-declared stop sequences over RCON before the operator scales the server to zero. - **Actions**: Execute module-declared operator actions (templated RCON commands with user parameters) without agent code changes. -- **Status metrics**: Query live game metrics (TPS, world time, etc.) via RCON and regex extraction; module-declared in `capabilities.status.metrics[]`. +- **Status metrics**: Query live game metrics (TPS, world time, etc.) via RCON and regex extraction; module-declared in `capabilities.status.metrics[]`. Optional `performance` declarations add closed numeric signal/unit semantics and decimal degradation thresholds. One shared source serves both protected route mounts and Prometheus: opted-in templates use a 30-second interval cache, 3-second command/10-second cycle budgets, one outstanding legacy RCON call, and 75-second sample freshness. Scrapes issue no commands; invalid or stale values are absent and valid zero is retained. See `docs/monitoring.md` for bounds and metric families. - **Mods**: List, install, and uninstall game mods from a registry; track per-volume metadata; guard downloads with strict SSRF egress validation. - **Heartbeat**: Periodically patch the owning GameServer's status with lastHeartbeat, playersOnline, playersMax, gameVersion, and resource usage (CPU, memory, disk). @@ -86,7 +86,7 @@ Per-package roles: - **`mods`**: Tracks installed mods in a per-volume manifest (`.gameplane-mods.json`); downloads from registry with strict egress validation via `netguard.IsPublic`. - **`players`**: Queries player count, names, ban lists, and runs moderation actions over RCON; a single template-driven `commander` renders each moderation command from the module's declared `capabilities.players` templates (no per-game Go implementations), and reports capabilities from what the template declares. - **`quiesce`**: Runs module-declared sequences (e.g., Minecraft's `save-off` + `save-all flush`) over RCON; responds `quiesced: false` + reason when unsupported (not an error). -- **`rcon`**: Factory pattern for wire-protocol clients (`Valve/Source`, `Telnet`, `WebSocket`, `BattlEye`, `Satisfactory`, `Palworld`, `NuclearOption`, `REST`, `CLI`, `Disabled`); `Exec(cmd) (string, error)` interface. RCON reply packets are accepted up to 16394 bytes (a 4096-character Minecraft chunk at its worst-case UTF-8 byte length, plus the 10-byte packet header) and rejected as malformed outside `[10, 16394]` (F-104). +- **`rcon`**: Factory pattern for wire-protocol clients (`Valve/Source`, `Telnet`, `WebSocket`, `BattlEye`, `Satisfactory`, `Palworld`, `NuclearOption`, `REST`, `CLI`, `Disabled`); `Exec(cmd) (string, error)` interface. RCON reply packets are accepted up to 16394 bytes (a 4096-character Minecraft chunk at its worst-case UTF-8 byte length, plus the 10-byte packet header) and rejected as malformed outside `[10, 16394]` (F-104). Source RCON also caps aggregate response text at 8 MiB and enforces an absolute 30-second command exchange deadline after connection/authentication; each 400-ms fragment idle window is clamped to that deadline. Exceeding the aggregate cap or hard deadline drops the connection and returns an error without partial output. The status collector separately accepts at most 64 KiB. - **`status`**: Runs module-declared metrics queries over RCON; each metric specifies a command and a regex with named group `"value"` for extraction. - **`usage`**: Reads CPU/memory from `/proc` (proc mode, default) or cgroup v2; disk via `statfs`; exposes `Sample` with `Known` flags so callers distinguish "unknown" from "zero". @@ -210,7 +210,7 @@ All endpoints on the `--addr` control mux, except `/healthz`, return `401 Unauth - **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. - **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. +- **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. Dialing has a five-second limit; authentication has an absolute ten-second limit including continuous preamble packets. Healthy idle completion permits connection reuse; aggregate response overflow or absolute command deadline exhaustion discards partial output and drops the connection. - **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. ## Dependencies @@ -244,7 +244,7 @@ The agent and operator modules use `k8s.io/apimachinery`/`k8s.io/client-go` v0.3 - `battleye`: UDP-based CRC32 packet framing. - `satisfactory`: HTTPS JSON function-call API with session token. - `palworld`: HTTP Basic REST admin API. - - `nuclearoption`: Dedicated TCP stream for Nuclear Option remote command protocol. + - `nuclearoption`: Dedicated TCP stream for Nuclear Option remote command protocol. One 30-second deadline bounds dialing, request writes, and response reads; responses are capped at 10 MiB. A timed-out partial exchange closes the connection before the next command. - `rest`: Generic HTTP/JSON REST console client with lazy auth (`PassFn`), 1 MiB bounded body read, configurable timeouts, auth failure cooldown, and loopback TLS protection. Provides adapters for FiveM (txAdmin: `POST /fxserver/commands`, bearer/X-TxAdmin-Token, `{"action":"console","parameter":""}`), Farming Simulator 25 (`POST /api/console`, Basic auth, `{"command":""}`), and generic REST (`POST /api/command`). - `cli`: Container stdin/PTY / local execution client (Option A in OPEN-DECISIONS.md). Drives commands via named FIFO pipe (`GAMEPLANE_CLI_PIPE`) or local process shell execution without requiring remote TCP networking or password secrets, while web console continues to attach via pod-attach PTY. diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml index d902ea995..2c964a9c7 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml index d85406090..f35faf587 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml index 4d6aadcbb..a00a67448 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml index 95178e56e..dcbc60df0 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: group: gameplane.local @@ -1546,15 +1546,12 @@ spec: tags: description: |- Tags are Tailscale ACL tags requested for the device when it - registers, via `tailscale up --advertise-tags` (tailscaled's - declarative config has no field for tags, and changing the tags of a - registered node needs a fresh login). Each entry is "tag:" or a bare - name, which gets the "tag:" prefix. The tailnet ACL must grant - tagOwners for these tags to the auth key's owner; if the request is - refused, the tunnel pod logs the error and the tunnel runs untagged. - If any entry is invalid, the whole list is logged and ignored and the - device registers untagged. See - tunnel/specs.md. + registers through embedded tsnet. Each entry is "tag:" or a + bare name, which gets the "tag:" prefix. The tailnet policy must grant + tagOwners to the auth key's owner. Invalid or refused tags keep the + tunnel not ready; it never silently registers without requested tags. + Changing tags replaces the Pod and requires a reusable auth key. + See tunnel/specs.md for identity persistence and registration details. items: type: string maxItems: 8 diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml index e5e3e1e3b..97d49d8bd 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local @@ -961,6 +961,41 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ type: string + performance: + description: |- + Performance opts this existing readout into cached numeric monitoring. + Display-only strings are never inferred to be performance measurements. + properties: + degradedThreshold: + description: |- + DegradedThreshold is a finite nonnegative decimal string in the input + unit. Rates below it, or durations above it, are degraded. Omit to + disable alerts. A string keeps JSON/schema handling portable. + maxLength: 16 + pattern: ^(0|[1-9][0-9]{0,8})(\.[0-9]{1,6})?$ + type: string + signal: + enum: + - tick_rate + - tick_duration + - frame_rate + type: string + unit: + enum: + - per_second + - seconds + - milliseconds + type: string + required: + - signal + - unit + type: object + x-kubernetes-validations: + - message: duration requires seconds/milliseconds; rates + require per_second + rule: 'self.signal == ''tick_duration'' ? self.unit + in [''seconds'', ''milliseconds''] : self.unit == + ''per_second''' regex: description: |- Regex extracts the value via the named group "value", e.g. @@ -2114,6 +2149,12 @@ spec: minimum: 0 type: integer type: object + sentinelIsolation: + description: |- + SentinelIsolation restricts a wake sentinel to its own server's advertised + ports. Templates opt in after module connectivity validation; absent or + false retains the legacy namespace-wide sentinel allowances. + type: boolean storage: description: Storage describes the default persistent storage layout. properties: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml index 5ade28cee..099d0e3b0 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml index 6c5dcbf36..e31ebb242 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml index cc113e7e8..565e4fb47 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: group: gameplane.local @@ -209,22 +209,15 @@ spec: format: int64 type: integer phase: - allOf: - - enum: - - Pending - - Running - - Completed - - Failed - - Expired - - enum: - - Pending - - Running - - Completed - - Failed - - Expired description: |- Phase is the current lifecycle phase of the capture. Valid values: Pending, Running, Completed, Failed, Expired. + enum: + - Pending + - Running + - Completed + - Failed + - Expired type: string startTime: description: |- diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml index b77cd5c25..ab12e096d 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml index d902ea995..2c964a9c7 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml index d85406090..f35faf587 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml index 4d6aadcbb..a00a67448 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml index 95178e56e..dcbc60df0 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: group: gameplane.local @@ -1546,15 +1546,12 @@ spec: tags: description: |- Tags are Tailscale ACL tags requested for the device when it - registers, via `tailscale up --advertise-tags` (tailscaled's - declarative config has no field for tags, and changing the tags of a - registered node needs a fresh login). Each entry is "tag:" or a bare - name, which gets the "tag:" prefix. The tailnet ACL must grant - tagOwners for these tags to the auth key's owner; if the request is - refused, the tunnel pod logs the error and the tunnel runs untagged. - If any entry is invalid, the whole list is logged and ignored and the - device registers untagged. See - tunnel/specs.md. + registers through embedded tsnet. Each entry is "tag:" or a + bare name, which gets the "tag:" prefix. The tailnet policy must grant + tagOwners to the auth key's owner. Invalid or refused tags keep the + tunnel not ready; it never silently registers without requested tags. + Changing tags replaces the Pod and requires a reusable auth key. + See tunnel/specs.md for identity persistence and registration details. items: type: string maxItems: 8 diff --git a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml index e5e3e1e3b..97d49d8bd 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local @@ -961,6 +961,41 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ type: string + performance: + description: |- + Performance opts this existing readout into cached numeric monitoring. + Display-only strings are never inferred to be performance measurements. + properties: + degradedThreshold: + description: |- + DegradedThreshold is a finite nonnegative decimal string in the input + unit. Rates below it, or durations above it, are degraded. Omit to + disable alerts. A string keeps JSON/schema handling portable. + maxLength: 16 + pattern: ^(0|[1-9][0-9]{0,8})(\.[0-9]{1,6})?$ + type: string + signal: + enum: + - tick_rate + - tick_duration + - frame_rate + type: string + unit: + enum: + - per_second + - seconds + - milliseconds + type: string + required: + - signal + - unit + type: object + x-kubernetes-validations: + - message: duration requires seconds/milliseconds; rates + require per_second + rule: 'self.signal == ''tick_duration'' ? self.unit + in [''seconds'', ''milliseconds''] : self.unit == + ''per_second''' regex: description: |- Regex extracts the value via the named group "value", e.g. @@ -2114,6 +2149,12 @@ spec: minimum: 0 type: integer type: object + sentinelIsolation: + description: |- + SentinelIsolation restricts a wake sentinel to its own server's advertised + ports. Templates opt in after module connectivity validation; absent or + false retains the legacy namespace-wide sentinel allowances. + type: boolean storage: description: Storage describes the default persistent storage layout. properties: diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index 5ade28cee..099d0e3b0 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml index 6c5dcbf36..e31ebb242 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml index cc113e7e8..565e4fb47 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: group: gameplane.local @@ -209,22 +209,15 @@ spec: format: int64 type: integer phase: - allOf: - - enum: - - Pending - - Running - - Completed - - Failed - - Expired - - enum: - - Pending - - Running - - Completed - - Failed - - Expired description: |- Phase is the current lifecycle phase of the capture. Valid values: Pending, Running, Completed, Failed, Expired. + enum: + - Pending + - Running + - Completed + - Failed + - Expired type: string startTime: description: |- diff --git a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml index b77cd5c25..ab12e096d 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml @@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 181aeee1d52a9cb178b0f8a8a862dc5959e772facf11817d5e70e5196574cd4c - controller-gen.kubebuilder.io/version: v0.20.1 + gameplane.local/crd-bundle-sha256: 519f827ad83dfea7450e7d4eb30f2433a7b3c04f111795fcef0496d81ee3a770 + controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/dashboards/gameplane-workloads.json b/charts/gameplane/dashboards/gameplane-workloads.json new file mode 100644 index 000000000..f0a978b26 --- /dev/null +++ b/charts/gameplane/dashboards/gameplane-workloads.json @@ -0,0 +1,345 @@ +{ + "title": "Gameplane Workloads", + "uid": "gameplane-workloads", + "tags": [ + "gameplane" + ], + "schemaVersion": 39, + "version": 1, + "editable": true, + "refresh": "30s", + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "query": "prometheus" + }, + { + "name": "namespace", + "type": "textbox", + "label": "Games namespace", + "current": { + "value": "gameplane-games", + "text": "gameplane-games" + } + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Scrape health", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (namespace,pod,gameplane_component) (up{gameplane_component=~\"agent|tunnel\",namespace=~\"$namespace\"})", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 2, + "title": "Relay readiness", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (namespace,pod) (gameplane_tunnel_ready{namespace=~\"$namespace\"})", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 3, + "title": "Relay health known", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (namespace,pod) (gameplane_tunnel_connection_known{namespace=~\"$namespace\"})", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 4, + "title": "Game rates (declared signals)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "hertz", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "gameplane_agent_performance_value{namespace=~\"$namespace\",unit=\"hertz\"}", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 5, + "title": "Game tick duration", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "gameplane_agent_performance_value{namespace=~\"$namespace\",unit=\"seconds\"}", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 6, + "title": "Performance available", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "gameplane_agent_performance_available{namespace=~\"$namespace\"}", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 7, + "title": "Container CPU throttled periods", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (namespace,pod) (rate(container_cpu_cfs_throttled_periods_total{namespace=~\"$namespace\",container=\"game\"}[5m]) / rate(container_cpu_cfs_periods_total{namespace=~\"$namespace\",container=\"game\"}[5m]))", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 8, + "title": "Container working set / limit", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (namespace,pod) (container_memory_working_set_bytes{namespace=~\"$namespace\",container=\"game\"} / (container_spec_memory_limit_bytes{namespace=~\"$namespace\",container=\"game\"} > 0))", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 9, + "title": "Container restarts", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (namespace,pod,container) (increase(kube_pod_container_status_restarts_total{namespace=~\"$namespace\"}[15m]))", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + }, + { + "id": 10, + "title": "Pending Pods", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "noValue": "Unknown", + "custom": { + "spanNulls": false + } + } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (namespace,pod) (kube_pod_status_phase{namespace=~\"$namespace\",phase=\"Pending\"})", + "legendFormat": "{{namespace}} / {{pod}} {{metric}}" + } + ] + } + ] +} diff --git a/charts/gameplane/templates/NOTES.txt b/charts/gameplane/templates/NOTES.txt index 6f5d24139..e33acd801 100644 --- a/charts/gameplane/templates/NOTES.txt +++ b/charts/gameplane/templates/NOTES.txt @@ -4,6 +4,16 @@ persistence and dashboard blocks are absent. */ -}} {{- $r := .Values.api.telemetry.receiver }} +{{- if .Values.serviceMonitors.enabled }} + +Monitoring: configure Prometheus monitor label selectors AND discovery of the +release and games namespaces. Verify agent and tunnel targets have up=1. +See docs/monitoring.md and docs/examples/monitoring for a complete configuration. +{{- if .Values.serviceMonitors.externallyManagedPolicy }} +External scrape policies are selected: this setting opens no network access. +Ensure your policies allow the scraper to agent TCP 9090 and tunnel TCP 8091. +{{- end }} +{{- end }} {{- if .Values.api.standalone }} Standalone central panel: no local game cluster or Kubernetes API credentials. diff --git a/charts/gameplane/templates/_helpers.tpl b/charts/gameplane/templates/_helpers.tpl index bec3e1ffb..8085e8ac0 100644 --- a/charts/gameplane/templates/_helpers.tpl +++ b/charts/gameplane/templates/_helpers.tpl @@ -135,3 +135,26 @@ true true {{- end -}} {{- end -}} + +{{- /* Shared PodSpec placement fields; absent keys preserve older reuse-values. */ -}} +{{- define "gameplane.placement" -}} +{{- range $key := list "nodeSelector" "tolerations" "affinity" "topologySpreadConstraints" "priorityClassName" -}} +{{- with index $ $key }} +{{ $key }}: {{ toYaml . | nindent 2 }} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- /* Complete auxiliary overrides; empty objects must not become defaults. */ -}} +{{- define "gameplane.auxiliaryResourceArgs" -}} +{{- range $name := list "agent" "sentinel" "tunnel" "backup" -}} +{{- $key := printf "%sResources" $name -}} +{{- $value := index $ $key -}} +{{- if and (hasKey $ $key) (not (kindIs "invalid" $value)) -}} +{{- if not (kindIs "map" $value) -}} +{{- fail (printf "operator.%s must be a resources object or null" $key) -}} +{{- end }} +- {{ printf "--%s-resources=%s" $name (toJson $value) | quote }} +{{- end -}} +{{- end -}} +{{- end -}} diff --git a/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index a562d7c7b..f04fa99ec 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -258,6 +258,7 @@ spec: checksum/mtls: {{ include "gameplane.mtlsChecksum" . }} {{- end }} spec: + {{- include "gameplane.placement" .Values.api | nindent 6 }} serviceAccountName: gameplane-api {{- if .Values.api.standalone }} automountServiceAccountToken: false diff --git a/charts/gameplane/templates/audit-syslog-bridge.yaml b/charts/gameplane/templates/audit-syslog-bridge.yaml index 11bb8b279..c030d0150 100644 --- a/charts/gameplane/templates/audit-syslog-bridge.yaml +++ b/charts/gameplane/templates/audit-syslog-bridge.yaml @@ -27,6 +27,7 @@ spec: app.kubernetes.io/name: gameplane-audit-syslog-bridge {{- include "gameplane.labels" . | nindent 8 }} spec: + {{- include "gameplane.placement" $b | nindent 6 }} securityContext: runAsNonRoot: true runAsUser: 65532 diff --git a/charts/gameplane/templates/crd-apply-hook.yaml b/charts/gameplane/templates/crd-apply-hook.yaml index b767b4e28..4ad3925cb 100644 --- a/charts/gameplane/templates/crd-apply-hook.yaml +++ b/charts/gameplane/templates/crd-apply-hook.yaml @@ -194,6 +194,7 @@ spec: app.kubernetes.io/name: gameplane-crd-apply {{- include "gameplane.labels" . | nindent 8 }} spec: + {{- include "gameplane.placement" .Values.crds.autoApply | nindent 6 }} serviceAccountName: {{ .Release.Name }}-crd-apply restartPolicy: Never securityContext: diff --git a/charts/gameplane/templates/gateway.yaml b/charts/gameplane/templates/gateway.yaml index 82c46613e..aeab6bafc 100644 --- a/charts/gameplane/templates/gateway.yaml +++ b/charts/gameplane/templates/gateway.yaml @@ -113,6 +113,7 @@ spec: app.kubernetes.io/name: gameplane-gateway {{- include "gameplane.labels" . | nindent 8 }} spec: + {{- include "gameplane.placement" $gateway | nindent 6 }} serviceAccountName: gameplane-gateway securityContext: runAsNonRoot: true diff --git a/charts/gameplane/templates/grafana-dashboard.yaml b/charts/gameplane/templates/grafana-dashboard.yaml index de1052252..9c9664fb2 100644 --- a/charts/gameplane/templates/grafana-dashboard.yaml +++ b/charts/gameplane/templates/grafana-dashboard.yaml @@ -15,6 +15,8 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} data: + gameplane-workloads.json: |- + {{- .Files.Get "dashboards/gameplane-workloads.json" | nindent 4 }} gameplane-operator.json: |- {{- .Files.Get "dashboards/gameplane-operator.json" | nindent 4 }} {{- end }} diff --git a/charts/gameplane/templates/mcp-server.yaml b/charts/gameplane/templates/mcp-server.yaml index 3858878a3..40051e993 100644 --- a/charts/gameplane/templates/mcp-server.yaml +++ b/charts/gameplane/templates/mcp-server.yaml @@ -66,6 +66,7 @@ spec: app.kubernetes.io/name: gameplane-mcp-server {{- include "gameplane.labels" . | nindent 8 }} spec: + {{- include "gameplane.placement" .Values.mcpServer | nindent 6 }} serviceAccountName: gameplane-mcp-server securityContext: runAsNonRoot: true diff --git a/charts/gameplane/templates/monitoring-rules.yaml b/charts/gameplane/templates/monitoring-rules.yaml new file mode 100644 index 000000000..120c5c9a6 --- /dev/null +++ b/charts/gameplane/templates/monitoring-rules.yaml @@ -0,0 +1,72 @@ +{{- if and .Values.prometheusRules.enabled (eq (include "gameplane.operatorEnabled" .) "true") }} +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: gameplane-workloads + namespace: {{ .Release.Namespace }} + labels: + {{- include "gameplane.labels" . | nindent 4 }} + {{- with .Values.prometheusRules.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + groups: + - name: gameplane.workloads + rules: + - alert: GameplaneScrapeUnavailable + expr: max by (namespace, pod, gameplane_component) (up{gameplane_component=~"agent|tunnel",namespace="{{ .Values.gamesNamespace }}"}) == 0 + for: 5m + labels: { severity: warning } + annotations: { summary: "Gameplane metrics target cannot be scraped" } + - alert: GameplaneRelayUnavailable + expr: (max by (namespace, pod) (gameplane_tunnel_ready{namespace="{{ .Values.gamesNamespace }}"}) == 0) and on (namespace, pod) (max by (namespace, pod) (gameplane_tunnel_connection_known{namespace="{{ .Values.gamesNamespace }}"}) == 1) + for: 2m + labels: { severity: warning } + annotations: { summary: "Gameplane relay connection or forwards are unavailable" } + - alert: GameplaneRelayHealthUnknown + expr: max by (namespace, pod) (gameplane_tunnel_connection_known{namespace="{{ .Values.gamesNamespace }}"}) == 0 + for: 5m + labels: { severity: info } + annotations: { summary: "Gameplane provider cannot confirm live relay health" } + - alert: GameplanePerformanceUnknown + expr: max by (namespace, pod, metric, signal, unit) (gameplane_agent_performance_available{namespace="{{ .Values.gamesNamespace }}"}) == 0 + for: 5m + labels: { severity: warning } + annotations: { summary: "Declared game performance measurement is unavailable" } + - alert: GameplanePerformanceDegraded + expr: | + (max by (namespace, pod, metric, signal, unit) (gameplane_agent_performance_value{namespace="{{ .Values.gamesNamespace }}"}) + < on (namespace, pod, metric, signal, unit) + max by (namespace, pod, metric, signal, unit) (gameplane_agent_performance_degraded_threshold{namespace="{{ .Values.gamesNamespace }}",direction="below"})) + or + (max by (namespace, pod, metric, signal, unit) (gameplane_agent_performance_value{namespace="{{ .Values.gamesNamespace }}"}) + > on (namespace, pod, metric, signal, unit) + max by (namespace, pod, metric, signal, unit) (gameplane_agent_performance_degraded_threshold{namespace="{{ .Values.gamesNamespace }}",direction="above"})) + for: 5m + labels: { severity: warning } + annotations: { summary: "Game performance crossed its module-declared threshold" } + - alert: GameplaneCPUThrottled + expr: max by (namespace, pod, container) (rate(container_cpu_cfs_throttled_periods_total{namespace="{{ .Values.gamesNamespace }}",container="game"}[5m]) / rate(container_cpu_cfs_periods_total{namespace="{{ .Values.gamesNamespace }}",container="game"}[5m])) > 0.25 + for: 10m + labels: { severity: warning } + annotations: { summary: "Game container CPU is throttled in more than 25% of periods" } + - alert: GameplaneMemoryPressure + expr: max by (namespace, pod, container) (container_memory_working_set_bytes{namespace="{{ .Values.gamesNamespace }}",container="game"} / (container_spec_memory_limit_bytes{namespace="{{ .Values.gamesNamespace }}",container="game"} > 0)) > 0.9 + for: 10m + labels: { severity: warning } + annotations: { summary: "Game container working set exceeds 90% of its memory limit" } + - alert: GameplaneContainerOOM + expr: (max by (namespace, pod, container) (increase(kube_pod_container_status_restarts_total{namespace="{{ .Values.gamesNamespace }}"}[10m])) > 0) and on (namespace, pod, container) (max by (namespace, pod, container) (kube_pod_container_status_last_terminated_reason{namespace="{{ .Values.gamesNamespace }}",reason="OOMKilled"}) == 1) + labels: { severity: warning } + annotations: { summary: "Gameplane container restarted after an OOM kill" } + - alert: GameplanePodPending + expr: max by (namespace, pod) (kube_pod_status_phase{namespace="{{ .Values.gamesNamespace }}",phase="Pending"}) == 1 + for: 10m + labels: { severity: warning } + annotations: { summary: "Gameplane Pod has remained Pending for ten minutes" } + - alert: GameplaneNodePressure + expr: (max by (node, condition) (kube_node_status_condition{condition=~"MemoryPressure|DiskPressure|PIDPressure",status="true"}) == 1) and on (node) max by (node) (kube_pod_info{namespace="{{ .Values.gamesNamespace }}"}) + for: 5m + labels: { severity: warning } + annotations: { summary: "A node hosting Gameplane Pods reports resource pressure" } +{{- end }} diff --git a/charts/gameplane/templates/monitoring-validation.yaml b/charts/gameplane/templates/monitoring-validation.yaml new file mode 100644 index 000000000..15fcacc60 --- /dev/null +++ b/charts/gameplane/templates/monitoring-validation.yaml @@ -0,0 +1,3 @@ +{{- if and (eq (include "gameplane.operatorEnabled" .) "true") .Values.serviceMonitors.enabled .Values.networkPolicies.enabled (not .Values.serviceMonitors.scrapeNamespaceSelector) (not .Values.serviceMonitors.externallyManagedPolicy) }} +{{- fail "serviceMonitors.enabled with networkPolicies.enabled requires serviceMonitors.scrapeNamespaceSelector (for example matchLabels: {kubernetes.io/metadata.name: monitoring}); alternatively set serviceMonitors.externallyManagedPolicy=true and supply your own scrape ingress policies" }} +{{- end }} diff --git a/charts/gameplane/templates/networkpolicies.yaml b/charts/gameplane/templates/networkpolicies.yaml index 81f963f4d..d652bd58b 100644 --- a/charts/gameplane/templates/networkpolicies.yaml +++ b/charts/gameplane/templates/networkpolicies.yaml @@ -163,6 +163,24 @@ spec: {{- toYaml . | nindent 12 }} ports: - { protocol: TCP, port: 9090 } +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-prometheus-to-tunnel + namespace: {{ $.Values.gamesNamespace }} + labels: {{- include "gameplane.labels" $ | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: gameplane-tunnel + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + {{- toYaml . | nindent 12 }} + ports: + - { protocol: TCP, port: 8091 } {{- end }} {{- end }} --- @@ -331,8 +349,9 @@ spec: - { protocol: TCP, port: 443 } - { protocol: TCP, port: 6443 } --- -# Allow the sentinel to egress to game pods on advertised ports for the proxy path. -# This is the INGRESS half only, on the game pod's own selector. +# Legacy sentinel proxy allowance, ingress half. This grants ALL ports between +# non-isolated sentinels and games. NetworkPolicies are additive; per-server +# player ingress does not narrow it. Opted-in templates use operator policies. # NetworkPolicy requires both sides to agree: the destination (this policy) # admits the sentinel, but default-deny-egress's podSelector: {} still # governs the sentinel as a SOURCE and only opens DNS. allow-sentinel-to-game-egress @@ -349,14 +368,17 @@ spec: podSelector: matchLabels: app.kubernetes.io/name: gameplane-game + matchExpressions: + - { key: gameplane.local/sentinel-isolation, operator: NotIn, values: ["true"] } policyTypes: [Ingress] ingress: - from: - podSelector: matchLabels: app.kubernetes.io/name: gameplane-waker - # All ports; the per-server ingress policy covers which ports are - # actually advertised for each server. + matchExpressions: + - { key: gameplane.local/sentinel-isolation, operator: NotIn, values: ["true"] } + # Intentionally unrestricted for templates that have not opted in. --- # The Egress half of allow-sentinel-to-game (see its comment above): admits # the sentinel, as a traffic SOURCE, to reach game pods. Selects the @@ -374,15 +396,17 @@ spec: podSelector: matchLabels: app.kubernetes.io/name: gameplane-waker + matchExpressions: + - { key: gameplane.local/sentinel-isolation, operator: NotIn, values: ["true"] } policyTypes: [Egress] egress: - to: - podSelector: matchLabels: app.kubernetes.io/name: gameplane-game - # All ports; allow-sentinel-to-game above (the matching ingress half) - # and the per-server ingress policy cover which ports are actually - # advertised for each server. + matchExpressions: + - { key: gameplane.local/sentinel-isolation, operator: NotIn, values: ["true"] } + # Intentionally unrestricted for templates that have not opted in. {{- end }} {{- end }} diff --git a/charts/gameplane/templates/operator-strategy-migrate.yaml b/charts/gameplane/templates/operator-strategy-migrate.yaml new file mode 100644 index 000000000..e376fca4b --- /dev/null +++ b/charts/gameplane/templates/operator-strategy-migrate.yaml @@ -0,0 +1,85 @@ +{{- /* SSA cannot remove a server-defaulted rollingUpdate field. Patch it before +Helm applies Recreate, including installations that manage CRDs externally. */ -}} +{{- if and .Release.IsUpgrade (eq (include "gameplane.operatorEnabled" .) "true") (not .Values.operator.leaderElect) }} +{{- $existing := lookup "apps/v1" "Deployment" .Release.Namespace "gameplane-operator" }} +{{- if $existing }} +{{- $name := printf "%s-op-roll" .Release.Name }} +{{- $annotations := dict "helm.sh/hook" "pre-upgrade" "helm.sh/hook-delete-policy" "before-hook-creation,hook-succeeded" }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} + annotations: {{- toYaml (merge (dict "helm.sh/hook-weight" "-5") $annotations) | nindent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} + annotations: {{- toYaml (merge (dict "helm.sh/hook-weight" "-5") $annotations) | nindent 4 }} +rules: + - apiGroups: [apps] + resources: [deployments] + resourceNames: [gameplane-operator] + verbs: [get, patch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} + annotations: {{- toYaml (merge (dict "helm.sh/hook-weight" "-5") $annotations) | nindent 4 }} +roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: {{ $name }} } +subjects: + - kind: ServiceAccount + name: {{ $name }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} + annotations: {{- toYaml (merge (dict "helm.sh/hook-weight" "-4") $annotations) | nindent 4 }} +spec: + backoffLimit: 2 + ttlSecondsAfterFinished: 300 + template: + spec: + {{- include "gameplane.placement" .Values.crds.autoApply | nindent 6 }} + serviceAccountName: {{ $name }} + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65532 + fsGroup: 65532 + seccompProfile: { type: RuntimeDefault } + containers: + - name: kubectl + image: {{ .Values.crds.autoApply.image | quote }} + imagePullPolicy: {{ .Values.crds.autoApply.pullPolicy }} + command: [kubectl, patch, deployment, gameplane-operator, --type=merge, -p, '{"spec":{"strategy":{"type":"Recreate","rollingUpdate":null}}}'] + env: + - { name: HOME, value: /tmp } + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { cpu: 500m, memory: 256Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumeMounts: + - { name: tmp, mountPath: /tmp } + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/gameplane/templates/operator.yaml b/charts/gameplane/templates/operator.yaml index 772e0b10e..0111536df 100644 --- a/charts/gameplane/templates/operator.yaml +++ b/charts/gameplane/templates/operator.yaml @@ -239,6 +239,17 @@ metadata: labels: {{- include "gameplane.labels" . | nindent 4 }} spec: replicas: {{ .Values.operator.replicas }} + {{- if not .Values.operator.leaderElect }} + # Without leader election, prevent a rolling update from overlapping writers. + strategy: + type: Recreate + rollingUpdate: null + {{- else }} + # Explicitly restore rolling updates when election is re-enabled after a + # singleton install; the migration hook may own the previous Recreate field. + strategy: + type: RollingUpdate + {{- end }} selector: matchLabels: app.kubernetes.io/name: gameplane-operator @@ -250,6 +261,7 @@ spec: annotations: checksum/mtls: {{ include "gameplane.mtlsChecksum" . }} spec: + {{- include "gameplane.placement" .Values.operator | nindent 6 }} serviceAccountName: gameplane-operator securityContext: runAsNonRoot: true @@ -261,6 +273,7 @@ spec: image: {{ include "gameplane.operatorImage" . }} imagePullPolicy: {{ .Values.image.pullPolicy }} args: + {{- include "gameplane.auxiliaryResourceArgs" .Values.operator | nindent 12 }} {{- if .Values.operator.leaderElect }} - --leader-elect {{- end }} diff --git a/charts/gameplane/templates/poddisruptionbudgets.yaml b/charts/gameplane/templates/poddisruptionbudgets.yaml new file mode 100644 index 000000000..35667e143 --- /dev/null +++ b/charts/gameplane/templates/poddisruptionbudgets.yaml @@ -0,0 +1,47 @@ +{{- $apiEnabled := eq (include "gameplane.apiEnabled" .) "true" -}} +{{- $bridge := .Values.api.audit.webhook.syslogBridge -}} +{{- $receiver := .Values.api.telemetry.receiver -}} +{{- $gateway := .Values.gateway | default dict -}} +{{- $components := list + (dict "name" "operator" "config" .Values.operator "enabled" (eq (include "gameplane.operatorEnabled" .) "true")) + (dict "name" "api" "config" .Values.api "enabled" $apiEnabled) + (dict "name" "web" "config" .Values.web "enabled" (and $apiEnabled .Values.web.enabled)) + (dict "name" "mcp-server" "config" .Values.mcpServer "enabled" .Values.mcpServer.enabled) + (dict "name" "gateway" "config" $gateway "enabled" $gateway.enabled) + (dict "name" "audit-syslog-bridge" "config" $bridge "enabled" (and $apiEnabled $bridge.enabled (not .Values.api.audit.webhook.url))) + (dict "name" "telemetry-receiver" "config" $receiver "enabled" (and $apiEnabled $receiver.enabled (not .Values.api.telemetry.endpoint))) -}} +{{- range $components -}} +{{- $pdb := .config.pdb | default dict -}} +{{- if and .enabled $pdb.enabled -}} +{{- $minimum := not (kindIs "invalid" $pdb.minAvailable) -}} +{{- $maximum := not (kindIs "invalid" $pdb.maxUnavailable) -}} +{{- if eq .name "api" -}} +{{- if or $minimum (and $maximum (ne (toString $pdb.maxUnavailable) "1")) -}} +{{- fail "api.pdb must allow the singleton API to be evicted: use maxUnavailable: 1 without minAvailable" -}} +{{- end -}} +{{- else if and $minimum $maximum -}} +{{- fail (printf "%s.pdb must set only one of minAvailable or maxUnavailable" .name) -}} +{{- end }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: gameplane-{{ .name }} + namespace: {{ $.Release.Namespace }} + labels: {{- include "gameplane.labels" $ | nindent 4 }} +spec: + {{- if eq .name "api" }} + maxUnavailable: 1 + {{- else if $minimum }} + minAvailable: {{ toYaml $pdb.minAvailable }} + {{- else if $maximum }} + maxUnavailable: {{ toYaml $pdb.maxUnavailable }} + {{- else }} + maxUnavailable: 1 + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: gameplane-{{ .name }} + app.kubernetes.io/instance: {{ $.Release.Name }} +{{- end -}} +{{- end -}} diff --git a/charts/gameplane/templates/servicemonitors.yaml b/charts/gameplane/templates/servicemonitors.yaml index 4521db18e..2b5b6834b 100644 --- a/charts/gameplane/templates/servicemonitors.yaml +++ b/charts/gameplane/templates/servicemonitors.yaml @@ -107,6 +107,34 @@ spec: - port: metrics interval: 30s path: /metrics + relabelings: + - targetLabel: gameplane_component + replacement: agent + - sourceLabels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance] + targetLabel: server +--- +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: gameplane-tunnel + namespace: {{ .Values.gamesNamespace }} + labels: + {{- include "gameplane.labels" . | nindent 4 }} + {{- with .Values.serviceMonitors.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: { app.kubernetes.io/name: gameplane-tunnel } + podMetricsEndpoints: + - port: relay-metrics + interval: 15s + path: /metrics + relabelings: + - targetLabel: gameplane_component + replacement: tunnel + - sourceLabels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance] + targetLabel: server {{- end }} {{- end }} diff --git a/charts/gameplane/templates/telemetry-receiver.yaml b/charts/gameplane/templates/telemetry-receiver.yaml index 4e8255fbc..18a084d32 100644 --- a/charts/gameplane/templates/telemetry-receiver.yaml +++ b/charts/gameplane/templates/telemetry-receiver.yaml @@ -88,6 +88,7 @@ spec: app.kubernetes.io/name: gameplane-telemetry-receiver {{- include "gameplane.labels" . | nindent 8 }} spec: + {{- include "gameplane.placement" $r | nindent 6 }} securityContext: runAsNonRoot: true runAsUser: 65532 diff --git a/charts/gameplane/templates/validate.yaml b/charts/gameplane/templates/validate.yaml index 79b5e17b6..ace7eba13 100644 --- a/charts/gameplane/templates/validate.yaml +++ b/charts/gameplane/templates/validate.yaml @@ -1,3 +1,34 @@ +{{- if and (eq (include "gameplane.operatorEnabled" .) "true") (gt (int .Values.operator.replicas) 1) (not .Values.operator.leaderElect) }} +{{- fail "operator.leaderElect must be enabled with more than one replica" }} +{{- end }} +{{- $network := .Values.networkPolicies | default dict }} +{{- if $network.strict }} +{{- if not $network.enabled }} +{{- fail "networkPolicies.strict requires networkPolicies.enabled=true" }} +{{- end }} +{{- range $key := list "kubeletCIDRs" "apiServerCIDRs" "probePorts" }} +{{- $entries := index $network $key }} +{{- if or (not (kindIs "slice" $entries)) (eq (len $entries) 0) }} +{{- fail (printf "networkPolicies.strict requires a non-empty networkPolicies.%s list" $key) }} +{{- end }} +{{- end }} +{{- $octet := "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])" }} +{{- $ipv4 := printf "^%s(\\.%s){3}/([0-9]|[12][0-9]|3[0-2])$" $octet $octet }} +{{- $h := "[0-9A-Fa-f]{1,4}" }} +{{- $ipv6 := printf "^((%s:){7}%s|(%s:){1,7}:|(%s:){1,6}:%s|(%s:){1,5}(:%s){1,2}|(%s:){1,4}(:%s){1,3}|(%s:){1,3}(:%s){1,4}|(%s:){1,2}(:%s){1,5}|%s:((:%s){1,6})|:((:%s){1,7}|:))/([0-9]|[1-9][0-9]|1[01][0-9]|12[0-8])$" $h $h $h $h $h $h $h $h $h $h $h $h $h $h $h $h }} +{{- range $key := list "kubeletCIDRs" "apiServerCIDRs" }} +{{- range (index $network $key) }} +{{- if or (not (kindIs "string" .)) (not (or (regexMatch $ipv4 (toString .)) (regexMatch $ipv6 (toString .)))) }} +{{- fail (printf "networkPolicies.%s entries must be explicit IPv4/IPv6 CIDRs" $key) }} +{{- end }} +{{- end }} +{{- end }} +{{- range $network.probePorts }} +{{- if or (not (has (.protocol | default "") (list "TCP" "UDP" "SCTP"))) (not (regexMatch "^[0-9]+$" (toString .port))) (lt (int .port) 1) (gt (int .port) 65535) }} +{{- fail "networkPolicies.probePorts requires protocol TCP/UDP/SCTP and an integer port from 1 to 65535" }} +{{- end }} +{{- end }} +{{- end }} {{- if .Values.api.standalone }} {{- if ne (include "gameplane.apiEnabled" .) "true" }} {{- fail "api.standalone requires api.enabled=true" }} diff --git a/charts/gameplane/templates/web.yaml b/charts/gameplane/templates/web.yaml index 4429fc5e4..736fd7136 100644 --- a/charts/gameplane/templates/web.yaml +++ b/charts/gameplane/templates/web.yaml @@ -25,6 +25,7 @@ spec: app.kubernetes.io/name: gameplane-web {{- include "gameplane.labels" . | nindent 8 }} spec: + {{- include "gameplane.placement" .Values.web | nindent 6 }} automountServiceAccountToken: false securityContext: runAsNonRoot: true diff --git a/charts/gameplane/tests/monitoring-rules.sh b/charts/gameplane/tests/monitoring-rules.sh new file mode 100644 index 000000000..6e39efd8e --- /dev/null +++ b/charts/gameplane/tests/monitoring-rules.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Render and evaluate shipped rules in CI, using the same Prometheus as E2E. +set -euo pipefail +cd "$(dirname "$0")/../../.." +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +helm template gameplane charts/gameplane -f docs/examples/monitoring/gameplane-values.yaml > "$scratch/rendered.yaml" +python3 - "$scratch" <<'PY' +import pathlib,sys,yaml +root=pathlib.Path(sys.argv[1]) +groups=[] +for document in yaml.safe_load_all((root/'rendered.yaml').read_text()): + if document and document.get('kind')=='PrometheusRule': + groups.extend(document['spec']['groups']) +(root/'rules.yaml').write_text(yaml.safe_dump({'groups':groups})) +PY +cp charts/gameplane/tests/monitoring-rules.test.yaml "$scratch/tests.yaml" +# mktemp creates a private directory, but promtool runs as the image's nobody +# user. These generated fixtures contain no credentials and are mounted read-only. +chmod 755 "$scratch" +chmod 644 "$scratch/rules.yaml" "$scratch/tests.yaml" +docker run --rm --volume "$scratch:/work:ro" --workdir /work --entrypoint /bin/promtool \ + prom/prometheus:v3.13.4 test rules tests.yaml diff --git a/charts/gameplane/tests/monitoring-rules.test.yaml b/charts/gameplane/tests/monitoring-rules.test.yaml new file mode 100644 index 000000000..3bd63c84f --- /dev/null +++ b/charts/gameplane/tests/monitoring-rules.test.yaml @@ -0,0 +1,146 @@ +rule_files: [rules.yaml] +evaluation_interval: 1m +tests: + - interval: 1m + input_series: + - series: 'gameplane_agent_performance_value{namespace="gameplane-games",pod="duration-0",metric="tick",signal="tick_duration",unit="seconds"}' + values: '0.06x10 0.04x10' + - series: 'gameplane_agent_performance_degraded_threshold{namespace="gameplane-games",pod="duration-0",metric="tick",signal="tick_duration",unit="seconds",direction="above"}' + values: '0.05x20' + - series: 'container_memory_working_set_bytes{namespace="gameplane-games",pod="unlimited",container="game"}' + values: '1000x20' + - series: 'container_spec_memory_limit_bytes{namespace="gameplane-games",pod="unlimited",container="game"}' + values: '0x20' + alert_rule_test: + - eval_time: 6m + alertname: GameplanePerformanceDegraded + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: duration-0, metric: tick, signal: tick_duration, unit: seconds, severity: warning} + exp_annotations: {summary: "Game performance crossed its module-declared threshold"} + - eval_time: 12m + alertname: GameplanePerformanceDegraded + exp_alerts: [] + - eval_time: 16m + alertname: GameplaneMemoryPressure + exp_alerts: [] + - eval_time: 16m + alertname: GameplanePerformanceUnknown + exp_alerts: [] + - interval: 1m + input_series: + - series: 'up{namespace="gameplane-games",pod="game-0",gameplane_component="agent"}' + values: '0x10 1x10' + - series: 'gameplane_tunnel_ready{namespace="gameplane-games",pod="relay"}' + values: '0x10 1x10' + - series: 'gameplane_tunnel_connection_known{namespace="gameplane-games",pod="relay"}' + values: '1x20' + alert_rule_test: + - eval_time: 6m + alertname: GameplaneScrapeUnavailable + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: game-0, gameplane_component: agent, severity: warning} + exp_annotations: {summary: "Gameplane metrics target cannot be scraped"} + - eval_time: 6m + alertname: GameplaneRelayUnavailable + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: relay, severity: warning} + exp_annotations: {summary: "Gameplane relay connection or forwards are unavailable"} + - eval_time: 12m + alertname: GameplaneScrapeUnavailable + exp_alerts: [] + - eval_time: 12m + alertname: GameplaneRelayUnavailable + exp_alerts: [] + - interval: 1m + input_series: + - series: 'gameplane_agent_performance_value{namespace="gameplane-games",pod="game-0",metric="tps",signal="tick_rate",unit="hertz"}' + values: '0x10 stale _x10' + - series: 'gameplane_agent_performance_degraded_threshold{namespace="gameplane-games",pod="game-0",metric="tps",signal="tick_rate",unit="hertz",direction="below"}' + values: '18x20' + - series: 'gameplane_agent_performance_available{namespace="gameplane-games",pod="game-0",metric="tps",signal="tick_rate",unit="hertz"}' + values: '1x10 0x10' + - series: 'gameplane_tunnel_connection_known{namespace="gameplane-games",pod="playit"}' + values: '0x10 1x10' + - series: 'gameplane_tunnel_ready{namespace="gameplane-games",pod="playit"}' + values: '0x10 1x10' + alert_rule_test: + - eval_time: 6m + alertname: GameplanePerformanceDegraded + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: game-0, metric: tps, signal: tick_rate, unit: hertz, severity: warning} + exp_annotations: {summary: "Game performance crossed its module-declared threshold"} + - eval_time: 12m + alertname: GameplanePerformanceDegraded + exp_alerts: [] + - eval_time: 17m + alertname: GameplanePerformanceUnknown + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: game-0, metric: tps, signal: tick_rate, unit: hertz, severity: warning} + exp_annotations: {summary: "Declared game performance measurement is unavailable"} + - eval_time: 6m + alertname: GameplaneRelayHealthUnknown + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: playit, severity: info} + exp_annotations: {summary: "Gameplane provider cannot confirm live relay health"} + - eval_time: 6m + alertname: GameplaneRelayUnavailable + exp_alerts: [] + - eval_time: 12m + alertname: GameplaneRelayHealthUnknown + exp_alerts: [] + - interval: 1m + input_series: + - series: 'container_cpu_cfs_throttled_periods_total{namespace="gameplane-games",pod="game-0",container="game"}' + values: '0+50x25' + - series: 'container_cpu_cfs_periods_total{namespace="gameplane-games",pod="game-0",container="game"}' + values: '0+100x25' + - series: 'container_memory_working_set_bytes{namespace="gameplane-games",pod="game-0",container="game"}' + values: '95x25' + - series: 'container_spec_memory_limit_bytes{namespace="gameplane-games",pod="game-0",container="game"}' + values: '100x25' + - series: 'kube_pod_status_phase{namespace="gameplane-games",pod="pending",phase="Pending"}' + values: '1x25' + - series: 'kube_pod_container_status_restarts_total{namespace="gameplane-games",pod="game-0",container="game"}' + values: '0+1x25' + - series: 'kube_pod_container_status_last_terminated_reason{namespace="gameplane-games",pod="game-0",container="game",reason="OOMKilled"}' + values: '1x25' + - series: 'kube_node_status_condition{node="worker",condition="MemoryPressure",status="true"}' + values: '1x25' + - series: 'kube_pod_info{namespace="gameplane-games",pod="game-0",node="worker"}' + values: '1x25' + - series: 'gameplane_gameservers{phase="Failed",instance="operator-a"}' + values: '1x25' + - series: 'gameplane_gameservers{phase="Failed",instance="operator-b"}' + values: '1x25' + alert_rule_test: + - eval_time: 16m + alertname: GameplaneCPUThrottled + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: game-0, container: game, severity: warning} + exp_annotations: {summary: "Game container CPU is throttled in more than 25% of periods"} + - eval_time: 16m + alertname: GameplaneMemoryPressure + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: game-0, container: game, severity: warning} + exp_annotations: {summary: "Game container working set exceeds 90% of its memory limit"} + - eval_time: 16m + alertname: GameplanePodPending + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: pending, severity: warning} + exp_annotations: {summary: "Gameplane Pod has remained Pending for ten minutes"} + - eval_time: 16m + alertname: GameplaneContainerOOM + exp_alerts: + - exp_labels: {namespace: gameplane-games, pod: game-0, container: game, severity: warning} + exp_annotations: {summary: "Gameplane container restarted after an OOM kill"} + - eval_time: 16m + alertname: GameplaneNodePressure + exp_alerts: + - exp_labels: {node: worker, condition: MemoryPressure, severity: warning} + exp_annotations: {summary: "A node hosting Gameplane Pods reports resource pressure"} + promql_expr_test: + - expr: 'max(gameplane_gameservers{phase="Failed"})' + eval_time: 16m + exp_samples: + - labels: '{}' + value: 1 diff --git a/charts/gameplane/values-production.example.yaml b/charts/gameplane/values-production.example.yaml new file mode 100644 index 000000000..229448ac3 --- /dev/null +++ b/charts/gameplane/values-production.example.yaml @@ -0,0 +1,36 @@ +# Example policy, not a capacity recommendation. Label/taint worker nodes before +# applying this profile; see docs/production-placement.md. Never remove or +# tolerate control-plane taints just to satisfy these selectors. +operator: + replicas: 2 + leaderElect: true + nodeSelector: &managementNodes { gameplane.local/pool: management } + tolerations: &managementTolerations + - { key: gameplane.local/pool, operator: Equal, value: management, effect: NoSchedule } + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: { app.kubernetes.io/name: gameplane-operator } + pdb: { enabled: true, maxUnavailable: 1 } +api: + replicas: 1 + nodeSelector: *managementNodes + tolerations: *managementTolerations + pdb: { enabled: true, maxUnavailable: 1 } +web: + replicas: 2 + nodeSelector: *managementNodes + tolerations: *managementTolerations + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: { app.kubernetes.io/name: gameplane-web } + pdb: { enabled: true, maxUnavailable: 1 } +crds: + autoApply: + nodeSelector: *managementNodes + tolerations: *managementTolerations diff --git a/charts/gameplane/values.yaml b/charts/gameplane/values.yaml index 66d95af0b..4c93637ae 100644 --- a/charts/gameplane/values.yaml +++ b/charts/gameplane/values.yaml @@ -39,6 +39,12 @@ image: crds: autoApply: enabled: true + # The upgrade hook must be schedulable before new Deployments roll out. + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" # kubectl image the apply Job runs. Any image with kubectl on PATH works; # retag to a private mirror for air-gapped installs (needed at upgrade # time, and also on an install over CRDs an earlier, uninstalled release @@ -53,17 +59,28 @@ operator: # Disable the operator and bundled native CRDs for a standalone central panel. enabled: true replicas: 1 + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" + pdb: { enabled: false } # optional minAvailable OR maxUnavailable; defaults to maxUnavailable: 1 logLevel: info # zap log level: debug|info|error (warn not supported by zap's --zap-log-level) agentLogLevel: "" # injected into every agent sidecar as GAMEPLANE_LOG_LEVEL (debug|info|warn|error); empty injects nothing, avoiding StatefulSet rollouts on upgrade - # Leader election guards against two operators reconciling at once. With a - # single replica it's unnecessary, and on a resource-starved node a missed - # lease renewal makes controller-runtime exit (the operator then crash-loops - # and stops reconciling). Leave on for HA installs; turn off for single-replica - # or constrained clusters (the e2e install sets it false). + # Required with multiple replicas and recommended during rolling upgrades. + # Disabling it with one replica selects Recreate to prevent overlapping writers. leaderElect: true resources: requests: { cpu: 100m, memory: 64Mi } limits: { cpu: 500m, memory: 256Mi } + # Complete auxiliary ResourceRequirements overrides. Null/missing uses binary + # defaults, including on --reuse-values upgrades. {} (or requests: {}, limits: + # {}) clears defaults. No new CPU/memory limits are set by default. + agentResources: null # requests 25m CPU / 64Mi memory; also config-init + sentinelResources: null # requests 25m CPU / 32Mi memory + tunnelResources: null # requests 25m CPU / 64Mi memory (every provider) + backupResources: null # backup/init/restore requests 100m CPU / 128Mi memory; + # cleanup retains 50m / 128Mi, memory limit 512Mi agentImage: "" # defaults to {image.registry}/agent:{image.tag} sentinelImage: "" # defaults to {image.registry}/sentinel:{image.tag} # Which load-balancer address manager runs in this cluster, so the operator @@ -133,6 +150,14 @@ api: # Standalone mode also skips the CRD apply hook and its RBAC. standalone: false replicas: 1 # Keep at 1. SQLite is single-writer on a RWO volume; with db.driver=postgres the user-management lock and audit hash chain are still per-process + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" + # The API is a singleton: eviction causes downtime. A blocking budget would + # hang voluntary node drains, so only maxUnavailable: 1 is supported here. + pdb: { enabled: false, maxUnavailable: 1 } logLevel: info # API log verbosity (debug|info|warn|error), passed as GAMEPLANE_LOG_LEVEL # Port of the API's Prometheus metrics listener. It is separate from the # public API port (the one the ingress and the web front end route to), so @@ -241,6 +266,12 @@ api: facility: local0 # RFC 5424 facility: kern/user/daemon/auth/.../local0..local7 severity: info # emerg/alert/crit/err/warning/notice/info/debug replicas: 1 + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" + pdb: { enabled: false } resources: {} # Native S3-compatible audit sink. When endpoint and bucket are set, the API # batches audit events as NDJSON and uploads them to S3 (or MinIO, Backblaze, @@ -314,6 +345,12 @@ api: # Must stay 1 while persistence is enabled: the SQLite file on the PVC # has a single writer, so the template fails the render otherwise. replicas: 1 + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" + pdb: { enabled: false } resources: {} # Aggregate storage (SQLite on a PVC mounted at /data). With # persistence disabled an emptyDir is used instead and every @@ -422,6 +459,12 @@ api: gateway: enabled: false replicas: 1 + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" + pdb: { enabled: false } # Must match the Cluster resource name in the central Gameplane installation. clusterID: "" # Exact URI SAN trusted in the central API client certificate. @@ -465,6 +508,12 @@ gateway: web: enabled: true replicas: 1 + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" + pdb: { enabled: false } resources: requests: { cpu: 10m, memory: 16Mi } limits: { cpu: 200m, memory: 128Mi } @@ -493,6 +542,9 @@ gamesNamespace: gameplane-games networkPolicies: enabled: true + # Fail closed unless actual kubelet source CIDRs/probe ports and apiserver + # endpoint CIDRs are supplied. Defaults retain the legacy broad allowances. + strict: false # CIDRs that hold kubelet source IPs for liveness/readiness probes. # If unset, the chart falls back to RFC1918 + link-local ranges, # which cover most clusters without opening the public internet. @@ -602,6 +654,12 @@ clusterOps: mcpServer: enabled: false replicas: 1 + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: "" + pdb: { enabled: false } resources: requests: { cpu: 50m, memory: 32Mi } limits: { cpu: 200m, memory: 128Mi } @@ -618,6 +676,9 @@ updates: channel: "" serviceMonitors: + # Explicit escape for independently managed scrape ingress policies. This + # does not open any ports; you must install and verify those policies. + externallyManagedPolicy: false enabled: false # when true, creates ServiceMonitors for operator/api/telemetry-receiver and a PodMonitor for agent sidecars on their separate plain metrics port 9090 (the control port 8090 stays mTLS-only) labels: {} # additional labels applied to ServiceMonitor/PodMonitor objects for Prometheus selector matching # Namespace selector (standard Kubernetes namespaceSelector label match @@ -629,9 +690,9 @@ serviceMonitors: # so the receiver ServiceMonitor also needs # api.telemetry.receiver.dashboard.tokenSecretRef.name) when # networkPolicies.enabled is also true. Required - # alongside enabled: true whenever networkPolicies.enabled is also true; - # left empty, both stay unreachable to Prometheus even though their - # ServiceMonitor/PodMonitor objects are rendered. Example: + # alongside enabled: true when the operator and networkPolicies are enabled; + # left empty, chart validation fails unless externallyManagedPolicy is true. + # Also permits tunnel health/metrics on port 8091. Example: # `{matchLabels: {kubernetes.io/metadata.name: monitoring}}`. scrapeNamespaceSelector: {} diff --git a/charts/tests/check_gateway.py b/charts/tests/check_gateway.py index 83f174ed6..819e46979 100644 --- a/charts/tests/check_gateway.py +++ b/charts/tests/check_gateway.py @@ -81,7 +81,9 @@ def main(): gateway["namespaces"] = ["games-one", "games-two"] objects = render({ "gateway": gateway, "api": {"enabled": False, "telemetry": {"receiver": {"enabled": True}}}, - "serviceMonitors": {"enabled": True}, + "serviceMonitors": {"enabled": True, "scrapeNamespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "monitoring"}, + }}, "clusterOps": {"enabled": True}, }, upgrade=True) assert not any(obj["metadata"]["name"] in {"gameplane-api", "gameplane-api-data", "gameplane-web", "gameplane-strategy-migrate", "gameplane-telemetry-receiver"} for obj in objects) diff --git a/charts/tests/check_network_isolation.py b/charts/tests/check_network_isolation.py new file mode 100644 index 000000000..7c180d143 --- /dev/null +++ b/charts/tests/check_network_isolation.py @@ -0,0 +1,49 @@ +"""CI-only rendered effective policy tuple and strict-profile regressions.""" +from check_standalone import render, one + + +def main(): + values = {"networkPolicies": {"strict": True, + "kubeletCIDRs": ["192.0.2.10/32"], + "probePorts": [{"protocol": "TCP", "port": 8090}], + "apiServerCIDRs": ["198.51.100.20/32"]}} + objects = render(values) + probe = one(objects, "NetworkPolicy", "allow-kubelet-probes")["spec"]["ingress"] + assert probe == [{"from": [{"ipBlock": {"cidr": "192.0.2.10/32"}}], + "ports": [{"protocol": "TCP", "port": 8090}]}] + for name in ("allow-agent-to-apiserver", "allow-sentinel-to-apiserver"): + egress = one(objects, "NetworkPolicy", name)["spec"]["egress"] + assert egress == [{"to": [{"ipBlock": {"cidr": "198.51.100.20/32"}}], + "ports": [{"protocol": "TCP", "port": 443}, {"protocol": "TCP", "port": 6443}]}] + excluded = [{"key": "gameplane.local/sentinel-isolation", "operator": "NotIn", "values": ["true"]}] + for name, direction, peer in (("allow-sentinel-to-game", "ingress", "from"), + ("allow-sentinel-to-game-egress", "egress", "to")): + policy = one(objects, "NetworkPolicy", name)["spec"] + assert policy["podSelector"]["matchExpressions"] == excluded + assert policy[direction][0][peer][0]["podSelector"]["matchExpressions"] == excluded + import copy + for key in ("kubeletCIDRs", "apiServerCIDRs", "probePorts"): + invalid = copy.deepcopy(values) + invalid["networkPolicies"][key] = [] + render(invalid, failure="requires a non-empty networkPolicies." + key) + for cidr in ("", "10.0.0.1", "999.0.0.1/32", "10.0.0.1/33", "2001:::1/64", "2001:db8/64", "::/129"): + invalid = copy.deepcopy(values) + invalid["networkPolicies"]["apiServerCIDRs"] = [cidr] + render(invalid, failure="must be explicit IPv4/IPv6 CIDRs") + for cidr in ("::/0", "::1/128", "2001:db8::/64", "2001:db8:1:2:3:4:5:6/128"): + valid = copy.deepcopy(values) + valid["networkPolicies"]["apiServerCIDRs"] = [cidr] + render(valid) + for port in (0, 65536, 1.5, "all"): + invalid = copy.deepcopy(values) + invalid["networkPolicies"]["probePorts"][0]["port"] = port + render(invalid, failure="integer port") + render({"networkPolicies": {"strict": True, "enabled": False}}, failure="requires networkPolicies.enabled=true") + # Legacy/default installs still render the documented broad fallback. + fallback = one(render(), "NetworkPolicy", "allow-kubelet-probes")["spec"]["ingress"][0] + assert "ports" not in fallback and {p["ipBlock"]["cidr"] for p in fallback["from"]} == { + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16"} + + +if __name__ == "__main__": + main() diff --git a/charts/tests/check_standalone.py b/charts/tests/check_standalone.py index 4caea9cc6..e6d43eb82 100644 --- a/charts/tests/check_standalone.py +++ b/charts/tests/check_standalone.py @@ -53,6 +53,8 @@ def main(): assert any(rule.get("resources") == ["clusters"] and "update" in rule["verbs"] for rule in api_rules) assert "--standalone" not in one(defaults, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["containers"][0]["args"] + # With the operator disabled no chart NetworkPolicies render, so monitors + # must remain valid without a game-namespace scrape selector. values = { "operator": {"enabled": False}, "api": {"standalone": True}, "serviceMonitors": {"enabled": True}, "prometheusRules": {"enabled": True}, diff --git a/charts/tests/check_workloads.py b/charts/tests/check_workloads.py new file mode 100644 index 000000000..a4c433528 --- /dev/null +++ b/charts/tests/check_workloads.py @@ -0,0 +1,112 @@ +"""CI-only resource override, management placement and disruption checks.""" +import json +from pathlib import Path +import shutil +import tempfile + +import yaml + +from check_gateway import CHART, one, render, settings + + +def check_operator_migration(): + """Exercise live-lookup hook output with the longest valid Helm name.""" + with tempfile.TemporaryDirectory() as directory: + chart = Path(directory) / "chart" + shutil.copytree(CHART, chart) + template = chart / "templates/operator-strategy-migrate.yaml" + source = template.read_text() + lookup = 'lookup "apps/v1" "Deployment" .Release.Namespace "gameplane-operator"' + assert lookup in source + # helm template has no API server. Supply only the existing-object + # lookup response; leave the actual hook gates and resources intact. + template.write_text(source.replace(lookup, 'dict "metadata" (dict "name" "gameplane-operator")')) + release = "r" * 53 + name = release + "-op-roll" + values = {"api": {"enabled": False}, "operator": {"leaderElect": False}, + "crds": {"autoApply": {"enabled": False, "nodeSelector": {"pool": "management"}}}} + objects = render(values, upgrade=True, release=release, chart=chart) + job = one(objects, "Job", name) + assert len(job["metadata"]["name"]) <= 63 + pod = job["spec"]["template"]["spec"] + assert pod["serviceAccountName"] == name + assert pod["nodeSelector"] == {"pool": "management"} + assert one(objects, "Role", name)["rules"] == [{"apiGroups": ["apps"], "resources": ["deployments"], + "resourceNames": ["gameplane-operator"], "verbs": ["get", "patch"]}] + binding = one(objects, "RoleBinding", name) + assert binding["roleRef"]["name"] == name and binding["subjects"][0]["name"] == name + one(objects, "ServiceAccount", name) + assert not any(obj["kind"] == "Job" for obj in render(values, release=release, chart=chart)) + values["operator"]["leaderElect"] = True + assert not any(obj["kind"] == "Job" for obj in render(values, upgrade=True, release=release, chart=chart)) + + +def main(): + check_operator_migration() + defaults = render() + assert one(defaults, "Deployment", "gameplane-operator")["spec"]["strategy"]["type"] == "RollingUpdate" + assert not any(obj["kind"] == "PodDisruptionBudget" for obj in defaults) + args = one(defaults, "Deployment", "gameplane-operator")["spec"]["template"]["spec"]["containers"][0]["args"] + assert not any(arg.startswith("--tunnel-resources=") for arg in args) + placement = { + "nodeSelector": {"gameplane.local/pool": "management"}, + "tolerations": [{"key": "gameplane.local/pool", "operator": "Equal", "value": "management", "effect": "NoSchedule"}], + "affinity": {"podAntiAffinity": {"preferredDuringSchedulingIgnoredDuringExecution": [{"weight": 100, "podAffinityTerm": {"topologyKey": "kubernetes.io/hostname", "labelSelector": {"matchLabels": {"app.kubernetes.io/instance": "gameplane"}}}}]}}, + "topologySpreadConstraints": [{"maxSkew": 1, "topologyKey": "kubernetes.io/hostname", "whenUnsatisfiable": "DoNotSchedule", "labelSelector": {"matchLabels": {"app.kubernetes.io/instance": "gameplane"}}}], + "priorityClassName": "management", + } + component = dict(placement, pdb={"enabled": True, "maxUnavailable": 1}) + gateway = dict(settings(), **component) + values = { + "operator": dict(component, replicas=2, tunnelResources={"requests": {}, "limits": {}}, agentResources={"requests": {"cpu": "80m"}}), + "api": dict(component, audit={"webhook": {"syslogBridge": dict(component, enabled=True, syslog={"addr": "logs:514"})}}, telemetry={"receiver": dict(component, enabled=True)}), + "web": component, "mcpServer": dict(component, enabled=True), "gateway": gateway, + "crds": {"autoApply": placement}, + } + production = render(values, upgrade=True) + for name in ["operator", "api", "web", "mcp-server", "gateway", "audit-syslog-bridge", "telemetry-receiver"]: + deployment = one(production, "Deployment", "gameplane-" + name) + spec = deployment["spec"]["template"]["spec"] + for key, expected in placement.items(): + assert spec[key] == expected, (name, key) + pdb = one(production, "PodDisruptionBudget", "gameplane-" + name)["spec"] + assert pdb["maxUnavailable"] == 1 and "minAvailable" not in pdb + assert pdb["selector"]["matchLabels"].items() <= deployment["spec"]["template"]["metadata"]["labels"].items() + hook = one(production, "Job", "gameplane-crd-apply")["spec"]["template"]["spec"] + for key, expected in placement.items(): + assert hook[key] == expected + args = one(production, "Deployment", "gameplane-operator")["spec"]["template"]["spec"]["containers"][0]["args"] + overrides = {arg.split("=", 1)[0]: json.loads(arg.split("=", 1)[1]) for arg in args if arg.startswith(("--agent-resources=", "--tunnel-resources="))} + assert overrides["--tunnel-resources"] == {"requests": {}, "limits": {}} + assert overrides["--agent-resources"] == {"requests": {"cpu": "80m"}} + render({"api": {"pdb": {"enabled": True, "minAvailable": 1}}}, failure="api.pdb must allow the singleton API to be evicted") + render({"api": {"pdb": {"enabled": True, "maxUnavailable": 0}}}, failure="api.pdb must allow the singleton API to be evicted") + render({"operator": {"replicas": 2, "leaderElect": False}}, failure="operator.leaderElect must be enabled") + single = render({"operator": {"leaderElect": False}}) + assert one(single, "Deployment", "gameplane-operator")["spec"]["strategy"] == {"type": "Recreate", "rollingUpdate": None} + removed = render({"operator": {"agentResources": None, "tunnelResources": {}}}) + args = one(removed, "Deployment", "gameplane-operator")["spec"]["template"]["spec"]["containers"][0]["args"] + assert not any(arg.startswith("--agent-resources=") for arg in args) + assert "--tunnel-resources={}" in args + disabled = render({"api": {"enabled": False, "pdb": {"enabled": True}}, "web": {"pdb": {"enabled": True}}}) + assert not any(obj["kind"] == "PodDisruptionBudget" for obj in disabled) + profile = yaml.safe_load((CHART / "values-production.example.yaml").read_text()) + example = render(profile, upgrade=True) + assert one(example, "Deployment", "gameplane-operator")["spec"]["replicas"] == 2 + assert one(example, "PodDisruptionBudget", "gameplane-api")["spec"]["maxUnavailable"] == 1 + # Helm --reuse-values may not contain new keys. Omission must retain binary + # resource defaults and empty placement without indexing a missing map. + with tempfile.TemporaryDirectory() as directory: + old_chart = Path(directory) / "chart" + shutil.copytree(CHART, old_chart) + old_values = yaml.safe_load((old_chart / "values.yaml").read_text()) + for key in ["agentResources", "sentinelResources", "tunnelResources", "backupResources", "pdb", *placement]: + old_values["operator"].pop(key, None) + (old_chart / "values.yaml").write_text(yaml.safe_dump(old_values)) + old = render(chart=old_chart) + args = one(old, "Deployment", "gameplane-operator")["spec"]["template"]["spec"]["containers"][0]["args"] + assert not any(arg.startswith("--tunnel-resources=") for arg in args) + + +if __name__ == "__main__": + main() diff --git a/deploy/kind/e2e.sh b/deploy/kind/e2e.sh index 3c2f7b12f..1031b94bc 100755 --- a/deploy/kind/e2e.sh +++ b/deploy/kind/e2e.sh @@ -6,7 +6,7 @@ # deploy/kind/e2e.sh down [cluster-name] # # Diffs from dev-up (deploy/kind/up.sh): -# - Single-node cluster (faster boot, sufficient for E2E coverage). +# - Single-node by default; management profile adds two scheduling workers. # - Skips ingress-nginx (the dashboard isn't exercised here). # - Loads pre-built gameplane-test/{operator,api,agent,sentinel,capture-sidecar,telemetry-receiver,fakeoidc}: # images (and gameplane-test/gameprobe: when present). @@ -27,10 +27,15 @@ ACTION="${1:-up}" CLUSTER="${2:-gameplane-e2e}" TAG="${3:-${GAMEPLANE_E2E_TAG:-e2e}}" PROFILE="${4:-default}" +CNI="${GAMEPLANE_E2E_CNI:-kindnet}" +case "${CNI}" in +kindnet | cilium) ;; +*) echo "unsupported E2E CNI: ${CNI}" >&2; exit 2 ;; +esac case "${PROFILE}" in -default | postgres) ;; +default | postgres | management) ;; *) - echo "unknown profile '${PROFILE}' (want default or postgres)" >&2 + echo "unknown profile '${PROFILE}' (want default, postgres or management)" >&2 exit 2 ;; esac @@ -188,16 +193,39 @@ up) echo "removing pre-existing cluster ${CLUSTER} for a clean slate" kind delete cluster --name "${CLUSTER}" || true fi - echo "creating kind cluster ${CLUSTER} (single-node)" - kind create cluster --name "${CLUSTER}" --wait 90s --config - </dev/null + if [ "${CNI}" = cilium ]; then + # Pin the enforcing CNI; kindnet does not implement NetworkPolicy. + # https://docs.cilium.io/en/stable/installation/kind/ + helm upgrade --install cilium cilium --repo https://helm.cilium.io/ \ + --version 1.20.3 --namespace kube-system \ + --set image.pullPolicy=IfNotPresent --set ipam.mode=kubernetes \ + --set policyCIDRMatchMode=nodes \ + --set operator.replicas=1 --wait --timeout 5m + kubectl wait nodes --all --for=condition=Ready --timeout=120s + fi + install_metallb apply_metallb_pools @@ -358,6 +386,17 @@ EOF deployment/gameplane-test-fakeoidc --timeout=120s DB_SETS=() + NETWORK_SETS=() + if [ "${CNI}" = cilium ]; then + # Single-node IPv4 fixture: use actual endpoint/source addresses. + node_ip="$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')" + api_ip="$(kubectl get endpoints kubernetes -n default -o jsonpath='{.subsets[0].addresses[0].ip}')" + NETWORK_SETS=(--set networkPolicies.strict=true + --set-string "networkPolicies.kubeletCIDRs[0]=${node_ip}/32" + --set-string "networkPolicies.apiServerCIDRs[0]=${api_ip}/32" + --set networkPolicies.probePorts[0].protocol=TCP + --set networkPolicies.probePorts[0].port=25566) + fi if [ "${PROFILE}" = postgres ]; then dsn="$(kubectl get secret gameplane-postgres --namespace gameplane-system \ -o jsonpath='{.data.dsn}' | base64 -d)" @@ -403,6 +442,7 @@ EOF --set "defaultModuleSource.enabled=false" \ --set "api.telemetry.receiver.enabled=true" \ ${DB_SETS[@]+"${DB_SETS[@]}"} \ + ${NETWORK_SETS[@]+"${NETWORK_SETS[@]}"} \ --wait --timeout 5m echo diff --git a/deploy/kind/monitoring.sh b/deploy/kind/monitoring.sh new file mode 100644 index 000000000..f5c2e4a4e --- /dev/null +++ b/deploy/kind/monitoring.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# CI-only real scrape fixture; never invoke as a local validation shortcut. +set -euo pipefail +cd "$(dirname "$0")/../.." +context="${CTX:-kind-gameplane-e2e}" +kubectl() { command kubectl --context "$context" "$@"; } +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +# Retry transient upstream failures without relaxing the pinned checksum below. +curl --fail --silent --show-error --location \ + --connect-timeout 10 --max-time 30 --retry 5 --retry-max-time 120 \ + https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/v0.94.1/bundle.yaml \ + -o "$scratch/bundle.yaml" +echo "7ab177610b271c70c57c7d6fc550a1e934eff4f707a5d8b61dfaa3845d52deea $scratch/bundle.yaml" | sha256sum --check +kubectl create namespace monitoring --dry-run=client -o yaml | kubectl apply -f - +# The official bundle's namespaced objects and binding subjects use default. +sed 's/namespace: default/namespace: monitoring/g' "$scratch/bundle.yaml" > "$scratch/monitoring.yaml" +kubectl apply --server-side -f "$scratch/monitoring.yaml" +kubectl wait --for=condition=Established --timeout=120s crd/prometheuses.monitoring.coreos.com crd/podmonitors.monitoring.coreos.com crd/servicemonitors.monitoring.coreos.com crd/prometheusrules.monitoring.coreos.com +kubectl rollout status -n monitoring deployment/prometheus-operator --timeout=180s +helm --kube-context "$context" upgrade gameplane charts/gameplane --namespace gameplane-system --reuse-values \ + -f docs/examples/monitoring/gameplane-values.yaml --wait --timeout 5m +kubectl apply -f docs/examples/monitoring/prometheus.yaml +kubectl wait -n monitoring --for=create statefulset/prometheus-gameplane --timeout=120s +kubectl rollout status -n monitoring statefulset/prometheus-gameplane --timeout=180s diff --git a/docs/dependencies.md b/docs/dependencies.md index 1017fa980..4bfad498f 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -407,7 +407,7 @@ These aren't code dependencies of any component; they're invoked by - **`github.com/vladopajic/go-test-coverage/v2@v2.18.9`** — pinned in `Makefile`'s `GO_TEST_COVERAGE_PKG`, run via `go run` (no install step) by `make cover-go-check` and CI's `go` job to enforce each module's `.testcoverage.yml` threshold. - **`github.com/wadey/gocovmerge@v0.0.0-20160331181800-b5bfa59ec0ad`** — pinned in `Makefile`'s `GOCOVMERGE_PKG`, merges each module's `unit.out` + `envtest.out` coverage profiles before the threshold gate. - **`sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.19`** — installed by `make envtest-bin`; downloads the K8s 1.36.2 envtest binaries (`kube-apiserver`, `etcd`) that back `operator`'s and `api`'s `-tags=envtest` integration tests. -- **`golangci-lint` v2.12.2** — pinned in `.devcontainer/post-create.sh` (built from source, not the release binary, because the release binary is built with an older Go than the repo's `go 1.26.0` toolchain wants). Configured by `.golangci.yml` (bodyclose, errcheck, gosec, govet, ineffassign, staticcheck, unused, misspell, gofmt, goimports, revive, unparam, nilerr, noctx, errorlint, contextcheck). **Not currently invoked by CI** — `ci.yaml`'s `go` job runs only `go vet`, not `golangci-lint run`; `make lint-go` exists for local/devcontainer use and is called out in `docs/contributing.md` as a pre-PR step, but nothing in `.github/workflows/` gates on it. Same for the web job: it runs `npm run build` and `npm run test:cover`, not `npm run lint` — ESLint isn't CI-gated either. +- **`golangci-lint` v2.14.0** — pinned in `.github/workflows/ci.yaml`, `.devcontainer/post-create.sh`, and the exact-version guard in `test/e2e/lint-gate-verify.sh`. Its `golang.org/x/tools v0.50.0` importer supports the export-data version 5 emitted by Go 1.27.3; v2.13.2's importer does not. CI uses the published binary and the devcontainer builds the same release from source. The CI `lint` matrix gates every selected workspace module with `.golangci.yml`, using `envtest` tags for operator/API and `e2e` tags for test/e2e. Update the verifier's valid fixtures with the pin; unsupported or unpinned versions must still fail. Tests and general lint run only in CI under repository policy. - **`oras` CLI ≥ 1.2.0** (pinned `1.2.0` in the devcontainer; `oras-project/setup-oras@v2.0.1` action in `release.yaml`) — `modules/build.sh` pushes each `modules//` directory as an OCI module bundle (`make modules-push`); this is a *different* thing from `operator`'s `oras.land/oras-go/v2` **library** dependency, which is the operator's own pull-side client, not the CLI. - **`cosign` CLI v3.1.2** (`sigstore/cosign-installer@v4.1.2`) — used in `publish-edge.yaml`/`release.yaml` to sign published container images, the Helm chart, and module bundles against `COSIGN_PRIVATE_KEY`, recording each signature in the public Rekor transparency log, and to round-trip-verify against the derived public key before publishing. Signatures use the classic format (`--new-bundle-format=false --use-signing-config=false`), which is what keeps them readable by the operator's `sigstore/cosign/v2` library; cosign's newer Rekor v2 flow additionally requires an RFC3161 timestamp authority that keyed signing cannot satisfy. `modules/build.sh` gates the same behaviour behind its own `--tlog-upload` flag so local and air-gapped authoring stays offline. Distinct from `operator`'s `sigstore/cosign/v2` **library** dependency, which only ever *verifies* (never signs) at reconcile time. - **`kind`** v0.32.0 (devcontainer pin) / `helm/kind-action@v1.14.0` (`install_only: true` in CI) — spins up the ephemeral clusters for `make dev-up`, `make test-e2e`, and every `e2e-*` CI job. diff --git a/docs/examples/monitoring/gameplane-values.yaml b/docs/examples/monitoring/gameplane-values.yaml new file mode 100644 index 000000000..8b51fe3cb --- /dev/null +++ b/docs/examples/monitoring/gameplane-values.yaml @@ -0,0 +1,19 @@ +# Apply to the Gameplane Helm release. See docs/monitoring.md for node-specific +# strict NetworkPolicy settings and the companion Prometheus resource. +networkPolicies: + enabled: true +serviceMonitors: + enabled: true + labels: + monitoring: gameplane + scrapeNamespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring +prometheusRules: + enabled: true + labels: + monitoring: gameplane +grafanaDashboards: + enabled: true + labels: + grafana_dashboard: "1" diff --git a/docs/examples/monitoring/prometheus.yaml b/docs/examples/monitoring/prometheus.yaml new file mode 100644 index 000000000..11793ace4 --- /dev/null +++ b/docs/examples/monitoring/prometheus.yaml @@ -0,0 +1,71 @@ +# Requires the Prometheus Operator and its CRDs. The monitoring namespace must +# match serviceMonitors.scrapeNamespaceSelector in gameplane-values.yaml. +apiVersion: v1 +kind: Namespace +metadata: + name: monitoring +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: gameplane-prometheus + namespace: monitoring +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: gameplane-prometheus-discovery +rules: + - apiGroups: [""] + resources: [nodes, nodes/metrics, services, endpoints, pods] + verbs: [get, list, watch] + - apiGroups: [discovery.k8s.io] + resources: [endpointslices] + verbs: [get, list, watch] + - nonResourceURLs: [/metrics] + verbs: [get] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: gameplane-prometheus-discovery +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: gameplane-prometheus-discovery +subjects: + - kind: ServiceAccount + name: gameplane-prometheus + namespace: monitoring +--- +apiVersion: monitoring.coreos.com/v1 +kind: Prometheus +metadata: + name: gameplane + namespace: monitoring +spec: + version: v3.13.4 + serviceAccountName: gameplane-prometheus + replicas: 1 + retention: 24h + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 1Gi } + serviceMonitorSelector: + matchLabels: { monitoring: gameplane } + serviceMonitorNamespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: In + values: [gameplane-system, gameplane-games] + podMonitorSelector: + matchLabels: { monitoring: gameplane } + podMonitorNamespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: In + values: [gameplane-system, gameplane-games] + ruleSelector: + matchLabels: { monitoring: gameplane } + ruleNamespaceSelector: + matchLabels: { kubernetes.io/metadata.name: gameplane-system } diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 1141965bb..55b2436aa 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -12,6 +12,15 @@ materializes into an in-cluster `GameTemplate`. ## Quickstart: Authoring with `gp-module` CLI & Web Builder +For a validated wake proxy, add `sentinelIsolation: true` under the template's +`spec`. The operator permits that server's sentinel to reach only its own +advertised container ports, preserving each port's TCP/UDP/SCTP protocol. +Leave management ports unadvertised. A template with no advertised ports grants +no sentinel access. Omission preserves legacy behavior; adoption in the +GameplanePanel/module repository is separate from the operator upgrade. +Test player joins, status queries and sleep/wake cycles with an enforcing CNI +before publishing the opt-in. See [network policy migration](networking.md#networkpolicy-isolation-and-migration). + Gameplane provides a unified toolkit for creating, verifying, previewing, and packaging modules: - **Command-Line Interface (`gp-module`)**: First-class Go CLI with Makefile targets. diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 000000000..34c6346ec --- /dev/null +++ b/docs/monitoring.md @@ -0,0 +1,142 @@ +# Monitoring games and relays + +Gameplane exports the existing agent CPU, process memory, disk and player gauges, +optional game performance measurements, and relay health. Prometheus and Grafana +are optional. A monitor resource alone does not prove discovery or network access. + +## Complete setup + +Install the Prometheus Operator and its CRDs first. Apply +[`examples/monitoring/gameplane-values.yaml`](examples/monitoring/gameplane-values.yaml) +to the Gameplane release and deploy +[`examples/monitoring/prometheus.yaml`](examples/monitoring/prometheus.yaml), or +copy that resource's selectors into your existing Prometheus configuration. + +The examples agree on the `monitoring: gameplane` monitor/rule label, discover +both `gameplane-system` and `gameplane-games`, and place Prometheus in `monitoring`. +Change all three files/settings together when renaming namespaces. With +kube-prometheus-stack, configure `prometheus.prometheusSpec.serviceMonitorSelector`, +`podMonitorSelector`, their namespace selectors and `ruleSelector` to match the +example. Do not leave the chart's default release-label selector pointing at a +different Helm release. The Grafana sidecar must watch ConfigMaps labeled +`grafana_dashboard: "1"` in the Gameplane release namespace. + +The chart enables rules and both Grafana dashboards in this example. Agent +metrics use plain HTTP port 9090; tunnel metrics share the health listener on +8091. Neither monitor uses the agent's mTLS control port or a client certificate. +Check Prometheus **Status → Targets** for agent and tunnel targets with `up=1`, +then query `gameplane_agent_players_online` and `gameplane_tunnel_ready`. +Unknown player counts are absent even when scraping works. + +With the operator and its NetworkPolicies enabled, the chart requires a nonempty +`serviceMonitors.scrapeNamespaceSelector`. Missing configuration fails with a +message naming the setting. If another system owns the policies, set +`serviceMonitors.externallyManagedPolicy: true`; that flag opens no traffic. +When upgrading an installation that already enabled monitors without a selector, +set the selector or the explicit external-policy option in the same Helm upgrade. +Supply your own ingress policies selecting `app.kubernetes.io/name: gameplane-game` +on TCP 9090 and `app.kubernetes.io/name: gameplane-tunnel` on TCP 8091, with a +namespace selector for the scraper. Restricted Prometheus egress policies must +also allow those destinations, DNS and API discovery. + +For enforced isolation, configure `networkPolicies.strict: true`, actual +`kubeletCIDRs` and `apiServerCIDRs`, and the game modules' exact `probePorts`. +The legacy broad kubelet allowance can otherwise admit other private-address +sources on metrics ports. Keep metrics ports out of the game probe allowance; +the dedicated scrape policies grant their access. The CI monitoring bucket +uses Cilium and actual node/API addresses, tests a scraper in a separate +namespace, revokes/restores the scrape policy, and checks an unapproved namespace +cannot reach agent metrics. + +## Declaring numeric game measurements + +Reuse a module's existing `capabilities.status.metrics[]` entry. This opt-in +example describes an output such as `TPS: 19.5`: + +```yaml +capabilities: + status: + metrics: + - id: tps + displayName: TPS + command: stats + regex: 'TPS: (?P[0-9.]+)' + unit: TPS # presentation only + performance: + signal: tick_rate + unit: per_second # machine-readable input unit + degradedThreshold: "18" +``` + +`signal` is one of `tick_rate`, `tick_duration`, or `frame_rate`. Rates require +`per_second`; durations accept `seconds` or `milliseconds`. Values and thresholds +are converted to hertz or seconds. The optional threshold is a finite nonnegative +decimal string, up to nine integer and six fractional digits, for portable CRD +handling. Rates below the threshold or durations above it trigger the degradation +rule after five minutes. No default threshold or game-specific tick rate is +invented. Modules must choose a read-only command and a meaningful threshold. +No external module is opted in by this change. + +The source polls immediately and then 30 seconds after each completed cycle. +There are at most 16 declared entries, 16 distinct commands per cycle, 1024 bytes +per command, 4096 bytes per regex and 64 KiB of accepted response text. Identical +commands are executed once and parsed for each reading. Each command waits at +most three seconds and the whole cycle at most ten seconds. The legacy RCON +interface cannot cancel every protocol operation: one outstanding call may +finish later, but it blocks further background calls until it returns, its late +result is discarded, and no queue or growing set of workers is created. +Underlying transports retain their own deadlines and response limits. Source +RCON bounds dialing to five seconds, authentication to ten seconds (including +all preamble packets), and a command exchange to 30 seconds after authentication, +including continuously arriving fragments, and accepts at most 8 MiB of +aggregate response text for large administrative commands. Its 400-ms fragment +idle window never extends the absolute exchange deadline. Exceeding either +bound discards the incomplete response and closes the connection. This transport +cap is separate from the status collector's smaller 64-KiB acceptance limit. + +Both protected `/status` routes and Prometheus share the same source. Templates +with valid performance declarations use the interval cache for all their status +readouts; templates without them retain the five-second on-demand cache. Scrapes +never execute RCON or wait on an RCON command. Samples expire 75 seconds after +the command completed. Failed queries, invalid/negative/nonfinite values and +stale samples have no numeric value series. A genuine zero is retained. + +The metric families are `gameplane_agent_performance_value`, +`gameplane_agent_performance_available`, +`gameplane_agent_performance_sample_timestamp_seconds`, and optional +`gameplane_agent_performance_degraded_threshold`. Labels are the bounded metric +ID, closed signal and base unit; threshold adds `direction=below|above`. +PodMonitor adds namespace, Pod, server and component identity. Display strings, +commands, responses and errors never become labels. Unsupported declarations +produce no performance series. Failed supported declarations emit availability +zero; value and timestamp remain absent. + +## Relays and infrastructure + +Tunnel gauges read the same snapshot as readiness: `gameplane_tunnel_ready`, +`gameplane_tunnel_connection_known`, `gameplane_tunnel_connected`, +`gameplane_tunnel_forwards_registered`, `gameplane_tunnel_endpoint_assigned`, +and `gameplane_tunnel_observation_timestamp_seconds`. `connected` and observation +timestamp are absent when their evidence is unknown or stale. Readiness and +registration flags mean *confirmed*, so zero does not invent a disconnected +provider. The rules distinguish an unavailable relay from unknown health. +Pinned Playit cannot confirm live connectivity and therefore raises the +informational unknown-health alert; see [relay-health.md](relay-health.md). +Tailscale, frp and Playit all use the same metrics path. Metrics contain no +credentials, login URLs, proxy names or provider error messages. + +Container CPU throttling and working-set pressure require kubelet/cAdvisor +scrapes. OOM/restart, Pending Pod and node pressure rules require kube-state-metrics. +Install/configure those collectors (kube-prometheus-stack normally provides +them); the minimal Prometheus example demonstrates Gameplane discovery and +does not deploy them or Grafana. A missing upstream collector leaves those +series absent; it does not mean zero pressure. Agent process RSS is not total +container memory. These rules filter the games namespace and use `max` to +deduplicate replicated sources. The existing fleet rules likewise collapse +duplicate operator replicas instead of adding their cache-derived counts. + +The Workloads dashboard shows scrape health, relay readiness/known state, +supported game measurements, container pressure, restarts and Pending Pods. +Missing measurements remain gaps. CI renders and evaluates the shipped alert +rules with promtool, including outage/recovery, supported zero versus unknown, +pressure, throttling, OOM, Pending/node pressure and duplicate operator readings. diff --git a/docs/networking.md b/docs/networking.md index a8415fb72..fd3259012 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -2,6 +2,66 @@ Gameplane exposes game servers through Kubernetes Services, with optional integration with your cluster's load-balancer address manager to assign specific public addresses or address pools to each server. +## NetworkPolicy isolation and migration + +NetworkPolicies are additive. An allowance in one policy cannot be narrowed by +another policy. Enforcement also requires a CNI that implements NetworkPolicy. + +Modules may set `GameTemplate.spec.sentinelIsolation: true`. The operator then +labels the game and sentinel pod templates and creates two owned policies: +`-sentinel-ingress` and `-sentinel-egress`. They allow a sentinel +to reach only its own server's advertised container ports, with the declared +protocol. Zero advertised ports creates no allowance. Management ports must +remain `advertise: false`. The legacy chart policies exclude opted-in sources +and destinations. Templates without the field retain the existing broad +sentinel-to-game allowance; module maintainers can adopt it after validating +their proxy traffic. + +Upgrade the CRDs, operator and chart together before opting in a template. +Existing game pods roll out to acquire the isolation label; StatefulSet +selectors stay unchanged. Do not claim isolation until both game and sentinel +pods have the label and the per-server policies exist. Other administrator-added +policies can still grant wider access. Disabling chart NetworkPolicies or using +a non-enforcing CNI does not provide this boundary. + +Probe and API-server narrowing is a separate installation option: + +```yaml +networkPolicies: + enabled: true + strict: true + kubeletCIDRs: ["192.0.2.10/32"] # Replace with observed kubelet sources. + probePorts: + - {protocol: TCP, port: 25565} # Replace with all configured probe ports. + apiServerCIDRs: ["198.51.100.20/32"] # Replace with actual API endpoints. +``` + +Strict mode rejects missing/empty lists, malformed CIDRs and invalid probe +ports. It never supplies broad fallback ranges. API egress is limited to the +listed addresses on TCP 443/6443. Choose addresses based on your CNI's policy +evaluation point: service DNAT or source SNAT can change which address it sees. +Include every control-plane endpoint and kubelet source that can host a server; +update these lists when nodes or endpoints change. CNI host-traffic exceptions +may still apply. + +On Cilium, CIDR rules exclude node identities by default. For a self-hosted +API server on a node, configure `policyCIDRMatchMode=nodes` before using these +plain NetworkPolicy endpoint CIDRs, or provide equivalent Cilium-specific +API-server policy. The CI fixture uses nodes mode. See +[Cilium CIDR policy semantics](https://docs.cilium.io/en/stable/security/policy/layer3/#selecting-pods-or-nodes-with-cidr-ipblock) +and validate kubelet source handling for your Cilium configuration as well. + +Before enabling strict mode, inventory the actual endpoints and configured +game probes, observe their traffic in the CNI, then validate game readiness, +agent heartbeats, wake requests and API/operator agent access in a staging +namespace. The default profile retains RFC1918/link-local API egress and +unrestricted probe ports for compatibility; it does not isolate those paths. +Narrowing sentinel traffic alone cannot remove those separate grants. + +The dedicated CI `network-isolation` bucket installs pinned Cilium, exercises +real allowed/denied connections, and upgrades existing legacy templates. +The default kindnet fixture only verifies policy shape. + ## Address Pool Overview When a GameServer is exposed as a LoadBalancer (`spec.networking.expose: LoadBalancer`), it can request a specific external address or address pool. The operator translates this request to your cluster's load-balancer flavor so the same CRD works whether you run MetalLB, Cilium, or no address manager at all. diff --git a/docs/production-placement.md b/docs/production-placement.md new file mode 100644 index 000000000..175d23f3a --- /dev/null +++ b/docs/production-placement.md @@ -0,0 +1,147 @@ +# Resource reservations and management placement + +New installs and upgrades reserve resources for supporting containers. These +requests are scheduling floors, not measured capacity targets: + +| Container | CPU request | Memory request | +| --- | --- | --- | +| Agent and config initializer | 25m | 64Mi | +| Wake sentinel | 25m | 32Mi | +| Tunnel, every provider | 25m | 64Mi | +| Restic backup, repository initializer and restore | 100m | 128Mi | + +These defaults add no CPU or memory limits. CPU limits can throttle bursts; +memory limits can terminate the process when its working set exceeds them. +Repository forget/prune retains its existing 50m/128Mi requests and 512Mi memory +limit unless overridden. Init containers receive requests too; Kubernetes +accounts for their peak requirements when scheduling the Pod. + +## Overrides and upgrades + +The new chart keys `operator.agentResources`, `operator.sentinelResources`, +`operator.tunnelResources` and `operator.backupResources` each accept a complete +Kubernetes ResourceRequirements object. Backup resources also apply to restore +and forget/prune workers; agent resources also apply to the config initializer. +The matching operator flags are `--agent-resources`, `--sentinel-resources`, +`--tunnel-resources`, and `--backup-resources`, with JSON values. + +Null or a missing chart key uses the binary defaults, including older +`helm upgrade --reuse-values` configurations. A supplied object replaces defaults +wholesale. Clear reservations explicitly: + +```yaml +operator: + agentResources: { requests: {}, limits: {} } + sentinelResources: { requests: {}, limits: {} } + tunnelResources: { requests: {}, limits: {} } + backupResources: { requests: {}, limits: {} } +``` + +An empty object `{}` has the same effect. Setting a key back to `null` restores +the defaults. No chart-side default map is merged into the supplied object. +For example `agentResources: {requests: {memory: 96Mi}}` reserves only memory. + +Helm still merges user-supplied maps. When replacing a previously populated +override with empty maps, export the release's values, edit that complete file, +and upgrade with `--reset-values -f `. An empty nested map +combined with `--reuse-values` can retain the release's old resource entries. +Keep all unrelated install values in the edited file; resetting to chart defaults +alone is not a resource-only change. + +Existing `GameTemplate.spec.agent.resources` takes precedence when it declares +requests or limits, replacing the install agent object wholesale. An image-only +agent override keeps install defaults. Kubernetes may omit empty maps in a +template, so use explicit zero CPU/memory requests for a per-template zero +reservation, or the install-level empty object to clear all defaults. Game +container resources continue to use the existing template defaults and complete +`GameServer.spec.resources` override. Game placement fields are unchanged. + +Changing resources changes generated Pod templates. Existing game StatefulSets, +sentinels and tunnels roll according to their normal reconciliation policy; +new Restic Jobs use the new configuration. Schedule this upgrade when game pod +restarts are acceptable. Insufficient capacity can leave replacement pods or +Jobs Pending. Check requested resources and concurrent backups before upgrading. + +## Management workers + +The chart now exposes `nodeSelector`, `tolerations`, `affinity`, +`topologySpreadConstraints`, and `priorityClassName` beneath: + +- `operator`, `api`, `web`, `gateway`, and `mcpServer`. +- `api.audit.webhook.syslogBridge` and `api.telemetry.receiver`. +- `crds.autoApply` for the upgrade hook Job. + +Each Deployment also supports `pdb.enabled` and either `pdb.minAvailable` or +`pdb.maxUnavailable`; enabled budgets default to `maxUnavailable: 1`. PDBs select +both the component name and Helm release. Disabled components get no PDB. Hook +Jobs have placement settings but no disruption budget. + +The API remains one process for both SQLite and PostgreSQL. Its optional PDB +only permits `maxUnavailable: 1`, so a voluntary node drain can evict it and will +cause API downtime. A PDB is not an availability guarantee and does not prevent +node failures. The persistent telemetry receiver is also a singleton; avoid a +zero-unavailable budget unless intentionally accepting a blocked drain. + +Keep operator leader election enabled with redundant replicas and rolling +updates. The chart rejects multiple replicas with leader election disabled; +with one replica and election disabled it uses Recreate to avoid overlapping +reconcilers during upgrades. API replica/process restrictions remain unchanged. + +When switching an existing operator to Recreate, a pre-upgrade hook clears the +server-defaulted RollingUpdate fields before Helm applies the Deployment. The +hook uses `crds.autoApply.image` and its placement settings, including when CRDs +are managed externally. It can patch only `gameplane-operator` in the release +namespace and is skipped when that Deployment does not exist. Upgrades need +access to the configured kubectl image. + +The [example profile](../charts/gameplane/values-production.example.yaml) places +management services on labeled, tainted workers and spreads redundant operator +and dashboard pods. First label and taint the selected **worker** nodes: + +```sh +kubectl label node management-a management-b gameplane.local/pool=management +kubectl taint node management-a management-b gameplane.local/pool=management:NoSchedule +kubectl label node game-a game-b gameplane.local/pool=games +kubectl taint node game-a game-b gameplane.local/pool=games:NoSchedule +``` + +Use existing GameServer placement fields for game workers: + +```yaml +spec: + nodeSelector: { gameplane.local/pool: games } + tolerations: + - { key: gameplane.local/pool, operator: Equal, value: games, effect: NoSchedule } +``` + +Preserve control-plane taints. Avoid broad `operator: Exists` tolerations. +Optional management components need their corresponding placement values too. +Per-game sentinel and tunnel Deployments are operator-generated and are not +covered by these chart management placement keys. A priorityClassName must name +an existing PriorityClass. Unsatisfied hard selectors, taints or topology +constraints correctly leave pods Pending; do not silently relax them. + +Local or node-bound volumes cannot move just because a pod has a new selector. +Check API/receiver PVC node affinity before selecting management workers, and +game PVC topology before backups or moving game pods. Restic scheduling and +source preparation are separate from management placement. + +## Sizing and controlled-drain checks + +Collect per-container CPU/memory, pod requests, Pending events, game tick/query +latency, storage throughput and backup duration during busy periods and actual +backup concurrency. Use `kubectl top pods --containers`, scheduler events and +the available Prometheus metrics. Include headroom for one failed management +node, game startup, repository maintenance and replacement pods. No fixed server +count follows from these request values. + +In a disposable multi-worker environment, apply the example and verify every +management pod's node labels. Temporarily set a nonexistent selector and verify +Pending/FailedScheduling, then restore it. Cordon one management worker, evict +its redundant operator/web pods through the eviction API, and verify their PDB +allows only one unavailable replica while the remaining replica stays Ready. +Uncordon after checking replacements. A full `kubectl drain` also evicts the +singleton API and can interrupt access; account for local PVCs and validate +recovery before doing this in production. CI chart checks verify selectors, +PDB gates, resource clearing and the production profile; cluster behavior needs +the multi-node integration scenario, not just a successful render. diff --git a/docs/relay-health.md b/docs/relay-health.md new file mode 100644 index 000000000..6be7a1658 --- /dev/null +++ b/docs/relay-health.md @@ -0,0 +1,110 @@ +# Relay readiness and registration identity + +The frp/Playit wrapper's process health and its provider connection are separate. +On Pod TCP port 8091, `/healthz` reports that the wrapper's HTTP server is alive, +`/readyz` returns 200 only when the provider connection and configured forwards +are confirmed, and `/status` returns a small JSON state without credentials or +provider response text. The operator installs a startup probe on `/healthz` and +a readiness probe on `/readyz`. An external relay outage does not trigger a +liveness restart of the wrapper. + +Provider checks run every two seconds with a two-second query deadline. Local +observations expire after ten seconds, and child exit immediately clears them. +The operator also requires the current Deployment generation, updated replicas +and the expected readiness probe; an old Pod without the probe cannot satisfy +the new readiness contract. Configured addresses remain visible while the +connection is unconfirmed, but `TunnelReady` is not true. + +## frp + +The generated frpc TOML sets `user` to the immutable GameServer UID. The wire +registration is `.`; the GameTemplate's port names and +`remotePorts[].name` do not change. Pod and container restarts reuse the UID. +Deleting and recreating a GameServer creates a new identity, including when its +namespace and name are reused. Distinct clusters' UIDs also separate their +registrations on a shared frps. + +frpc's authenticated status API listens only on `127.0.0.1:7400`. It uses the +mounted token as its local admin password; it is never exposed by a Service or +forwarded from the public relay. The wrapper does not use an environment HTTP +proxy or follow redirects for this request. Every configured TCP/UDP proxy must +appear with the matching name and protocol, a `running` status and no error. +Authentication failure, an occupied registration, absent proxy or unavailable +local API cannot produce readiness. This confirms the relay registrations, not +the health of a sleeping game backend. + +Application heartbeat settings are five seconds between requests and fifteen +seconds before a silent control connection is closed; TCP multiplex keepalive +also uses five seconds. Allow the provider detection interval, local check and +Kubernetes probe/reconcile intervals for an outage to reach the dashboard. + +### Upgrade and replacement + +Upgrade the operator and tunnel image together. The new image requires +`GAMESERVER_UID`, so a mixed old-operator/new-image installation fails closed. +Existing unprefixed proxy names change to UID-prefixed names. Stop the old +client, wait for frps to unregister its old proxies and release their public +ports, or remove those registrations using the frps administrator's normal +procedure. Readiness stays false while a port is still occupied. Do not remove +an unrelated GameServer's registration to make an upgrade succeed. + +The operator uses one replica and the Recreate strategy for relay Deployments. +It never deliberately rolls out two clients with the same provider identity. +Replacement has a short forwarding interruption while the old connection +closes and the new one registers. When a deleted old Pod's node is unreachable, +wait for frps to expire its old session before expecting the replacement ready. +Do not manually scale a relay Deployment above one replica. + +## Playit connection evidence + +The pinned Playit v1.0.10 IPC v2 contract exposes lifecycle and address +assignment, but no authenticated relay heartbeat or connection timestamp. +Its daemon caches `Running` after a successful API refresh and leaves that +cache unchanged when later refreshes fail. Receiving another `get_state` +response therefore cannot prove that a relay is connected or refresh a health +lease. Process age and the presence of an assigned address do not prove it +either. + +Gameplane keeps reporting assigned addresses, but Playit's `/readyz` returns +503 and `TunnelReady` reports `RelayConnectionUnknown`; before assignment it +reports `AwaitingAddress`. `/healthz` remains successful, the child keeps running +and forwarding when it can, and Kubernetes does not restart it solely for this +unknown connection state. The JSON status distinguishes known assignment from +unknown connection. This is a deliberate limitation of the pinned provider, +not a claim that every Playit tunnel is disconnected. + +Positive Playit readiness requires a future provider interface that exposes +fresh, authenticated relay connection evidence, or a separately designed +end-to-end probe. The relevant upstream definitions are +[the IPC model](https://github.com/playit-cloud/playit-agent/blob/v1.0.10/packages/playit-ipc/src/model.rs) +and [the daemon's cached lifecycle publisher](https://github.com/playit-cloud/playit-agent/blob/v1.0.10/packages/playitd/src/daemon.rs). + +## Retry and resource policy + +Unexpected child exits, including clean exits, wait 1, 2, 4, 8 seconds and so on, +up to 300 seconds, before another attempt. Cancellation interrupts the wait. +There is no jitter, as requested by the maintainer. Retry escalation resets only +after one uninterrupted minute of confirmed provider readiness. A failed or +stale observation breaks that interval. A long-lived disconnected process does +not reset it. Playit's unsupported connection evidence cannot reset it either. +The embedded Tailscale loop uses the same interval, fed by confirmed online +tailnet status and active forwarding listeners rather than process lifetime. + +Resources use the existing `operator.tunnelResources` Helm value and +`--tunnel-resources` operator flag from the workload-resource policy. Omitted or +null configuration defaults to requests of 25m CPU and 64Mi memory, with no +limits. A non-null empty object explicitly clears defaults; supplied requests +and limits replace them. These are scheduling reservations, not a fleet +capacity guarantee. Readiness and replacement changes preserve this contract. + +## Regression coverage + +The CI optional-components lifecycle test runs two GameServers from the same +TCP/UDP echo template against one real frps, with different remote ports. An +independent client Pod checks replies identify the intended backend through +both protocols. It also covers registration rejection, replacement isolation, +invalid credentials, a shared relay outage and recovery, and credential-free +logs. Unit tests cover UID validation/stability, namespace/recreation identity, +status decoding, stale expiry, bounded retries/cancellation, healthy-interval +reset, old-deployment readiness, Playit's cached-running limitation, and +resource override removal. These regression tests are executed in CI. diff --git a/docs/release-notes.md b/docs/release-notes.md new file mode 100644 index 000000000..f3da01f11 --- /dev/null +++ b/docs/release-notes.md @@ -0,0 +1,21 @@ +# Unreleased operational changes + +## Auxiliary resource requests and management placement (#724) + +Agent/config-init (25m CPU, 64Mi), sentinel (25m, 32Mi), tunnel (25m, 64Mi), and +Restic backup/init/restore (100m, 128Mi) now request resources on every install. +These are initial scheduling reservations, not workload capacity estimates. +No new CPU or memory limits are imposed. Existing forget/prune limits remain. + +Upgrades change generated Pod templates and can restart game/auxiliary pods or +leave them Pending when capacity is insufficient. Review capacity and schedule +the upgrade accordingly. The four `operator.*Resources` chart values provide +complete overrides; explicit empty objects clear defaults, while null/missing +keys use the defaults even with `--reuse-values`. + +Chart-managed pods now accept placement, topology-spread and priority settings, +with optional PDBs for Deployments. API remains a singleton and its PDB uses +`maxUnavailable: 1`; node drains can cause API downtime. Operator replicas greater +than one require leader election. Disabling election on a singleton uses Recreate. +See [production placement](production-placement.md) for upgrade controls and a +worker-pool example. diff --git a/docs/tailscale-forwarding-integration.md b/docs/tailscale-forwarding-integration.md new file mode 100644 index 000000000..2874177b5 --- /dev/null +++ b/docs/tailscale-forwarding-integration.md @@ -0,0 +1,150 @@ +# Tailscale forwarding integration + +This procedure needs a disposable Kubernetes installation and a disposable +tailnet with two client devices. It creates real Tailscale devices and sends real +TCP and UDP traffic. It is an integration environment for #722, not evidence +that a live tailnet run has passed. Ordinary CI runs the local socket and +controller regressions without provider credentials. Follow repository policy: +execute this in an authorized integration environment, not a local test run. + +Use the operator and tunnel image built from the same revision. Record their +digests, the revision, client versions, tailnet device IDs, conditions and traffic +results. Do not record auth keys, login URLs or private node state. + +## Provision the disposable environment + +Enable MagicDNS. Create a reusable auth key permitted to request +`tag:gameplane-test`. Configure a tailnet policy with no broad pre-existing allow +rules: permit the approved client to reach that tag on TCP 25565 and UDP 19132. +The second client must have no grant to the tag. For example, replacing the +example email with the approved test user's identity: + +```json +{ + "tagOwners": {"tag:gameplane-test": ["autogroup:admin"]}, + "grants": [{ + "src": ["approved-test-user@example.com"], + "dst": ["tag:gameplane-test"], + "ip": ["tcp:25565", "udp:19132"] + }] +} +``` + +Install this fixture in the disposable Gameplane cluster. Its third TCP port is +deliberately unadvertised. The backend prefixes replies so replacing it can be +distinguished from continuing to reach an old process. + +```yaml +apiVersion: gameplane.local/v1alpha1 +kind: GameTemplate +metadata: + name: tailnet-echo +spec: + displayName: Tailnet echo integration + game: tailnet-echo + version: "1" + image: python:3.14-alpine + consoleMode: none + command: [python, -u, -c] + args: + - | + import socketserver, threading + class TCP(socketserver.BaseRequestHandler): + def handle(self): + while True: + data = self.request.recv(65536) + if not data: return + self.request.sendall(b'backend-v1:' + data) + class UDP(socketserver.BaseRequestHandler): + def handle(self): + data, sock = self.request + sock.sendto(b'backend-v1:' + data, self.client_address) + for port in (25565, 25575): + server = socketserver.ThreadingTCPServer(('0.0.0.0', port), TCP) + threading.Thread(target=server.serve_forever, daemon=True).start() + socketserver.ThreadingUDPServer(('0.0.0.0', 19132), UDP).serve_forever() + ports: + - {name: game, containerPort: 25565, protocol: TCP, advertise: true} + - {name: query, containerPort: 19132, protocol: UDP, advertise: true} + - {name: private, containerPort: 25575, protocol: TCP, advertise: false} +--- +apiVersion: gameplane.local/v1alpha1 +kind: GameServer +metadata: + name: tailnet-echo + namespace: gameplane-games +spec: + templateRef: {name: tailnet-echo} + networking: + expose: ClusterIP + tunnel: + enabled: true + provider: tailscale + credentialsSecretRef: {name: tailnet-echo-tunnel-auth} + tailscale: + hostname: gameplane-forward-test + tags: ["tag:gameplane-test"] +``` + +Provide the reusable auth key through the dashboard's tunnel credential form or +the credential API; these create the required GameServer ownership reference. +Wait for the tunnel Deployment's ready replica and `TunnelReady=True`. Confirm +the admin console shows the requested tag and exact hostname, with no advertised +subnet routes. Record the node ID and tailnet IPs. Remove conflicting stale test +devices if the hostname has acquired a suffix; that state must remain not ready. + +## Exercise the datapath + +On the approved tailnet client, run this with the fixture's MagicDNS name: + +```python +import socket +host = 'gameplane-forward-test' +for kind, port in [(socket.SOCK_STREAM, 25565), (socket.SOCK_DGRAM, 19132)]: + with socket.socket(socket.AF_INET, kind) as client: + client.settimeout(5) + client.connect((socket.gethostbyname(host), port)) + client.sendall(b'game-payload') + assert client.recv(65536) == b'backend-v1:game-payload' +``` + +Repeat with the node's IPv6 tailnet address and `AF_INET6` when the environment +supports IPv6. Run two UDP clients concurrently with distinct payloads; each +must receive only its own reply. TCP 25575 and any other unadvertised port must +fail even from the approved client. Repeat the advertised-port probes from the +denied client; both must time out/fail while the tunnel remains ready. Removing +the approved grant must block new flows too; restore it before continuing. + +Create an unrelated echo Service with a distinguishable prefix in the cluster. +It must not be reachable through this tailnet device on any port, and no route +to its ClusterIP or the cluster's Service/Pod CIDRs may appear on either client. +The local regression also rejects unrelated backing DNS names before listeners +are opened; no incoming packet contains a selectable destination. + +Change the fixture backend prefix to `backend-v2:` and wait for its game Pod +rollout. New TCP connections must receive v2. Stop UDP traffic for over 30 seconds +to expire the old session, then send again and require v2. In the disposable +cluster, delete only the fixture's game Service and wait for the operator to +recreate it; repeat both probes. Existing connections may fail during replacement; +new sessions must resolve the Service again and recover without replacing the +tunnel Pod. + +## Identity, authentication and shutdown outcomes + +| Change | Required outcome | +|---|---| +| Restart the tunnel **container** while retaining the Pod and EmptyDir (use the integration runner's container runtime controls) | Same node ID, hostname and tags reconnect; TCP/UDP recover. | +| Delete the tunnel **Pod**, remove its stale device from the admin console, allow replacement | New identity registers using the reusable key; exact hostname/tags recover and both protocols work. | +| Replace Pod with an expired/revoked key in its credential Secret | Registration never makes the Pod ready and no Tailscale endpoint is advertised. Store a valid reusable key, replace the Pod and require recovery. | +| Request a tag not permitted by tagOwners | Not ready; there must be no untagged fallback device serving traffic. Restore permitted tags and repair credentials/device state, then verify recovery. | +| Rename an existing device to collide with the configured hostname | A suffix assigned to the replacement device cannot make Gameplane advertise the wrong hostname as ready. Resolve the collision and re-register. | +| Disconnect the node from the tailnet/control plane | Readiness turns false after the status/probe intervals; restore connectivity and require recovery. Do not use client ACL denial as a simulation of a disconnected server. | +| Delete/terminate tunnel Pod during active TCP and UDP traffic | Listeners and sessions close, worker shutdown does not hang, and clients reconnect only after the replacement is ready. | + +Remove the GameServer and fixture template, revoke the disposable auth key, and +delete all test devices from the tailnet. Retain only the sanitized result record. + +The implementation uses the official [tsnet APIs](https://tailscale.com/docs/reference/tsnet-server-api). +Its Go 1.26.10 minimum and Go 1.27.3 image builders include the `crypto/x509` +security fix described in the [Go release history](https://go.dev/doc/devel/release) +and [Tailscale security bulletin TS-2026-013](https://tailscale.com/security-bulletins#ts-2026-013). diff --git a/docs/tunnels.md b/docs/tunnels.md index b8ce3fd2a..1ba4c9a57 100644 --- a/docs/tunnels.md +++ b/docs/tunnels.md @@ -1,5 +1,8 @@ # Tunnels +For Prometheus discovery, relay health alerts and the complete scrape-policy +configuration, see [Monitoring games and relays](monitoring.md). + A GameServer can optionally route players through an external relay — the server dials outbound to the relay and players connect to the relay's address. This exists for installs with no public IP and no ability to port-forward (homelab k3s behind @@ -197,21 +200,27 @@ specify in `spec.networking.tunnel.tailscale.hostname` (or defaults to the GameServer name if left empty). Port 25565 is available to devices on your tailnet. -> **`tags` requires an ACL grant.** `spec.networking.tunnel.tailscale.tags` -> is requested when the device registers with your tailnet: the tunnel pod -> runs `tailscale up --advertise-tags=` once, over the local daemon's -> socket. Tailscale's headless daemon config has no field for tags, and -> changing the tags of a registered device needs a fresh login (see -> `tunnel/specs.md`). The tailnet's ACL must grant `tagOwners` for the -> requested tags to the auth key's owner. If it doesn't, Tailscale refuses -> the request and the tunnel pod logs the error. If the device is not -> running afterwards, the pod tries once more without tags. That untagged -> attempt can fail too (for example with a single-use auth key), in which -> case the error is logged and the device stays logged out. Configure `tagOwners` in your tailnet's ACL before setting -> `tags` here. Each tag is `tag:` or a bare ``, where the name -> starts with a letter and holds only letters, digits and `-`. If any tag is -> invalid, the whole list is logged and ignored and the device registers -> untagged. +The embedded Tailscale node forwards every advertised TCP and UDP port to this +GameServer's backing Service. It does not provide a subnet route or expose +unadvertised ports. `TunnelReady` requires an online device and active forwarding +listeners. Tailnet grants still govern each player's access; readiness is not a +test of an individual grant or of the sleeping game's application health. + +Requested tags require a matching `tagOwners` policy. A bare tag name receives +the `tag:` prefix. Invalid or denied tags keep the tunnel not ready; it never +silently falls back to an untagged identity. + +Container restarts retain identity in a Pod volume. Replacing the Pod, changing +its tags, or upgrading from the older tailscaled implementation re-registers the +device and requires a valid reusable auth key. Remove stale devices from the +tailnet admin console so the configured MagicDNS hostname remains available. +If Tailscale assigns a suffixed name because of a conflict, Gameplane keeps the +advertised endpoint not ready until the conflict is resolved. Enable MagicDNS. + +Upgrade the operator and tunnel images together: the forwarding contract now +carries each port's TCP/UDP protocol. See the +[disposable tailnet integration procedure](tailscale-forwarding-integration.md) +for traffic, access-policy and restart checks. The `credentialsSecretRef.name` is the name that the dashboard or API write after you provide the credential. @@ -503,3 +512,8 @@ changes. the same security practices as you would for a public-facing LoadBalancer (e.g., disable admin consoles, run security updates, restrict in-game permissions). + +See [relay readiness and registration identity](relay-health.md) for the frp +UID migration, replacement policy, health probes, retry reset and the pinned +Playit version's connection-status limitation. An assigned address alone does +not make a relay ready. diff --git a/go.work b/go.work index fa6b1bb44..99fe792db 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.0 +go 1.27.3 use ( ./agent diff --git a/go.work.sum b/go.work.sum index 730a9a159..da1f24437 100644 --- a/go.work.sum +++ b/go.work.sum @@ -7,24 +7,144 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202602092021 buf.build/go/protovalidate v0.14.0/go.mod h1:+F/oISho9MO7gJQNYC2VWLzcO1fTPmaTA08SDYJZncA= buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI= +cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k= +cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI= +cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc= +cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk= +cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q= +cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0= +cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM= +cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y= +cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8= +cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M= +cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI= +cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg= +cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw= +cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY= +cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= +cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic= +cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk= +cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss= +cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8= +cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY= +cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA= +cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk= +cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE= +cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ= +cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s= +cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ= +cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM= +cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ= +cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk= +cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc= +cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q= +cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o= +cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4= +cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs= +cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So= +cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ= +cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U= +cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw= +cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ= +cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c= +cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk= +cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE= +cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84= +cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A= +cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU= +cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM= +cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= +cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0= +cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY= +cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ= +cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0= +cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM= +cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0= +cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ= +cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04= +cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U= +cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA= +cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao= +cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ= +cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg= +cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg= +cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU= +cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg= +cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM= +cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8= +cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ= +cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ= +cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk= +cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc= +cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k= +cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4= +cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8= cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0= cloud.google.com/go/profiler v0.6.0/go.mod h1:cJV7Qfj0o9PAC7q/xQTkM6qn2FO9So3TFk4P5O5yLis= cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w= +cloud.google.com/go/pubsub v1.51.0/go.mod h1:NERXf11sd82UV3VnflcUj8POIyQUXT/QwrKlxD8di/I= cloud.google.com/go/pubsub/v2 v2.3.0/go.mod h1:O5f0KHG9zDheZAd3z5rlCRhxt2JQtB+t/IYLKK3Bpvw= +cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA= cloud.google.com/go/pubsub/v2 v2.6.0/go.mod h1:4anqvV/w8Pcgu2tO0qr2XgsF3GXHowzryfQ5gOnVmWY= +cloud.google.com/go/pubsub/v2 v2.6.1/go.mod h1:1y2lZnKfUFPZz0PU4YmXyk4lA11+xmYA42zbC32RkxQ= +cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= +cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs= +cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k= +cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU= +cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw= +cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE= +cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= +cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk= +cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU= +cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA= +cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU= cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk= +cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U= +cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk= +cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY= cloud.google.com/go/spanner v1.86.1/go.mod h1:bbwCXbM+zljwSPLZ44wZOdzcdmy89hbUGmM/r9sD0ws= cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0= +cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY= +cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8= cloud.google.com/go/storage v1.57.1/go.mod h1:329cwlpzALLgJuu8beyJ/uvQznDHpa2U5lGjWednkzg= cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4= +cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y= +cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk= +cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU= +cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk= +cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4= +cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ= +cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY= +cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0= +cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8= +cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc= +cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8= +cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g= codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU= codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= @@ -56,7 +176,7 @@ github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdE github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= @@ -73,6 +193,7 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= @@ -148,6 +269,8 @@ github.com/cavaliercoder/badio v0.0.0-20160213150051-ce5280129e9e/go.mod h1:V284 github.com/cavaliercoder/go-rpm v0.0.0-20200122174316-8cb9fd9c31a8/go.mod h1:AZIh1CCnMrcVm6afFf96PBvE2MRpWFco91z8ObJtgDY= github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/chainguard-dev/clog v1.7.0/go.mod h1:4+WFhRMsGH79etYXY3plYdp+tCz/KCkU8fAr0HoaPvs= github.com/chainguard-dev/clog v1.8.0/go.mod h1:5MQOZi+Iu7fV7GcJG8ag8rCB5elEOpqRMKEASgnGVdo= @@ -170,8 +293,10 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= @@ -184,6 +309,7 @@ github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3 github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0= github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc v2.5.0+incompatible h1:6W0vGJR3Tu0r0PwfmjOrRZSlfxeEln8dsejt3ZWIvwo= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= @@ -208,6 +334,9 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/eggsampler/acme/v3 v3.6.2/go.mod h1:/qh0rKC/Dh7Jj+p4So7DbWmFNzC4dpcpK53r226Fhuo= github.com/eggsampler/acme/v3 v3.8.1/go.mod h1:/qh0rKC/Dh7Jj+p4So7DbWmFNzC4dpcpK53r226Fhuo= github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= @@ -215,10 +344,14 @@ github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1 github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= github.com/flynn/go-docopt v0.0.0-20140912013429-f6dd2ebbb31e/go.mod h1:HyVoz1Mz5Co8TFO8EupIdlcpwShBmY98dkT2xeHkvEI= github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= @@ -237,6 +370,7 @@ github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3I github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= @@ -289,10 +423,16 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= @@ -306,17 +446,29 @@ github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sC github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/addlicense v1.1.1/go.mod h1:Sm/DHu7Jk+T5miFHHehdIjbi4M5+dJDRS3Cq0rncIxA= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gopacket/gopacket v1.6.1 h1:S19Ok/KVGDFNHVW2uCva5U0vZ+uHqiZQdxteL50v6Ak= github.com/gopacket/gopacket v1.6.1/go.mod h1:i3NaGaqfoWKAr1+g7qxEdWsmfT+MXuWkAe9+THv8LME= github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= @@ -445,7 +597,9 @@ github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= +github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/open-policy-agent/opa v1.8.0/go.mod h1:vOVZuIJQISnaYcZtQ58yTDkVCp1FmGPwK43pO9qPDqM= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= @@ -460,6 +614,9 @@ github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQ github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/otlptranslator v0.0.2/go.mod h1:P8AwMgdD7XEr6QRUJ2QWLpiAZTgTE2UYgjlu3svompI= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= @@ -588,6 +745,7 @@ go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1 go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= +go.einride.tech/aip v0.83.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= @@ -626,9 +784,15 @@ go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOh go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= @@ -636,12 +800,23 @@ go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4 go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= @@ -662,11 +837,20 @@ golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbV golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee h1:WG0RUwxtNT4qqaXX3DPA8zHFNm/D9xaBpxzHt1WcA/E= @@ -680,8 +864,14 @@ golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -699,13 +889,16 @@ golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -715,8 +908,10 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -736,8 +931,10 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= @@ -745,6 +942,7 @@ golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/ golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= @@ -762,10 +960,16 @@ golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7 h1:EBZoQjiKKPaLbPrbpssUfuHtwM6KV/vb4U85g/cigFY= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -778,21 +982,36 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/plot v0.15.2/go.mod h1:DX+x+DWso3LTha+AdkJEv5Txvi+Tql3KAGkehP0/Ubg= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= +google.golang.org/api v0.287.1/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260706201446-f0a921348800/go.mod h1:MS5Wgu4uvm+nhCBwXzKEpYKPawaaNNUT7WfgpH44YVQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= @@ -800,23 +1019,50 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260727163830-6c54dddc4772/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk= +k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE= +k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= @@ -827,6 +1073,8 @@ k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppK mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/controller-tools v0.22.0 h1:eG3FAVja/KnlXKIWg95udIFz1cMyAtMjP11cqBh3t+k= +sigs.k8s.io/controller-tools v0.22.0/go.mod h1:VizwUStoZK7rReCj704czGGrB7mLxXTiJSJt7wN5ilI= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/release-utils v0.12.2/go.mod h1:Ab9Lb/FpGUw4lUXj1QYbUcF2TRzll+GS7Md54W1G7sA= sigs.k8s.io/release-utils v0.12.4/go.mod h1:Tc3iM9DVM3W9oJu/6rEI+LnREuhy8lZ7wInQhRBtUoo= diff --git a/hack/ci_scope.py b/hack/ci_scope.py index 91da39131..d6a001b99 100644 --- a/hack/ci_scope.py +++ b/hack/ci_scope.py @@ -29,7 +29,8 @@ FORCE_ALL_PREFIXES = (".github/actions/",) # Mirrors the e2e-go matrix buckets in .github/workflows/ci.yaml. E2E_BUCKETS = ["operator", "api-auth", "api-roles", "api-rbac", "api-agent", "api-mods", - "telemetry", "optional-components"] + "telemetry", "optional-components", "management-placement", "network-isolation", "monitoring"] +E2E_AMD64_ONLY = {"management-placement", "network-isolation", "monitoring"} SUITES_FILE = "test/e2e/suite-components.json" # Paths no Go e2e test reads. Their own jobs keep their paths-filter gates. NO_E2E_FILES = { @@ -936,7 +937,8 @@ def e2e_row(suite, gate_on, picked, key, feed): for bucket in E2E_BUCKETS: name = "e2e api-auth + ratelimit tail" if bucket == "api-auth" else f"e2e {bucket}" picked = bucket in e2e_run or (bucket == "api-auth" and "ratelimit" in e2e_run) - e2e_row(f"{name} (amd64, arm64)", gate("e2e"), picked, bucket, e2e_feed()) + arches = "amd64" if bucket in E2E_AMD64_ONLY else "amd64, arm64" + e2e_row(f"{name} ({arches})", gate("e2e"), picked, bucket, e2e_feed()) e2e_row("e2e multicluster (amd64, arm64)", gate("e2e"), flag(e2e, "e2e-multicluster"), "multicluster", e2e_feed()) e2e_row("e2e upgrade (amd64, arm64)", gate("e2e"), flag(e2e, "e2e-upgrade"), "upgrade", diff --git a/hack/sync-chart-crds.sh b/hack/sync-chart-crds.sh index 98ba9ff18..a86fe3f5a 100755 --- a/hack/sync-chart-crds.sh +++ b/hack/sync-chart-crds.sh @@ -48,19 +48,21 @@ if command -v sha256sum >/dev/null 2>&1; then else sha256() { shasum -a 256; } fi -hash=$(cat "${files[@]}" | sha256 | cut -d' ' -f1) +# Git may check out these text files with CRLF on Windows. Hash and copy the +# canonical LF representation so the stamp matches Linux code generation. +hash=$(sed $'s/\r$//' "${files[@]}" | sha256 | cut -d' ' -f1) for f in "${files[@]}"; do # controller-gen always emits exactly one top-level metadata.annotations # block (two-space indent); schema-level `annotations:` keys sit deeper. - if [ "$(grep -c '^ annotations:$' "$f")" != 1 ]; then + if [ "$(sed $'s/\r$//' "$f" | grep -c '^ annotations:$')" != 1 ]; then echo "sync-chart-crds: $f has no single metadata.annotations block to stamp" >&2 exit 1 fi base=$(basename "$f") for dir in charts/gameplane/charts/gameplane-crds/crds charts/gameplane/crd-manifests; do awk -v line=" ${annotation}: ${hash}" ' - { print } + { sub(/\r$/, ""); print } /^ annotations:$/ && !done { print line; done = 1 } ' "$f" > "$dir/$base" done diff --git a/hack/test_ci_scope.py b/hack/test_ci_scope.py index c8dd10dc1..6b4a23f11 100644 --- a/hack/test_ci_scope.py +++ b/hack/test_ci_scope.py @@ -9,7 +9,7 @@ from unittest.mock import patch from subprocess import CompletedProcess -from ci_scope import (E2E_BUCKETS, FORCE_ALL_FILES, ROOT, affected_modules, bot_matrix, +from ci_scope import (E2E_AMD64_ONLY, E2E_BUCKETS, FORCE_ALL_FILES, ROOT, affected_modules, bot_matrix, bucket_lists, changed_paths, e2e_outputs, full_scope_reason, image_inputs, is_unmapped, load_suites, main, publish_paths, select_images, summarize, verify_suites, go_packages, web_tests, CI_FILE, ci_blocks, ci_jobs, @@ -347,10 +347,10 @@ def test_every_e2e_bucket_and_bot_test_has_a_row(self): """Check every e2e bucket and bot test has a selection row.""" rows, _ = self.web_only() suites = {row["suite"] for row in rows} - self.assertEqual(len(E2E_BUCKETS), 8) for bucket in E2E_BUCKETS: name = "e2e api-auth + ratelimit tail" if bucket == "api-auth" else f"e2e {bucket}" - self.assertIn(f"{name} (amd64, arm64)", suites) + arches = "amd64" if bucket in E2E_AMD64_ONLY else "amd64, arm64" + self.assertIn(f"{name} ({arches})", suites) for test in SUMMARY_BOTS: self.assertIn(f"e2e game bot / {test}", suites) @@ -408,6 +408,38 @@ def select(self, *paths): """Return the e2e outputs for a partial-scope change to paths.""" return e2e_outputs(ROOT, list(paths), None) + def test_audit_network_isolation_has_a_run_pattern(self): + """Affected components and the owning test file select the audit bucket.""" + for path in ("operator/internal/controller/gameserver_controller.go", + "api/cmd/main.go", + "agent/cmd/main.go", + "test/e2e/network_isolation_e2e_test.go"): + with self.subTest(path=path): + values = self.select(path) + self.assertIn("TestNetworkIsolation_EnforcedAndUpgrade", + values["e2e-run"].get("network-isolation", "")) + + def test_audit_monitoring_has_a_run_pattern(self): + """Metric producers and the owning test file select the real scrape fixture.""" + for path in ("operator/internal/controller/gameserver_controller.go", + "api/cmd/main.go", "agent/internal/status/performance.go", + "tunnel/relay_health.go", "test/e2e/monitoring_e2e_test.go"): + with self.subTest(path=path): + values = self.select(path) + self.assertIn("TestMonitoring_DiscoveryPerformanceAndPolicy", + values["e2e-run"].get("monitoring", "")) + + def test_audit_management_placement_has_a_run_pattern(self): + """Affected components and the owning test file select the audit bucket.""" + for path in ("operator/cmd/main.go", + "api/cmd/main.go", + "agent/cmd/main.go", + "test/e2e/management_placement_e2e_test.go"): + with self.subTest(path=path): + values = self.select(path) + self.assertIn("TestManagementPlacementAndDisruption", + values["e2e-run"].get("management-placement", "")) + def test_agent_only_change_selects_agent_tests_and_skips_auth_and_telemetry(self): values = self.select("agent/internal/console/console.go") self.assertEqual(values["reason"], "") @@ -504,6 +536,20 @@ def test_repository_suite_map_is_valid(self): class E2ERunTests(unittest.TestCase): """The e2e-run regexes that replace buckets.sh regex in the e2e jobs (T016).""" + def test_bucket_registry_matches_workflow_matrices(self): + """Every matrix bucket emits a run pattern and reports its real architectures.""" + text = (ROOT / CI_FILE).read_text(encoding="utf-8") + owner, _ = ci_blocks(text) + matrices = {} + for number, line in enumerate(text.splitlines(), 1): + job = owner[number] + if job in {"e2e-go", "e2e-go-arm64"} and line.strip().startswith("bucket: ["): + self.assertNotIn(job, matrices) + matrices[job] = {name.strip() for name in line.split("[", 1)[1].split("]", 1)[0].split(",")} + self.assertEqual(set(matrices), {"e2e-go", "e2e-go-arm64"}) + self.assertEqual(set(E2E_BUCKETS), matrices["e2e-go"] | matrices["e2e-go-arm64"]) + self.assertEqual(E2E_AMD64_ONLY, matrices["e2e-go"] - matrices["e2e-go-arm64"]) + def test_full_scope_regex_equals_buckets_sh_regex(self): values = e2e_outputs(ROOT, None, "master push") self.assertEqual(values["reason"], "master push") diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index 059470fd2..a39c85d1a 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -567,6 +567,10 @@ "defaultMessage": "These settings apply to the backing Service, not to tunnel traffic: {settings}.", "description": "Informational condition when hostname, source ranges or node ports are set on a server with a tunnel. settings is a comma-separated list of field names." }, + "condition.tunnelready.awaitingaddress": { + "defaultMessage": "Waiting for the provider to assign a tunnel endpoint; the relay connection is unconfirmed.", + "description": "TunnelReady condition before Playit assigns an endpoint." + }, "condition.tunnelready.deploymentnotready": { "defaultMessage": "Waiting for the tunnel deployment to become ready.", "description": "TunnelReady condition while the tunnel pod is not ready." @@ -587,6 +591,10 @@ "defaultMessage": "{provider, select, playit {The tunnel deployment is ready. Waiting for the playit endpoint assignment.} tailscale {The tunnel deployment is ready and authenticated.} other {The tunnel deployment is ready and all ports are mapped.}}", "description": "TunnelReady condition when the tunnel works." }, + "condition.tunnelready.relayconnectionunknown": { + "defaultMessage": "The relay connection cannot be confirmed. Check the provider's connection status and the tunnel deployment's readiness probe.", + "description": "TunnelReady condition when provider connection evidence is missing or unsupported." + }, "condition.tunnelready.tunnelcredentialrefused": { "defaultMessage": "The tunnel credentials secret ''{secret}'' is not owned by this GameServer. {remedy, select, defaultName {Add an ownerReference to this GameServer to the Secret, or delete the Secret and save the credential again from the dashboard.} other {Save the credential from the dashboard, or add an ownerReference to this GameServer to the Secret.}}", "description": "TunnelReady condition when the operator refuses to mount a credentials secret. secret is the secret name, remedy is defaultName or other." @@ -595,6 +603,10 @@ "defaultMessage": "Unknown tunnel provider ''{provider}''.", "description": "TunnelReady condition when the provider name is not supported." }, + "condition.tunnelready.unsupportedprotocol": { + "defaultMessage": "Tailscale forwards only advertised TCP and UDP ports. Remove unsupported protocols from the advertised ports.", + "description": "TunnelReady condition when a Tailscale template advertises a protocol other than TCP or UDP." + }, "condition.unquiesced.failed": { "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." diff --git a/operator/api/v1alpha1/gameserver_types.go b/operator/api/v1alpha1/gameserver_types.go index 654cba6ed..df2a65660 100644 --- a/operator/api/v1alpha1/gameserver_types.go +++ b/operator/api/v1alpha1/gameserver_types.go @@ -460,15 +460,12 @@ type TailscaleTunnelSpec struct { Hostname string `json:"hostname,omitempty"` // Tags are Tailscale ACL tags requested for the device when it - // registers, via `tailscale up --advertise-tags` (tailscaled's - // declarative config has no field for tags, and changing the tags of a - // registered node needs a fresh login). Each entry is "tag:" or a bare - // name, which gets the "tag:" prefix. The tailnet ACL must grant - // tagOwners for these tags to the auth key's owner; if the request is - // refused, the tunnel pod logs the error and the tunnel runs untagged. - // If any entry is invalid, the whole list is logged and ignored and the - // device registers untagged. See - // tunnel/specs.md. + // registers through embedded tsnet. Each entry is "tag:" or a + // bare name, which gets the "tag:" prefix. The tailnet policy must grant + // tagOwners to the auth key's owner. Invalid or refused tags keep the + // tunnel not ready; it never silently registers without requested tags. + // Changing tags replaces the Pod and requires a reusable auth key. + // See tunnel/specs.md for identity persistence and registration details. // +kubebuilder:validation:MaxItems=8 // +optional Tags []string `json:"tags,omitempty"` diff --git a/operator/api/v1alpha1/gametemplate_types.go b/operator/api/v1alpha1/gametemplate_types.go index bea234aea..092b48acf 100644 --- a/operator/api/v1alpha1/gametemplate_types.go +++ b/operator/api/v1alpha1/gametemplate_types.go @@ -89,6 +89,12 @@ type GameTemplateSpec struct { // +optional Ports []GamePort `json:"ports,omitempty"` + // SentinelIsolation restricts a wake sentinel to its own server's advertised + // ports. Templates opt in after module connectivity validation; absent or + // false retains the legacy namespace-wide sentinel allowances. + // +optional + SentinelIsolation bool `json:"sentinelIsolation,omitempty"` + // Storage describes the default persistent storage layout. // +optional Storage GameStorageSpec `json:"storage,omitempty"` @@ -735,6 +741,28 @@ type StatusMetricSpec struct { // Unit is an optional suffix shown after the value (e.g. "ms", "TPS"). // +optional Unit string `json:"unit,omitempty"` + + // Performance opts this existing readout into cached numeric monitoring. + // Display-only strings are never inferred to be performance measurements. + // +optional + Performance *PerformanceMetricSpec `json:"performance,omitempty"` +} + +// PerformanceMetricSpec gives a status readout explicit numeric semantics. +// Rate signals use per_second; duration accepts seconds or milliseconds. +// +kubebuilder:validation:XValidation:rule="self.signal == 'tick_duration' ? self.unit in ['seconds', 'milliseconds'] : self.unit == 'per_second'",message="duration requires seconds/milliseconds; rates require per_second" +type PerformanceMetricSpec struct { + // +kubebuilder:validation:Enum=tick_rate;tick_duration;frame_rate + Signal string `json:"signal"` + // +kubebuilder:validation:Enum=per_second;seconds;milliseconds + Unit string `json:"unit"` + // DegradedThreshold is a finite nonnegative decimal string in the input + // unit. Rates below it, or durations above it, are degraded. Omit to + // disable alerts. A string keeps JSON/schema handling portable. + // +kubebuilder:validation:Pattern=`^(0|[1-9][0-9]{0,8})(\.[0-9]{1,6})?$` + // +kubebuilder:validation:MaxLength=16 + // +optional + DegradedThreshold *string `json:"degradedThreshold,omitempty"` } // PlayerActionsSpec maps moderation actions to console commands. Each diff --git a/operator/api/v1alpha1/zz_generated.deepcopy.go b/operator/api/v1alpha1/zz_generated.deepcopy.go index 05926fd58..20421d118 100644 --- a/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -2215,6 +2215,26 @@ func (in *OCISourceSpec) DeepCopy() *OCISourceSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PerformanceMetricSpec) DeepCopyInto(out *PerformanceMetricSpec) { + *out = *in + if in.DegradedThreshold != nil { + in, out := &in.DegradedThreshold, &out.DegradedThreshold + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PerformanceMetricSpec. +func (in *PerformanceMetricSpec) DeepCopy() *PerformanceMetricSpec { + if in == nil { + return nil + } + out := new(PerformanceMetricSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PlayerActionsSpec) DeepCopyInto(out *PlayerActionsSpec) { *out = *in @@ -2521,6 +2541,11 @@ func (in *ServerActionSpec) DeepCopy() *ServerActionSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StatusMetricSpec) DeepCopyInto(out *StatusMetricSpec) { *out = *in + if in.Performance != nil { + in, out := &in.Performance, &out.Performance + *out = new(PerformanceMetricSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusMetricSpec. @@ -2539,7 +2564,9 @@ func (in *StatusSpec) DeepCopyInto(out *StatusSpec) { if in.Metrics != nil { in, out := &in.Metrics, &out.Metrics *out = make([]StatusMetricSpec, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } diff --git a/operator/cmd/main.go b/operator/cmd/main.go index 85263e901..9625068fb 100644 --- a/operator/cmd/main.go +++ b/operator/cmd/main.go @@ -150,10 +150,18 @@ func main() { captureDefaultMaxSizeBytes int64 backupJobBackoffLimit int64 backupJobActiveDeadlineSeconds int64 + agentResourcesJSON string + sentinelResourcesJSON string + tunnelResourcesJSON string + backupResourcesJSON string ) flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address the metrics endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election.") + flag.StringVar(&agentResourcesJSON, "agent-resources", "", "JSON ResourceRequirements for agents and config init containers; default requests 25m/64Mi, {} clears defaults.") + flag.StringVar(&sentinelResourcesJSON, "sentinel-resources", "", "JSON ResourceRequirements for sentinels; default requests 25m/32Mi, {} clears defaults.") + flag.StringVar(&tunnelResourcesJSON, "tunnel-resources", "", "JSON ResourceRequirements for tunnel relays; default requests 25m/64Mi, {} clears defaults.") + flag.StringVar(&backupResourcesJSON, "backup-resources", "", "JSON ResourceRequirements for restic backup/init, restore and cleanup workers; default backup/restore requests 100m/128Mi, {} clears defaults.") flag.StringVar(&agentImage, "agent-image", "ghcr.io/gameplanepanel/gameplane/agent:dev", "Image to use for the Gameplane agent sidecar injected into game pods.") flag.StringVar(&agentImagePullPolicy, "agent-image-pull-policy", "", @@ -263,6 +271,26 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) setupLog := ctrl.Log.WithName("setup") + agentResources, err := controller.ParseAuxiliaryResources(agentResourcesJSON) + if err != nil { + setupLog.Error(err, "invalid --agent-resources") + os.Exit(1) + } + sentinelResources, err := controller.ParseAuxiliaryResources(sentinelResourcesJSON) + if err != nil { + setupLog.Error(err, "invalid --sentinel-resources") + os.Exit(1) + } + tunnelResources, err := controller.ParseAuxiliaryResources(tunnelResourcesJSON) + if err != nil { + setupLog.Error(err, "invalid --tunnel-resources") + os.Exit(1) + } + backupResources, err := controller.ParseAuxiliaryResources(backupResourcesJSON) + if err != nil { + setupLog.Error(err, "invalid --backup-resources") + os.Exit(1) + } backupJobBackoff32, err := boundedInt32(backupJobBackoffLimit) // rejects <0 and >MaxInt32 if err != nil { setupLog.Error(err, "invalid --backup-job-backoff-limit value (must be >= 0)", "value", backupJobBackoffLimit) @@ -380,6 +408,9 @@ func main() { APIReader: mgr.GetAPIReader(), Scheme: mgr.GetScheme(), AgentImage: agentImage, + AgentResources: agentResources, + SentinelResources: sentinelResources, + TunnelResources: tunnelResources, AgentImagePullPolicy: agentImagePullPolicy, ConfigInitImage: configInitImage, SentinelImage: sentinelImage, @@ -419,6 +450,7 @@ func main() { Clientset: kubernetes.NewForConfigOrDie(mgr.GetConfig()), AgentClient: agentClient, ResticImage: resticImage, + BackupResources: backupResources, JobBackoffLimit: &backupJobBackoff32, JobActiveDeadlineSeconds: backupJobActiveDeadlineSeconds, EventRecorder: mgr.GetEventRecorder("backup-controller"), @@ -438,6 +470,7 @@ func main() { APIReader: mgr.GetAPIReader(), Scheme: mgr.GetScheme(), ResticImage: resticImage, + BackupResources: backupResources, JobBackoffLimit: &backupJobBackoff32, JobActiveDeadlineSeconds: backupJobActiveDeadlineSeconds, }).SetupWithManager(mgr); err != nil { diff --git a/operator/config/crd/gameplane.local_backups.yaml b/operator/config/crd/gameplane.local_backups.yaml index f96731731..9b3af89a3 100644 --- a/operator/config/crd/gameplane.local_backups.yaml +++ b/operator/config/crd/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_backupschedules.yaml b/operator/config/crd/gameplane.local_backupschedules.yaml index ac32183ae..2011ca4cd 100644 --- a/operator/config/crd/gameplane.local_backupschedules.yaml +++ b/operator/config/crd/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_clusters.yaml b/operator/config/crd/gameplane.local_clusters.yaml index fffd585ac..285dcc73d 100644 --- a/operator/config/crd/gameplane.local_clusters.yaml +++ b/operator/config/crd/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_gameservers.yaml b/operator/config/crd/gameplane.local_gameservers.yaml index 4be670ebc..4616a25b0 100644 --- a/operator/config/crd/gameplane.local_gameservers.yaml +++ b/operator/config/crd/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: group: gameplane.local @@ -1545,15 +1545,12 @@ spec: tags: description: |- Tags are Tailscale ACL tags requested for the device when it - registers, via `tailscale up --advertise-tags` (tailscaled's - declarative config has no field for tags, and changing the tags of a - registered node needs a fresh login). Each entry is "tag:" or a bare - name, which gets the "tag:" prefix. The tailnet ACL must grant - tagOwners for these tags to the auth key's owner; if the request is - refused, the tunnel pod logs the error and the tunnel runs untagged. - If any entry is invalid, the whole list is logged and ignored and the - device registers untagged. See - tunnel/specs.md. + registers through embedded tsnet. Each entry is "tag:" or a + bare name, which gets the "tag:" prefix. The tailnet policy must grant + tagOwners to the auth key's owner. Invalid or refused tags keep the + tunnel not ready; it never silently registers without requested tags. + Changing tags replaces the Pod and requires a reusable auth key. + See tunnel/specs.md for identity persistence and registration details. items: type: string maxItems: 8 diff --git a/operator/config/crd/gameplane.local_gametemplates.yaml b/operator/config/crd/gameplane.local_gametemplates.yaml index 13d8a1fa5..1bab7b727 100644 --- a/operator/config/crd/gameplane.local_gametemplates.yaml +++ b/operator/config/crd/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local @@ -960,6 +960,41 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ type: string + performance: + description: |- + Performance opts this existing readout into cached numeric monitoring. + Display-only strings are never inferred to be performance measurements. + properties: + degradedThreshold: + description: |- + DegradedThreshold is a finite nonnegative decimal string in the input + unit. Rates below it, or durations above it, are degraded. Omit to + disable alerts. A string keeps JSON/schema handling portable. + maxLength: 16 + pattern: ^(0|[1-9][0-9]{0,8})(\.[0-9]{1,6})?$ + type: string + signal: + enum: + - tick_rate + - tick_duration + - frame_rate + type: string + unit: + enum: + - per_second + - seconds + - milliseconds + type: string + required: + - signal + - unit + type: object + x-kubernetes-validations: + - message: duration requires seconds/milliseconds; rates + require per_second + rule: 'self.signal == ''tick_duration'' ? self.unit + in [''seconds'', ''milliseconds''] : self.unit == + ''per_second''' regex: description: |- Regex extracts the value via the named group "value", e.g. @@ -2113,6 +2148,12 @@ spec: minimum: 0 type: integer type: object + sentinelIsolation: + description: |- + SentinelIsolation restricts a wake sentinel to its own server's advertised + ports. Templates opt in after module connectivity validation; absent or + false retains the legacy namespace-wide sentinel allowances. + type: boolean storage: description: Storage describes the default persistent storage layout. properties: diff --git a/operator/config/crd/gameplane.local_modules.yaml b/operator/config/crd/gameplane.local_modules.yaml index 3f35114f9..9c06cec82 100644 --- a/operator/config/crd/gameplane.local_modules.yaml +++ b/operator/config/crd/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_modulesources.yaml b/operator/config/crd/gameplane.local_modulesources.yaml index 879583947..a65b30cdd 100644 --- a/operator/config/crd/gameplane.local_modulesources.yaml +++ b/operator/config/crd/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: group: gameplane.local diff --git a/operator/config/crd/gameplane.local_networkcaptures.yaml b/operator/config/crd/gameplane.local_networkcaptures.yaml index 76bc066a7..40bd55ecf 100644 --- a/operator/config/crd/gameplane.local_networkcaptures.yaml +++ b/operator/config/crd/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: group: gameplane.local @@ -208,22 +208,15 @@ spec: format: int64 type: integer phase: - allOf: - - enum: - - Pending - - Running - - Completed - - Failed - - Expired - - enum: - - Pending - - Running - - Completed - - Failed - - Expired description: |- Phase is the current lifecycle phase of the capture. Valid values: Pending, Running, Completed, Failed, Expired. + enum: + - Pending + - Running + - Completed + - Failed + - Expired type: string startTime: description: |- diff --git a/operator/config/crd/gameplane.local_restores.yaml b/operator/config/crd/gameplane.local_restores.yaml index 7c8a9e530..4984f6817 100644 --- a/operator/config/crd/gameplane.local_restores.yaml +++ b/operator/config/crd/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: group: gameplane.local diff --git a/operator/hack/condition-ids/main.go b/operator/hack/condition-ids/main.go index e644cf041..cf7502c17 100644 --- a/operator/hack/condition-ids/main.go +++ b/operator/hack/condition-ids/main.go @@ -121,8 +121,11 @@ var rows = []row{ {types: []string{"AddressAssignment"}, reason: "Assigned", english: "{assignedFrom, select, pool {Address {address} assigned from pool ''{pool}''.} other {Address {address} assigned.}}", description: "AddressAssignment condition once an address is assigned. assignedFrom is pool or default."}, // GameServer: tunnel and other + {types: []string{"TunnelReady"}, reason: "UnsupportedProtocol", english: "Tailscale forwards only advertised TCP and UDP ports. Remove unsupported protocols from the advertised ports.", description: "TunnelReady condition when a Tailscale template advertises a protocol other than TCP or UDP."}, {types: []string{"TunnelReady"}, reason: "InvalidConfig", english: "{provider, select, none {The tunnel is enabled but the server has no tunnel configuration.} other {The ''{provider}'' tunnel provider is selected but its configuration is missing.}}", description: "TunnelReady condition for an incomplete tunnel setup. provider is the selected provider, or none."}, {types: []string{"TunnelReady"}, reason: "DeploymentNotReady", english: "Waiting for the tunnel deployment to become ready.", description: "TunnelReady condition while the tunnel pod is not ready."}, + {types: []string{"TunnelReady"}, reason: "RelayConnectionUnknown", english: "The relay connection cannot be confirmed. Check the provider's connection status and the tunnel deployment's readiness probe.", description: "TunnelReady condition when provider connection evidence is missing or unsupported."}, + {types: []string{"TunnelReady"}, reason: "AwaitingAddress", english: "Waiting for the provider to assign a tunnel endpoint; the relay connection is unconfirmed.", description: "TunnelReady condition before Playit assigns an endpoint."}, {types: []string{"TunnelReady"}, reason: "PortNotMapped", english: "No frp remote port is configured for: {ports}.", description: "TunnelReady condition for frp when advertised ports have no remote port. ports is a comma-separated list of port names."}, {types: []string{"TunnelReady"}, reason: "NoCredentials", english: "No credentials secret was provided, so the ''{provider}'' tunnel cannot authenticate.", description: "TunnelReady condition when a tunnel provider needs credentials that are missing."}, {types: []string{"TunnelReady"}, reason: "Ready", english: "{provider, select, playit {The tunnel deployment is ready. Waiting for the playit endpoint assignment.} tailscale {The tunnel deployment is ready and authenticated.} other {The tunnel deployment is ready and all ports are mapped.}}", description: "TunnelReady condition when the tunnel works."}, diff --git a/operator/internal/controller/auxiliary_resources.go b/operator/internal/controller/auxiliary_resources.go new file mode 100644 index 000000000..8f7bc7901 --- /dev/null +++ b/operator/internal/controller/auxiliary_resources.go @@ -0,0 +1,65 @@ +package controller + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/validation" +) + +// ParseAuxiliaryResources parses an install-level resource override. An absent +// flag returns nil (use defaults); an explicit object, including {}, replaces +// defaults wholesale. Reject typos before the operator starts creating pods. +func ParseAuxiliaryResources(value string) (*corev1.ResourceRequirements, error) { + if value == "" { + return nil, nil + } + var configured *corev1.ResourceRequirements + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&configured); err != nil { + return nil, fmt.Errorf("decode resources: %w", err) + } + if configured == nil { + return nil, fmt.Errorf("resources must be a JSON object; use {} to clear defaults") + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("resources must contain exactly one JSON object") + } + if len(configured.Claims) != 0 { + return nil, fmt.Errorf("resource claims are not supported for auxiliary containers") + } + for _, values := range []corev1.ResourceList{configured.Requests, configured.Limits} { + for name, quantity := range values { + if len(validation.IsQualifiedName(string(name))) != 0 || quantity.Sign() < 0 { + return nil, fmt.Errorf("invalid resource %q or negative quantity", name) + } + } + } + for name, request := range configured.Requests { + if limit, exists := configured.Limits[name]; exists && request.Cmp(limit) > 0 { + return nil, fmt.Errorf("request for %s exceeds its limit", name) + } + } + return configured, nil +} + +func auxiliaryResources(configured *corev1.ResourceRequirements, cpu, memory string) corev1.ResourceRequirements { + if configured != nil { + return *configured.DeepCopy() + } + return corev1.ResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(cpu), corev1.ResourceMemory: resource.MustParse(memory), + }} +} + +func optionalResources(values []*corev1.ResourceRequirements) *corev1.ResourceRequirements { + if len(values) == 0 { + return nil + } + return values[0] +} diff --git a/operator/internal/controller/auxiliary_resources_test.go b/operator/internal/controller/auxiliary_resources_test.go new file mode 100644 index 000000000..f63cef5f8 --- /dev/null +++ b/operator/internal/controller/auxiliary_resources_test.go @@ -0,0 +1,105 @@ +package controller + +import ( + "reflect" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" +) + +func TestAuxiliaryResourceConfiguration(t *testing.T) { + for _, bad := range []string{"null", `{"request":{"cpu":"1"}}`, `{"requests":{"cpu":"-1"}}`, `{"requests":{"memory":"128Mi"},"limits":{"memory":"64Mi"}}`, `{} {}`} { + if _, err := ParseAuxiliaryResources(bad); err == nil { + t.Errorf("accepted invalid resources %s", bad) + } + } + unset, err := ParseAuxiliaryResources("") + if err != nil || unset != nil { + t.Fatalf("absent configuration: %v %v", unset, err) + } + cleared, err := ParseAuxiliaryResources(`{"requests":{},"limits":{}}`) + if err != nil || cleared == nil { + t.Fatalf("explicit clear: %v %v", cleared, err) + } + if got := auxiliaryResources(cleared, "25m", "64Mi"); len(got.Requests) != 0 || len(got.Limits) != 0 { + t.Fatal("clear restored default reservations") + } + defaults := auxiliaryResources(nil, "25m", "64Mi") + if defaults.Requests.Cpu().String() != "25m" || defaults.Requests.Memory().String() != "64Mi" || len(defaults.Limits) != 0 { + t.Fatalf("unexpected scheduling defaults: %+v", defaults) + } +} + +func TestAuxiliaryResourcesReachAgentAndResticContainers(t *testing.T) { + configured := &corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("73m")}} + gs := &gameplanev1alpha1.GameServer{} + tmpl := &gameplanev1alpha1.GameTemplate{} + agent := buildAgentContainer(gs, tmpl, nil, "agent", "", "", configured) + if !reflect.DeepEqual(agent.Resources, *configured) { + t.Fatalf("agent lost install resources: %+v", agent.Resources) + } + tmpl.Spec.Agent = &gameplanev1alpha1.AgentSpec{Image: "custom-agent"} + agent = buildAgentContainer(gs, tmpl, nil, "agent", "", "", configured) + if !reflect.DeepEqual(agent.Resources, *configured) { + t.Fatal("image-only template override removed resource defaults") + } + tmpl.Spec.Agent.Resources = corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("91Mi")}} + agent = buildAgentContainer(gs, tmpl, nil, "agent", "", "", configured) + if !reflect.DeepEqual(agent.Resources, tmpl.Spec.Agent.Resources) { + t.Fatal("template agent resources did not replace the install defaults") + } + b := &gameplanev1alpha1.Backup{Spec: gameplanev1alpha1.BackupSpec{RepoRef: &gameplanev1alpha1.SecretKeySelector{Name: "repo"}}} + br := &BackupReconciler{BackupResources: configured} + backup := br.buildBackupPodSpec(b, tmpl) + forget := br.buildForgetPodSpec(b) + rr := &RestoreReconciler{BackupResources: configured} + restore := rr.buildRestorePodSpec(&gameplanev1alpha1.Restore{}, b, tmpl) + for _, container := range append(append(append(backup.Containers, backup.InitContainers...), forget.Containers...), restore.Containers...) { + if !reflect.DeepEqual(container.Resources, *configured) { + t.Errorf("container %s lost resource configuration: %+v", container.Name, container.Resources) + } + } + if init := buildConfigInitContainer("init", tmpl, configured); !reflect.DeepEqual(init.Resources, *configured) { + t.Fatal("config init lost install resource configuration") + } + // Builders must not return the shared maps: one container's customization + // cannot mutate the defaults for another server or operation. + agent.Resources.Requests[corev1.ResourceMemory] = resource.MustParse("1Mi") + if tmpl.Spec.Agent.Resources.Requests.Memory().String() != "91Mi" { + t.Fatal("agent resources alias the template") + } +} + +func TestAuxiliaryDeploymentResourceOverrideAndRemoval(t *testing.T) { + s := wipeScheme(t) + gs := &gameplanev1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "games", UID: "server-uid"}, Spec: gameplanev1alpha1.GameServerSpec{Networking: gameplanev1alpha1.GameServerNetworking{Tunnel: &gameplanev1alpha1.GameServerTunnel{Enabled: true, Provider: "frp", Frp: &gameplanev1alpha1.FrpTunnelSpec{ServerAddr: "relay.example"}}}}} + c := fake.NewClientBuilder().WithScheme(s).WithObjects(gs).Build() + r := &GameServerReconciler{Client: c, Scheme: s} + custom := &corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("81m")}} + for _, configured := range []*corev1.ResourceRequirements{nil, custom, {}} { + r.SentinelResources, r.TunnelResources = configured, configured + if err := r.reconcileSentinel(t.Context(), gs, singlePortTemplate(true, "minecraft"), true); err != nil { + t.Fatal(err) + } + if err := r.reconcileTunnel(t.Context(), gs, singlePortTemplate(true, "minecraft"), true); err != nil { + t.Fatal(err) + } + for _, component := range []struct{ suffix, memory string }{{"waker", "32Mi"}, {"tunnel", "64Mi"}} { + var dep appsv1.Deployment + if err := c.Get(t.Context(), client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-" + component.suffix}, &dep); err != nil { + t.Fatal(err) + } + want := auxiliaryResources(configured, "25m", component.memory) + if !reflect.DeepEqual(dep.Spec.Template.Spec.Containers[0].Resources, want) { + t.Fatalf("%s did not apply/clear resources", component.suffix) + } + } + } +} diff --git a/operator/internal/controller/backup_controller.go b/operator/internal/controller/backup_controller.go index b350a5448..3e5a82d2b 100644 --- a/operator/internal/controller/backup_controller.go +++ b/operator/internal/controller/backup_controller.go @@ -119,6 +119,9 @@ type BackupReconciler struct { // operator flag so air-gapped installs can point it at a private // registry mirror. Empty falls back to DefaultResticImage. ResticImage string + // BackupResources replaces resources for backup/init and repository cleanup + // workers. Nil uses defaults; an explicit empty object clears them. + BackupResources *corev1.ResourceRequirements // JobBackoffLimit / JobActiveDeadlineSeconds bound the restic backup Job. // Set from operator flags; nil / zero fall back to // DefaultBackupJobBackoffLimit / DefaultBackupJobActiveDeadlineSeconds. @@ -965,9 +968,10 @@ func (r *BackupReconciler) buildBackupPodSpec(b *gameplanev1alpha1.Backup, tmpl return corev1.PodSpec{ SecurityContext: buildBackupPodSecurityContext(tmpl), InitContainers: []corev1.Container{{ - Name: "restic-init", - Image: resticImageOrDefault(r.ResticImage), - Command: []string{"/bin/sh", "-c"}, + Name: "restic-init", + Image: resticImageOrDefault(r.ResticImage), + Resources: auxiliaryResources(r.BackupResources, "100m", "128Mi"), + Command: []string{"/bin/sh", "-c"}, // `cat config` is the canonical "does the repo exist" probe; // `init` is idempotent against an empty target. Args: []string{"restic cat config --no-lock >/dev/null 2>&1 || restic init"}, @@ -978,10 +982,11 @@ func (r *BackupReconciler) buildBackupPodSpec(b *gameplanev1alpha1.Backup, tmpl SecurityContext: containerSec, }}, Containers: []corev1.Container{{ - Name: "restic", - Image: resticImageOrDefault(r.ResticImage), - Args: args, - Env: env, + Name: "restic", + Image: resticImageOrDefault(r.ResticImage), + Resources: auxiliaryResources(r.BackupResources, "100m", "128Mi"), + Args: args, + Env: env, VolumeMounts: []corev1.VolumeMount{ {Name: "data", MountPath: "/data", ReadOnly: true}, {Name: "cache", MountPath: "/tmp"}, diff --git a/operator/internal/controller/backup_forget.go b/operator/internal/controller/backup_forget.go index 86f1c53cf..2d97d9410 100644 --- a/operator/internal/controller/backup_forget.go +++ b/operator/internal/controller/backup_forget.go @@ -420,6 +420,12 @@ func forgetJobName(backupName string) string { func (r *BackupReconciler) buildForgetPodSpec(b *gameplanev1alpha1.Backup) corev1.PodSpec { nonRoot := true uid := int64(65532) + resources := auxiliaryResources(r.BackupResources, "50m", "128Mi") + if r.BackupResources == nil { + // Preserve the existing bounded repository-cleanup default. Explicit + // install overrides replace it along with other restic worker resources. + resources.Limits = corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("512Mi")} + } env := resticEnv(b.Spec.RepoRef.Name) env = append(env, corev1.EnvVar{Name: forgetSnapshotIDEnv, Value: b.Status.SnapshotID}) @@ -433,20 +439,12 @@ func (r *BackupReconciler) buildForgetPodSpec(b *gameplanev1alpha1.Backup) corev SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, }, Containers: []corev1.Container{{ - Name: "restic", - Image: resticImageOrDefault(r.ResticImage), - Command: []string{"/bin/sh", "-c"}, - Args: []string{forgetScript}, - Env: env, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("50m"), - corev1.ResourceMemory: resource.MustParse("128Mi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("512Mi"), - }, - }, + Name: "restic", + Image: resticImageOrDefault(r.ResticImage), + Command: []string{"/bin/sh", "-c"}, + Args: []string{forgetScript}, + Env: env, + Resources: resources, VolumeMounts: []corev1.VolumeMount{ {Name: "cache", MountPath: "/tmp"}, }, diff --git a/operator/internal/controller/gameserver_controller.go b/operator/internal/controller/gameserver_controller.go index 67e8be4e3..3389a4c2b 100644 --- a/operator/internal/controller/gameserver_controller.go +++ b/operator/internal/controller/gameserver_controller.go @@ -68,6 +68,12 @@ type GameServerReconciler struct { // to info and existing StatefulSets don't roll on operator upgrade. AgentLogLevel string + // Auxiliary resources are complete install-level overrides. Nil uses the + // built-in requests; a non-nil empty object explicitly clears them. + AgentResources *corev1.ResourceRequirements + SentinelResources *corev1.ResourceRequirements + TunnelResources *corev1.ResourceRequirements + // ConfigInitImage is the image for the init container that copies // operator-rendered config files onto the data volume. Set from an // operator flag so air-gapped installs can point it at a private @@ -298,6 +304,9 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) logger.Error(err, "reconcile ingress NetworkPolicy") return ctrl.Result{}, err } + if err := r.reconcileSentinelIsolation(ctx, &gs, &tmpl); err != nil { + return ctrl.Result{}, err + } if err := r.reconcileAgentTLS(ctx, &gs); err != nil { logger.Error(err, "reconcile agent TLS") return ctrl.Result{}, err @@ -1494,7 +1503,13 @@ func (r *GameServerReconciler) reconcileStatefulSet( ss.Spec.Replicas = &actualReplicas ss.Spec.ServiceName = gs.Name ss.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels} - ss.Spec.Template.Labels = labels + ss.Spec.Template.Labels = make(map[string]string, len(labels)+1) + for key, value := range labels { + ss.Spec.Template.Labels[key] = value + } + if tmpl.Spec.SentinelIsolation { + ss.Spec.Template.Labels[sentinelIsolationLabel] = "true" + } // Stamp (or clear) the config fingerprint without touching // annotations other actors may have set on the pod template. ann := ss.Spec.Template.Annotations @@ -1509,7 +1524,7 @@ func (r *GameServerReconciler) reconcileStatefulSet( ss.Spec.Template.Annotations = ann ss.Spec.Template.Spec.Containers = []corev1.Container{ buildGameContainer(gs, tmpl, image, ver, mc), - buildAgentContainer(gs, tmpl, ver, r.AgentImage, r.AgentLogLevel, r.AgentImagePullPolicy), + buildAgentContainer(gs, tmpl, ver, r.AgentImage, r.AgentLogLevel, r.AgentImagePullPolicy, r.AgentResources), } volumes := []corev1.Volume{ { @@ -1569,7 +1584,7 @@ func (r *GameServerReconciler) reconcileStatefulSet( }, }, }) - ss.Spec.Template.Spec.InitContainers = []corev1.Container{buildConfigInitContainer(r.ConfigInitImage, tmpl)} + ss.Spec.Template.Spec.InitContainers = []corev1.Container{buildConfigInitContainer(r.ConfigInitImage, tmpl, r.AgentResources)} } else { ss.Spec.Template.Spec.InitContainers = nil } @@ -1983,7 +1998,7 @@ func configInitImageOrDefault(image string) string { // When the template sets security.fsGroup, config-init makes each file // and directory it copied group-writable so a non-root game sharing that // fsGroup can update them. -func buildConfigInitContainer(image string, tmpl *gameplanev1alpha1.GameTemplate) corev1.Container { +func buildConfigInitContainer(image string, tmpl *gameplanev1alpha1.GameTemplate, defaults ...*corev1.ResourceRequirements) corev1.Container { image = configInitImageOrDefault(image) mountPath := effectiveMountPath(tmpl) cpCmd := "cp -RL " + configFilesStagingPath + "/* '" + mountPath + "/'" @@ -1996,9 +2011,10 @@ func buildConfigInitContainer(image string, tmpl *gameplanev1alpha1.GameTemplate } return corev1.Container{ - Name: "config-init", - Image: image, - Command: []string{"/bin/sh", "-c"}, + Name: "config-init", + Image: image, + Resources: auxiliaryResources(optionalResources(defaults), "25m", "64Mi"), + Command: []string{"/bin/sh", "-c"}, // -L dereferences the kubelet's per-key symlinks; the * glob // skips the ..data/.. dot-entries of the Secret mount. Args: []string{cpCmd}, @@ -2242,14 +2258,17 @@ func gamePodSecurityContext(tmpl *gameplanev1alpha1.GameTemplate) *corev1.PodSec func buildAgentContainer( gs *gameplanev1alpha1.GameServer, tmpl *gameplanev1alpha1.GameTemplate, ver *gameplanev1alpha1.GameVersion, fallbackImage, logLevel, pullPolicy string, + defaults ...*corev1.ResourceRequirements, ) corev1.Container { image := fallbackImage - res := corev1.ResourceRequirements{} + res := auxiliaryResources(optionalResources(defaults), "25m", "64Mi") if tmpl.Spec.Agent != nil { if tmpl.Spec.Agent.Image != "" { image = tmpl.Spec.Agent.Image } - res = tmpl.Spec.Agent.Resources + if tmpl.Spec.Agent.Resources.Requests != nil || tmpl.Spec.Agent.Resources.Limits != nil || len(tmpl.Spec.Agent.Resources.Claims) != 0 { + res = *tmpl.Spec.Agent.Resources.DeepCopy() + } } mountPath := effectiveMountPath(tmpl) nonRoot := true diff --git a/operator/internal/controller/gameserver_relay_health_test.go b/operator/internal/controller/gameserver_relay_health_test.go new file mode 100644 index 000000000..edbce76a0 --- /dev/null +++ b/operator/internal/controller/gameserver_relay_health_test.go @@ -0,0 +1,126 @@ +package controller + +import ( + "reflect" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" +) + +func TestRelayDeploymentIdentityProbesAndResourceRemoval(t *testing.T) { + t.Parallel() + s := wipeScheme(t) + for _, identity := range []struct{ namespace, uid string }{{"one", "uid-one"}, {"two", "uid-two"}, {"one", "replacement-uid"}} { + gs := &gameplanev1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "same", Namespace: identity.namespace, UID: types.UID(identity.uid)}, Spec: gameplanev1alpha1.GameServerSpec{Networking: gameplanev1alpha1.GameServerNetworking{Tunnel: &gameplanev1alpha1.GameServerTunnel{Enabled: true, Provider: "frp", Frp: &gameplanev1alpha1.FrpTunnelSpec{ServerAddr: "relay.example"}}}}} + c := fake.NewClientBuilder().WithScheme(s).WithObjects(gs).Build() + r := &GameServerReconciler{Client: c, Scheme: s} + for _, resources := range []*corev1.ResourceRequirements{nil, {Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("90m")}, Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("128Mi")}}, {}, nil} { + r.TunnelResources = resources + if err := r.reconcileTunnel(t.Context(), gs, singlePortTemplate(true, "minecraft"), true); err != nil { + t.Fatal(err) + } + var dep appsv1.Deployment + if err := c.Get(t.Context(), client.ObjectKey{Namespace: gs.Namespace, Name: gs.Name + "-tunnel"}, &dep); err != nil { + t.Fatal(err) + } + if dep.Spec.Strategy.Type != appsv1.RecreateDeploymentStrategyType || dep.Spec.Strategy.RollingUpdate != nil { + t.Fatal("relay permits overlapping identities") + } + container := dep.Spec.Template.Spec.Containers[0] + metricsPorts := 0 + for _, port := range container.Ports { + if port.Name == "relay-metrics" && port.ContainerPort == 8091 { + metricsPorts++ + } + } + if metricsPorts != 1 { + t.Fatal("tunnel metrics port missing or duplicated") + } + if container.StartupProbe == nil || container.StartupProbe.HTTPGet == nil || container.StartupProbe.HTTPGet.Path != "/healthz" || !hasRelayReadinessProbe(&dep) || container.LivenessProbe != nil { + t.Fatal("provider outage must affect readiness without restarting the supervisor") + } + if !reflect.DeepEqual(container.Resources, auxiliaryResources(resources, "25m", "64Mi")) { + t.Fatal("resource defaults or override removal changed") + } + found := false + for _, env := range container.Env { + if env.Name == "GAMESERVER_UID" { + found = env.Value == identity.uid + } + } + if !found { + t.Fatal("UID identity missing or unstable") + } + } + } +} + +func TestRelayConditionsRequireCurrentProviderProbe(t *testing.T) { + t.Parallel() + s := wipeScheme(t) + gs := &gameplanev1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "relay", Namespace: "games", UID: "uid"}, Spec: gameplanev1alpha1.GameServerSpec{Networking: gameplanev1alpha1.GameServerNetworking{Tunnel: &gameplanev1alpha1.GameServerTunnel{Provider: "frp", Frp: &gameplanev1alpha1.FrpTunnelSpec{ServerAddr: "relay"}}}}} + c := fake.NewClientBuilder().WithScheme(s).WithObjects(gs).Build() + r := &GameServerReconciler{Client: c, Scheme: s} + if err := r.reconcileTunnel(t.Context(), gs, singlePortTemplate(true, "minecraft"), true); err != nil { + t.Fatal(err) + } + var dep appsv1.Deployment + if err := c.Get(t.Context(), client.ObjectKey{Namespace: gs.Namespace, Name: "relay-tunnel"}, &dep); err != nil { + t.Fatal(err) + } + dep.Generation = 2 + dep.Status = appsv1.DeploymentStatus{ReadyReplicas: 1, UpdatedReplicas: 1, ObservedGeneration: 2} + assert := func(want metav1.ConditionStatus) { + t.Helper() + cond := meta.FindStatusCondition(computeTunnelConditions(gs, tunnelPlan{wantTunnel: true}, &dep), "TunnelReady") + if cond == nil || cond.Status != want { + t.Fatalf("condition=%+v want=%s", cond, want) + } + } + assert(metav1.ConditionTrue) + dep.Status.ObservedGeneration = 1 + assert(metav1.ConditionUnknown) + dep.Status.ObservedGeneration = 2 + dep.Status.ReadyReplicas = 0 + assert(metav1.ConditionFalse) + dep.Status.ReadyReplicas = 1 + dep.Spec.Template.Spec.Containers[0].ReadinessProbe = nil + assert(metav1.ConditionUnknown) +} + +func TestPlayitAssignmentDoesNotImplyRelayReady(t *testing.T) { + t.Parallel() + gs := &gameplanev1alpha1.GameServer{Spec: gameplanev1alpha1.GameServerSpec{Networking: gameplanev1alpha1.GameServerNetworking{Tunnel: &gameplanev1alpha1.GameServerTunnel{Provider: "playit", Playit: &gameplanev1alpha1.PlayitTunnelSpec{}, CredentialsSecretRef: &gameplanev1alpha1.SecretNameRef{Name: "relay-credentials"}}}}} + dep := tunnelReadyDeployment() + // A legacy deployment without the provider probe remains unknown even + // when its Pod is Ready; assignment alone cannot bypass that requirement. + legacy := meta.FindStatusCondition(computeTunnelConditions(gs, tunnelPlan{wantTunnel: true}, dep), "TunnelReady") + if legacy == nil || legacy.Status != metav1.ConditionUnknown || legacy.Reason != "RelayConnectionUnknown" { + t.Fatalf("legacy playit readiness=%+v, want unknown connection", legacy) + } + dep.Generation = 1 + dep.Status.ObservedGeneration = 1 + dep.Status.UpdatedReplicas = 1 + dep.Spec.Template.Spec.Containers = []corev1.Container{{Name: "tunnel", ReadinessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/readyz", Port: intstr.FromInt32(8091)}}}}} + for _, endpoints := range [][]gameplanev1alpha1.GameServerEndpoint{nil, {{Name: "game", Host: "example.playit.gg", Port: 30000}}} { + gs.Status.TunnelEndpoints = endpoints + cond := meta.FindStatusCondition(computeTunnelConditions(gs, tunnelPlan{wantTunnel: true}, dep), "TunnelReady") + wantStatus, wantReason := metav1.ConditionUnknown, "RelayConnectionUnknown" + if len(endpoints) == 0 { + wantStatus, wantReason = metav1.ConditionFalse, "AwaitingAddress" + } + if cond == nil || cond.Status != wantStatus || cond.Reason != wantReason { + t.Fatalf("playit readiness=%+v, want %s/%s", cond, wantStatus, wantReason) + } + } +} diff --git a/operator/internal/controller/gameserver_sentinel.go b/operator/internal/controller/gameserver_sentinel.go index b2c8a9edc..c2549fc75 100644 --- a/operator/internal/controller/gameserver_sentinel.go +++ b/operator/internal/controller/gameserver_sentinel.go @@ -230,6 +230,9 @@ func (r *GameServerReconciler) reconcileSentinel( sentinelLabel: sentinelValue, "app.kubernetes.io/instance": gs.Name, } + if tmpl.Spec.SentinelIsolation { + dep.Spec.Template.Labels[sentinelIsolationLabel] = "true" + } // Container ports: advertised ports only, mirroring buildGameContainer. ports := make([]corev1.ContainerPort, 0) @@ -278,8 +281,9 @@ func (r *GameServerReconciler) reconcileSentinel( dep.Spec.Template.Spec.ServiceAccountName = sentinelSAName(gs) dep.Spec.Template.Spec.Containers = []corev1.Container{{ - Name: "sentinel", - Image: image, + Name: "sentinel", + Image: image, + Resources: auxiliaryResources(r.SentinelResources, "25m", "32Mi"), Env: []corev1.EnvVar{ {Name: "GAMESERVER_NAME", Value: gs.Name}, {Name: "GAMESERVER_NAMESPACE", Value: gs.Namespace}, diff --git a/operator/internal/controller/gameserver_status.go b/operator/internal/controller/gameserver_status.go index 54393de0b..19ecbe8d4 100644 --- a/operator/internal/controller/gameserver_status.go +++ b/operator/internal/controller/gameserver_status.go @@ -260,8 +260,19 @@ func (r *GameServerReconciler) reconcileStatus( // Connection card reads the address players actually use. if svcExists { gs.Status.Endpoints = endpointsFromService(&svc, addrPlan) + } else { + // A deleted/replacing Service must not preserve a stale tailnet endpoint + // when the forwarding deployment has since lost readiness. + var retained []gameplanev1alpha1.GameServerEndpoint + for _, endpoint := range gs.Status.Endpoints { + if endpoint.TunnelProvider != "tailscale" { + retained = append(retained, endpoint) + } + } + gs.Status.Endpoints = retained } - if tunnelPlan.wantTunnel && len(tunnelPlan.endpoints) > 0 { + if tunnelPlan.wantTunnel && len(tunnelPlan.endpoints) > 0 && + (gs.Spec.Networking.Tunnel.Provider != "tailscale" || meta.IsStatusConditionTrue(gs.Status.Conditions, "TunnelReady")) { gs.Status.Endpoints = append(tunnelPlan.endpoints, gs.Status.Endpoints...) } if phase == gameplanev1alpha1.GameServerPhaseRunning { @@ -1105,6 +1116,21 @@ func computeTunnelConditions( } // Tunnel Deployment not yet created or not ready. + if tunnel.Provider == "tailscale" && len(plan.unsupportedPorts) > 0 { + tunnelReady.Status = metav1.ConditionFalse + tunnelReady.Reason = "UnsupportedProtocol" + tunnelReady.Message = "Tailscale supports only TCP and UDP; unsupported advertised ports: " + strings.Join(plan.unsupportedPorts, ", ") + return upsertCondition(conds, tunnelReady) + } + if tunnel.Provider == "playit" && (tunnel.Playit == nil || tunnel.CredentialsSecretRef == nil || tunnel.CredentialsSecretRef.Name == "") { + tunnelReady.Status = metav1.ConditionFalse + tunnelReady.Reason = "NoCredentials" + tunnelReady.Message = "playit configuration and credentials are required before relay connection can be confirmed" + if tunnel.Playit == nil { + tunnelReady.Reason = "InvalidConfig" + } + return upsertCondition(conds, tunnelReady) + } if tunnelDep == nil { tunnelReady.Status = metav1.ConditionFalse tunnelReady.Reason = "DeploymentNotReady" @@ -1112,11 +1138,30 @@ func computeTunnelConditions( conds = upsertCondition(conds, tunnelReady) return conds } + if tunnel.Provider == "frp" || tunnel.Provider == "playit" { + if !hasRelayReadinessProbe(tunnelDep) || tunnelDep.Status.ObservedGeneration < tunnelDep.Generation || tunnelDep.Status.UpdatedReplicas == 0 { + tunnelReady.Status = metav1.ConditionUnknown + tunnelReady.Reason = "RelayConnectionUnknown" + tunnelReady.Message = "waiting for the current relay deployment and its connection readiness probe" + return upsertCondition(conds, tunnelReady) + } + if tunnel.Provider == "playit" { + tunnelReady.Status = metav1.ConditionUnknown + tunnelReady.Reason = "RelayConnectionUnknown" + tunnelReady.Message = "playit v1.0.10 IPC reports cached assignment but cannot confirm relay connection; check provider connectivity separately" + if len(gs.Status.TunnelEndpoints) == 0 { + tunnelReady.Status = metav1.ConditionFalse + tunnelReady.Reason = "AwaitingAddress" + tunnelReady.Message = "waiting for playit endpoint assignment; relay connection is unconfirmed" + } + return upsertCondition(conds, tunnelReady) + } + } if tunnelDep.Status.ReadyReplicas == 0 { tunnelReady.Status = metav1.ConditionFalse tunnelReady.Reason = "DeploymentNotReady" - tunnelReady.Message = "tunnel deployment has no ready replicas" + tunnelReady.Message = "tunnel deployment has no ready replicas; relay connection or forward registration is unconfirmed" conds = upsertCondition(conds, tunnelReady) return conds } @@ -1147,7 +1192,7 @@ func computeTunnelConditions( // All endpoints known: deployment ready and mappings complete. tunnelReady.Status = metav1.ConditionTrue tunnelReady.Reason = "Ready" - tunnelReady.Message = "tunnel deployment ready and all ports mapped" + tunnelReady.Message = "relay connection and all configured frp forward registrations confirmed" case "tailscale": if tunnel.Tailscale == nil { @@ -1167,37 +1212,23 @@ func computeTunnelConditions( return conds } - // Deployment is ready and credentials are configured. - tunnelReady.Status = metav1.ConditionTrue - tunnelReady.Reason = "Ready" - tunnelReady.Message = "tunnel deployment ready and authenticated" - - case "playit": - if tunnel.Playit == nil { - tunnelReady.Status = metav1.ConditionFalse - tunnelReady.Reason = "InvalidConfig" - tunnelReady.Message = "playit provider selected but no playit config" - conds = upsertCondition(conds, tunnelReady) - return conds + // A legacy image merely registering a device cannot satisfy readiness. + forwardingProbe := false + for _, container := range tunnelDep.Spec.Template.Spec.Containers { + if container.Name == "tunnel" && container.ReadinessProbe != nil && container.ReadinessProbe.HTTPGet != nil { + probe := container.ReadinessProbe.HTTPGet + forwardingProbe = probe.Path == "/readyz" && probe.Port.IntValue() == 8091 + } } - - // Check if credentials are provided (playit requires authentication). - if tunnel.CredentialsSecretRef == nil { + if !forwardingProbe || tunnelDep.Status.ObservedGeneration < tunnelDep.Generation || tunnelDep.Status.UpdatedReplicas == 0 { tunnelReady.Status = metav1.ConditionFalse - tunnelReady.Reason = "NoCredentials" - tunnelReady.Message = "no credentials secret provided; playit requires authentication" - conds = upsertCondition(conds, tunnelReady) - return conds + tunnelReady.Reason = "DeploymentNotReady" + tunnelReady.Message = "waiting for the Tailscale forwarding deployment and its readiness probe" + return upsertCondition(conds, tunnelReady) } - - // Deployment is ready; playit endpoint will arrive asynchronously. - // Report AwaitingAddress while the tunnel pod hasn't yet reported an address. - // This is optimistic: the condition flips based on whether the tunnel pod - // has written to status.tunnelEndpoint. The agent or operator reconcile must - // populate it separately. tunnelReady.Status = metav1.ConditionTrue tunnelReady.Reason = "Ready" - tunnelReady.Message = "tunnel deployment ready; waiting for playit endpoint assignment" + tunnelReady.Message = "tailnet online and all advertised TCP/UDP forwarding listeners active; client access remains subject to tailnet policy" default: tunnelReady.Status = metav1.ConditionFalse @@ -1209,6 +1240,16 @@ func computeTunnelConditions( return conds } +func hasRelayReadinessProbe(dep *appsv1.Deployment) bool { + for _, container := range dep.Spec.Template.Spec.Containers { + if container.Name == "tunnel" && container.ReadinessProbe != nil && container.ReadinessProbe.HTTPGet != nil { + probe := container.ReadinessProbe.HTTPGet + return probe.Path == "/readyz" && probe.Port.IntValue() == 8091 + } + } + return false +} + // removeCondition removes a condition by type from the list, or returns the list unchanged. func removeCondition(conds []metav1.Condition, condType string) []metav1.Condition { out := make([]metav1.Condition, 0, len(conds)) diff --git a/operator/internal/controller/gameserver_tailscale_test.go b/operator/internal/controller/gameserver_tailscale_test.go new file mode 100644 index 000000000..1653fccf2 --- /dev/null +++ b/operator/internal/controller/gameserver_tailscale_test.go @@ -0,0 +1,48 @@ +package controller + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" +) + +func TestTailscaleAdvertisedProtocolsAndReadiness(t *testing.T) { + tmpl := &gameplanev1alpha1.GameTemplate{Spec: gameplanev1alpha1.GameTemplateSpec{Ports: []gameplanev1alpha1.GamePort{ + {Name: "game", ContainerPort: 25565, Advertise: true}, + {Name: "query", ContainerPort: 19132, Protocol: corev1.ProtocolUDP, Advertise: true}, + {Name: "rcon", ContainerPort: 25575, Protocol: corev1.ProtocolTCP}, + }}} + if got := buildTailscalePortsConfig(tmpl); got != "game:25565:tcp,query:19132:udp" { + t.Fatalf("advertised mapping: %s", got) + } + gs := &gameplanev1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "game", Namespace: "games"}, Spec: gameplanev1alpha1.GameServerSpec{Networking: gameplanev1alpha1.GameServerNetworking{Tunnel: &gameplanev1alpha1.GameServerTunnel{Enabled: true, Provider: "tailscale", Tailscale: &gameplanev1alpha1.TailscaleTunnelSpec{}, CredentialsSecretRef: &gameplanev1alpha1.SecretNameRef{Name: "auth"}}}}} + r := &GameServerReconciler{} + plan := r.planTunnel(context.Background(), gs, tmpl) + dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Generation: 2}, Status: appsv1.DeploymentStatus{ReadyReplicas: 1, UpdatedReplicas: 1, ObservedGeneration: 2}} + assert := func(want metav1.ConditionStatus) { + t.Helper() + c := meta.FindStatusCondition(computeTunnelConditions(gs, plan, dep), "TunnelReady") + if c == nil || c.Status != want { + t.Fatalf("TunnelReady=%+v, want %s", c, want) + } + } + assert(metav1.ConditionFalse) // A registered legacy deployment has no forwarding probe. + dep.Spec.Template.Spec.Containers = []corev1.Container{{Name: "tunnel", ReadinessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/readyz", Port: intstr.FromInt(8091)}}}}} + assert(metav1.ConditionTrue) + dep.Status.ObservedGeneration = 1 + assert(metav1.ConditionFalse) + dep.Status.ObservedGeneration = 2 + dep.Status.ReadyReplicas = 0 + assert(metav1.ConditionFalse) + dep.Status.ReadyReplicas = 1 + tmpl.Spec.Ports[0].Protocol = corev1.ProtocolSCTP + plan = r.planTunnel(context.Background(), gs, tmpl) + assert(metav1.ConditionFalse) +} diff --git a/operator/internal/controller/gameserver_tunnel.go b/operator/internal/controller/gameserver_tunnel.go index a950292b2..8a6c10aed 100644 --- a/operator/internal/controller/gameserver_tunnel.go +++ b/operator/internal/controller/gameserver_tunnel.go @@ -52,7 +52,8 @@ type tunnelPlan struct { endpoints []gameplanev1alpha1.GameServerEndpoint // noMapping is the set of advertised port names that have no frp remote-port mapping. // Used only by frp to report a helpful condition reason. - noMapping []string + noMapping []string + unsupportedPorts []string } // planTunnel is the single source of truth for the tunnel pod's existence @@ -74,6 +75,7 @@ func (r *GameServerReconciler) planTunnel( tunnel := gs.Spec.Networking.Tunnel endpoints := make([]gameplanev1alpha1.GameServerEndpoint, 0) var noMapping []string + var unsupportedPorts []string switch tunnel.Provider { case "frp": @@ -128,6 +130,10 @@ func (r *GameServerReconciler) planTunnel( if !p.Advertise { continue } + if p.Protocol != "" && p.Protocol != corev1.ProtocolTCP && p.Protocol != corev1.ProtocolUDP { + unsupportedPorts = append(unsupportedPorts, p.Name) + continue + } ep := gameplanev1alpha1.GameServerEndpoint{ Name: p.Name, @@ -152,9 +158,10 @@ func (r *GameServerReconciler) planTunnel( } return tunnelPlan{ - wantTunnel: true, - endpoints: endpoints, - noMapping: noMapping, + wantTunnel: true, + endpoints: endpoints, + noMapping: noMapping, + unsupportedPorts: unsupportedPorts, } } @@ -199,6 +206,11 @@ func (r *GameServerReconciler) reconcileTunnel( refusedSecret = "" replicas := int32(1) dep.Spec.Replicas = &replicas + // A provider identity owns one relay session. Recreate unregisters the + // old child before its replacement can claim the same names/ports. + if tunnel.Provider == "frp" || tunnel.Provider == "playit" || tunnel.Provider == "tailscale" { + dep.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType} + } dep.Spec.Selector = &metav1.LabelSelector{ MatchLabels: map[string]string{ tunnelLabel: tunnelValue, @@ -223,6 +235,12 @@ func (r *GameServerReconciler) reconcileTunnel( ContainerPort: p.ContainerPort, Protocol: p.Protocol, } + // The generated health port owns this discovery name. Forwarded + // port metadata is optional; the provider still uses the unchanged + // template/API name in its actual mapping. + if cp.Name == "relay-metrics" { + cp.Name = "" + } ports = append(ports, cp) } @@ -272,6 +290,7 @@ func (r *GameServerReconciler) reconcileTunnel( envVars = []corev1.EnvVar{ {Name: "GAMESERVER_NAME", Value: gs.Name}, {Name: "GAMESERVER_NAMESPACE", Value: gs.Namespace}, + {Name: "GAMESERVER_UID", Value: string(gs.UID)}, {Name: "TUNNEL_TYPE", Value: "frp"}, {Name: "FRP_SERVER_ADDR", Value: tunnel.Frp.ServerAddr}, {Name: "FRP_SERVER_PORT", Value: fmt.Sprintf("%d", serverPort)}, @@ -388,13 +407,22 @@ func (r *GameServerReconciler) reconcileTunnel( } } + var readiness *corev1.Probe + if tunnel.Provider == "tailscale" { + volumes = append(volumes, corev1.Volume{Name: "tailscale-state", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}) + volumeMounts = append(volumeMounts, corev1.VolumeMount{Name: "tailscale-state", MountPath: "/var/lib/gameplane-tailscale"}) + readiness = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/readyz", Port: intstr.FromInt(8091)}}, PeriodSeconds: 5, TimeoutSeconds: 2, FailureThreshold: 1} + } dep.Spec.Template.Spec.Volumes = volumes + ports = append(ports, corev1.ContainerPort{Name: "relay-metrics", ContainerPort: 8091, Protocol: corev1.ProtocolTCP}) dep.Spec.Template.Spec.Containers = []corev1.Container{{ - Name: "tunnel", - Image: image, - Env: envVars, - Ports: ports, - VolumeMounts: volumeMounts, + Name: "tunnel", + Image: image, + Resources: auxiliaryResources(r.TunnelResources, "25m", "64Mi"), + Env: envVars, + Ports: ports, + VolumeMounts: volumeMounts, + ReadinessProbe: readiness, SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: &nonRoot, RunAsUser: &uid, @@ -402,6 +430,11 @@ func (r *GameServerReconciler) reconcileTunnel( Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, }, }} + if tunnel.Provider == "frp" || tunnel.Provider == "playit" { + container := &dep.Spec.Template.Spec.Containers[0] + container.StartupProbe = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/healthz", Port: intstr.FromInt32(8091)}}, PeriodSeconds: 2, TimeoutSeconds: 1, FailureThreshold: 30} + container.ReadinessProbe = &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/readyz", Port: intstr.FromInt32(8091)}}, PeriodSeconds: 2, TimeoutSeconds: 1, FailureThreshold: 1} + } return controllerutil.SetControllerReference(gs, dep, r.Scheme) }) @@ -694,14 +727,18 @@ func buildFrpRemotePortsConfig(frp *gameplanev1alpha1.FrpTunnelSpec, tmpl *gamep } // buildTailscalePortsConfig constructs the BACKING_SERVICE_PORTS env var for Tailscale. -// Format: "port_name:port,..." e.g. "java:25565,bedrock:19133" +// Format: "port_name:port:protocol,..." e.g. "java:25565:tcp,bedrock:19133:udp". func buildTailscalePortsConfig(tmpl *gameplanev1alpha1.GameTemplate) string { var entries []string for _, p := range tmpl.Spec.Ports { if !p.Advertise { continue } - entries = append(entries, fmt.Sprintf("%s:%d", p.Name, p.ContainerPort)) + protocol := strings.ToLower(string(p.Protocol)) + if protocol == "" { + protocol = "tcp" + } + entries = append(entries, fmt.Sprintf("%s:%d:%s", p.Name, p.ContainerPort, protocol)) } if len(entries) == 0 { return "" diff --git a/operator/internal/controller/gameserver_tunnel_envtest_test.go b/operator/internal/controller/gameserver_tunnel_envtest_test.go index d86582fb9..2447b2ee1 100644 --- a/operator/internal/controller/gameserver_tunnel_envtest_test.go +++ b/operator/internal/controller/gameserver_tunnel_envtest_test.go @@ -109,6 +109,32 @@ func TestTunnelReconciliation_CredentialKeyProjection(t *testing.T) { } // Verify the Secret volume has Items projection with only "authKey" + if dep.Spec.Strategy.Type != appsv1.RecreateDeploymentStrategyType || dep.Spec.Strategy.RollingUpdate != nil { + t.Fatalf("Tailscale must recreate its single identity: %+v", dep.Spec.Strategy) + } + container := dep.Spec.Template.Spec.Containers[0] + if container.ReadinessProbe == nil || container.ReadinessProbe.HTTPGet == nil || container.ReadinessProbe.HTTPGet.Path != "/readyz" || container.ReadinessProbe.HTTPGet.Port.IntValue() != 8091 { + t.Fatalf("Tailscale lacks forwarding readiness probe: %+v", container.ReadinessProbe) + } + stateVolume, stateMount, protocolEnv := false, false, false + for _, volume := range dep.Spec.Template.Spec.Volumes { + if volume.Name == "tailscale-state" && volume.EmptyDir != nil { + stateVolume = true + } + } + for _, mount := range container.VolumeMounts { + if mount.Name == "tailscale-state" && mount.MountPath == "/var/lib/gameplane-tailscale" { + stateMount = true + } + } + for _, env := range container.Env { + if env.Name == "BACKING_SERVICE_PORTS" && env.Value == "game:25565:tcp" { + protocolEnv = true + } + } + if !stateVolume || !stateMount || !protocolEnv { + t.Fatal("Tailscale lost persistent pod state or advertised protocol contract") + } if len(dep.Spec.Template.Spec.Volumes) == 0 { t.Fatal("no volumes in tunnel pod template") } diff --git a/operator/internal/controller/gametemplate_performance_envtest_test.go b/operator/internal/controller/gametemplate_performance_envtest_test.go new file mode 100644 index 000000000..ee0a0c96a --- /dev/null +++ b/operator/internal/controller/gametemplate_performance_envtest_test.go @@ -0,0 +1,46 @@ +//go:build envtest + +package controller + +import ( + "testing" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +func TestTemplatePerformanceSchema(t *testing.T) { + for _, tc := range []struct { + signal, unit, threshold string + valid bool + }{ + {"tick_rate", "per_second", "18", true}, + {"tick_duration", "milliseconds", "50.5", true}, + {"frame_rate", "per_second", "0", true}, + {"seed", "per_second", "1", false}, + {"tick_rate", "seconds", "1", false}, + {"tick_duration", "per_second", "1", false}, + {"tick_rate", "per_second", "NaN", false}, + {"tick_rate", "per_second", "-1", false}, + {"tick_rate", "per_second", "1e999", false}, + } { + t.Run(tc.signal+"-"+tc.unit+"-"+tc.threshold, func(t *testing.T) { + tmpl := buildGameTemplate(uniqueName("performance")) + tmpl.Spec.RCON = &gameplanev1alpha1.RCONSpec{Protocol: "source", Port: 25575} + if tmpl.Spec.Capabilities == nil { + tmpl.Spec.Capabilities = &gameplanev1alpha1.CapabilitiesSpec{} + } + threshold := tc.threshold + tmpl.Spec.Capabilities.Status = &gameplanev1alpha1.StatusSpec{Metrics: []gameplanev1alpha1.StatusMetricSpec{{ID: "performance", DisplayName: "Performance", Command: "stats", Regex: `(?P\S+)`, Performance: &gameplanev1alpha1.PerformanceMetricSpec{Signal: tc.signal, Unit: tc.unit, DegradedThreshold: &threshold}}}} + err := k8sClient.Create(t.Context(), tmpl) + if tc.valid { + if err != nil { + t.Fatal(err) + } + deleteCleanup(t, tmpl) + } else if !apierrors.IsInvalid(err) { + t.Fatalf("wanted schema refusal, got %v", err) + } + }) + } +} diff --git a/operator/internal/controller/restore_controller.go b/operator/internal/controller/restore_controller.go index 4cbf94e10..fe46011ef 100644 --- a/operator/internal/controller/restore_controller.go +++ b/operator/internal/controller/restore_controller.go @@ -36,6 +36,8 @@ type RestoreReconciler struct { // operator flag so air-gapped installs can point it at a private // registry mirror. Empty falls back to DefaultResticImage. ResticImage string + // BackupResources shares the backup worker resource configuration. + BackupResources *corev1.ResourceRequirements // JobBackoffLimit / JobActiveDeadlineSeconds bound the restic restore Job. // Set from operator flags; nil / zero fall back to // DefaultBackupJobBackoffLimit / DefaultBackupJobActiveDeadlineSeconds. @@ -327,8 +329,9 @@ func (r *RestoreReconciler) buildRestorePodSpec( return corev1.PodSpec{ SecurityContext: buildBackupPodSecurityContext(tmpl), Containers: []corev1.Container{{ - Name: "restic", - Image: resticImageOrDefault(r.ResticImage), + Name: "restic", + Image: resticImageOrDefault(r.ResticImage), + Resources: auxiliaryResources(r.BackupResources, "100m", "128Mi"), // --target / restores each snapshot entry at its original // absolute path. The companion backup runs `restic backup // /data`, so the snapshot tree is rooted at /data/...; with diff --git a/operator/internal/controller/sentinel_isolation.go b/operator/internal/controller/sentinel_isolation.go new file mode 100644 index 000000000..6cd2969bd --- /dev/null +++ b/operator/internal/controller/sentinel_isolation.go @@ -0,0 +1,66 @@ +package controller + +import ( + "context" + "fmt" + + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" +) + +const sentinelIsolationLabel = "gameplane.local/sentinel-isolation" + +// The chart's legacy policies exclude opted-in pods on both sides. Policies +// are additive: these per-server grants cannot subtract a namespace-wide grant. +func (r *GameServerReconciler) reconcileSentinelIsolation(ctx context.Context, gs *gameplanev1alpha1.GameServer, tmpl *gameplanev1alpha1.GameTemplate) error { + ports := networkPolicyPortsFromTemplate(tmpl) + for _, ingress := range []bool{true, false} { + suffix, target, peer := "egress", "gameplane-waker", "gameplane-game" + if ingress { + suffix, target, peer = "ingress", "gameplane-game", "gameplane-waker" + } + np := &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: gs.Name + "-sentinel-" + suffix, Namespace: gs.Namespace}} + if !tmpl.Spec.SentinelIsolation || len(ports) == 0 { + if err := r.Get(ctx, client.ObjectKeyFromObject(np), np); err != nil { + if client.IgnoreNotFound(err) != nil { + return err + } + continue + } + if metav1.IsControlledBy(np, gs) { + uid := np.UID + if err := client.IgnoreNotFound(r.Delete(ctx, np, &client.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &uid}})); err != nil { + return err + } + } + continue + } + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, np, func() error { + if np.ResourceVersion != "" && !metav1.IsControlledBy(np, gs) { + return fmt.Errorf("sentinel policy %s is not owned by this GameServer", np.Name) + } + np.Spec = networkingv1.NetworkPolicySpec{PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{ + "app.kubernetes.io/name": target, "app.kubernetes.io/instance": gs.Name, + }}} + peers := []networkingv1.NetworkPolicyPeer{{PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{ + "app.kubernetes.io/name": peer, "app.kubernetes.io/instance": gs.Name, + }}}} + if ingress { + np.Spec.PolicyTypes = []networkingv1.PolicyType{networkingv1.PolicyTypeIngress} + np.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{From: peers, Ports: ports}} + } else { + np.Spec.PolicyTypes = []networkingv1.PolicyType{networkingv1.PolicyTypeEgress} + np.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{To: peers, Ports: ports}} + } + return controllerutil.SetControllerReference(gs, np, r.Scheme) + }) + if err != nil { + return err + } + } + return nil +} diff --git a/operator/internal/controller/sentinel_isolation_test.go b/operator/internal/controller/sentinel_isolation_test.go new file mode 100644 index 000000000..1db7f3587 --- /dev/null +++ b/operator/internal/controller/sentinel_isolation_test.go @@ -0,0 +1,106 @@ +package controller + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" +) + +func TestSentinelIsolationPolicies(t *testing.T) { + scheme := runtime.NewScheme() + if err := networkingv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := gameplanev1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + c := fake.NewClientBuilder().WithScheme(scheme).Build() + r := &GameServerReconciler{Client: c, Scheme: scheme} + gs := &gameplanev1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "games", UID: "original"}} + tmpl := singlePortTemplate(true, "minecraft") + tmpl.Spec.SentinelIsolation = true + tmpl.Spec.Ports = append(tmpl.Spec.Ports, + gameplanev1alpha1.GamePort{Name: "query", ContainerPort: 19132, Protocol: corev1.ProtocolUDP, Advertise: true}, + gameplanev1alpha1.GamePort{Name: "management", ContainerPort: 25575, Protocol: corev1.ProtocolTCP}) + if err := r.reconcileSentinelIsolation(t.Context(), gs, tmpl); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, target, peer string + ingress bool + }{ + {"alpha-sentinel-ingress", "gameplane-game", "gameplane-waker", true}, + {"alpha-sentinel-egress", "gameplane-waker", "gameplane-game", false}, + } { + var policy networkingv1.NetworkPolicy + if err := c.Get(t.Context(), client.ObjectKey{Namespace: "games", Name: tc.name}, &policy); err != nil { + t.Fatal(err) + } + if !metav1.IsControlledBy(&policy, gs) { + t.Fatal("missing server ownership") + } + selector := policy.Spec.PodSelector.MatchLabels + if selector["app.kubernetes.io/name"] != tc.target || selector["app.kubernetes.io/instance"] != "alpha" { + t.Fatalf("wrong target: %v", selector) + } + var peers []networkingv1.NetworkPolicyPeer + var ports []networkingv1.NetworkPolicyPort + if tc.ingress { + if len(policy.Spec.Ingress) != 1 || len(policy.Spec.Egress) != 0 { + t.Fatal("wrong ingress policy shape") + } + peers, ports = policy.Spec.Ingress[0].From, policy.Spec.Ingress[0].Ports + } else { + if len(policy.Spec.Egress) != 1 || len(policy.Spec.Ingress) != 0 { + t.Fatal("wrong egress policy shape") + } + peers, ports = policy.Spec.Egress[0].To, policy.Spec.Egress[0].Ports + } + if len(peers) != 1 || peers[0].NamespaceSelector != nil || peers[0].IPBlock != nil || peers[0].PodSelector.MatchLabels["app.kubernetes.io/name"] != tc.peer || peers[0].PodSelector.MatchLabels["app.kubernetes.io/instance"] != "alpha" { + t.Fatalf("cross-server peer grant: %+v", peers) + } + if len(ports) != 2 || ports[0].Port.IntVal != 25565 || *ports[0].Protocol != corev1.ProtocolTCP || ports[1].Port.IntVal != 19132 || *ports[1].Protocol != corev1.ProtocolUDP { + t.Fatalf("wrong protocol/port tuples: %+v", ports) + } + } + // Zero advertised ports removes both grants instead of opening all ports. + tmpl.Spec.Ports = nil + if err := r.reconcileSentinelIsolation(t.Context(), gs, tmpl); err != nil { + t.Fatal(err) + } + var policies networkingv1.NetworkPolicyList + if err := c.List(t.Context(), &policies); err != nil || len(policies.Items) != 0 { + t.Fatalf("zero-port grants remain: %v %+v", err, policies.Items) + } + // Absent/default false preserves legacy behavior and creates no policies. + tmpl = singlePortTemplate(true, "minecraft") + if err := r.reconcileSentinelIsolation(t.Context(), gs, tmpl); err != nil { + t.Fatal(err) + } + if err := c.List(t.Context(), &policies); err != nil || len(policies.Items) != 0 { + t.Fatalf("legacy template changed: %v %+v", err, policies.Items) + } +} + +func TestSentinelIsolationDoesNotDeleteForeignPolicy(t *testing.T) { + scheme := runtime.NewScheme() + _ = networkingv1.AddToScheme(scheme) + _ = gameplanev1alpha1.AddToScheme(scheme) + foreign := &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: "alpha-sentinel-ingress", Namespace: "games"}} + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(foreign).Build() + r := &GameServerReconciler{Client: c, Scheme: scheme} + gs := &gameplanev1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "games", UID: "original"}} + if err := r.reconcileSentinelIsolation(t.Context(), gs, singlePortTemplate(false, "none")); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(foreign), foreign); err != nil { + t.Fatal("deleted foreign policy", err) + } +} diff --git a/operator/specs.md b/operator/specs.md index c7aea8225..dede0e559 100644 --- a/operator/specs.md +++ b/operator/specs.md @@ -10,6 +10,10 @@ The operator is a Kubernetes controller-runtime-based process that reconciles Ga ## Responsibilities +- Preserve explicit optional numeric performance declarations in template capabilities passed to the agent; CRD validation restricts signal/unit pairs and finite decimal thresholds. Tunnel containers name their existing 8091 listener `relay-metrics` for PodMonitor discovery; health, resources and provider identity policies remain unchanged. + +- **Relay health:** frp identities use GameServer UID without changing advertised port names. frp/Playit Deployments use one replica, Recreate, a wrapper startup probe and a provider readiness probe on TCP 8091. `TunnelReady` requires the current observed generation and updated/ready replicas with the new probe; legacy unprobed Pods cannot report success. Playit v1.0.10's cached IPC assignment cannot confirm a relay connection, so it reports unknown/not ready rather than optimistic success. Preserve `TunnelResources` defaults and override removal. See `docs/relay-health.md` for health bounds, migration and the provider limitation. + - **GameServer lifecycle:** reconcile GameServer CRs into StatefulSets, Services, PVCs, and pod-injected agent sidecars; manage pod affinity, RCON/console integration, file I/O, and graceful stop sequences. - **Module installation:** pull OCI module bundles from remote or local sources, verify signatures (cosign), and materialize GameTemplate CRs; refresh source indexes periodically. Translation files (`locales/.json`) are carried by every module source (OCI layers, directory, git, HTTP archive, upload, `gp-module package` tarball) and validated against the ids derived from the module (`i18n/modlocale`); valid entries are written to the `