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..d9474080f 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 @@ -1232,6 +1233,8 @@ jobs: run: python3 charts/tests/check_gateway.py - name: standalone panel isolation and persistence chart renders run: python3 charts/tests/check_standalone.py + - name: auxiliary resources and management placement chart renders + run: python3 charts/tests/check_workloads.py - name: sanity-check rendered manifests run: | grep -q 'kind: Deployment' /tmp/rendered.yaml @@ -1793,13 +1796,17 @@ 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] arch: [amd64] include: - bucket: operator parallel: 8 test_timeout: 20m job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite. + - bucket: management-placement + parallel: 1 + test_timeout: 15m + job_timeout: 60 - bucket: api-auth parallel: 4 test_timeout: 15m @@ -1827,8 +1834,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 +1863,9 @@ jobs: arch: ${{ matrix.arch }} - name: bring up cluster + helm install if: env.RUN_REGEX != '' || env.TAIL_REGEX != '' - run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e + env: + GAMEPLANE_E2E_PROFILE: ${{ matrix.bucket == 'management-placement' && 'management' || 'default' }} + run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e "$GAMEPLANE_E2E_PROFILE" - name: run go e2e (${{ matrix.bucket }}) if: env.RUN_REGEX != '' run: | @@ -1934,8 +1943,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 +2669,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) \//.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 +2822,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) \//); 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/internal/mods/manifest.go b/agent/internal/mods/manifest.go index dea1d1cd6..cef5cbda7 100644 --- a/agent/internal/mods/manifest.go +++ b/agent/internal/mods/manifest.go @@ -1,6 +1,7 @@ package mods import ( + "crypto/rand" "encoding/json" "errors" "io/fs" @@ -11,7 +12,8 @@ import ( // manifestName is the per-volume install ledger. The leading dot keeps it // out of mod listings and out of reach of client-supplied names (safeName -// rejects dotfiles), so it needs no extra guarding. Each per-(version+loader) +// rejects dotfiles). Filesystem confinement still applies to ledger reads and +// replacements. Each per-(version+loader) // mod volume carries its own manifest, so metadata follows the files across // game-version switches. const manifestName = ".gameplane-mods.json" @@ -44,11 +46,15 @@ type manifest struct { // degrades to an empty manifest — mods then list as unmanaged rather than // breaking the endpoint; the next mutation rewrites a clean file. func loadManifest(dir string) *manifest { - empty := &manifest{Version: 1, Mods: map[string]*ModMeta{}} // Validate that dir doesn't escape expected bounds. dirClean := filepath.Clean(dir) manifestPath := filepath.Join(dirClean, manifestName) data, err := os.ReadFile(manifestPath) + return decodeManifest(data, err) +} + +func decodeManifest(data []byte, err error) *manifest { + empty := &manifest{Version: 1, Mods: map[string]*ModMeta{}} if err != nil { if !errors.Is(err, fs.ErrNotExist) { slog.Warn("mod manifest read", "err", err) @@ -67,6 +73,46 @@ func loadManifest(dir string) *manifest { return &parsed } +// updateManifestInRoot keeps DELETE's metadata on the same directory handle as +// the removal. Reopening h.dir here would let a concurrent directory replacement +// redirect this otherwise confined operation. Metadata remains best-effort. +func (h *handler) updateManifestInRoot(root *os.Root, fn func(map[string]*ModMeta)) { + h.mu.Lock() + defer h.mu.Unlock() + m := decodeManifest(root.ReadFile(manifestName)) + fn(m.Mods) + for name := range m.Mods { + if _, err := root.Lstat(name); errors.Is(err, fs.ErrNotExist) { + delete(m.Mods, name) + } + } + if err := m.saveInRoot(root); err != nil { + slog.Warn("mod manifest write", "err", err) + } +} + +func (m *manifest) saveInRoot(root *os.Root) error { + data, err := json.Marshal(m) + if err != nil { + return err + } + tmpName := ".manifest-" + rand.Text() + tmp, err := root.OpenFile(tmpName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { _ = root.Remove(tmpName) }() + _, writeErr := tmp.Write(data) + closeErr := tmp.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + return root.Rename(tmpName, manifestName) +} + // save writes the manifest atomically (dot-prefixed temp + rename, the same // pattern as downloads) so a crash never leaves a torn file behind. func (m *manifest) save(dir string) error { diff --git a/agent/internal/mods/mods.go b/agent/internal/mods/mods.go index 52f2f705b..a34b12650 100644 --- a/agent/internal/mods/mods.go +++ b/agent/internal/mods/mods.go @@ -52,6 +52,7 @@ const ( ) type handler struct { + dataRoot string // trusted data-volume root; removal opens descendants relative to it dir string // absolute mods directory, "" when unconfigured exts []string // lowercased extension allowlist ("" = any) allowed []string // download host allowlist; nil disables installs @@ -96,6 +97,7 @@ func newHandler(dataRoot string, spec *caps.Mods) *handler { return h } h.dir = dir + h.dataRoot = root h.extract = spec.Extract for _, e := range spec.Extensions { h.exts = append(h.exts, strings.ToLower(e)) @@ -455,23 +457,12 @@ func (h *handler) upload(w http.ResponseWriter, req *http.Request) { // removeEntry deletes an installed mod by name — the whole folder for // extract loaders, a single file otherwise. func (h *handler) removeEntry(name string) error { - // Use ConfinePath to validate and resolve the target within h.dir. - // ConfinePath consolidates validation and symlink resolution in one function, - // making the guard legible to CodeQL taint analysis. - target, err := ConfinePath(h.dir, name) + root, err := h.openModsRoot() if err != nil { - return fmt.Errorf("path escape attempt: %w", err) - } - // Re-check inline so the guard is visible at the point of use. - rootClean := filepath.Clean(h.dir) - target = filepath.Clean(target) - if target != rootClean && !strings.HasPrefix(target, rootClean+string(os.PathSeparator)) { - return errors.New("path escape attempt") - } - if h.extract { - return os.RemoveAll(target) + return err } - return os.Remove(target) + defer func() { _ = root.Close() }() + return removeModEntry(root, name, h.extract) } // downloadTemp streams rawURL into a temp file under the mods dir, enforcing @@ -738,31 +729,25 @@ func (h *handler) remove(w http.ResponseWriter, req *http.Request) { httpjson.Error(w, http.StatusBadRequest, err.Error()) return } - // Use ConfinePath to validate and resolve the target within h.dir. - // ConfinePath consolidates validation and symlink resolution in one function, - // making the guard legible to CodeQL taint analysis. - target, err := ConfinePath(h.dir, name) - if err != nil { - httpjson.Error(w, http.StatusBadRequest, err.Error()) - return + root, err := h.openModsRoot() + if err == nil { + defer func() { _ = root.Close() }() + err = removeModEntry(root, name, h.extract) } - // Re-check inline so the guard is visible at the point of use. - rootClean := filepath.Clean(h.dir) - target = filepath.Clean(target) - if target != rootClean && !strings.HasPrefix(target, rootClean+string(os.PathSeparator)) { + if errors.Is(err, ErrEscapesRoot) { httpjson.Error(w, http.StatusBadRequest, "path escape attempt") return } - if _, statErr := os.Stat(target); errors.Is(statErr, os.ErrNotExist) { + if errors.Is(err, fs.ErrNotExist) { httpjson.Error(w, http.StatusNotFound, "no such mod") return } - if err := h.removeEntry(name); err != nil { + if err != nil { slog.Warn("mod remove", "err", err) httpjson.Error(w, http.StatusInternalServerError, "could not remove mod") return } - h.updateManifest(func(mods map[string]*ModMeta) { + h.updateManifestInRoot(root, func(mods map[string]*ModMeta) { delete(mods, name) }) w.WriteHeader(http.StatusNoContent) diff --git a/agent/internal/mods/mods_test.go b/agent/internal/mods/mods_test.go index 5026b1463..fb65a80cf 100644 --- a/agent/internal/mods/mods_test.go +++ b/agent/internal/mods/mods_test.go @@ -689,8 +689,8 @@ func TestInstall_ExtractBadArchive(t *testing.T) { // --- Tests for ConfinePath-based refactors (T006) --- -// TestRemoveEntry_ConfinePath verifies that removeEntry uses ConfinePath -// to validate the target and rejects path traversal attempts. +// TestRemoveEntry_ConfinePath preserves the original valid-name and traversal +// contract now enforced by descriptor-relative removal. func TestRemoveEntry_ConfinePath(t *testing.T) { root := t.TempDir() modsDir := filepath.Join(root, "mods") @@ -786,8 +786,8 @@ func TestSwapInArchive_ConfinePath(t *testing.T) { } } -// TestRemoveWithConfinePath verifies that the remove handler uses ConfinePath -// to validate the name parameter and rejects traversal attempts. +// TestRemoveWithConfinePath preserves the HTTP name-validation contract when +// deletion is performed through the opened mods root. func TestRemoveWithConfinePath(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "mods"), 0o755); err != nil { @@ -808,7 +808,7 @@ func TestRemoveWithConfinePath(t *testing.T) { t.Fatal("file should be gone") } - // remove should reject traversal attempts via ConfinePath validation. + // Rooted removal must still reject the original traversal cases. for _, bad := range []string{"../escape", "a/b", ".."} { status, _ := do(t, srv, http.MethodDelete, "/mods?name="+bad, nil) if status != http.StatusBadRequest { diff --git a/agent/internal/mods/removal.go b/agent/internal/mods/removal.go new file mode 100644 index 000000000..b1f4a00c8 --- /dev/null +++ b/agent/internal/mods/removal.go @@ -0,0 +1,75 @@ +package mods + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// openModsRoot anchors removal to the configured data volume. Open each mods +// directory component without accepting symlink aliases, then retain its handle +// so a later rename cannot redirect removal or its manifest update. +func (h *handler) openModsRoot() (*os.Root, error) { + rel, err := filepath.Rel(h.dataRoot, h.dir) + if err != nil || !filepath.IsLocal(rel) { + return nil, ErrEscapesRoot + } + root, err := os.OpenRoot(h.dataRoot) + if err != nil { + return nil, fmt.Errorf("open mod data root: %w", err) + } + if rel == "." { + return root, nil + } + for _, component := range strings.Split(rel, string(os.PathSeparator)) { + next, err := openModDirectory(root, component) + _ = root.Close() + if err != nil { + return nil, err + } + root = next + } + return root, nil +} + +func openModDirectory(parent *os.Root, name string) (*os.Root, error) { + info, err := parent.Lstat(name) + if err != nil { + return nil, fmt.Errorf("inspect mod directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, ErrEscapesRoot + } + root, err := parent.OpenRoot(name) + if err != nil { + return nil, fmt.Errorf("open mod directory: %w", err) + } + opened, err := root.Stat(".") + if err != nil || !os.SameFile(info, opened) { + _ = root.Close() + return nil, ErrEscapesRoot + } + return root, nil +} + +// removeModEntry never resolves the final name to a symlink's referent. Existing +// links are rejected; a link swapped in after Lstat is only unlinked by Remove +// or RemoveAll. Recursive removal likewise never follows child symlinks. +func removeModEntry(root *os.Root, name string, recursive bool) error { + name, err := safeName(name) + if err != nil { + return fmt.Errorf("invalid mod name: %w", err) + } + info, err := root.Lstat(name) + if err != nil { + return fmt.Errorf("inspect mod entry: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return ErrEscapesRoot + } + if recursive { + return root.RemoveAll(name) + } + return root.Remove(name) +} diff --git a/agent/internal/mods/removal_test.go b/agent/internal/mods/removal_test.go new file mode 100644 index 000000000..3da0f9444 --- /dev/null +++ b/agent/internal/mods/removal_test.go @@ -0,0 +1,237 @@ +package mods + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/GameplanePanel/gameplane/agent/internal/caps" +) + +func TestRemove_RejectsSymlinkEntries(t *testing.T) { + for _, extract := range []bool{false, true} { + for _, target := range []string{"inside", "outside", "root", "dangling", "loop"} { + t.Run(target+map[bool]string{false: "/file", true: "/extracted"}[extract], func(t *testing.T) { + data := t.TempDir() + dir := filepath.Join(data, "mods") + outside := t.TempDir() + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, parent := range []string{dir, outside} { + if err := os.WriteFile(filepath.Join(parent, "kept.jar"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + } + ledger := []byte(`{"version":1,"mods":{"alias":{"provider":"upload"}}}`) + if err := os.WriteFile(filepath.Join(dir, manifestName), ledger, 0o600); err != nil { + t.Fatal(err) + } + destination := map[string]string{"inside": "kept.jar", "outside": filepath.Join(outside, "kept.jar"), "root": ".", "dangling": "missing.jar", "loop": "alias"}[target] + if err := os.Symlink(destination, filepath.Join(dir, "alias")); err != nil { + t.Fatal(err) + } + srv := newSrv(t, data, &caps.Mods{Path: "mods", Extract: extract}) + if status, _ := do(t, srv, http.MethodDelete, "/mods?name=alias", nil); status != http.StatusBadRequest { + t.Fatalf("delete symlink: status=%d, want 400", status) + } + for _, parent := range []string{dir, outside} { + assertRemovalFile(t, filepath.Join(parent, "kept.jar"), "keep") + } + assertRemovalFile(t, filepath.Join(dir, manifestName), string(ledger)) + if info, err := os.Lstat(filepath.Join(dir, "alias")); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("rejected link changed: info=%v err=%v", info, err) + } + }) + } + } +} + +func TestRemove_RejectsAliasedModsDirectory(t *testing.T) { + for _, target := range []string{"outside", "root", "inside"} { + t.Run(target, func(t *testing.T) { + data, outside := t.TempDir(), t.TempDir() + inside := filepath.Join(data, "other") + if err := os.Mkdir(inside, 0o755); err != nil { + t.Fatal(err) + } + for _, parent := range []string{data, outside, inside} { + if err := os.WriteFile(filepath.Join(parent, "kept.jar"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + } + destination := map[string]string{"outside": outside, "root": ".", "inside": "other"}[target] + if err := os.Symlink(destination, filepath.Join(data, "mods")); err != nil { + t.Fatal(err) + } + srv := newSrv(t, data, &caps.Mods{Path: "mods"}) + if status, _ := do(t, srv, http.MethodDelete, "/mods?name=kept.jar", nil); status != http.StatusBadRequest { + t.Fatalf("delete through aliased root: status=%d, want 400", status) + } + for _, parent := range []string{data, outside, inside} { + assertRemovalFile(t, filepath.Join(parent, "kept.jar"), "keep") + } + }) + } +} + +func TestRemove_RecursivePreservesSymlinkTargets(t *testing.T) { + data, outside := t.TempDir(), t.TempDir() + dir := filepath.Join(data, "mods", "pack") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outside, "kept.jar"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "child")); err != nil { + t.Fatal(err) + } + srv := newSrv(t, data, &caps.Mods{Path: "mods", Extract: true}) + if status, _ := do(t, srv, http.MethodDelete, "/mods?name=pack", nil); status != http.StatusNoContent { + t.Fatalf("delete pack: status=%d, want 204", status) + } + if _, err := os.Lstat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("pack remains: %v", err) + } + assertRemovalFile(t, filepath.Join(outside, "kept.jar"), "keep") +} + +func TestRemove_RootAndManifestSurviveDirectoryReplacement(t *testing.T) { + data, outside := t.TempDir(), t.TempDir() + dir := filepath.Join(data, "mods") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + ledger := `{"version":1,"mods":{"gone.jar":{"provider":"upload"}}}` + for _, parent := range []string{dir, outside} { + if err := os.WriteFile(filepath.Join(parent, "gone.jar"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, manifestName), []byte(ledger), 0o600); err != nil { + t.Fatal(err) + } + } + h := newHandler(data, &caps.Mods{Path: "mods"}) + root, err := h.openModsRoot() + if err != nil { + t.Fatal(err) + } + defer root.Close() + // Deterministically replace the pathname after the operation acquires its + // root handle. Neither deletion nor metadata may re-open that pathname. + moved := filepath.Join(data, "moved") + if err := os.Rename(dir, moved); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, dir); err != nil { + t.Fatal(err) + } + if err := removeModEntry(root, "gone.jar", false); err != nil { + t.Fatal(err) + } + h.updateManifestInRoot(root, func(mods map[string]*ModMeta) { delete(mods, "gone.jar") }) + if _, err := os.Stat(filepath.Join(moved, "gone.jar")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("original entry remains: %v", err) + } + if entries := readManifest(t, moved); len(entries) != 0 { + t.Fatalf("original manifest did not update: %+v", entries) + } + assertRemovalFile(t, filepath.Join(outside, "gone.jar"), "keep") + assertRemovalFile(t, filepath.Join(outside, manifestName), ledger) +} + +func TestRemove_ConfiguredDirectories(t *testing.T) { + for _, path := range []string{"mods", "nested/mods", "/mods", "."} { + t.Run(path, func(t *testing.T) { + data := t.TempDir() + h := newHandler(data, &caps.Mods{Path: path}) + if err := os.MkdirAll(h.dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(h.dir, "gone.jar"), []byte("remove"), 0o600); err != nil { + t.Fatal(err) + } + if err := h.removeEntry("gone.jar"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(h.dir, "gone.jar")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("entry remains: %v", err) + } + }) + } +} + +func TestRemove_ManifestOutsideSymlinkPreservesTarget(t *testing.T) { + data, outside := t.TempDir(), t.TempDir() + h := newHandler(data, &caps.Mods{Path: "mods"}) + if err := os.Mkdir(h.dir, 0o755); err != nil { + t.Fatal(err) + } + ledger := `{"version":1,"mods":{"foreign.jar":{"provider":"upload"}}}` + target := filepath.Join(outside, manifestName) + if err := os.WriteFile(target, []byte(ledger), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(h.dir, manifestName)); err != nil { + t.Fatal(err) + } + root, err := h.openModsRoot() + if err != nil { + t.Fatal(err) + } + defer root.Close() + h.updateManifestInRoot(root, func(mods map[string]*ModMeta) { + if len(mods) != 0 { + t.Fatalf("loaded outside manifest: %+v", mods) + } + }) + assertRemovalFile(t, target, ledger) + if info, err := os.Lstat(filepath.Join(h.dir, manifestName)); err != nil || !info.Mode().IsRegular() { + t.Fatalf("confined manifest was not published: info=%v err=%v", info, err) + } +} + +func TestManifestSaveInRoot_PublishFailureCleansTemporaryFile(t *testing.T) { + data := t.TempDir() + // A directory at the ledger path deterministically prevents atomic rename, + // even when CI runs as root. Its existing contents must remain intact. + if err := os.Mkdir(filepath.Join(data, manifestName), 0o755); err != nil { + t.Fatal(err) + } + previous := filepath.Join(data, manifestName, "previous") + if err := os.WriteFile(previous, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(data) + if err != nil { + t.Fatal(err) + } + defer root.Close() + m := &manifest{Version: 1, Mods: map[string]*ModMeta{}} + if err := m.saveInRoot(root); err == nil { + t.Fatal("publishing over a directory succeeded") + } + assertRemovalFile(t, previous, "keep") + entries, err := os.ReadDir(data) + if err != nil || len(entries) != 1 || entries[0].Name() != manifestName { + t.Fatalf("temporary manifest leaked: entries=%v err=%v", entries, err) + } +} + +func TestRemove_MissingDirectory(t *testing.T) { + srv := newSrv(t, t.TempDir(), &caps.Mods{Path: "mods"}) + if status, _ := do(t, srv, http.MethodDelete, "/mods?name=missing.jar", nil); status != http.StatusNotFound { + t.Fatalf("missing mods directory: status=%d, want 404", status) + } +} + +func assertRemovalFile(t *testing.T, name, want string) { + t.Helper() + data, err := os.ReadFile(name) + if err != nil || string(data) != want { + t.Fatalf("file changed: %s: data=%q err=%v", name, data, err) + } +} diff --git a/agent/internal/rcon/nuclearoption_test.go b/agent/internal/rcon/nuclearoption_test.go index 248c6a7fb..9061d2c94 100644 --- a/agent/internal/rcon/nuclearoption_test.go +++ b/agent/internal/rcon/nuclearoption_test.go @@ -1275,18 +1275,47 @@ type pipeFrameCase struct { forbidErr string // substring the error must NOT contain (e.g. a detail suffix) } +type responseDeadlineConn struct { + net.Conn + deadlineSet chan<- struct{} +} + +func (c *responseDeadlineConn) SetDeadline(deadline time.Time) error { + if err := c.Conn.SetDeadline(deadline); err != nil { + return err + } + select { + case c.deadlineSet <- struct{}{}: + default: + } + return nil +} + // runPipeFrameCases sends each case's raw frame back to the client over net.Pipe // and checks the body or the error. func runPipeFrameCases(t *testing.T, tests []pipeFrameCase) { t.Helper() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + deadlineSet := make(chan struct{}, 1) client := newPipeClient(t, func(remote net.Conn) { if _, _, err := readNuclearOptionRequest(remote); err != nil { return } + if len(tt.frame) == 0 { + // Exec sets its response deadline after sending the request. + // Closing first can fail SetDeadline instead of the status read. + select { + case <-deadlineSet: + case <-t.Context().Done(): + return + } + } writeFrameSegments(remote, tt.frame, tt.oneByte) }) + if len(tt.frame) == 0 { + client.conn = &responseDeadlineConn{Conn: client.conn, deadlineSet: deadlineSet} + } body, err := client.Exec("test-command") if tt.wantErr != "" { diff --git a/agent/specs.md b/agent/specs.md index af1f57529..13d03c989 100644 --- a/agent/specs.md +++ b/agent/specs.md @@ -200,7 +200,7 @@ All endpoints on the `--addr` control mux, except `/healthz`, return `401 Unauth - **RCON is a lower-trust boundary**: The agent uses `netguard.IsAllowed()` for WebRcon dial operations (permissive, allows loopback and private addresses on the assumption game servers run inside the cluster), and `netguard.IsPublic()` (strict, permissive only for well-known registries) for mod-install downloads, assuming modules are less trusted than the operator. - **Gameaction validation is independent**: Both the API (stdin pod-attach) and the agent (RCON) call `gameaction.Resolve()` independently to validate action inputs (no control characters, 512-char cap, required-ness checks, etc.). Neither trusts the other. The players moderation endpoints (kick/ban reason) also call gameaction.CheckText, so moderation text follows the same character policy as actions. - **No persistent storage**: The agent has no database. GameServer status patches flow through the operator; all transient state (WebSocket streams, RCON sessions) is in-memory. -- **Path confinement via per-component validation**: The `files` package validates the requested path lexically in `resolve()` (direct traversal: `..`, `/`, absolute paths; any dot-prefixed component is rejected as a dotfile) and then performs every operation relative to a directory descriptor opened on the data root (`rooted_linux.go`). Each component is opened with `openat(O_NOFOLLOW)` (directories also `O_DIRECTORY`), so a symlink at any component of the path, ancestor or final, is refused with HTTP 400 (`dotfile access denied` when the link's target lies below a dot-prefixed component, otherwise `path escapes root (symbolic link in path)`). Reads, writes, creates, deletes and listings use the descriptors they obtained (`fstatat`, `mkdirat`, `unlinkat`, `renameat`, `ServeContent` on the opened fd) and never re-open a path string, so replacing a checked directory or file with a symlink between validation and use cannot move the operation outside the root; a delete removes a symlink as the link it is and a recursive delete never follows links. The data-root path itself is the one trusted, followed component (it may be a symlink). `savePart` likewise rejects dot-prefixed upload filenames; `/files/list` excludes dot-prefixed entries from its output and a delete refuses trees containing them. The `mods` package uses `ConfinePath(rootDir, untrustedName)` as the authoritative guard for single-component mod paths (upload, download, removal, archive swap), and `ConfineRelPath(root, relPath)` for multi-component archive entry paths during extraction. ConfinePath returns a cleaned, absolute path guaranteed to be confined within rootDir (or raises an error if escape is attempted), validating against both direct traversal and symlink escape. The prior `safeName()` function remains in use as caller-side defense-in-depth (pre-filtering before ConfinePath), but ConfinePath is the authoritative guard within the function boundary where the archive operation occurs. +- **Path confinement via per-component validation**: The `files` package validates the requested path lexically in `resolve()` (direct traversal: `..`, `/`, absolute paths; any dot-prefixed component is rejected as a dotfile) and then performs every operation relative to a directory descriptor opened on the data root (`rooted_linux.go`). Each component is opened with `openat(O_NOFOLLOW)` (directories also `O_DIRECTORY`), so a symlink at any component of the path, ancestor or final, is refused with HTTP 400 (`dotfile access denied` when the link's target lies below a dot-prefixed component, otherwise `path escapes root (symbolic link in path)`). Reads, writes, creates, deletes and listings use the descriptors they obtained (`fstatat`, `mkdirat`, `unlinkat`, `renameat`, `ServeContent` on the opened fd) and never re-open a path string, so replacing a checked directory or file with a symlink between validation and use cannot move the operation outside the root; a delete removes a symlink as the link it is and a recursive delete never follows links. The data-root path itself is the one trusted, followed component (it may be a symlink). `savePart` likewise rejects dot-prefixed upload filenames; `/files/list` excludes dot-prefixed entries from its output and a delete refuses trees containing them. The `mods` package retains `ConfinePath(rootDir, untrustedName)` for upload, download and archive swap, and `ConfineRelPath(root, relPath)` for archive entry paths during extraction; these validate paths before those operations. Mod deletion instead validates a bare name with `safeName()` and opens an `os.Root` at the trusted data-volume root. It refuses symlinks in each configured mods-directory component, checks the opened directory identity, and retains the resulting directory handle for removal and the HTTP DELETE manifest update. Existing mod-entry symlinks are rejected, including aliases to the mods root; recursive deletion never follows child symlinks. Replacing a checked path cannot redirect the operation to a symlink target. Missing mods directories still return HTTP 404. Manifest writes remain best-effort and publish through a temporary file and atomic rename on that same handle, cleaning the temporary file on failure. Separate upload, download and archive-publication paths are unchanged by this deletion boundary. - **WebRcon dials through netguard**: `rcon.websocket.go`'s `ensureLocked()` method dials the Rust WebSocket using `netguard.IsAllowed()` dial policy for defense-in-depth, allowing loopback and private addresses (since game servers legitimately run inside the cluster) while blocking the ranges `netguard.IsAllowed` rejects (link-local, multicast, NAT64/6to4). - **WebRcon dial errors are redacted**: On dial failure, `ensureLocked()` strips the URL path (which carries the escaped password) from any `*url.Error` via `redactURLErr`. If the redacted message still contains the password in raw, path-escaped, or query-escaped form (`errorLeaksSecret`; passwords shorter than three characters are not checked), it returns a generic `connection failed` error without the original cause; otherwise it wraps the redacted error with `%w` so `errors.Is`/`errors.As` still work. - **Agent-created directories are 0755**: `files` (mkdir, upload and write ancestors) and `mods` (mods dir, archive extraction dirs) create directories 0o755, matching the 0o644 files, because the game container runs as a different uid and on templates without an fsGroup reaches the shared volume only through the "other" bits. The gosec G301 finding for these two files is scoped in `.golangci.yml`. 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..e2aa9dce4 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..f74e59433 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..5fb318214 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..be016422a 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..b15227625 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local 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..91ec252d1 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..8722e927a 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..17d38fc46 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..37f7a476c 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..e2aa9dce4 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..f74e59433 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..5fb318214 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..be016422a 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..b15227625 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: group: gameplane.local diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index 5ade28cee..91ec252d1 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..8722e927a 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..17d38fc46 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + 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..37f7a476c 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: afbe99d46e972c35a07f42486a1f8f6ec895c183017a9d2eef67b7ea07824f6b + controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: group: gameplane.local 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/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/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/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..c6df65ede 100644 --- a/charts/gameplane/templates/validate.yaml +++ b/charts/gameplane/templates/validate.yaml @@ -1,3 +1,6 @@ +{{- 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 }} {{- 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/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..9346f8e00 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 } @@ -602,6 +651,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 } 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..8f5df5211 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). @@ -28,9 +28,9 @@ CLUSTER="${2:-gameplane-e2e}" TAG="${3:-${GAMEPLANE_E2E_TAG:-e2e}}" PROFILE="${4:-default}" 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,12 +188,17 @@ 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)" + echo "creating kind cluster ${CLUSTER} (${PROFILE})" + extra_nodes="" + if [[ "${PROFILE}" == "management" ]]; then + extra_nodes=$' - role: worker\n - role: worker' + fi kind create cluster --name "${CLUSTER}" --wait 90s --config - </dev/null diff --git a/docs/dependencies.md b/docs/dependencies.md index 1017fa980..ac72ac668 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/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..504d98d96 100644 --- a/docs/tunnels.md +++ b/docs/tunnels.md @@ -197,21 +197,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 +509,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..45a1887a4 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"] +E2E_AMD64_ONLY = {"management-placement"} 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..3814677aa 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,17 @@ 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_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 +515,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/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..c6428c858 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 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/conflict.go b/operator/internal/controller/conflict.go index 988c7d2fa..0559053f1 100644 --- a/operator/internal/controller/conflict.go +++ b/operator/internal/controller/conflict.go @@ -1,17 +1,25 @@ package controller import ( + "time" + apierrors "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" ) +// reconcileRetryInterval yields after expected write races or persisted +// lifecycle transitions so the next pass can read fresh state. These are not +// failures: real errors retain controller-runtime's exponential backoff. +// A positive delay avoids both dropping retries and spinning on a stale cache. +const reconcileRetryInterval = time.Second + // requeueOnConflict turns an optimistic-concurrency conflict ("the object has // been modified") into a quiet requeue. Conflicts are an expected, transient // race between reconcilers and the API server cache; returning them as errors // logs a spurious ERROR on every race. Any other result passes through. func requeueOnConflict(res ctrl.Result, err error) (ctrl.Result, error) { if apierrors.IsConflict(err) { - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } return res, err } diff --git a/operator/internal/controller/conflict_test.go b/operator/internal/controller/conflict_test.go index 73adf02e3..5b8d30e42 100644 --- a/operator/internal/controller/conflict_test.go +++ b/operator/internal/controller/conflict_test.go @@ -24,10 +24,11 @@ func TestRequeueOnConflict(t *testing.T) { wantRes ctrl.Result wantErr bool }{ - {name: "conflict becomes quiet requeue", res: keep, err: conflict, wantRes: ctrl.Result{Requeue: true}}, - {name: "wrapped conflict becomes quiet requeue", res: keep, err: fmt.Errorf("update status: %w", conflict), wantRes: ctrl.Result{Requeue: true}}, + {name: "conflict becomes quiet paced retry", res: keep, err: conflict, wantRes: ctrl.Result{RequeueAfter: time.Second}}, + {name: "wrapped conflict becomes quiet paced retry", res: keep, err: fmt.Errorf("update status: %w", conflict), wantRes: ctrl.Result{RequeueAfter: time.Second}}, {name: "other error passes through", res: keep, err: other, wantRes: keep, wantErr: true}, {name: "nil error passes result through", res: keep, err: nil, wantRes: keep}, + {name: "settled reconcile stays settled", res: ctrl.Result{}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -35,6 +36,9 @@ func TestRequeueOnConflict(t *testing.T) { if (err != nil) != tc.wantErr { t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) } + if tc.wantErr && !errors.Is(err, tc.err) { + t.Fatalf("non-conflict error changed: %v, want %v", err, tc.err) + } if got != tc.wantRes { t.Errorf("result = %+v, want %+v", got, tc.wantRes) } diff --git a/operator/internal/controller/gameserver_controller.go b/operator/internal/controller/gameserver_controller.go index 67e8be4e3..143a1c06b 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 @@ -246,7 +252,7 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) if changed, err := r.ensureWipeGuard(ctx, &gs, &wipeState); err != nil { return requeueOnConflict(ctrl.Result{}, err) } else if changed { - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } // Resolve the template this GameServer points at. Templates are @@ -388,7 +394,7 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) } if err := r.reconcileStatefulSet(ctx, &gs, &tmpl, ver, mc, replicas, &wipeState); err != nil { if apierrors.IsConflict(err) { - return ctrl.Result{Requeue: true}, nil + return requeueOnConflict(ctrl.Result{}, err) } logger.Error(err, "reconcile StatefulSet") return ctrl.Result{}, err @@ -416,7 +422,7 @@ func (r *GameServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) } if err := wipeErr; err != nil { if apierrors.IsConflict(err) { - return ctrl.Result{Requeue: true}, nil + return requeueOnConflict(ctrl.Result{}, err) } logger.Error(err, "reconcile data wipe") return ctrl.Result{}, err @@ -1509,7 +1515,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 +1575,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 +1989,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 +2002,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 +2249,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..beee78a5a --- /dev/null +++ b/operator/internal/controller/gameserver_relay_health_test.go @@ -0,0 +1,117 @@ +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] + 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..5415fbbf8 100644 --- a/operator/internal/controller/gameserver_sentinel.go +++ b/operator/internal/controller/gameserver_sentinel.go @@ -278,8 +278,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..03785c236 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, @@ -272,6 +284,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 +401,21 @@ 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 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 +423,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 +720,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/gameserver_wipe_guard_test.go b/operator/internal/controller/gameserver_wipe_guard_test.go index 1e1fca1ba..330786b28 100644 --- a/operator/internal/controller/gameserver_wipe_guard_test.go +++ b/operator/internal/controller/gameserver_wipe_guard_test.go @@ -4,12 +4,14 @@ import ( "context" "fmt" "testing" + "time" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" @@ -52,7 +54,10 @@ func TestWipePendingStartAcquiresGuardBeforeLaunching(t *testing.T) { gs := wipeGameServer(false, "current") c := fake.NewClientBuilder().WithScheme(s).WithObjects(gs, wipeStoppedSet(gs)).Build() r := &GameServerReconciler{Client: c, APIReader: c, Scheme: s} - wipePasses(t, r, gs, 1) + result, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(gs)}) + if err != nil || result != (ctrl.Result{RequeueAfter: time.Second}) { + t.Fatalf("guard acquisition must yield for a fresh reconcile: result=%+v err=%v", result, err) + } current := wipeCurrent(t, c, gs) if current.Annotations[wipeGuardAnnotation] != "current" || !current.Spec.Suspend { t.Fatal("direct start bypassed durable wipe acquisition") diff --git a/operator/internal/controller/module_controller.go b/operator/internal/controller/module_controller.go index c3c54b48a..975cfe5d9 100644 --- a/operator/internal/controller/module_controller.go +++ b/operator/internal/controller/module_controller.go @@ -36,6 +36,14 @@ import ( // refresh floor instead of hot-looping (F-258). const moduleFailedRetryInterval = minRefreshInterval +// Pending catalog/version state is refreshed by the ModuleSource watch; its +// refresh floor is only a polling backstop. Finalization has no GameServer +// watch, so check references every five seconds until removal is safe. +const ( + modulePendingRetryInterval = minRefreshInterval + moduleFinalizerPollInterval = 5 * time.Second +) + // ModuleReconciler materializes Module CRs into GameTemplate CRs. The // produced GameTemplate carries an OwnerReference back to the Module so // uninstall = `kubectl delete module ` and the K8s GC reaps the @@ -368,7 +376,7 @@ func (r *ModuleReconciler) finalize(ctx context.Context, mod *gameplanev1alpha1. } // Don't release the finalizer; requeue so we re-check after the // user removes the GameServers. - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: moduleFinalizerPollInterval}, nil } // Delete the materialized GameTemplate. SetControllerReference would @@ -423,7 +431,7 @@ func (r *ModuleReconciler) markPending(ctx context.Context, mod *gameplanev1alph if uerr := r.updateStatusIfChanged(ctx, mod, before); uerr != nil { return ctrl.Result{}, uerr } - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: modulePendingRetryInterval}, nil } func (r *ModuleReconciler) markFailed(ctx context.Context, mod *gameplanev1alpha1.Module, reason string, err error) (ctrl.Result, error) { diff --git a/operator/internal/controller/module_finalizer_poll_test.go b/operator/internal/controller/module_finalizer_poll_test.go new file mode 100644 index 000000000..f9fe9f23f --- /dev/null +++ b/operator/internal/controller/module_finalizer_poll_test.go @@ -0,0 +1,61 @@ +package controller + +import ( + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + gameplanev1alpha1 "github.com/GameplanePanel/gameplane/operator/api/v1alpha1" +) + +func TestModuleFinalizerPollRetainsTemplateUntilUnreferenced(t *testing.T) { + t.Parallel() + scheme := scrapeScheme(t) + mod := &gameplanev1alpha1.Module{ObjectMeta: metav1.ObjectMeta{Name: "module", UID: "module-uid", Finalizers: []string{gameplanev1alpha1.ModuleFinalizer}}} + tmpl := &gameplanev1alpha1.GameTemplate{ObjectMeta: metav1.ObjectMeta{Name: mod.Name}} + if err := controllerutil.SetControllerReference(mod, tmpl, scheme); err != nil { + t.Fatal(err) + } + gs := &gameplanev1alpha1.GameServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "ns"}, + Spec: gameplanev1alpha1.GameServerSpec{TemplateRef: gameplanev1alpha1.GameTemplateRef{Name: tmpl.Name}}, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(mod, tmpl, gs). + WithStatusSubresource(&gameplanev1alpha1.Module{}).Build() + r := &ModuleReconciler{Client: c, Scheme: scheme} + if err := c.Delete(t.Context(), mod); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(mod), mod); err != nil { + t.Fatal(err) + } + result, err := r.finalize(t.Context(), mod) + if err != nil || result != (ctrl.Result{RequeueAfter: 5 * time.Second}) { + t.Fatalf("in-use finalizer must poll: result=%+v err=%v", result, err) + } + if !controllerutil.ContainsFinalizer(mod, gameplanev1alpha1.ModuleFinalizer) { + t.Fatal("finalizer released while template is referenced") + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(tmpl), tmpl); err != nil { + t.Fatalf("referenced template removed: %v", err) + } + if err := c.Delete(t.Context(), gs); err != nil { + t.Fatal(err) + } + result, err = r.finalize(t.Context(), mod) + if err != nil || !result.IsZero() { + t.Fatalf("unreferenced module must finish: result=%+v err=%v", result, err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(tmpl), tmpl); !apierrors.IsNotFound(err) { + t.Fatalf("owned template survived finalization: %v", err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(mod), mod); !apierrors.IsNotFound(err) { + t.Fatalf("Module finalizer was not released: %v", err) + } +} diff --git a/operator/internal/controller/module_markpending_test.go b/operator/internal/controller/module_markpending_test.go index 15f6da4fc..6f408dcb9 100644 --- a/operator/internal/controller/module_markpending_test.go +++ b/operator/internal/controller/module_markpending_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -38,10 +39,14 @@ func TestMarkPending_ClearsStalePullingCondition(t *testing.T) { Build() r := &ModuleReconciler{Client: cl} - if _, err := r.markPending(context.Background(), mod, "WaitingForCatalog", - errors.New("source has not yet indexed module")); err != nil { + result, err := r.markPending(context.Background(), mod, "WaitingForCatalog", + errors.New("source has not yet indexed module")) + if err != nil { t.Fatalf("markPending: %v", err) } + if result.RequeueAfter != time.Minute { + t.Fatalf("catalog retry = %s, want the one-minute refresh floor", result.RequeueAfter) + } got := &gameplanev1alpha1.Module{} if err := r.Get(context.Background(), types.NamespacedName{Name: "mc"}, got); err != nil { diff --git a/operator/internal/controller/restore_controller.go b/operator/internal/controller/restore_controller.go index 4cbf94e10..fe2692cd8 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. @@ -111,7 +113,7 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err := r.Status().Update(ctx, &rs); err != nil { return ctrl.Result{}, err } - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } // Re-resolve the source Backup so we know its strategy on every pass @@ -145,7 +147,7 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, err } if changed { - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } target, err := parseRestoreIdentity(rs.Annotations[restoreTargetAnnotation]) if err != nil { @@ -194,7 +196,7 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err := r.Status().Update(ctx, &rs); err != nil { return ctrl.Result{}, err } - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } // src (the source Backup, with RepoRef for the Job env) was resolved @@ -229,7 +231,7 @@ func (r *RestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct } if err := r.Create(ctx, job); err != nil { if apierrors.IsAlreadyExists(err) { - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } return ctrl.Result{}, err } @@ -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/restore_fence_requeue_test.go b/operator/internal/controller/restore_fence_requeue_test.go index 9371c7b4a..acf997a0a 100644 --- a/operator/internal/controller/restore_fence_requeue_test.go +++ b/operator/internal/controller/restore_fence_requeue_test.go @@ -18,7 +18,11 @@ func TestRestoreFenceWriteRequeuesImmediatelyAndRechecksWriters(t *testing.T) { for _, phase := range []gameplanev1alpha1.RestorePhase{gameplanev1alpha1.RestorePhaseSuspending, gameplanev1alpha1.RestorePhaseRunning} { t.Run(string(phase), func(t *testing.T) { r, _, rs := restoreLifecycleFixture(t) - lifecyclePasses(t, r, "first", 1) // Persist target identity first. + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(rs)} + result, err := r.Reconcile(t.Context(), request) // Persist target identity first. + if err != nil || result != (ctrl.Result{RequeueAfter: time.Second}) { + t.Fatalf("identity binding must yield for a fresh reconcile: result=%+v err=%v", result, err) + } if err := r.Get(t.Context(), client.ObjectKeyFromObject(rs), rs); err != nil { t.Fatal(err) } @@ -26,8 +30,7 @@ func TestRestoreFenceWriteRequeuesImmediatelyAndRechecksWriters(t *testing.T) { if err := r.Status().Update(t.Context(), rs); err != nil { t.Fatal(err) } - request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(rs)} - result, err := r.Reconcile(t.Context(), request) + result, err = r.Reconcile(t.Context(), request) if err != nil { t.Fatal(err) } @@ -69,6 +72,15 @@ func TestRestoreFenceWriteRequeuesImmediatelyAndRechecksWriters(t *testing.T) { if err := r.Delete(t.Context(), writer); err != nil { t.Fatal(err) } + if phase == gameplanev1alpha1.RestorePhaseSuspending { + result, err = r.Reconcile(t.Context(), request) + if err != nil || result != (ctrl.Result{RequeueAfter: time.Second}) { + t.Fatalf("Running transition must yield before Job creation: result=%+v err=%v", result, err) + } + if len(lifecycleJobs(t, r)) != 0 { + t.Fatal("restore Job created in the Running transition pass") + } + } lifecyclePasses(t, r, "first", 4) jobs = lifecycleJobs(t, r) if len(jobs) != 1 || jobs[0].Name != "restore-first" { diff --git a/operator/internal/controller/restore_volumesnapshot.go b/operator/internal/controller/restore_volumesnapshot.go index c2d1819f5..440356c0b 100644 --- a/operator/internal/controller/restore_volumesnapshot.go +++ b/operator/internal/controller/restore_volumesnapshot.go @@ -157,7 +157,7 @@ func (r *RestoreReconciler) reconcileVolumeSnapshotRestore( if err := r.Create(ctx, created); err != nil { if apierrors.IsAlreadyExists(err) { // Lost a create race; the next pass finds it via the annotation. - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: reconcileRetryInterval}, nil } // Route non-AlreadyExists errors through the same deadline-aware path // used for reference-copy failures. This ensures transient errors do not diff --git a/operator/specs.md b/operator/specs.md index c7aea8225..326c7a4f4 100644 --- a/operator/specs.md +++ b/operator/specs.md @@ -10,6 +10,18 @@ The operator is a Kubernetes controller-runtime-based process that reconciles Ga ## Responsibilities +Expected write conflicts, create races and persisted lifecycle transitions +request a fresh reconcile after one second without reporting an error. Module +catalog/version waits use the existing one-minute source refresh floor as a +backstop; ModuleSource events can reconcile them sooner. Module deletion polls +every five seconds while a GameServer references its template and retains the +finalizer until those references are gone. These explicit waits use +`RequeueAfter`; genuine errors retain controller-runtime's exponential backoff. +Existing restore fence-write requeues and writer-drain polling remain unchanged, +and every resumed restore pass still verifies the workload fence and live writers. + +- **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 `