From 1dd1cf3524d48af748721d94e10adfa6b7e65d17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 01/19] chore: add dev container version source and profile catalogue (spec 026 T002, T003) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- .devcontainer/profiles.toml | 207 ++++++++++++++++++++++++++++++++++++ .devcontainer/versions.toml | 18 ++++ 2 files changed, 225 insertions(+) create mode 100644 .devcontainer/profiles.toml create mode 100644 .devcontainer/versions.toml diff --git a/.devcontainer/profiles.toml b/.devcontainer/profiles.toml new file mode 100644 index 000000000..d73b3b04e --- /dev/null +++ b/.devcontainer/profiles.toml @@ -0,0 +1,207 @@ +# Dev container profile catalogue (spec 026, contracts/profiles-catalogue.md). +# Hand-written. Versions live only in versions.toml; this file refers to them by key. +# Folder ids are relative to the repo root. Catalogue order = table order below. +# Token {go_build} expands to "cd && go build ./..." for each covered go.work module (except test/e2e). + +[baseline] +image = "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" +remoteUser = "vscode" +runArgs = ["--init"] +extensions = [ + "anthropic.claude-code", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "ms-vscode.makefile-tools", +] +env = { GOTOOLCHAIN = "local", CI = "1" } + +[baseline.settings] +"go.toolsManagement.checkForUpdates" = "off" +"go.useLanguageServer" = true +"editor.formatOnSave" = true +"[go]" = { "editor.defaultFormatter" = "golang.go" } +"[typescript]" = { "editor.defaultFormatter" = "esbenp.prettier-vscode" } +"[typescriptreact]" = { "editor.defaultFormatter" = "esbenp.prettier-vscode" } +"workbench.browser.openLocalhostLinks" = false +"vs-kubernetes" = { "vs-kubernetes.crd-code-completion" = "enabled" } + +[[baseline.deps]] +name = "common-utils" +kind = "os-package" +reason = "Base shell tooling and the vscode user (feature common-utils:2, zsh); devcontainer.json:5-9." + +[[baseline.deps]] +name = "git" +kind = "os-package" +reason = "Submodule init and git plumbing come from the base image (setup.sh step_submodules)." + +[[baseline.deps]] +name = "gh" +kind = "cli" +reason = "GitHub CLI used by agents and PR scripts (inventory 6(f); modules/.github/workflows/refresh-pins.yml:108-109); devcontainer.json:26." + +[[baseline.deps]] +name = "uv" +kind = "cli" +reason = "Python tool installer that provides specify-cli (setup.sh step_uv)." + +[[baseline.deps]] +name = "specify" +kind = "cli" +version = "specify" +reason = "Spec-kit CLI used by the spec workflow (setup.sh step_specify)." + +[[baseline.deps]] +name = "ai-clis" +kind = "cli" +optional = true +reason = "Optional AI coding CLIs installed warn-only (setup.sh step_ai_clis)." + +# ---------------------------------------------------------------- component profiles + +[profile.web] +size = "component" +title = "Web dashboard" +dirs = ["web"] +ports = [ + { port = 5173, label = "Vite (web dev)", onAutoForward = "notify" }, +] +extensions = [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", +] +checks = [ + "cd web && npm ci && npx tsc --noEmit", +] + +[profile.api] +size = "component" +title = "API" +dirs = ["api", "svcutil"] +extensions = ["golang.go"] +checks = ["{go_build}"] + +[profile.operator] +size = "component" +title = "Operator" +dirs = ["operator"] +extensions = [ + "golang.go", + "ms-kubernetes-tools.vscode-kubernetes-tools", +] +checks = ["{go_build}"] + +[profile.agent] +size = "component" +title = "Agent" +dirs = ["agent"] +extensions = ["golang.go"] +checks = ["{go_build}"] + +[profile.go-services] +size = "component" +title = "Go services" +dirs = [ + "audit-syslog-bridge", + "capture-sidecar", + "gameproto", + "gp-module", + "mcp-server", + "sentinel", + "telemetry-receiver", + "tunnel", +] +extensions = ["golang.go"] +checks = ["{go_build}"] + +[profile.website] +size = "component" +title = "Website" +dirs = ["website"] +submodules = ["website"] +ports = [ + { port = 4321, label = "Astro (website dev)", onAutoForward = "notify" }, +] +extensions = [ + "astro-build.astro-vscode", + "bradlc.vscode-tailwindcss", +] +checks = ["cd website && npm ci && npm run check"] + +[profile.modules] +size = "component" +title = "Modules" +dirs = ["modules"] +submodules = ["modules"] +checks = ["make module-validate"] + +# ---------------------------------------------------------------- part profiles + +[profile.dashboard-api] +size = "part" +title = "Dashboard and API" +includes = ["web", "api", "operator", "agent"] +dirs = ["deploy/kind", "charts/gameplane"] +submodules = ["modules"] +extras = [ + { name = "docker", kind = "service", version = "docker", reason = "In-container Docker daemon for the kind dev cluster (OD-2; deploy/kind/up.sh:195-199)." }, +] +ports = [ + { port = 5173, label = "Vite (web dev)", onAutoForward = "notify" }, + { port = 8080, label = "Gameplane ingress (HTTP)", onAutoForward = "silent" }, + { port = 8443, label = "Gameplane ingress (HTTPS)", onAutoForward = "silent" }, + { port = 5001, label = "Local OCI registry", onAutoForward = "silent" }, +] +env = { GAMEPLANE_REG_HOST_PORT = "5001" } +notes = ["Run order: make dev-up -> make web-dev -> open forwarded port 5173."] + +[profile.control-plane] +size = "part" +title = "Control plane" +includes = ["operator", "api", "agent"] +dirs = ["charts/gameplane", "deploy/kind", "test/e2e"] +submodules = ["modules"] +extras = [ + { name = "docker", kind = "service", version = "docker", reason = "In-container Docker daemon for the kind cluster and E2E images (OD-2; test/e2e/gatewayparity_e2e_test.go:156)." }, +] +ports = [ + { port = 8080, label = "Gameplane ingress (HTTP)", onAutoForward = "silent" }, + { port = 8443, label = "Gameplane ingress (HTTPS)", onAutoForward = "silent" }, + { port = 5001, label = "Local OCI registry", onAutoForward = "silent" }, + { port = 30565, label = "Sample Minecraft NodePort", onAutoForward = "silent" }, +] +env = { GAMEPLANE_REG_HOST_PORT = "5001" } +checks = ["cd test/e2e && go vet -tags e2e ./..."] + +[profile.modules-gp] +size = "part" +title = "Modules and gp-module" +includes = ["modules"] +dirs = ["gp-module"] +submodules = ["modules"] + +# ---------------------------------------------------------------- full + +[profile.full] +size = "full" +title = "Full" + +[full] +# FR-003: full keeps today's ports and extensions; website-only extras stay in the website profile. +omit_ports = [4321] +omit_extensions = ["astro-build.astro-vscode"] +personal_extensions = [ + "google.geminicodeassist", + "google.gemini-cli-vscode-ide-companion", + "highagency.pencildev", + "fill-labs.dependi", + "rust-lang.rust-analyzer", + "dustypomerleau.rust-syntax", + "1yib.rust-bundle", + "vue.volar", + "nicolasdschmidt.lunar-pink", + "ahmedsaid.darkpinkpro", + "ms-azuretools.vscode-containers", +] +personal_settings = { "workbench.colorTheme" = "Lunar Pink" } diff --git a/.devcontainer/versions.toml b/.devcontainer/versions.toml new file mode 100644 index 000000000..bd46afce1 --- /dev/null +++ b/.devcontainer/versions.toml @@ -0,0 +1,18 @@ +# Pinned tool versions for the dev containers (spec 026). +# Every key must be used by a system-deps.toml entry, a derived step, or the generator registry. +# Keep this file the only place a version number is written; profiles.toml refers to keys here. + +[tools] +go = "1.26" +node = "24" +docker = "latest" +docker_compose = "v2" +kubectl = "1.36" +# latest stable helm/helm tag on 2026-10-22 (OD-5) +helm = "4.3.1" +kind = "v0.32.0" +oras = "1.2.0" +golangci_lint = "v2.12.2" +setup_envtest = "release-0.24" +envtest_k8s = "1.36.2" +specify = "v1.0.3" From 4d146fe27bd7d7d59d5c8dce0e8d7c227d46dc12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 02/19] chore: list each component's system dependencies (spec 026 T011) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- agent/system-deps.toml | 15 +++++++++++++ api/system-deps.toml | 21 ++++++++++++++++++ audit-syslog-bridge/system-deps.toml | 15 +++++++++++++ capture-sidecar/system-deps.toml | 15 +++++++++++++ charts/gameplane/system-deps.toml | 9 ++++++++ deploy/kind/system-deps.toml | 27 +++++++++++++++++++++++ gameaction/system-deps.toml | 15 +++++++++++++ gameproto/system-deps.toml | 15 +++++++++++++ gp-module/system-deps.toml | 15 +++++++++++++ i18n/system-deps.toml | 15 +++++++++++++ mcp-server/system-deps.toml | 15 +++++++++++++ netguard/system-deps.toml | 15 +++++++++++++ operator/system-deps.toml | 21 ++++++++++++++++++ sentinel/system-deps.toml | 15 +++++++++++++ svcutil/system-deps.toml | 15 +++++++++++++ telemetry-receiver/system-deps.toml | 15 +++++++++++++ telemetryschema/system-deps.toml | 15 +++++++++++++ test/e2e/system-deps.toml | 33 ++++++++++++++++++++++++++++ tunnel/system-deps.toml | 15 +++++++++++++ web/system-deps.toml | 9 ++++++++ 20 files changed, 330 insertions(+) create mode 100644 agent/system-deps.toml create mode 100644 api/system-deps.toml create mode 100644 audit-syslog-bridge/system-deps.toml create mode 100644 capture-sidecar/system-deps.toml create mode 100644 charts/gameplane/system-deps.toml create mode 100644 deploy/kind/system-deps.toml create mode 100644 gameaction/system-deps.toml create mode 100644 gameproto/system-deps.toml create mode 100644 gp-module/system-deps.toml create mode 100644 i18n/system-deps.toml create mode 100644 mcp-server/system-deps.toml create mode 100644 netguard/system-deps.toml create mode 100644 operator/system-deps.toml create mode 100644 sentinel/system-deps.toml create mode 100644 svcutil/system-deps.toml create mode 100644 telemetry-receiver/system-deps.toml create mode 100644 telemetryschema/system-deps.toml create mode 100644 test/e2e/system-deps.toml create mode 100644 tunnel/system-deps.toml create mode 100644 web/system-deps.toml diff --git a/agent/system-deps.toml b/agent/system-deps.toml new file mode 100644 index 000000000..24cbb33a6 --- /dev/null +++ b/agent/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for agent (spec 026). Language libraries stay in go.mod/package.json. + +component = "agent" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the agent module (go.work:3-21; inventory 2.1 Go modules table)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/api/system-deps.toml b/api/system-deps.toml new file mode 100644 index 000000000..a2bfecc9d --- /dev/null +++ b/api/system-deps.toml @@ -0,0 +1,21 @@ +# System dependencies for api (spec 026). Language libraries stay in go.mod/package.json. + +component = "api" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the API module (go.work:3-21; inventory 2.1 api row)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." + +[[dep]] +name = "envtest" +kind = "service" +version = "envtest_k8s" +reason = "Kubernetes API server and etcd binaries for api envtest suites (Makefile:166-171; test-integration at Makefile:174-179)." diff --git a/audit-syslog-bridge/system-deps.toml b/audit-syslog-bridge/system-deps.toml new file mode 100644 index 000000000..5ebd0c027 --- /dev/null +++ b/audit-syslog-bridge/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for audit-syslog-bridge (spec 026). Language libraries stay in go.mod/package.json. + +component = "audit-syslog-bridge" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the audit-syslog-bridge module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/capture-sidecar/system-deps.toml b/capture-sidecar/system-deps.toml new file mode 100644 index 000000000..6399a6aab --- /dev/null +++ b/capture-sidecar/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for capture-sidecar (spec 026). Language libraries stay in go.mod/package.json. + +component = "capture-sidecar" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the capture-sidecar module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/charts/gameplane/system-deps.toml b/charts/gameplane/system-deps.toml new file mode 100644 index 000000000..eeb01f310 --- /dev/null +++ b/charts/gameplane/system-deps.toml @@ -0,0 +1,9 @@ +# System dependencies for charts/gameplane (spec 026). Language libraries stay in go.mod/package.json. + +component = "charts/gameplane" + +[[dep]] +name = "helm" +kind = "cli" +version = "helm" +reason = "helm lint and helm upgrade --install for the chart (Makefile:443-460; .github/workflows/ci.yaml:1170-1171)." diff --git a/deploy/kind/system-deps.toml b/deploy/kind/system-deps.toml new file mode 100644 index 000000000..957a8b9b6 --- /dev/null +++ b/deploy/kind/system-deps.toml @@ -0,0 +1,27 @@ +# System dependencies for deploy/kind (spec 026). Language libraries stay in go.mod/package.json. + +component = "deploy/kind" + +[[dep]] +name = "docker" +kind = "service" +version = "docker" +reason = "kind nodes and the registry:2 container run on Docker (deploy/kind/up.sh:19, :177-179)." + +[[dep]] +name = "kind" +kind = "cli" +version = "kind" +reason = "up.sh and e2e.sh create the kind cluster (deploy/kind/up.sh:207; deploy/kind/e2e.sh:192)." + +[[dep]] +name = "kubectl" +kind = "cli" +version = "kubectl" +reason = "up.sh wraps kubectl for MetalLB and ingress-nginx applies (deploy/kind/up.sh:31, :51-52, :245)." + +[[dep]] +name = "helm" +kind = "cli" +version = "helm" +reason = "e2e.sh runs helm upgrade --install gameplane (deploy/kind/e2e.sh:384; need check at up.sh:195-199)." diff --git a/gameaction/system-deps.toml b/gameaction/system-deps.toml new file mode 100644 index 000000000..ac75faceb --- /dev/null +++ b/gameaction/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for gameaction (spec 026). Language libraries stay in go.mod/package.json. + +component = "gameaction" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the gameaction module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/gameproto/system-deps.toml b/gameproto/system-deps.toml new file mode 100644 index 000000000..39f891fcc --- /dev/null +++ b/gameproto/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for gameproto (spec 026). Language libraries stay in go.mod/package.json. + +component = "gameproto" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the gameproto module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/gp-module/system-deps.toml b/gp-module/system-deps.toml new file mode 100644 index 000000000..da2f65084 --- /dev/null +++ b/gp-module/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for gp-module (spec 026). Language libraries stay in go.mod/package.json. + +component = "gp-module" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles the gp-module CLI and tests the module (go.work:3-21; Makefile:383-385 builds bin/gp-module)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/i18n/system-deps.toml b/i18n/system-deps.toml new file mode 100644 index 000000000..c08e39fe0 --- /dev/null +++ b/i18n/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for i18n (spec 026). Language libraries stay in go.mod/package.json. + +component = "i18n" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the i18n module (go.work:3-21; i18n-sync at Makefile:272-273 uses go run)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/mcp-server/system-deps.toml b/mcp-server/system-deps.toml new file mode 100644 index 000000000..e5383724d --- /dev/null +++ b/mcp-server/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for mcp-server (spec 026). Language libraries stay in go.mod/package.json. + +component = "mcp-server" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the mcp-server module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/netguard/system-deps.toml b/netguard/system-deps.toml new file mode 100644 index 000000000..e48c73bea --- /dev/null +++ b/netguard/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for netguard (spec 026). Language libraries stay in go.mod/package.json. + +component = "netguard" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the netguard module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/operator/system-deps.toml b/operator/system-deps.toml new file mode 100644 index 000000000..687aa2aef --- /dev/null +++ b/operator/system-deps.toml @@ -0,0 +1,21 @@ +# System dependencies for operator (spec 026). Language libraries stay in go.mod/package.json. + +component = "operator" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the operator module (go.work:3-21; generate and manifests run controller-gen via go run, Makefile:347-362)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." + +[[dep]] +name = "envtest" +kind = "service" +version = "envtest_k8s" +reason = "Kubernetes API server and etcd binaries for operator envtest suites (Makefile:166-171; inventory 2.1 operator row)." diff --git a/sentinel/system-deps.toml b/sentinel/system-deps.toml new file mode 100644 index 000000000..167718db8 --- /dev/null +++ b/sentinel/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for sentinel (spec 026). Language libraries stay in go.mod/package.json. + +component = "sentinel" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the sentinel module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/svcutil/system-deps.toml b/svcutil/system-deps.toml new file mode 100644 index 000000000..c2bdeba27 --- /dev/null +++ b/svcutil/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for svcutil (spec 026). Language libraries stay in go.mod/package.json. + +component = "svcutil" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the svcutil module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/telemetry-receiver/system-deps.toml b/telemetry-receiver/system-deps.toml new file mode 100644 index 000000000..2cb80dd30 --- /dev/null +++ b/telemetry-receiver/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for telemetry-receiver (spec 026). Language libraries stay in go.mod/package.json. + +component = "telemetry-receiver" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the telemetry-receiver module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/telemetryschema/system-deps.toml b/telemetryschema/system-deps.toml new file mode 100644 index 000000000..b93affcc8 --- /dev/null +++ b/telemetryschema/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for telemetryschema (spec 026). Language libraries stay in go.mod/package.json. + +component = "telemetryschema" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the telemetryschema module (go.work:3-21; inventory 2.1)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/test/e2e/system-deps.toml b/test/e2e/system-deps.toml new file mode 100644 index 000000000..00bdc8c66 --- /dev/null +++ b/test/e2e/system-deps.toml @@ -0,0 +1,33 @@ +# System dependencies for test/e2e (spec 026). Language libraries stay in go.mod/package.json. + +component = "test/e2e" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and runs the Go E2E suite, a separate module (go.work:3-21; Makefile:50 GO_UNIT_MODULES)." + +[[dep]] +name = "kind" +kind = "cli" +version = "kind" +reason = "Creates and reads the kind cluster the E2E suite targets (deploy/kind/e2e.sh:192; test/e2e/multicluster_e2e_test.go:126)." + +[[dep]] +name = "kubectl" +kind = "cli" +version = "kubectl" +reason = "Runtime binary the E2E suite executes (test/e2e/env.go:167, :249, :392)." + +[[dep]] +name = "helm" +kind = "cli" +version = "helm" +reason = "Runtime binary the E2E suite executes (test/e2e/helm_install_e2e_test.go:359, :514)." + +[[dep]] +name = "docker" +kind = "service" +version = "docker" +reason = "Builds E2E images and is called by the gateway parity and multicluster tests (Makefile:185-191; test/e2e/gatewayparity_e2e_test.go:156)." diff --git a/tunnel/system-deps.toml b/tunnel/system-deps.toml new file mode 100644 index 000000000..e54877b4f --- /dev/null +++ b/tunnel/system-deps.toml @@ -0,0 +1,15 @@ +# System dependencies for tunnel (spec 026). Language libraries stay in go.mod/package.json. + +component = "tunnel" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "Compiles and tests the tunnel module (go.work:3-21; inventory 2.1; nested frpc module is outside go.work, tunnel/frpc/go.mod)." + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "make lint-go runs golangci-lint in this module (Makefile:302-305; GO_MODULES at Makefile:43)." diff --git a/web/system-deps.toml b/web/system-deps.toml new file mode 100644 index 000000000..9b9ffb4f3 --- /dev/null +++ b/web/system-deps.toml @@ -0,0 +1,9 @@ +# System dependencies for web (spec 026). Language libraries stay in go.mod/package.json. + +component = "web" + +[[dep]] +name = "node" +kind = "toolchain" +version = "node" +reason = "Runs npm ci, the Vite dev server and tsc for web (web/package.json:6-28; Makefile:79-80, 477-478; CI Node 24 at .github/workflows/ci.yaml:996)." From 94f501eaa9602f9247210fb1e01adba864b27b96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 03/19] feat: generate dev container profiles from per-component deps (spec 026 T004-T007, T010) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- Makefile | 14 +- hack/gen-devcontainers.py | 969 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 982 insertions(+), 1 deletion(-) create mode 100755 hack/gen-devcontainers.py diff --git a/Makefile b/Makefile index 364339f79..229fed7c8 100644 --- a/Makefile +++ b/Makefile @@ -291,12 +291,24 @@ check-coverage-gaps: ## Validate spec 013's coverage gap record and prove the va hack/check-coverage-gaps.sh hack/check-coverage-gaps_test.sh +.PHONY: devcontainers +devcontainers: ## Regenerate the dev container profiles and submodule copies (spec 026) + python3 hack/gen-devcontainers.py --submodules + +.PHONY: check-devcontainers +check-devcontainers: ## Verify generated dev container files match their inputs (spec 026) + python3 hack/gen-devcontainers.py --check + +.PHONY: test-gen-devcontainers +test-gen-devcontainers: ## Run the fixture tests for the dev container generator (CI only, spec 026) + python3 hack/test-gen-devcontainers.py + .PHONY: test-up-registry test-up-registry: ## Run the fixture tests for deploy/kind/up.sh's registry/context bootstrap hack/test-up-registry.sh .PHONY: lint -lint: check-doc-versions check-links check-features-sync check-specs check-telemetry-catalog check-i18n check-dev-load-images check-coverage-gaps test-up-registry lint-go lint-web ## Run all linters +lint: check-doc-versions check-links check-features-sync check-specs check-telemetry-catalog check-i18n check-dev-load-images check-coverage-gaps check-devcontainers test-up-registry lint-go lint-web ## Run all linters .PHONY: lint-go lint-go: ## Run golangci-lint across all modules diff --git a/hack/gen-devcontainers.py b/hack/gen-devcontainers.py new file mode 100755 index 000000000..aab5befe1 --- /dev/null +++ b/hack/gen-devcontainers.py @@ -0,0 +1,969 @@ +#!/usr/bin/env python3 +"""Generate the modular dev container profiles (spec 026). + +Hand-written inputs: + .devcontainer/versions.toml pinned tool versions (one key per tool) + .devcontainer/profiles.toml profile catalogue (baseline, profiles, full) + /system-deps.toml system dependencies of each folder + .devcontainer/devcontainer-lock.json source feature lock (used by the full profile) + +Generated outputs (never edit by hand): + .devcontainer/devcontainer.json, .devcontainer/full/{setup.env,ready.txt} + .devcontainer//{devcontainer.json,devcontainer-lock.json,setup.env,ready.txt} + .devcontainer/tool-index.tsv + --submodules: /.devcontainer/* for the website and modules profiles + +Contracts: specs/026-modular-dev-containers/contracts/. Standard library only. +""" + +import argparse +import difflib +import json +import os +import re +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +DEVC = ".devcontainer" +SYSTEM_DEPS = "system-deps.toml" +GO_PREFIX = "github.com/GameplanePanel/gameplane/" +GO_BUILD_TOKEN = "{go_build}" +GO_NO_BUILD = "test/e2e" +SIZES = ("component", "part", "full") +SIZE_RANK = {"component": 0, "part": 1, "full": 2} +DEP_KINDS = frozenset({"toolchain", "cli", "os-package", "service", "submodule"}) +AUTO_FORWARD = frozenset({"notify", "openBrowser", "openBrowserOnce", "openPreview", "silent", "ignore"}) +DEP_KEYS = frozenset({"name", "kind", "version", "reason", "optional"}) +SYSTEM_DEPS_KEYS = frozenset({"component", "none", "dep"}) +PROFILE_KEYS = frozenset({"size", "title", "dirs", "includes", "extras", "submodules", + "ports", "extensions", "checks", "env", "notes"}) +PORT_KEYS = frozenset({"port", "label", "onAutoForward"}) +BASELINE_KEYS = frozenset({"image", "remoteUser", "runArgs", "extensions", "env", "settings", "deps"}) +FULL_KEYS = frozenset({"personal_extensions", "personal_settings", "omit_ports", "omit_extensions"}) +PRUNE_DIRS = frozenset({"node_modules", "bin", "vendor", "dist"}) +LITERAL_VERSION = re.compile(r"^v?\d+(\.\d+)+") +GLOBAL_TRIGGERS = frozenset({ + ".devcontainer/versions.toml", + ".devcontainer/profiles.toml", + ".devcontainer/setup.sh", + ".devcontainer/devcontainer-lock.json", + "hack/gen-devcontainers.py", + "go.work", + "Makefile", +}) + +FEAT_COMMON = "ghcr.io/devcontainers/features/common-utils:2" +FEAT_GH = "ghcr.io/devcontainers/features/github-cli:1" +FEAT_GO = "ghcr.io/devcontainers/features/go:1" +FEAT_NODE = "ghcr.io/devcontainers/features/node:1" +FEAT_DOCKER = "ghcr.io/devcontainers/features/docker-in-docker:2" +FEAT_KUBE = "ghcr.io/devcontainers/features/kubectl-helm-minikube:1" +FEATURE_FOR_DEP = { + "common-utils": FEAT_COMMON, + "gh": FEAT_GH, + "go": FEAT_GO, + "node": FEAT_NODE, + "docker": FEAT_DOCKER, + "kubectl": FEAT_KUBE, + "helm": FEAT_KUBE, +} +# dep name -> (setup.sh step, {_VERSION env var: versions.toml key}) +STEP_FOR_DEP = { + "kind": ("kind", {"KIND_VERSION": "kind"}), + "oras": ("oras", {"ORAS_VERSION": "oras"}), + "golangci-lint": ("golangci_lint", {"GOLANGCI_LINT_VERSION": "golangci_lint"}), + "envtest": ("envtest", {"SETUP_ENVTEST_VERSION": "setup_envtest", + "ENVTEST_K8S_VERSION": "envtest_k8s"}), + "uv": ("uv", {}), + "specify": ("specify", {"SPECIFY_VERSION": "specify"}), + "ai-clis": ("ai_clis", {}), +} +FIXED_STEP_ORDER = ("submodules", "kind", "oras", "golangci_lint", "envtest", "go_mod_download", + "npm_ci_web", "npm_ci_website", "uv", "specify", "ai_clis") +# versions.toml keys a dep consumes besides its own `version` key. +EXTRA_KEYS = {"docker": ("docker_compose",), "envtest": ("setup_envtest",)} +REGISTRY_KEYS = ("go", "node", "docker", "docker_compose", "kubectl", "helm", "kind", "oras", + "golangci_lint", "setup_envtest", "envtest_k8s", "specify") +# make targets listed under "Works here:". A target is listed when the profile has every +# tool in its tools set, every folder in its folders set is covered, and the profile's +# checks do not already name it. (make target, tools, folders) +MAKE_TARGETS = ( + ("make build-go", frozenset({"go"}), frozenset()), + ("make build-web", frozenset({"node"}), frozenset({"web"})), + ("make web-dev", frozenset({"node"}), frozenset({"web"})), + ("make generate", frozenset({"go"}), frozenset({"operator"})), + ("make manifests", frozenset({"go"}), frozenset({"operator"})), + ("make module-validate", frozenset({"go"}), frozenset()), + ("make modules-push", frozenset({"oras"}), frozenset({"modules"})), + ("make dev-load", frozenset({"docker", "kind"}), frozenset({"deploy/kind"})), + ("make e2e-up", frozenset({"docker", "kind", "kubectl", "helm"}), frozenset()), + ("make dev-up", frozenset({"docker", "kind", "kubectl", "helm", "oras"}), + frozenset({"deploy/kind"})), +) + + +@dataclass +class Profile: + """One [profile.] table from profiles.toml, normalised.""" + + pid: str + index: int + size: str + title: str + dirs: list[str] + includes: list[str] + extras: list[dict] + submodules: list[str] + ports: list[dict] + extensions: list[str] + checks: list[str] + env: dict + notes: list[str] + + +@dataclass +class Model: + """Validated inputs. Built once per run by build_model.""" + + root: Path + tools: dict + baseline: dict + baseline_deps: list[dict] + profiles: list[Profile] + personal_extensions: list[str] + personal_settings: dict + folder_deps: dict + go_modules: list[str] + go_refs: dict + source_lock: dict + full_omit_ports: set + full_omit_extensions: set + + +@dataclass +class Resolved: + """A profile after composition: what its generated files are built from.""" + + pid: str + index: int + size: str + title: str + covered: list[str] + go_modules: list[str] + deps: list[dict] + submodules: list[str] + ports: list[dict] + extensions: list[str] + checks: list[str] + env: dict + notes: list[str] + settings: dict + sub_folder: str | None + + +@dataclass +class Context: + """Cross-profile lookups for ready.txt and tool-index.tsv.""" + + all_tools: set + tool_profiles: dict + declaring: dict + + +# ---------------------------------------------------------------- loading and validation + +def load_toml(path: Path, label: str, errors: list[str]) -> dict: + try: + with path.open("rb") as fh: + return tomllib.load(fh) + except FileNotFoundError: + errors.append(f"{label}: file not found") + except (tomllib.TOMLDecodeError, OSError) as exc: + errors.append(f"{label}: cannot read TOML: {exc}") + return {} + + +def load_versions(root: Path, errors: list[str]) -> dict: + label = f"{DEVC}/versions.toml" + doc = load_toml(root / DEVC / "versions.toml", label, errors) + for key in sorted(set(doc) - {"tools"}): + errors.append(f"{label}: unknown table {key!r}") + tools = doc.get("tools", {}) + for key, value in tools.items(): + if not isinstance(value, str): + errors.append(f"{label}: [tools].{key} must be a string") + return {key: str(value) for key, value in tools.items()} + + +def check_dep(dep: dict, label: str, tools: dict, errors: list[str]) -> None: + """Shape and registry rules shared by system-deps.toml, extras and the baseline.""" + name = dep.get("name") + if not isinstance(name, str) or not name: + errors.append(f"{label}: a dep has no name") + return + for key in sorted(set(dep) - DEP_KEYS): + errors.append(f"{label}: dep {name!r} has unknown key {key!r}") + kind = dep.get("kind") + if kind not in DEP_KINDS: + errors.append(f"{label}: dep {name!r} kind {kind!r} is not one of {sorted(DEP_KINDS)}") + reason = dep.get("reason") + if not isinstance(reason, str) or not reason.strip(): + errors.append(f"{label}: dep {name!r} needs a non-empty reason") + version = dep.get("version") + if version is not None and version not in tools: + errors.append(f"{label}: dep {name!r} version {version!r} is not a versions.toml key " + "(literal versions are not allowed, FR-007)") + if "optional" in dep and not isinstance(dep["optional"], bool): + errors.append(f"{label}: dep {name!r} optional must be true or false") + registered = name in FEATURE_FOR_DEP or name in STEP_FOR_DEP + if not registered and (kind != "os-package" or version is not None): + errors.append(f"{label}: dep {name!r} is not in the generator's dep registry; " + "an unregistered dep must be kind 'os-package' with no version") + + +def load_folder_deps(root: Path, folder: str, tools: dict, errors: list[str]) -> list[dict]: + label = f"{folder}/{SYSTEM_DEPS}" + doc = load_toml(root / folder / SYSTEM_DEPS, label, errors) + for key in sorted(set(doc) - SYSTEM_DEPS_KEYS): + errors.append(f"{label}: unknown key {key!r} (rule 6)") + if doc.get("component") != folder: + errors.append(f"{label}: component must equal {folder!r} (rule 1)") + none = doc.get("none", False) + if not isinstance(none, bool): + errors.append(f"{label}: none must be true or false") + deps = doc.get("dep", []) + if none is True and deps: + errors.append(f"{label}: none = true excludes [[dep]] entries (rule 5)") + seen: set = set() + for dep in deps: + check_dep(dep, label, tools, errors) + name = dep.get("name") + if name in seen: + errors.append(f"{label}: duplicate dep {name!r}") + seen.add(name) + return list(deps) + + +def discover_folders(root: Path) -> list[str]: + found = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in PRUNE_DIRS] + current = Path(dirpath) + if current != root and SYSTEM_DEPS in filenames: + found.append(current.relative_to(root).as_posix()) + return sorted(found) + + +def load_go_work(root: Path, errors: list[str]) -> list[str]: + try: + text = (root / "go.work").read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"go.work: cannot read ({exc.strerror})") + return [] + match = re.search(r"^use \((.*?)^\)", text, re.S | re.M) + if match is None: + errors.append("go.work: no use (...) block found") + return [] + modules = [] + for line in match.group(1).splitlines(): + entry = line.strip() + if entry and not entry.startswith("//"): + modules.append(entry.removeprefix("./")) + return modules + + +def match_go_module(token: str, go_modules: list[str]) -> str | None: + best = None + for module in go_modules: + prefix = GO_PREFIX + module + if (token == prefix or token.startswith(prefix + "/")) \ + and (best is None or len(module) > len(best)): + best = module + return best + + +def load_go_refs(root: Path, go_modules: list[str], errors: list[str]) -> dict: + refs: dict = {} + for module in go_modules: + refs[module] = [] + try: + text = (root / module / "go.mod").read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"{module}/go.mod: cannot read ({exc.strerror})") + continue + for token in re.findall(r"github\.com/GameplanePanel/gameplane/[\w./-]+", text): + target = match_go_module(token, go_modules) + if target and target != module and target not in refs[module]: + refs[module].append(target) + return refs + + +def load_source_lock(root: Path, errors: list[str]) -> dict: + path = root / DEVC / "devcontainer-lock.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"{DEVC}/devcontainer-lock.json: cannot read ({exc})") + return {"features": {}} + return data + + +def check_makefile_pins(root: Path, tools: dict, errors: list[str]) -> None: + try: + text = (root / "Makefile").read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"Makefile: cannot read ({exc.strerror})") + return + pin = re.search(r"^ENVTEST_K8S_VERSION\s*\?=\s*(\S+)", text, re.M) + if pin is None: + errors.append("Makefile: ENVTEST_K8S_VERSION ?= pin not found (OD-5)") + elif pin.group(1) != tools.get("envtest_k8s"): + errors.append(f"Makefile: ENVTEST_K8S_VERSION {pin.group(1)} differs from " + f"versions.toml envtest_k8s {tools.get('envtest_k8s')} (OD-5)") + refs = re.findall(r"setup-envtest@(\S+)", text) + if not refs: + errors.append("Makefile: no setup-envtest@ ref found (OD-5)") + for ref in refs: + if ref != tools.get("setup_envtest"): + errors.append(f"Makefile: setup-envtest@{ref} differs from " + f"versions.toml setup_envtest {tools.get('setup_envtest')} (OD-5)") + + +def scan_literals(node, trail: list[str], tools: dict, errors: list[str]) -> None: + """Reject literal versions anywhere in profiles.toml (FR-007).""" + if isinstance(node, dict): + for key, value in node.items(): + if key == "version" and isinstance(value, str) and value not in tools: + errors.append(f"{DEVC}/profiles.toml {'.'.join(trail) or '(root)'}: version {value!r} " + "is not a versions.toml key (FR-007)") + scan_literals(value, trail + [str(key)], tools, errors) + elif isinstance(node, list): + for item in node: + scan_literals(item, trail, tools, errors) + elif isinstance(node, str) and LITERAL_VERSION.match(node): + errors.append(f"{DEVC}/profiles.toml {'.'.join(trail)}: literal version {node!r} " + "(FR-007); name a versions.toml key instead") + + +def str_list(table: dict, key: str, label: str, errors: list[str]) -> list[str]: + value = table.get(key, []) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + errors.append(f"{label}: {key} must be a list of strings") + return [] + return list(value) + + +def build_baseline(table: dict, tools: dict, errors: list[str]) -> tuple[dict, list[dict]]: + label = f"{DEVC}/profiles.toml [baseline]" + for key in sorted(set(table) - BASELINE_KEYS): + errors.append(f"{label}: unknown key {key!r}") + for key in ("image", "remoteUser"): + if not isinstance(table.get(key), str): + errors.append(f"{label}: {key} must be a string") + baseline = { + "image": table.get("image", ""), + "remoteUser": table.get("remoteUser", ""), + "runArgs": str_list(table, "runArgs", label, errors), + "extensions": str_list(table, "extensions", label, errors), + "env": dict(table.get("env", {})), + "settings": dict(table.get("settings", {})), + } + deps = list(table.get("deps", [])) + for dep in deps: + check_dep(dep, f"{label} deps", tools, errors) + return baseline, deps + + +def build_profile(pid: str, index: int, table: dict, tools: dict, errors: list[str]) -> Profile: + label = f"{DEVC}/profiles.toml [profile.{pid}]" + for key in sorted(set(table) - PROFILE_KEYS): + errors.append(f"{label}: unknown key {key!r}") + size = table.get("size") + if size not in SIZES: + errors.append(f"{label}: size must be one of {list(SIZES)}") + if not isinstance(table.get("title"), str): + errors.append(f"{label}: title must be a string") + extras = list(table.get("extras", [])) + for dep in extras: + check_dep(dep, f"{label} extras", tools, errors) + ports = list(table.get("ports", [])) + for port in ports: + if set(port) != PORT_KEYS: + errors.append(f"{label}: port entries need exactly {sorted(PORT_KEYS)}") + if not isinstance(port.get("port"), int): + errors.append(f"{label}: port must be an integer") + if port.get("onAutoForward") not in AUTO_FORWARD: + errors.append(f"{label}: onAutoForward {port.get('onAutoForward')!r} is not valid") + return Profile( + pid=pid, index=index, size=size or "", title=str(table.get("title", pid)), + dirs=str_list(table, "dirs", label, errors), + includes=str_list(table, "includes", label, errors), + extras=extras, + submodules=str_list(table, "submodules", label, errors), + ports=ports, + extensions=str_list(table, "extensions", label, errors), + checks=str_list(table, "checks", label, errors), + env=dict(table.get("env", {})), + notes=str_list(table, "notes", label, errors), + ) + + +def build_profiles(table: dict, tools: dict, errors: list[str]) -> list[Profile]: + profiles = [build_profile(pid, index, body, tools, errors) + for index, (pid, body) in enumerate(table.items())] + fulls = [p for p in profiles if p.size == "full"] + components = {p.pid for p in profiles if p.size == "component"} + if len(fulls) != 1: + errors.append(f"{DEVC}/profiles.toml: exactly one profile must have size = \"full\"") + for prof in profiles: + label = f"{DEVC}/profiles.toml [profile.{prof.pid}]" + if prof.size == "component" and (prof.includes or not prof.dirs): + errors.append(f"{label}: a component lists dirs and no includes") + if prof.size == "part": + if not prof.includes: + errors.append(f"{label}: a part must list includes") + for inc in prof.includes: + if inc not in components: + errors.append(f"{label}: includes {inc!r} is not a component profile") + if prof.size == "full" and (prof.dirs or prof.includes): + errors.append(f"{label}: the full profile has no dirs or includes") + return profiles + + +def build_full_personal(doc: dict, errors: list[str]) -> tuple[list[str], dict]: + table = doc.get("full", {}) + for key in sorted(set(table) - FULL_KEYS): + errors.append(f"{DEVC}/profiles.toml [full]: unknown key {key!r}") + exts = table.get("personal_extensions", []) + if not isinstance(exts, list) or not all(isinstance(e, str) for e in exts): + errors.append(f"{DEVC}/profiles.toml [full]: personal_extensions must be a list of strings") + exts = [] + return list(exts), dict(table.get("personal_settings", {})) + + +def build_full_omits(doc: dict, errors: list[str]) -> tuple[set, set]: + """[full] omit_ports / omit_extensions: what the full profile leaves out of the union.""" + table = doc.get("full", {}) + ports = table.get("omit_ports", []) + exts = table.get("omit_extensions", []) + if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports): + errors.append(f"{DEVC}/profiles.toml [full]: omit_ports must be a list of integers") + ports = [] + if not isinstance(exts, list) or not all(isinstance(e, str) for e in exts): + errors.append(f"{DEVC}/profiles.toml [full]: omit_extensions must be a list of strings") + exts = [] + return set(ports), set(exts) + + +def build_model(root: Path) -> tuple[Model | None, list[str]]: + errors: list[str] = [] + tools = load_versions(root, errors) + for key in REGISTRY_KEYS: + if key not in tools: + errors.append(f"versions.toml: key {key!r} is required by the generator registry") + doc = load_toml(root / DEVC / "profiles.toml", f"{DEVC}/profiles.toml", errors) + scan_literals(doc, [], tools, errors) + baseline, baseline_deps = build_baseline(doc.get("baseline", {}), tools, errors) + profiles = build_profiles(doc.get("profile", {}), tools, errors) + personal_ext, personal_settings = build_full_personal(doc, errors) + omit_ports, omit_extensions = build_full_omits(doc, errors) + folders = discover_folders(root) + folder_deps = {folder: load_folder_deps(root, folder, tools, errors) for folder in folders} + go_modules = load_go_work(root, errors) + go_refs = load_go_refs(root, go_modules, errors) + check_makefile_pins(root, tools, errors) + model = Model(root=root, tools=tools, baseline=baseline, baseline_deps=baseline_deps, + profiles=profiles, personal_extensions=personal_ext, + personal_settings=personal_settings, folder_deps=folder_deps, + go_modules=go_modules, go_refs=go_refs, + source_lock=load_source_lock(root, errors), + full_omit_ports=omit_ports, full_omit_extensions=omit_extensions) + return model, errors + + +# ---------------------------------------------------------------- composition + +def closure(base: list[str], model: Model) -> list[str]: + """Add the in-repo Go modules each covered Go module references (transitively).""" + ordered: list[str] = [] + for folder in base: + if folder not in ordered: + ordered.append(folder) + members = set(ordered) + todo = [folder for folder in ordered if folder in model.go_refs] + while todo: + module = todo.pop() + for ref in model.go_refs.get(module, []): + if ref not in members: + members.add(ref) + ordered.append(ref) + todo.append(ref) + return ordered + + +def own_go_modules(model: Model, prof: Profile) -> list[str]: + covered = set(closure(prof.dirs + prof.submodules, model)) + return [m for m in model.go_modules if m in covered] + + +def expand_checks(checks: list[str], go_modules: list[str]) -> list[str]: + out: list[str] = [] + for check in checks: + if check == GO_BUILD_TOKEN: + out.extend(f"cd {m} && go build ./..." for m in go_modules if m != GO_NO_BUILD) + else: + out.append(check) + return out + + +def union(lists) -> list: + out: list = [] + for values in lists: + for value in values: + if value not in out: + out.append(value) + return out + + +def merge_deps(deps: list[dict]) -> list[dict]: + merged: dict = {} + for dep in deps: + merged.setdefault(dep["name"], dep) + return list(merged.values()) + + +def merge_ports(profiles: list[Profile]) -> list[dict]: + out: list[dict] = [] + seen: set = set() + for prof in profiles: + for port in prof.ports: + if port["port"] not in seen: + seen.add(port["port"]) + out.append(dict(port)) + return out + + +def resolve_profiles(model: Model) -> dict[str, Resolved]: + resolved: dict[str, Resolved] = {} + for prof in model.profiles: + if prof.size == "full": + scope = list(model.profiles) + base = list(model.folder_deps) + [f for sp in scope for f in sp.dirs + sp.submodules] + drop_ports = model.full_omit_ports + drop_extensions = model.full_omit_extensions + extensions_extra = list(model.personal_extensions) + settings = {**model.baseline["settings"], **model.personal_settings} + else: + wanted = set(prof.includes) | {prof.pid} + scope = [sp for sp in model.profiles if sp.pid in wanted] + base = [f for sp in scope for f in sp.dirs + sp.submodules] + drop_ports = set() + drop_extensions = set() + extensions_extra = [] + settings = dict(model.baseline["settings"]) + covered = closure(base, model) + extras = [d for sp in scope for d in sp.extras] + deps = merge_deps(model.baseline_deps + + [d for f in covered for d in model.folder_deps.get(f, [])] + + extras) + submodules = union([sp.submodules for sp in scope] + + [[d["name"] for d in deps if d["kind"] == "submodule"]]) + checks = union([expand_checks(sp.checks, own_go_modules(model, sp)) for sp in scope]) + env: dict = {} + for sp in scope: + for key, value in sp.env.items(): + env.setdefault(key, value) + sub_folder = None + if prof.size == "component" and len(prof.dirs) == 1 and prof.submodules == prof.dirs: + sub_folder = prof.dirs[0] + resolved[prof.pid] = Resolved( + pid=prof.pid, index=prof.index, size=prof.size, title=prof.title, + covered=covered, + go_modules=[m for m in model.go_modules if m in covered], + deps=deps, + submodules=submodules, + ports=[p for p in merge_ports(scope) if p["port"] not in drop_ports], + extensions=[e for e in union([sp.extensions for sp in scope] + [extensions_extra]) + if e not in drop_extensions], + checks=checks, + env=env, + notes=union([sp.notes for sp in scope]), + settings=settings, + sub_folder=sub_folder, + ) + return resolved + + +def feature_options(res: Resolved, tools: dict) -> dict: + names = {d["name"] for d in res.deps} + feats: dict = {} + if "common-utils" in names: + feats[FEAT_COMMON] = {"installZsh": "true", "configureZshAsDefaultShell": "false", + "username": "vscode"} + if "gh" in names: + feats[FEAT_GH] = {} + if "go" in names: + feats[FEAT_GO] = {"version": tools["go"]} + if "node" in names: + feats[FEAT_NODE] = {"version": tools["node"]} + if "docker" in names: + feats[FEAT_DOCKER] = {"version": tools["docker"], "moby": True, + "dockerDashComposeVersion": tools["docker_compose"]} + if names & {"kubectl", "helm"}: + feats[FEAT_KUBE] = { + "version": tools["kubectl"] if "kubectl" in names else "none", + "helm": tools["helm"] if "helm" in names else "none", + "minikube": "none", + } + return feats + + +def steps_for(res: Resolved, sub_variant: bool) -> tuple[list[str], list[str]]: + names = [d["name"] for d in res.deps] + present = {STEP_FOR_DEP[n][0] for n in names if n in STEP_FOR_DEP} + if not sub_variant and res.submodules: + present.add("submodules") + if res.go_modules: + present.add("go_mod_download") + if "web" in res.covered: + present.add("npm_ci_web") + if "website" in res.covered: + present.add("npm_ci_website") + steps = [s for s in FIXED_STEP_ORDER if s in present] + optional = {STEP_FOR_DEP[d["name"]][0] for d in res.deps + if d.get("optional") and d["name"] in STEP_FOR_DEP} + return steps, [s for s in FIXED_STEP_ORDER if s in optional] + + +def version_vars(res: Resolved, tools: dict) -> dict: + out: dict = {} + for dep in res.deps: + for var, key in STEP_FOR_DEP.get(dep["name"], ("", {}))[1].items(): + out[var] = tools[key] + return out + + +def ready_path(res: Resolved) -> str: + if res.size == "full": + return f"{DEVC}/full/ready.txt" + return f"{DEVC}/{res.pid}/ready.txt" + + +def setup_env_bytes(res: Resolved, tools: dict, sub_variant: bool) -> bytes: + steps, optional = steps_for(res, sub_variant) + values = { + "PROFILE": res.pid, + "STEPS": " ".join(steps), + "OPTIONAL_STEPS": " ".join(optional), + **version_vars(res, tools), + "GO_MODULES": " ".join(res.go_modules), + "SUBMODULES": "" if sub_variant else " ".join(res.submodules), + "WEBSITE_DIR": "." if sub_variant else "website", + "MODULES_DIR": "." if sub_variant else "modules", + "READY": f"{DEVC}/ready.txt" if sub_variant else ready_path(res), + } + for key, value in values.items(): + if any(ch in value for ch in '"$`\\\n'): + raise ValueError(f"setup.env value for {key} needs quoting: {value!r}") + text = "".join(f'{key}="{values[key]}"\n' for key in sorted(values)) + return text.encode("utf-8") + + +def feature_ids(res: Resolved, tools: dict) -> list[str]: + return sorted(feature_options(res, tools)) + + +def lock_bytes(model: Model, res: Resolved) -> bytes: + features = model.source_lock.get("features", {}) + subset = {fid: features[fid] for fid in feature_ids(res, model.tools) if fid in features} + return json_bytes({"features": subset}) + + +def json_bytes(obj) -> bytes: + return (json.dumps(obj, indent="\t", sort_keys=True) + "\n").encode("utf-8") + + +def devcontainer_bytes(model: Model, res: Resolved, post_command: str) -> bytes: + base = model.baseline + extensions = sorted(set(base["extensions"]) | set(res.extensions)) + obj: dict = { + "name": f"Gameplane · {res.title}", + "image": base["image"], + "features": feature_options(res, model.tools), + "runArgs": list(base["runArgs"]), + "containerEnv": {**base["env"], **res.env}, + "postCreateCommand": post_command, + "remoteUser": base["remoteUser"], + "customizations": {"vscode": {"extensions": extensions, + "settings": dict(res.settings)}}, + } + if res.ports: + obj["forwardPorts"] = [p["port"] for p in res.ports] + obj["portsAttributes"] = { + str(p["port"]): {"label": p["label"], "onAutoForward": p["onAutoForward"]} + for p in res.ports + } + return json_bytes(obj) + + +def build_context(model: Model, resolved: dict[str, Resolved]) -> Context: + order = sorted(resolved.values(), key=lambda r: (SIZE_RANK[r.size], r.index)) + tool_profiles: dict = {} + for res in order: + for dep in res.deps: + tool_profiles.setdefault(dep["name"], []) + if res.pid not in tool_profiles[dep["name"]]: + tool_profiles[dep["name"]].append(res.pid) + declaring: dict = {} + for tool in tool_profiles: + declaring[tool] = declaring_dir(model, tool) + return Context(all_tools=set(tool_profiles), tool_profiles=tool_profiles, declaring=declaring) + + +def declaring_dir(model: Model, tool: str) -> str: + if any(d["name"] == tool for d in model.baseline_deps): + return "baseline" + folders = sorted(f for f, deps in model.folder_deps.items() + if any(d["name"] == tool for d in deps)) + if folders: + return folders[0] + for prof in model.profiles: + if any(d["name"] == tool for d in prof.extras): + return f"extras:{prof.pid}" + return "baseline" + + +def strip_cd(command: str, folder: str) -> str: + prefix = f"cd {folder} && " + return command[len(prefix):] if command.startswith(prefix) else command + + +def need_lines(res: Resolved, ctx: Context) -> list[str]: + names = {d["name"] for d in res.deps} + owners: dict = {} + for tool in sorted(ctx.all_tools - names): + owners.setdefault(ctx.tool_profiles[tool][0], []).append(tool) + return [f'Need something else? {", ".join(tools)} → profile "{owner}"' + for owner, tools in owners.items()] + + +def ready_bytes(res: Resolved, ctx: Context, sub_folder: str | None) -> bytes: + names = {d["name"] for d in res.deps} + lines = [f"[devcontainer] ready: Gameplane · {res.title}", "", "Works here:"] + for command in res.checks: + lines.append(" " + (strip_cd(command, sub_folder) if sub_folder else command)) + if sub_folder is None: + for target, tools, folders in MAKE_TARGETS: + if tools <= names and folders <= set(res.covered) \ + and not any(target in check for check in res.checks): + lines.append(f" {target}") + lines.append("") + if res.ports: + lines.append("Ports: " + ", ".join(f"{p['port']} {p['label']}" for p in res.ports)) + else: + lines.append("Ports: none") + lines.extend(res.notes) + lines.append("Tests and lint run in CI (CLAUDE.md rule 8).") + if sub_folder is None: + lines.extend(need_lines(res, ctx)) + return ("\n".join(lines) + "\n").encode("utf-8") + + +def tool_index_bytes(ctx: Context) -> bytes: + rows = [f"{tool}\t{','.join(ctx.tool_profiles[tool])}\t{ctx.declaring[tool]}" + for tool in sorted(ctx.all_tools)] + return ("\n".join(rows) + "\n").encode("utf-8") + + +# ---------------------------------------------------------------- rendering + +def render_main(model: Model, resolved: dict[str, Resolved], ctx: Context) -> dict[str, bytes]: + out: dict[str, bytes] = {} + for res in resolved.values(): + if res.size == "full": + out[f"{DEVC}/devcontainer.json"] = devcontainer_bytes( + model, res, "bash .devcontainer/setup.sh .devcontainer/full/setup.env") + base = f"{DEVC}/full" + else: + base = f"{DEVC}/{res.pid}" + out[f"{base}/devcontainer.json"] = devcontainer_bytes( + model, res, f"bash .devcontainer/setup.sh {base}/setup.env") + out[f"{base}/devcontainer-lock.json"] = lock_bytes(model, res) + out[f"{base}/setup.env"] = setup_env_bytes(res, model.tools, sub_variant=False) + out[f"{base}/ready.txt"] = ready_bytes(res, ctx, None) + out[f"{DEVC}/tool-index.tsv"] = tool_index_bytes(ctx) + return out + + +def render_submodule_copies(model: Model, resolved: dict[str, Resolved], ctx: Context, + folders: set[str] | None = None) -> dict[str, bytes]: + """website/.devcontainer and modules/.devcontainer copies. `folders` limits them.""" + out: dict[str, bytes] = {} + setup_src = (model.root / DEVC / "setup.sh").read_bytes() + for res in resolved.values(): + if res.sub_folder is None or (folders is not None and res.sub_folder not in folders): + continue + base = f"{res.sub_folder}/{DEVC}" + out[f"{base}/devcontainer.json"] = devcontainer_bytes( + model, res, "bash .devcontainer/setup.sh .devcontainer/setup.env") + out[f"{base}/devcontainer-lock.json"] = lock_bytes(model, res) + out[f"{base}/setup.sh"] = setup_src + out[f"{base}/setup.env"] = setup_env_bytes(res, model.tools, sub_variant=True) + out[f"{base}/ready.txt"] = ready_bytes(res, ctx, res.sub_folder) + return out + + +# ---------------------------------------------------------------- validation + +def validate_resolved(model: Model, resolved: dict[str, Resolved]) -> list[str]: + errors: list[str] = [] + for res in resolved.values(): + if res.size == "full": + continue + for folder in res.covered: + if folder not in model.folder_deps: + errors.append(f"{DEVC}/profiles.toml [profile.{res.pid}]: covers {folder}, which has " + f"no {folder}/{SYSTEM_DEPS} (FR-004a)") + for feature in feature_ids(res, model.tools): + if feature not in model.source_lock.get("features", {}): + errors.append(f"{DEVC}/profiles.toml [profile.{res.pid}]: feature {feature} is not in " + f"{DEVC}/devcontainer-lock.json") + non_full = [r for r in resolved.values() if r.size != "full"] + for module in model.go_modules: + if not any(module in r.covered for r in non_full): + errors.append(f"go.work: module {module} is covered by no profile (FR-014)") + used: set = set() + for dep in resolved["full"].deps if "full" in resolved else []: + if dep.get("version"): + used.add(dep["version"]) + used.update(EXTRA_KEYS.get(dep["name"], ())) + for key in sorted(set(model.tools) - used): + errors.append(f"{DEVC}/versions.toml: key {key!r} is used by no dependency") + return errors + + +def validate_lock_all(model: Model, resolved: dict[str, Resolved]) -> list[str]: + errors: list[str] = [] + for res in resolved.values(): + for feature in feature_ids(res, model.tools): + if feature not in model.source_lock.get("features", {}): + errors.append(f"{res.pid}: feature {feature} missing from {DEVC}/devcontainer-lock.json") + return errors + + +def affected_ids(model: Model, resolved: dict[str, Resolved], paths: list[str]) -> list[str]: + all_ids = [p.pid for p in model.profiles] + hit: set = {"full"} + for raw in paths: + rel = Path(raw) + if rel.is_absolute(): + try: + rel = rel.relative_to(model.root) + except ValueError: + continue # outside the repository: no profile covers it + text = rel.as_posix().removeprefix("./") + if text in GLOBAL_TRIGGERS: + return all_ids + for pid, res in resolved.items(): + if any(text == f or text.startswith(f + "/") for f in res.covered): + hit.add(pid) + return [pid for pid in all_ids if pid in hit] + + +# ---------------------------------------------------------------- output + +def check_outputs(root: Path, outputs: dict[str, bytes]) -> int: + stale = 0 + for rel in sorted(outputs): + want = outputs[rel] + path = root / rel + have = path.read_bytes() if path.is_file() else None + if have == want: + continue + stale += 1 + print(f"stale: {rel}") + if have is None: + print(f" missing on disk: {rel}") + continue + diff = difflib.unified_diff( + have.decode("utf-8", "replace").splitlines(keepends=True), + want.decode("utf-8", "replace").splitlines(keepends=True), + fromfile=f"{rel} (disk)", tofile=f"{rel} (generated)") + sys.stdout.writelines(diff) + return 1 if stale else 0 + + +def write_outputs(root: Path, outputs: dict[str, bytes]) -> None: + for rel, data in outputs.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate the modular dev container profiles.") + parser.add_argument("--check", action="store_true", + help="diff generated files against disk; exit 1 on drift or invalid input") + parser.add_argument("--submodules", action="store_true", + help="also write website/.devcontainer and modules/.devcontainer") + parser.add_argument("--affected", nargs="+", metavar="PATH", + help="print the profile ids affected by these changed paths") + parser.add_argument("--checks", metavar="PROFILE", + help="print the profile's checks joined with ' && ' (CI runCmd)") + parser.add_argument("--root", default=str(Path(__file__).resolve().parent.parent), + help="repository root (default: parent of hack/)") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + root = Path(args.root).resolve() + model, errors = build_model(root) + resolved: dict[str, Resolved] = {} + if model is not None and not errors: + resolved = resolve_profiles(model) + errors.extend(validate_resolved(model, resolved)) + errors.extend(validate_lock_all(model, resolved)) + if errors or model is None: + for message in errors: + print(f"error: {message}", file=sys.stderr) + return 1 + if args.affected is not None: + for pid in affected_ids(model, resolved, args.affected): + print(pid) + return 0 + if args.checks is not None: + if args.checks not in resolved: + print(f"error: unknown profile {args.checks!r}", file=sys.stderr) + return 1 + checks = resolved[args.checks].checks + if not checks: + print(f"error: profile {args.checks!r} declares no checks", file=sys.stderr) + return 1 + # Each check is parenthesised so a `cd` in one check does not leak into the next. + print(" && ".join(f"({check})" for check in checks)) + return 0 + ctx = build_context(model, resolved) + outputs = render_main(model, resolved, ctx) + if args.check: + present = {r.sub_folder for r in resolved.values() + if r.sub_folder and (root / r.sub_folder / DEVC).is_dir()} + outputs.update(render_submodule_copies(model, resolved, ctx, present)) + return check_outputs(root, outputs) + write_outputs(root, outputs) + if args.submodules: + checked_out = {r.sub_folder for r in resolved.values() + if r.sub_folder and (root / r.sub_folder).is_dir() + and any((root / r.sub_folder).iterdir())} + for res in resolved.values(): + if res.sub_folder and res.sub_folder not in checked_out: + print(f"skipped {res.sub_folder}/{DEVC}: submodule has no checkout", file=sys.stderr) + write_outputs(root, render_submodule_copies(model, resolved, ctx, checked_out)) + print(f"wrote {len(outputs)} generated files") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From feb5c983f58346706fd24880f533fb95fd5f1cff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 04/19] test: fixture tests for the dev container generator (spec 026 T008) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- hack/test-gen-devcontainers.py | 393 +++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 hack/test-gen-devcontainers.py diff --git a/hack/test-gen-devcontainers.py b/hack/test-gen-devcontainers.py new file mode 100644 index 000000000..8c2ab1811 --- /dev/null +++ b/hack/test-gen-devcontainers.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Fixture tests for hack/gen-devcontainers.py (spec 026, T008). + +Each test builds a small repository tree in a temporary directory and runs the +generator in-process with --root pointing at that tree. Run only in CI +(CLAUDE.md rule 8); never run locally. +""" + +import contextlib +import importlib.util +import io +import json +import tempfile +import unittest +from pathlib import Path + + +spec = importlib.util.spec_from_file_location( + "gen_devcontainers", Path(__file__).with_name("gen-devcontainers.py") +) +gen = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gen) + + +GO_WORK = "go 1.26\n\nuse (\n\t./api\n\t./gp-module\n\t./operator\n)\n" + +MAKEFILE = ( + "ENVTEST_K8S_VERSION ?= @PIN@\n" + "\n" + "envtest:\n" + "\tsetup-envtest@@REF@ use\n" +) + +VERSIONS = """[tools] +go = "1.26" +node = "24" +docker = "latest" +docker_compose = "v2" +kubectl = "1.36" +helm = "4.3.1" +kind = "v0.32.0" +oras = "1.2.0" +golangci_lint = "v2.12.2" +setup_envtest = "release-0.24" +envtest_k8s = "1.36.2" +specify = "v1.0.3" +""" + +PROFILES = """[baseline] +image = "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" +remoteUser = "vscode" +runArgs = ["--init"] +extensions = ["anthropic.claude-code"] +env = { GOTOOLCHAIN = "local", CI = "1" } +settings = { "editor.formatOnSave" = true } + +[[baseline.deps]] +name = "common-utils" +kind = "os-package" +reason = "shell and base utilities" + +[profile.api] +size = "component" +title = "API" +dirs = ["api"] +extensions = ["golang.go"] +checks = ["{go_build}"] +ports = [{ port = 8080, label = "API", onAutoForward = "silent" }] + +[profile.operator] +size = "component" +title = "Operator" +dirs = ["operator"] +extensions = ["golang.go"] +checks = ["{go_build}"] +ports = [{ port = 9443, label = "Webhook", onAutoForward = "silent" }] + +[profile.gp-module] +size = "component" +title = "Module CLI" +dirs = ["gp-module"] +checks = ["{go_build}"] + +[profile.dashboard] +size = "part" +title = "Dashboard" +includes = ["api", "operator"] + +[profile.full] +size = "full" +title = "Full" +""" + +GP_MODULE_SECTION = ( + '[profile.gp-module]\n' + 'size = "component"\n' + 'title = "Module CLI"\n' + 'dirs = ["gp-module"]\n' + 'checks = ["{go_build}"]\n' + '\n' +) + +WEB_PROFILE = ( + '\n[profile.web]\n' + 'size = "component"\n' + 'title = "Web"\n' + 'dirs = ["web"]\n' +) + +API_DEPS = """component = "api" + +[[dep]] +name = "go" +kind = "toolchain" +version = "go" +reason = "builds the API module" + +[[dep]] +name = "gh" +kind = "cli" +reason = "GitHub CLI for pull request work" + +[[dep]] +name = "docker" +kind = "service" +version = "docker" +reason = "container runtime for kind" + +[[dep]] +name = "kind" +kind = "cli" +version = "kind" +reason = "local clusters" + +[[dep]] +name = "oras" +kind = "cli" +version = "oras" +reason = "OCI module pushes" +""" + +OPERATOR_DEPS = """component = "operator" + +[[dep]] +name = "gh" +kind = "cli" +reason = "GitHub CLI for pull request work" + +[[dep]] +name = "kubectl" +kind = "cli" +version = "kubectl" +reason = "cluster inspection" + +[[dep]] +name = "helm" +kind = "cli" +version = "helm" +reason = "chart installs" + +[[dep]] +name = "envtest" +kind = "cli" +version = "envtest_k8s" +reason = "envtest control-plane binaries" +""" + +GP_MODULE_DEPS = """component = "gp-module" + +[[dep]] +name = "node" +kind = "toolchain" +version = "node" +reason = "scaffold preview renders web bundles" + +[[dep]] +name = "golangci-lint" +kind = "cli" +version = "golangci_lint" +reason = "lint inside the container" + +[[dep]] +name = "specify" +kind = "cli" +version = "specify" +reason = "spec tooling" +""" + +LOCK = json.dumps({"features": { + gen.FEAT_COMMON: {}, gen.FEAT_GH: {}, gen.FEAT_GO: {}, gen.FEAT_NODE: {}, + gen.FEAT_DOCKER: {}, gen.FEAT_KUBE: {}, +}}, indent=2) + "\n" + + +def go_mod(module: str) -> str: + return f"module github.com/GameplanePanel/gameplane/{module}\n\ngo 1.26\n" + + +def base_files() -> dict: + return { + "go.work": GO_WORK, + "Makefile": MAKEFILE.replace("@PIN@", "1.36.2").replace("@REF@", "release-0.24"), + ".devcontainer/versions.toml": VERSIONS, + ".devcontainer/profiles.toml": PROFILES, + ".devcontainer/devcontainer-lock.json": LOCK, + "api/go.mod": go_mod("api"), + "operator/go.mod": go_mod("operator"), + "gp-module/go.mod": go_mod("gp-module"), + "api/system-deps.toml": API_DEPS, + "operator/system-deps.toml": OPERATOR_DEPS, + "gp-module/system-deps.toml": GP_MODULE_DEPS, + } + + +def run_gen(root: Path, *argv: str) -> tuple: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = gen.main(["--root", str(root), *argv]) + return code, out.getvalue(), err.getvalue() + + +class GenDevcontainersTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + + def write(self, overrides: dict | None = None) -> None: + """Write the valid base tree; an override with value None drops that file.""" + files = base_files() + for rel, text in (overrides or {}).items(): + if text is None: + files.pop(rel, None) + else: + files[rel] = text + for rel, text in files.items(): + path = self.root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def read_json(self, rel: str): + return json.loads((self.root / rel).read_text(encoding="utf-8")) + + def test_valid_fixture_generates_and_checks_clean(self): + self.write() + code, _, err = run_gen(self.root) + self.assertEqual(code, 0, err) + code, out, err = run_gen(self.root, "--check") + self.assertEqual(code, 0, out + err) + + def test_literal_dep_version_rejected(self): + self.write({"api/system-deps.toml": API_DEPS.replace('version = "go"', 'version = "1.26"')}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("api/system-deps.toml", err) + self.assertIn("literal versions are not allowed", err) + + def test_literal_profile_value_rejected(self): + self.write({".devcontainer/profiles.toml": PROFILES.replace( + 'CI = "1" }', 'CI = "1", GO_VERSION = "1.26.1" }')}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn(".devcontainer/profiles.toml", err) + self.assertIn("literal version '1.26.1'", err) + + def test_unknown_kind_rejected(self): + self.write({"api/system-deps.toml": API_DEPS.replace('kind = "toolchain"', 'kind = "bogus"')}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("api/system-deps.toml", err) + self.assertIn("kind 'bogus' is not one of", err) + + def test_empty_reason_rejected(self): + self.write({"api/system-deps.toml": API_DEPS.replace( + 'reason = "builds the API module"', 'reason = " "')}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("dep 'go' needs a non-empty reason", err) + + def test_none_with_deps_rejected(self): + self.write({"api/system-deps.toml": API_DEPS.replace( + 'component = "api"\n', 'component = "api"\nnone = true\n', 1)}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("none = true excludes [[dep]] entries (rule 5)", err) + + def test_unknown_dep_name_rejected(self): + self.write({"api/system-deps.toml": API_DEPS + ( + '\n[[dep]]\nname = "mystery"\nkind = "cli"\nreason = "not registered"\n')}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("dep 'mystery' is not in the generator's dep registry", err) + + def test_duplicate_dep_within_one_file_rejected(self): + self.write({"operator/system-deps.toml": OPERATOR_DEPS + ( + '\n[[dep]]\nname = "gh"\nkind = "cli"\nreason = "again"\n')}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("operator/system-deps.toml: duplicate dep 'gh'", err) + + def test_missing_system_deps_rejected(self): + # web/ is covered by a profile but has no system-deps.toml (FR-004a). + self.write({ + ".devcontainer/profiles.toml": PROFILES + WEB_PROFILE, + "web/package.json": "{}\n", + }) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("profiles.toml [profile.web]: covers web, which has no web/system-deps.toml", err) + + def test_go_module_in_no_profile_rejected(self): + profiles = PROFILES.replace(GP_MODULE_SECTION, "") + self.assertNotEqual(profiles, PROFILES) + self.write({".devcontainer/profiles.toml": profiles}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("go.work: module gp-module is covered by no profile (FR-014)", err) + + def test_unused_version_key_rejected(self): + self.write({".devcontainer/versions.toml": VERSIONS + 'spare = "x"\n'}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("versions.toml: key 'spare' is used by no dependency", err) + + def test_part_takes_union_of_included_components(self): + self.write() + code, _, err = run_gen(self.root) + self.assertEqual(code, 0, err) + part = self.read_json(".devcontainer/dashboard/devcontainer.json") + self.assertEqual(part["forwardPorts"], [8080, 9443]) + setup = (self.root / ".devcontainer/dashboard/setup.env").read_text(encoding="utf-8") + self.assertIn('GO_MODULES="api operator"\n', setup) + for changed in ("api/main.go", "operator/main.go"): + with self.subTest(path=changed): + code, out, _ = run_gen(self.root, "--affected", changed) + self.assertEqual(code, 0) + self.assertIn("dashboard", out.split()) + + def test_duplicate_dep_collapses_across_folders(self): + # gh is declared by api and operator; the output names it once, under the first folder. + self.write() + code, _, err = run_gen(self.root) + self.assertEqual(code, 0, err) + rows = (self.root / ".devcontainer/tool-index.tsv").read_text(encoding="utf-8").splitlines() + gh_rows = [row for row in rows if row.split("\t", 1)[0] == "gh"] + self.assertEqual(gh_rows, ["gh\tapi,operator,dashboard,full\tapi"]) + full = self.read_json(".devcontainer/devcontainer.json") + self.assertIn(gen.FEAT_GH, full["features"]) + + def test_hand_edited_output_detected_by_check(self): + self.write() + code, _, err = run_gen(self.root) + self.assertEqual(code, 0, err) + path = self.root / ".devcontainer/api/setup.env" + path.write_text(path.read_text(encoding="utf-8") + 'HAND_EDITED="1"\n', encoding="utf-8") + code, out, _ = run_gen(self.root, "--check") + self.assertEqual(code, 1) + self.assertIn("stale: .devcontainer/api/setup.env", out) + + def test_affected_system_deps_file_lists_covering_profiles(self): + self.write() + code, out, err = run_gen(self.root, "--affected", "api/system-deps.toml") + self.assertEqual(code, 0, err) + self.assertEqual(out.split(), ["api", "dashboard", "full"]) + + def test_affected_versions_toml_lists_every_profile(self): + self.write() + code, out, err = run_gen(self.root, "--affected", ".devcontainer/versions.toml") + self.assertEqual(code, 0, err) + self.assertEqual(out.split(), ["api", "operator", "gp-module", "dashboard", "full"]) + + def test_affected_uncovered_path_lists_only_full(self): + self.write() + code, out, err = run_gen(self.root, "--affected", "docs/README.md") + self.assertEqual(code, 0, err) + self.assertEqual(out.split(), ["full"]) + + def test_makefile_envtest_k8s_pin_mismatch_rejected(self): + self.write({"Makefile": MAKEFILE.replace("@PIN@", "1.35.0").replace("@REF@", "release-0.24")}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("Makefile: ENVTEST_K8S_VERSION 1.35.0 differs from versions.toml envtest_k8s 1.36.2", err) + + def test_makefile_setup_envtest_ref_mismatch_rejected(self): + self.write({"Makefile": MAKEFILE.replace("@PIN@", "1.36.2").replace("@REF@", "release-0.23")}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("Makefile: setup-envtest@release-0.23 differs from versions.toml setup_envtest " + "release-0.24", err) + + +if __name__ == "__main__": + unittest.main() From 6ab64557daab85a76df67bcf49d1c34b0a62117a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 05/19] chore: replace post-create.sh with the shared step-based setup.sh (spec 026 T009, T012) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- .devcontainer/post-create.sh | 158 -------------------- .devcontainer/setup.sh | 273 +++++++++++++++++++++++++++++++++++ docs/dependencies.md | 12 +- 3 files changed, 279 insertions(+), 164 deletions(-) delete mode 100755 .devcontainer/post-create.sh create mode 100755 .devcontainer/setup.sh diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh deleted file mode 100755 index fa606c5b5..000000000 --- a/.devcontainer/post-create.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash -# Devcontainer post-create bootstrap for Gameplane. -# -# Installs the extra tooling the Makefile expects but the devcontainer -# features don't provide (kind, oras, golangci-lint, uv, specify), pre-fetches Go -# modules + envtest assets, and installs the web npm deps so the first -# `make test` / `make web-dev` run is fast. -# -# Idempotent: every step is `command -v` guarded or version-checked. - -set -euo pipefail - -log() { printf "\n\033[1;34m[devcontainer]\033[0m %s\n" "$*"; } - -ARCH="$(dpkg --print-architecture)" # amd64 | arm64 -GOBIN_DIR="$(go env GOPATH)/bin" -mkdir -p "$GOBIN_DIR" -case ":$PATH:" in *":$GOBIN_DIR:"*) ;; *) export PATH="$GOBIN_DIR:$PATH" ;; esac - -# Ensure $HOME/.local/bin is on PATH for uv/specify. -LOCAL_BIN_DIR="$HOME/.local/bin" -mkdir -p "$LOCAL_BIN_DIR" -case ":$PATH:" in *":$LOCAL_BIN_DIR:"*) ;; *) export PATH="$LOCAL_BIN_DIR:$PATH" ;; esac - -# ---------- submodules (modules/ lives in the GameplanePanel/module repo) ---------- -# The game-module bundles are a git submodule at modules/; init them so -# `make modules-push` / `make dev-up` find modules/build.sh and the bundles. -if [ -f .gitmodules ]; then - log "git submodule update --init --recursive" - git submodule update --init --recursive -fi - -# ---------- kind ---------- -KIND_VERSION="v0.32.0" -if ! command -v kind >/dev/null 2>&1; then - log "installing kind ${KIND_VERSION}" - curl -fsSL -o /tmp/kind \ - "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-${ARCH}" - sudo install -m 0755 /tmp/kind /usr/local/bin/kind - rm -f /tmp/kind -fi - -# ---------- oras (≥ 1.2.0; required by modules/build.sh) ---------- -ORAS_VERSION="1.2.0" -if ! command -v oras >/dev/null 2>&1; then - log "installing oras ${ORAS_VERSION}" - curl -fsSL -o /tmp/oras.tar.gz \ - "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ARCH}.tar.gz" - sudo tar -C /usr/local/bin -xzf /tmp/oras.tar.gz oras - sudo chmod 0755 /usr/local/bin/oras - rm -f /tmp/oras.tar.gz -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" -GOLANGCI_BIN="/go/bin/golangci-lint" -if ! "$GOLANGCI_BIN" --version 2>/dev/null | grep -qE 'version v?2\.'; then - log "installing golangci-lint ${GOLANGCI_VERSION}" - GOBIN=/go/bin go install \ - "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_VERSION}" -fi - -# ---------- envtest assets (K8s 1.36.2; pulled lazily by the Makefile but -# we warm the cache here so the first `make test-integration` doesn't -# need network) ---------- -if [ ! -x "$GOBIN_DIR/setup-envtest" ]; then - log "installing setup-envtest" - GOBIN="$GOBIN_DIR" go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest -fi -log "fetching envtest binaries for K8s 1.36.2" -"$GOBIN_DIR/setup-envtest" use 1.36.2 >/dev/null - -# ---------- Go module cache warmup ---------- -# Go modules from go.work. Derived from 'use' entries in go.work. -# Keep in sync with go.work when adding/removing modules. -for m in agent api audit-syslog-bridge capture-sidecar gameaction gameproto i18n mcp-server netguard operator sentinel svcutil telemetry-receiver telemetryschema test/e2e tunnel; do - log "go mod download ($m)" - ( cd "$m" && go mod download ) -done - -# ---------- Web deps ---------- -log "npm ci (web)" -( cd web && npm ci ) - -# ---------- uv (Python package installer for spec-kit) ---------- -if ! command -v uv >/dev/null 2>&1; then - log "installing uv" - curl -LsSf https://astral.sh/uv/install.sh | sh -fi - -# ---------- specify (spec-kit CLI for GitHub specs) ---------- -SPECKIT_VERSION="v1.0.3" -if ! command -v specify >/dev/null 2>&1; then - log "installing specify (spec-kit) ${SPECKIT_VERSION}" - uv tool install specify-cli --from "git+https://github.com/github/spec-kit.git@${SPECKIT_VERSION}" -fi - -# ---------- AI coding CLIs ---------- -# Installed globally so every shell has `claude`, `opencode`, `gemini`, and -# `codex` on PATH. No credentials are baked in: each authenticates interactively -# at runtime (login or its API-key env var), keeping the image sharable and -# secret-free. Falls back to sudo if the node feature's global prefix isn't -# user-writable; best-effort so a registry hiccup doesn't fail the whole setup. -AI_CLIS=(@anthropic-ai/claude-code opencode-ai @google/gemini-cli @openai/codex) -if ! command -v claude >/dev/null 2>&1 \ - || ! command -v opencode >/dev/null 2>&1 \ - || ! command -v gemini >/dev/null 2>&1 \ - || ! command -v codex >/dev/null 2>&1; then - log "installing AI coding CLIs (claude, opencode, gemini, codex)" - npm install -g "${AI_CLIS[@]}" || sudo npm install -g "${AI_CLIS[@]}" || \ - log "WARN: AI CLI install failed; run 'npm install -g ${AI_CLIS[*]}' by hand" -fi - -# ---------- Friendly summary ---------- -log "tool versions" -{ - go version - node --version - npm --version - docker version --format 'docker {{.Server.Version}}' 2>/dev/null || true - kubectl version --client=true --output=yaml 2>/dev/null | grep gitVersion || true - helm version --short - kind version - oras version | head -1 - golangci-lint --version | head -1 - uv --version - specify --version - echo "claude $(claude --version 2>/dev/null || echo '(installed; log in at runtime)')" - echo "opencode $(opencode --version 2>/dev/null || echo '(installed; log in at runtime)')" - echo "gemini $(gemini --version 2>/dev/null || echo '(installed; log in at runtime)')" - echo "codex $(codex --version 2>/dev/null || echo '(installed; log in at runtime)')" -} || true - -cat <<'EOF' - -[devcontainer] ready. - -Quick commands: - make lint test # Go + web unit tests, lint - make test-integration # envtest tier (operator + api) - make dev-up # kind cluster + helm install (~5 min) - make web-dev # Vite dev server (forwarded on port 5173) - make test-e2e # full kind-based e2e (~10–20 min) - -Notes: - * Docker runs *inside* the devcontainer (docker-in-docker). The kind - cluster, local registry, and built images all live in this - container's docker daemon — they're gone when the container is - rebuilt. - * Ports 5173 (web dev), 8080/8443 (ingress), and 5001 (OCI registry) - are forwarded to your host. - * AI CLIs (claude, opencode, gemini, codex) are installed but NOT logged - in. Authenticate each on first use (its login flow or API-key env var); - no credentials are baked into the image. -EOF diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100755 index 000000000..8cbc8e32c --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Devcontainer post-create bootstrap for Gameplane (generated profiles). +# +# Usage: bash .devcontainer/setup.sh +# +# setup.env is generated by hack/gen-devcontainers.py for each profile. It +# lists the steps this container needs (STEPS, in run order), the steps that +# may fail without failing the setup (OPTIONAL_STEPS), the pinned versions +# (_VERSION, from .devcontainer/versions.toml), and the Go modules, +# submodules and ready message for the profile. +# +# Installs the extra tooling the Makefile expects but the devcontainer +# features don't provide (kind, oras, golangci-lint, envtest, uv, specify), +# pre-fetches Go modules + envtest assets, and installs the web/website npm +# deps so the first `make test` / `make web-dev` run is fast. +# +# Idempotent: every step is `command -v` or version guarded. +# +# Credentials: this script never reads or writes credentials. The AI coding +# CLIs are installed but not logged in; each authenticates interactively at +# runtime (its login flow or API-key env var), keeping the image shareable +# and secret-free. + +set -euo pipefail + +log() { printf "\n\033[1;34m[devcontainer]\033[0m %s\n" "$*"; } +die() { printf "\n\033[1;31m[devcontainer] ERROR:\033[0m %s\n" "$*" >&2; exit 1; } + +if [ "$#" -ne 1 ]; then + echo "usage: setup.sh " >&2 + exit 2 +fi + +# Resolve the env file against the caller's cwd before moving to the repo root. +ENV_FILE_ARG="$1" +[ -f "$ENV_FILE_ARG" ] || die "setup.env not found: $ENV_FILE_ARG" +ENV_FILE="$(cd "$(dirname "$ENV_FILE_ARG")" && pwd)/$(basename "$ENV_FILE_ARG")" + +# This script lives in /.devcontainer/ (or /.devcontainer/). +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# shellcheck source=/dev/null +. "$ENV_FILE" + +: "${STEPS:=}" +: "${OPTIONAL_STEPS:=}" +: "${GO_MODULES:=}" +: "${SUBMODULES:=}" +: "${WEBSITE_DIR:=website}" +: "${MODULES_DIR:=modules}" +: "${READY:=}" + +# PATH: go-installed binaries and uv/specify live in these directories. +if command -v go >/dev/null 2>&1; then + GOBIN_DIR="$(go env GOPATH)/bin" + mkdir -p "$GOBIN_DIR" + case ":$PATH:" in *":$GOBIN_DIR:"*) ;; *) export PATH="$GOBIN_DIR:$PATH" ;; esac +fi +LOCAL_BIN_DIR="$HOME/.local/bin" +mkdir -p "$LOCAL_BIN_DIR" +case ":$PATH:" in *":$LOCAL_BIN_DIR:"*) ;; *) export PATH="$LOCAL_BIN_DIR:$PATH" ;; esac + +is_optional() { + local s + for s in $OPTIONAL_STEPS; do + [ "$s" = "$1" ] && return 0 + done + return 1 +} + +# ---------- submodules (modules/ lives in the GameplanePanel/module repo) ---------- +# The game-module bundles are a git submodule at modules/; init them so +# `make modules-push` / `make dev-up` find modules/build.sh and the bundles. +step_submodules() { + if [ -f .gitmodules ] && [ -n "$SUBMODULES" ]; then + log "git submodule update --init --recursive ($SUBMODULES)" + # SUBMODULES is a space-separated path list; word splitting is intended. + git submodule update --init --recursive -- $SUBMODULES + fi +} + +# ---------- kind ---------- +step_kind() { + if command -v kind >/dev/null 2>&1 \ + && kind version 2>/dev/null | grep -qF "kind ${KIND_VERSION} "; then + return 0 + fi + local arch tmp + arch="$(dpkg --print-architecture)" # amd64 | arm64 + log "installing kind ${KIND_VERSION}" + tmp="$(mktemp)" + curl -fsSL -o "$tmp" \ + "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-${arch}" + sudo install -m 0755 "$tmp" /usr/local/bin/kind + rm -f "$tmp" +} + +# ---------- oras (≥ 1.2.0; required by modules/build.sh) ---------- +step_oras() { + if command -v oras >/dev/null 2>&1 \ + && oras version 2>/dev/null | grep -qF "${ORAS_VERSION}"; then + return 0 + fi + local arch tmp + arch="$(dpkg --print-architecture)" # amd64 | arm64 + log "installing oras ${ORAS_VERSION}" + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/oras.tar.gz" \ + "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${arch}.tar.gz" + sudo tar -C /usr/local/bin -xzf "$tmp/oras.tar.gz" oras + sudo chmod 0755 /usr/local/bin/oras + rm -rf "$tmp" +} + +# ---------- 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). +step_golangci_lint() { + local bin_dir bin + bin_dir="$(go env GOPATH)/bin" + bin="$bin_dir/golangci-lint" + if "$bin" --version 2>/dev/null | grep -qF "${GOLANGCI_LINT_VERSION#v}"; then + return 0 + fi + log "installing golangci-lint ${GOLANGCI_LINT_VERSION}" + GOBIN="$bin_dir" go install \ + "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}" +} + +# ---------- envtest assets (K8s ${ENVTEST_K8S_VERSION}; pulled lazily by the Makefile but +# we warm the cache here so the first `make test-integration` doesn't +# need network) ---------- +step_envtest() { + local bin_dir bin + bin_dir="$(go env GOPATH)/bin" + bin="$bin_dir/setup-envtest" + if [ ! -x "$bin" ]; then + log "installing setup-envtest ${SETUP_ENVTEST_VERSION}" + GOBIN="$bin_dir" go install \ + "sigs.k8s.io/controller-runtime/tools/setup-envtest@${SETUP_ENVTEST_VERSION}" + fi + log "fetching envtest binaries for K8s ${ENVTEST_K8S_VERSION}" + "$bin" use "${ENVTEST_K8S_VERSION}" >/dev/null +} + +# ---------- Go module cache warmup ---------- +# GO_MODULES is generated from go.work 'use' entries (in go.work order) for +# the modules this profile covers. +step_go_mod_download() { + local m + for m in $GO_MODULES; do + log "go mod download ($m)" + ( cd "$m" && go mod download ) + done +} + +# ---------- Web deps ---------- +step_npm_ci_web() { + log "npm ci (web)" + ( cd web && npm ci ) +} + +# ---------- Website deps ---------- +step_npm_ci_website() { + log "npm ci (website)" + ( cd "$WEBSITE_DIR" && npm ci ) +} + +# ---------- uv (Python package installer for spec-kit) ---------- +step_uv() { + if command -v uv >/dev/null 2>&1; then + return 0 + fi + log "installing uv" + curl -LsSf https://astral.sh/uv/install.sh | sh +} + +# ---------- specify (spec-kit CLI for GitHub specs) ---------- +step_specify() { + if command -v specify >/dev/null 2>&1; then + return 0 + fi + log "installing specify (spec-kit) ${SPECIFY_VERSION}" + uv tool install specify-cli --from "git+https://github.com/github/spec-kit.git@${SPECIFY_VERSION}" +} + +# ---------- AI coding CLIs (optional) ---------- +# Installed globally so every shell has `claude`, `opencode`, `gemini`, and +# `codex` on PATH. No credentials are baked in: each authenticates +# interactively at runtime (login or its API-key env var), keeping the image +# shareable and secret-free. Falls back to sudo if the node feature's global +# prefix isn't user-writable. Listed in OPTIONAL_STEPS, so a registry hiccup +# logs a WARN instead of failing the whole setup. +step_ai_clis() { + local ai_clis=(@anthropic-ai/claude-code opencode-ai @google/gemini-cli @openai/codex) + if command -v claude >/dev/null 2>&1 \ + && command -v opencode >/dev/null 2>&1 \ + && command -v gemini >/dev/null 2>&1 \ + && command -v codex >/dev/null 2>&1; then + return 0 + fi + log "installing AI coding CLIs (claude, opencode, gemini, codex)" + if npm install -g "${ai_clis[@]}"; then + return 0 + fi + if sudo npm install -g "${ai_clis[@]}"; then + return 0 + fi + echo "install failed; run 'npm install -g ${ai_clis[*]}' by hand" >&2 + return 1 +} + +# Runs one step. Steps run in a subshell with errexit so a failing command +# stops that step. errexit is turned off in the parent around the subshell, +# because bash ignores set -e inside a subshell that is part of an `if` +# condition. +run_step() { + local step="$1" fn rc=0 + fn="step_${step}" + if ! declare -F "$fn" >/dev/null; then + die "unknown step in STEPS: $step" + fi + set +e + ( set -e; "$fn" ) + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + if is_optional "$step"; then + log "WARN: ${step} failed (exit ${rc}); continuing" + else + die "step ${step} failed (exit ${rc})" + fi + fi +} + +for step in $STEPS; do + run_step "$step" +done + +# ---------- Friendly summary: versions of installed tools only ---------- +print_versions() { + local tool + log "tool versions" + for tool in go node npm docker kubectl helm kind oras golangci-lint uv specify; do + command -v "$tool" >/dev/null 2>&1 || continue + case "$tool" in + go) go version ;; + node) node --version ;; + npm) npm --version ;; + docker) docker version --format 'docker {{.Server.Version}}' 2>/dev/null || echo "docker (server not running)" ;; + kubectl) kubectl version --client=true --output=yaml 2>/dev/null | grep gitVersion || echo "kubectl (installed)" ;; + helm) helm version --short ;; + kind) kind version ;; + oras) oras version | head -1 ;; + golangci-lint) golangci-lint --version | head -1 ;; + uv) uv --version ;; + specify) specify --version ;; + esac || true + done + for tool in claude opencode gemini codex; do + command -v "$tool" >/dev/null 2>&1 || continue + echo "$tool $("$tool" --version 2>/dev/null || echo '(installed; log in at runtime)')" + done +} +print_versions || true + +if [ -n "$READY" ] && [ -f "$READY" ]; then + cat "$READY" +else + log "ready. (no ready.txt at ${READY:-})" +fi diff --git a/docs/dependencies.md b/docs/dependencies.md index 1017fa980..13b92b6c5 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -24,7 +24,7 @@ direct `require` block, then `grep -rn "" / --include="*.go to find call sites (or `go mod why -m ` from inside the module). For `web/`, check `web/package.json` against `grep -rln "" web/src`. Toolchain versions come from `Makefile`, `.github/workflows/*.yaml`, and -`.devcontainer/post-create.sh`. +`.devcontainer/versions.toml` (dev container pins; `.devcontainer/setup.sh` installs them). Accurate as of **2026-09-02**, repo state `v0.3.0`; the `gameproto`, `svcutil`, `sentinel`, `capture-sidecar`, `tunnel`, and `gp-module` sections @@ -406,12 +406,12 @@ These aren't code dependencies of any component; they're invoked by - **`sigs.k8s.io/controller-tools/cmd/controller-gen`** (`tool` directive in `operator/go.mod`, pulling in `sigs.k8s.io/controller-tools v0.20.1` indirect) — `make generate`/`make manifests` regenerate `zz_generated.deepcopy.go` and the CRD/RBAC YAML. - **`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. -- **`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. +- **`sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.24`** — 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/versions.toml` (`golangci_lint`) and installed with `go install` by `.devcontainer/setup.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. +- **`oras` CLI ≥ 1.2.0** (pinned `1.2.0` in `.devcontainer/versions.toml`; `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. -- **`helm`** (`azure/setup-helm@v5.0.1` in CI; `kubectl-helm-minikube` devcontainer feature, `helm: latest`) — chart lint (`helm lint`), chart render sanity-check, `helm template`, and `helm upgrade --install` in `deploy/kind/*.sh`/`Makefile`'s `dev-install`. +- **`kind`** v0.32.0 (pinned in `.devcontainer/versions.toml`) / `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. +- **`helm`** (`azure/setup-helm@v5.0.1` in CI; `helm` 4.3.1 pinned in `.devcontainer/versions.toml` and installed by the full dev container profile) — chart lint (`helm lint`), chart render sanity-check, `helm template`, and `helm upgrade --install` in `deploy/kind/*.sh`/`Makefile`'s `dev-install`. - **`docker buildx bake`** (`containers/docker-bake.hcl`, `docker/bake-action@v6`) — builds the operator/api/agent e2e images concurrently with a shared GitHub Actions layer cache (`.github/actions/e2e-images`); also builds the `e2e-gameprobe` headless bot image used only by the game-bot CI job. - **`docker/build-push-action@v6` + `docker/metadata-action@v5`** — the actual multi-arch (`linux/amd64,linux/arm64`) image builds and GHCR tagging in `publish-edge.yaml` (main → `:edge`) and `release.yaml` (`v*` tags → versioned images + `oci://ghcr.io//charts` Helm push). From fda6c9c6ffe9325a01c33c510bbfc60302956590 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 06/19] chore: generate the full, component and part dev container profiles (spec 026 T013, T015, T019) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- .devcontainer/agent/devcontainer-lock.json | 19 +++ .devcontainer/agent/devcontainer.json | 53 +++++++ .devcontainer/agent/ready.txt | 15 ++ .devcontainer/agent/setup.env | 10 ++ .devcontainer/api/devcontainer-lock.json | 19 +++ .devcontainer/api/devcontainer.json | 53 +++++++ .devcontainer/api/ready.txt | 18 +++ .devcontainer/api/setup.env | 12 ++ .../control-plane/devcontainer-lock.json | 29 ++++ .devcontainer/control-plane/devcontainer.json | 89 +++++++++++ .devcontainer/control-plane/ready.txt | 25 ++++ .devcontainer/control-plane/setup.env | 14 ++ .../dashboard-api/devcontainer-lock.json | 34 +++++ .devcontainer/dashboard-api/devcontainer.json | 95 ++++++++++++ .devcontainer/dashboard-api/ready.txt | 27 ++++ .devcontainer/dashboard-api/setup.env | 14 ++ .devcontainer/devcontainer.json | 141 ++++++++++-------- .devcontainer/full/ready.txt | 36 +++++ .devcontainer/full/setup.env | 14 ++ .../go-services/devcontainer-lock.json | 19 +++ .devcontainer/go-services/devcontainer.json | 53 +++++++ .devcontainer/go-services/ready.txt | 22 +++ .devcontainer/go-services/setup.env | 10 ++ .../modules-gp/devcontainer-lock.json | 19 +++ .devcontainer/modules-gp/devcontainer.json | 52 +++++++ .devcontainer/modules-gp/ready.txt | 12 ++ .devcontainer/modules-gp/setup.env | 11 ++ .devcontainer/modules/devcontainer-lock.json | 19 +++ .devcontainer/modules/devcontainer.json | 52 +++++++ .devcontainer/modules/ready.txt | 12 ++ .devcontainer/modules/setup.env | 10 ++ .devcontainer/operator/devcontainer-lock.json | 19 +++ .devcontainer/operator/devcontainer.json | 54 +++++++ .devcontainer/operator/ready.txt | 16 ++ .devcontainer/operator/setup.env | 12 ++ .devcontainer/tool-index.tsv | 15 ++ .devcontainer/web/devcontainer-lock.json | 19 +++ .devcontainer/web/devcontainer.json | 64 ++++++++ .devcontainer/web/ready.txt | 12 ++ .devcontainer/web/setup.env | 9 ++ .devcontainer/website/devcontainer-lock.json | 19 +++ .devcontainer/website/devcontainer.json | 63 ++++++++ .devcontainer/website/ready.txt | 10 ++ .devcontainer/website/setup.env | 9 ++ 44 files changed, 1270 insertions(+), 59 deletions(-) create mode 100644 .devcontainer/agent/devcontainer-lock.json create mode 100644 .devcontainer/agent/devcontainer.json create mode 100644 .devcontainer/agent/ready.txt create mode 100644 .devcontainer/agent/setup.env create mode 100644 .devcontainer/api/devcontainer-lock.json create mode 100644 .devcontainer/api/devcontainer.json create mode 100644 .devcontainer/api/ready.txt create mode 100644 .devcontainer/api/setup.env create mode 100644 .devcontainer/control-plane/devcontainer-lock.json create mode 100644 .devcontainer/control-plane/devcontainer.json create mode 100644 .devcontainer/control-plane/ready.txt create mode 100644 .devcontainer/control-plane/setup.env create mode 100644 .devcontainer/dashboard-api/devcontainer-lock.json create mode 100644 .devcontainer/dashboard-api/devcontainer.json create mode 100644 .devcontainer/dashboard-api/ready.txt create mode 100644 .devcontainer/dashboard-api/setup.env create mode 100644 .devcontainer/full/ready.txt create mode 100644 .devcontainer/full/setup.env create mode 100644 .devcontainer/go-services/devcontainer-lock.json create mode 100644 .devcontainer/go-services/devcontainer.json create mode 100644 .devcontainer/go-services/ready.txt create mode 100644 .devcontainer/go-services/setup.env create mode 100644 .devcontainer/modules-gp/devcontainer-lock.json create mode 100644 .devcontainer/modules-gp/devcontainer.json create mode 100644 .devcontainer/modules-gp/ready.txt create mode 100644 .devcontainer/modules-gp/setup.env create mode 100644 .devcontainer/modules/devcontainer-lock.json create mode 100644 .devcontainer/modules/devcontainer.json create mode 100644 .devcontainer/modules/ready.txt create mode 100644 .devcontainer/modules/setup.env create mode 100644 .devcontainer/operator/devcontainer-lock.json create mode 100644 .devcontainer/operator/devcontainer.json create mode 100644 .devcontainer/operator/ready.txt create mode 100644 .devcontainer/operator/setup.env create mode 100644 .devcontainer/tool-index.tsv create mode 100644 .devcontainer/web/devcontainer-lock.json create mode 100644 .devcontainer/web/devcontainer.json create mode 100644 .devcontainer/web/ready.txt create mode 100644 .devcontainer/web/setup.env create mode 100644 .devcontainer/website/devcontainer-lock.json create mode 100644 .devcontainer/website/devcontainer.json create mode 100644 .devcontainer/website/ready.txt create mode 100644 .devcontainer/website/setup.env diff --git a/.devcontainer/agent/devcontainer-lock.json b/.devcontainer/agent/devcontainer-lock.json new file mode 100644 index 000000000..7dfc7c7e2 --- /dev/null +++ b/.devcontainer/agent/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + } + } +} diff --git a/.devcontainer/agent/devcontainer.json b/.devcontainer/agent/devcontainer.json new file mode 100644 index 000000000..e776db9e8 --- /dev/null +++ b/.devcontainer/agent/devcontainer.json @@ -0,0 +1,53 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "golang.go", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + } + }, + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Agent", + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/agent/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/agent/ready.txt b/.devcontainer/agent/ready.txt new file mode 100644 index 000000000..21e4093e9 --- /dev/null +++ b/.devcontainer/agent/ready.txt @@ -0,0 +1,15 @@ +[devcontainer] ready: Gameplane · Agent + +Works here: + cd agent && go build ./... + cd gameaction && go build ./... + cd netguard && go build ./... + make build-go + make module-validate + +Ports: none +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? envtest → profile "api" +Need something else? node → profile "web" +Need something else? oras → profile "modules" diff --git a/.devcontainer/agent/setup.env b/.devcontainer/agent/setup.env new file mode 100644 index 000000000..c0303d889 --- /dev/null +++ b/.devcontainer/agent/setup.env @@ -0,0 +1,10 @@ +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="agent gameaction netguard" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +PROFILE="agent" +READY=".devcontainer/agent/ready.txt" +SPECIFY_VERSION="v1.0.3" +STEPS="golangci_lint go_mod_download uv specify ai_clis" +SUBMODULES="" +WEBSITE_DIR="website" diff --git a/.devcontainer/api/devcontainer-lock.json b/.devcontainer/api/devcontainer-lock.json new file mode 100644 index 000000000..7dfc7c7e2 --- /dev/null +++ b/.devcontainer/api/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + } + } +} diff --git a/.devcontainer/api/devcontainer.json b/.devcontainer/api/devcontainer.json new file mode 100644 index 000000000..4439d79f4 --- /dev/null +++ b/.devcontainer/api/devcontainer.json @@ -0,0 +1,53 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "golang.go", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + } + }, + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 API", + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/api/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/api/ready.txt b/.devcontainer/api/ready.txt new file mode 100644 index 000000000..f43b8fc32 --- /dev/null +++ b/.devcontainer/api/ready.txt @@ -0,0 +1,18 @@ +[devcontainer] ready: Gameplane · API + +Works here: + cd api && go build ./... + cd gameaction && go build ./... + cd gp-module && go build ./... + cd i18n && go build ./... + cd netguard && go build ./... + cd svcutil && go build ./... + cd telemetryschema && go build ./... + make build-go + make module-validate + +Ports: none +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? node → profile "web" +Need something else? oras → profile "modules" diff --git a/.devcontainer/api/setup.env b/.devcontainer/api/setup.env new file mode 100644 index 000000000..67f68de82 --- /dev/null +++ b/.devcontainer/api/setup.env @@ -0,0 +1,12 @@ +ENVTEST_K8S_VERSION="1.36.2" +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="api gameaction gp-module i18n netguard svcutil telemetryschema" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +PROFILE="api" +READY=".devcontainer/api/ready.txt" +SETUP_ENVTEST_VERSION="release-0.24" +SPECIFY_VERSION="v1.0.3" +STEPS="golangci_lint envtest go_mod_download uv specify ai_clis" +SUBMODULES="" +WEBSITE_DIR="website" diff --git a/.devcontainer/control-plane/devcontainer-lock.json b/.devcontainer/control-plane/devcontainer-lock.json new file mode 100644 index 000000000..8e9f75681 --- /dev/null +++ b/.devcontainer/control-plane/devcontainer-lock.json @@ -0,0 +1,29 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "version": "2.17.0" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + }, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "integrity": "sha256:bbe8adf6b37fff8c67412ab0a4579f4c2f30bbaba1d9a5cebd9e38bade54025b", + "resolved": "ghcr.io/devcontainers/features/kubectl-helm-minikube@sha256:bbe8adf6b37fff8c67412ab0a4579f4c2f30bbaba1d9a5cebd9e38bade54025b", + "version": "1.3.1" + } + } +} diff --git a/.devcontainer/control-plane/devcontainer.json b/.devcontainer/control-plane/devcontainer.json new file mode 100644 index 000000000..2458cfbf4 --- /dev/null +++ b/.devcontainer/control-plane/devcontainer.json @@ -0,0 +1,89 @@ +{ + "containerEnv": { + "CI": "1", + "GAMEPLANE_REG_HOST_PORT": "5001", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "golang.go", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2", + "moby": true, + "version": "latest" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + }, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "helm": "4.3.1", + "minikube": "none", + "version": "1.36" + } + }, + "forwardPorts": [ + 8080, + 8443, + 5001, + 30565 + ], + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Control plane", + "portsAttributes": { + "30565": { + "label": "Sample Minecraft NodePort", + "onAutoForward": "silent" + }, + "5001": { + "label": "Local OCI registry", + "onAutoForward": "silent" + }, + "8080": { + "label": "Gameplane ingress (HTTP)", + "onAutoForward": "silent" + }, + "8443": { + "label": "Gameplane ingress (HTTPS)", + "onAutoForward": "silent" + } + }, + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/control-plane/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/control-plane/ready.txt b/.devcontainer/control-plane/ready.txt new file mode 100644 index 000000000..de193ecef --- /dev/null +++ b/.devcontainer/control-plane/ready.txt @@ -0,0 +1,25 @@ +[devcontainer] ready: Gameplane · Control plane + +Works here: + cd api && go build ./... + cd gameaction && go build ./... + cd gp-module && go build ./... + cd i18n && go build ./... + cd netguard && go build ./... + cd svcutil && go build ./... + cd telemetryschema && go build ./... + cd operator && go build ./... + cd agent && go build ./... + cd test/e2e && go vet -tags e2e ./... + make build-go + make generate + make manifests + make module-validate + make modules-push + make dev-load + make e2e-up + make dev-up + +Ports: 8080 Gameplane ingress (HTTP), 8443 Gameplane ingress (HTTPS), 5001 Local OCI registry, 30565 Sample Minecraft NodePort +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? node → profile "web" diff --git a/.devcontainer/control-plane/setup.env b/.devcontainer/control-plane/setup.env new file mode 100644 index 000000000..b76ee1e82 --- /dev/null +++ b/.devcontainer/control-plane/setup.env @@ -0,0 +1,14 @@ +ENVTEST_K8S_VERSION="1.36.2" +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="agent api gameaction gameproto gp-module i18n netguard operator svcutil telemetryschema test/e2e" +KIND_VERSION="v0.32.0" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +ORAS_VERSION="1.2.0" +PROFILE="control-plane" +READY=".devcontainer/control-plane/ready.txt" +SETUP_ENVTEST_VERSION="release-0.24" +SPECIFY_VERSION="v1.0.3" +STEPS="submodules kind oras golangci_lint envtest go_mod_download uv specify ai_clis" +SUBMODULES="modules" +WEBSITE_DIR="website" diff --git a/.devcontainer/dashboard-api/devcontainer-lock.json b/.devcontainer/dashboard-api/devcontainer-lock.json new file mode 100644 index 000000000..f810c0917 --- /dev/null +++ b/.devcontainer/dashboard-api/devcontainer-lock.json @@ -0,0 +1,34 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "version": "2.17.0" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + }, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "integrity": "sha256:bbe8adf6b37fff8c67412ab0a4579f4c2f30bbaba1d9a5cebd9e38bade54025b", + "resolved": "ghcr.io/devcontainers/features/kubectl-helm-minikube@sha256:bbe8adf6b37fff8c67412ab0a4579f4c2f30bbaba1d9a5cebd9e38bade54025b", + "version": "1.3.1" + }, + "ghcr.io/devcontainers/features/node:1": { + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "version": "1.7.1" + } + } +} diff --git a/.devcontainer/dashboard-api/devcontainer.json b/.devcontainer/dashboard-api/devcontainer.json new file mode 100644 index 000000000..400464e15 --- /dev/null +++ b/.devcontainer/dashboard-api/devcontainer.json @@ -0,0 +1,95 @@ +{ + "containerEnv": { + "CI": "1", + "GAMEPLANE_REG_HOST_PORT": "5001", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "golang.go", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2", + "moby": true, + "version": "latest" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + }, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "helm": "4.3.1", + "minikube": "none", + "version": "1.36" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "24" + } + }, + "forwardPorts": [ + 5173, + 8080, + 8443, + 5001 + ], + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Dashboard and API", + "portsAttributes": { + "5001": { + "label": "Local OCI registry", + "onAutoForward": "silent" + }, + "5173": { + "label": "Vite (web dev)", + "onAutoForward": "notify" + }, + "8080": { + "label": "Gameplane ingress (HTTP)", + "onAutoForward": "silent" + }, + "8443": { + "label": "Gameplane ingress (HTTPS)", + "onAutoForward": "silent" + } + }, + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/dashboard-api/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/dashboard-api/ready.txt b/.devcontainer/dashboard-api/ready.txt new file mode 100644 index 000000000..edde26e8e --- /dev/null +++ b/.devcontainer/dashboard-api/ready.txt @@ -0,0 +1,27 @@ +[devcontainer] ready: Gameplane · Dashboard and API + +Works here: + cd web && npm ci && npx tsc --noEmit + cd api && go build ./... + cd gameaction && go build ./... + cd gp-module && go build ./... + cd i18n && go build ./... + cd netguard && go build ./... + cd svcutil && go build ./... + cd telemetryschema && go build ./... + cd operator && go build ./... + cd agent && go build ./... + make build-go + make build-web + make web-dev + make generate + make manifests + make module-validate + make modules-push + make dev-load + make e2e-up + make dev-up + +Ports: 5173 Vite (web dev), 8080 Gameplane ingress (HTTP), 8443 Gameplane ingress (HTTPS), 5001 Local OCI registry +Run order: make dev-up -> make web-dev -> open forwarded port 5173. +Tests and lint run in CI (CLAUDE.md rule 8). diff --git a/.devcontainer/dashboard-api/setup.env b/.devcontainer/dashboard-api/setup.env new file mode 100644 index 000000000..481efc82a --- /dev/null +++ b/.devcontainer/dashboard-api/setup.env @@ -0,0 +1,14 @@ +ENVTEST_K8S_VERSION="1.36.2" +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="agent api gameaction gp-module i18n netguard operator svcutil telemetryschema" +KIND_VERSION="v0.32.0" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +ORAS_VERSION="1.2.0" +PROFILE="dashboard-api" +READY=".devcontainer/dashboard-api/ready.txt" +SETUP_ENVTEST_VERSION="release-0.24" +SPECIFY_VERSION="v1.0.3" +STEPS="submodules kind oras golangci_lint envtest go_mod_download npm_ci_web uv specify ai_clis" +SUBMODULES="modules" +WEBSITE_DIR="website" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0a7011aa2..6c504f8b0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,74 +1,34 @@ { - "name": "Gameplane", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": "true", - "configureZshAsDefaultShell": "false", - "username": "vscode" - }, - "ghcr.io/devcontainers/features/go:1": { - "version": "1.26" - }, - "ghcr.io/devcontainers/features/node:1": { - "version": "24" - }, - "ghcr.io/devcontainers/features/docker-in-docker:2": { - "version": "latest", - "moby": true, - "dockerDashComposeVersion": "v2" - }, - "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { - "version": "1.31", - "helm": "latest", - "minikube": "none" - }, - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - "runArgs": ["--init"], "containerEnv": { - "GOTOOLCHAIN": "local", + "CI": "1", "GAMEPLANE_REG_HOST_PORT": "5001", - "CI": "1" - }, - "postCreateCommand": "bash .devcontainer/post-create.sh", - "forwardPorts": [5173, 8080, 8443, 5001, 30565], - "portsAttributes": { - "5173": { "label": "Vite (web dev)", "onAutoForward": "notify" }, - "8080": { "label": "Gameplane ingress (HTTP)", "onAutoForward": "silent" }, - "8443": { "label": "Gameplane ingress (HTTPS)", "onAutoForward": "silent" }, - "5001": { "label": "Local OCI registry", "onAutoForward": "silent" }, - "30565": { "label": "Sample Minecraft NodePort", "onAutoForward": "silent" } + "GOTOOLCHAIN": "local" }, - "remoteUser": "vscode", "customizations": { "vscode": { "extensions": [ - "golang.go", + "1yib.rust-bundle", + "ahmedsaid.darkpinkpro", + "anthropic.claude-code", + "bradlc.vscode-tailwindcss", "dbaeumer.vscode-eslint", + "dustypomerleau.rust-syntax", "esbenp.prettier-vscode", - "bradlc.vscode-tailwindcss", - "ms-kubernetes-tools.vscode-kubernetes-tools", - "redhat.vscode-yaml", - "tamasfe.even-better-toml", - "anthropic.claude-code", - "google.geminicodeassist", + "fill-labs.dependi", + "golang.go", "google.gemini-cli-vscode-ide-companion", + "google.geminicodeassist", "highagency.pencildev", "ms-azuretools.vscode-containers", + "ms-kubernetes-tools.vscode-kubernetes-tools", "ms-vscode.makefile-tools", - "fill-labs.dependi", - "rust-lang.rust-analyzer", - "dustypomerleau.rust-syntax", - "1yib.rust-bundle", - "vue.volar", "nicolasdschmidt.lunar-pink", - "ahmedsaid.darkpinkpro" + "redhat.vscode-yaml", + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "vue.volar" ], "settings": { - "go.toolsManagement.checkForUpdates": "off", - "go.useLanguageServer": true, - "editor.formatOnSave": true, "[go]": { "editor.defaultFormatter": "golang.go" }, @@ -78,12 +38,75 @@ "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "workbench.colorTheme": "Lunar Pink", - "workbench.browser.openLocalhostLinks": false, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, "vs-kubernetes": { "vs-kubernetes.crd-code-completion": "enabled" - } + }, + "workbench.browser.openLocalhostLinks": false, + "workbench.colorTheme": "Lunar Pink" } } - } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2", + "moby": true, + "version": "latest" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + }, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "helm": "4.3.1", + "minikube": "none", + "version": "1.36" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "24" + } + }, + "forwardPorts": [ + 5173, + 8080, + 8443, + 5001, + 30565 + ], + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Full", + "portsAttributes": { + "30565": { + "label": "Sample Minecraft NodePort", + "onAutoForward": "silent" + }, + "5001": { + "label": "Local OCI registry", + "onAutoForward": "silent" + }, + "5173": { + "label": "Vite (web dev)", + "onAutoForward": "notify" + }, + "8080": { + "label": "Gameplane ingress (HTTP)", + "onAutoForward": "silent" + }, + "8443": { + "label": "Gameplane ingress (HTTPS)", + "onAutoForward": "silent" + } + }, + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/full/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] } diff --git a/.devcontainer/full/ready.txt b/.devcontainer/full/ready.txt new file mode 100644 index 000000000..6aa46279d --- /dev/null +++ b/.devcontainer/full/ready.txt @@ -0,0 +1,36 @@ +[devcontainer] ready: Gameplane · Full + +Works here: + cd web && npm ci && npx tsc --noEmit + cd api && go build ./... + cd gameaction && go build ./... + cd gp-module && go build ./... + cd i18n && go build ./... + cd netguard && go build ./... + cd svcutil && go build ./... + cd telemetryschema && go build ./... + cd operator && go build ./... + cd agent && go build ./... + cd audit-syslog-bridge && go build ./... + cd capture-sidecar && go build ./... + cd gameproto && go build ./... + cd mcp-server && go build ./... + cd sentinel && go build ./... + cd telemetry-receiver && go build ./... + cd tunnel && go build ./... + cd website && npm ci && npm run check + make module-validate + cd test/e2e && go vet -tags e2e ./... + make build-go + make build-web + make web-dev + make generate + make manifests + make modules-push + make dev-load + make e2e-up + make dev-up + +Ports: 5173 Vite (web dev), 8080 Gameplane ingress (HTTP), 8443 Gameplane ingress (HTTPS), 5001 Local OCI registry, 30565 Sample Minecraft NodePort +Run order: make dev-up -> make web-dev -> open forwarded port 5173. +Tests and lint run in CI (CLAUDE.md rule 8). diff --git a/.devcontainer/full/setup.env b/.devcontainer/full/setup.env new file mode 100644 index 000000000..fc91b9c5f --- /dev/null +++ b/.devcontainer/full/setup.env @@ -0,0 +1,14 @@ +ENVTEST_K8S_VERSION="1.36.2" +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="agent api audit-syslog-bridge capture-sidecar gameaction gameproto gp-module i18n mcp-server netguard operator sentinel svcutil telemetryschema telemetry-receiver test/e2e tunnel" +KIND_VERSION="v0.32.0" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +ORAS_VERSION="1.2.0" +PROFILE="full" +READY=".devcontainer/full/ready.txt" +SETUP_ENVTEST_VERSION="release-0.24" +SPECIFY_VERSION="v1.0.3" +STEPS="submodules kind oras golangci_lint envtest go_mod_download npm_ci_web npm_ci_website uv specify ai_clis" +SUBMODULES="website modules" +WEBSITE_DIR="website" diff --git a/.devcontainer/go-services/devcontainer-lock.json b/.devcontainer/go-services/devcontainer-lock.json new file mode 100644 index 000000000..7dfc7c7e2 --- /dev/null +++ b/.devcontainer/go-services/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + } + } +} diff --git a/.devcontainer/go-services/devcontainer.json b/.devcontainer/go-services/devcontainer.json new file mode 100644 index 000000000..89ef84eee --- /dev/null +++ b/.devcontainer/go-services/devcontainer.json @@ -0,0 +1,53 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "golang.go", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + } + }, + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Go services", + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/go-services/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/go-services/ready.txt b/.devcontainer/go-services/ready.txt new file mode 100644 index 000000000..cadcea24b --- /dev/null +++ b/.devcontainer/go-services/ready.txt @@ -0,0 +1,22 @@ +[devcontainer] ready: Gameplane · Go services + +Works here: + cd audit-syslog-bridge && go build ./... + cd capture-sidecar && go build ./... + cd gameproto && go build ./... + cd gp-module && go build ./... + cd i18n && go build ./... + cd mcp-server && go build ./... + cd sentinel && go build ./... + cd telemetryschema && go build ./... + cd telemetry-receiver && go build ./... + cd tunnel && go build ./... + make build-go + make module-validate + +Ports: none +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? envtest → profile "api" +Need something else? node → profile "web" +Need something else? oras → profile "modules" diff --git a/.devcontainer/go-services/setup.env b/.devcontainer/go-services/setup.env new file mode 100644 index 000000000..0de60eb0f --- /dev/null +++ b/.devcontainer/go-services/setup.env @@ -0,0 +1,10 @@ +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="audit-syslog-bridge capture-sidecar gameproto gp-module i18n mcp-server sentinel telemetryschema telemetry-receiver tunnel" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +PROFILE="go-services" +READY=".devcontainer/go-services/ready.txt" +SPECIFY_VERSION="v1.0.3" +STEPS="golangci_lint go_mod_download uv specify ai_clis" +SUBMODULES="" +WEBSITE_DIR="website" diff --git a/.devcontainer/modules-gp/devcontainer-lock.json b/.devcontainer/modules-gp/devcontainer-lock.json new file mode 100644 index 000000000..7dfc7c7e2 --- /dev/null +++ b/.devcontainer/modules-gp/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + } + } +} diff --git a/.devcontainer/modules-gp/devcontainer.json b/.devcontainer/modules-gp/devcontainer.json new file mode 100644 index 000000000..7f222ff41 --- /dev/null +++ b/.devcontainer/modules-gp/devcontainer.json @@ -0,0 +1,52 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + } + }, + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Modules and gp-module", + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/modules-gp/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/modules-gp/ready.txt b/.devcontainer/modules-gp/ready.txt new file mode 100644 index 000000000..f5a4c0d6b --- /dev/null +++ b/.devcontainer/modules-gp/ready.txt @@ -0,0 +1,12 @@ +[devcontainer] ready: Gameplane · Modules and gp-module + +Works here: + make module-validate + make build-go + make modules-push + +Ports: none +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? envtest → profile "api" +Need something else? node → profile "web" diff --git a/.devcontainer/modules-gp/setup.env b/.devcontainer/modules-gp/setup.env new file mode 100644 index 000000000..db7d1cc37 --- /dev/null +++ b/.devcontainer/modules-gp/setup.env @@ -0,0 +1,11 @@ +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="gp-module i18n" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +ORAS_VERSION="1.2.0" +PROFILE="modules-gp" +READY=".devcontainer/modules-gp/ready.txt" +SPECIFY_VERSION="v1.0.3" +STEPS="submodules oras golangci_lint go_mod_download uv specify ai_clis" +SUBMODULES="modules" +WEBSITE_DIR="website" diff --git a/.devcontainer/modules/devcontainer-lock.json b/.devcontainer/modules/devcontainer-lock.json new file mode 100644 index 000000000..7dfc7c7e2 --- /dev/null +++ b/.devcontainer/modules/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + } + } +} diff --git a/.devcontainer/modules/devcontainer.json b/.devcontainer/modules/devcontainer.json new file mode 100644 index 000000000..88c25b1aa --- /dev/null +++ b/.devcontainer/modules/devcontainer.json @@ -0,0 +1,52 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + } + }, + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Modules", + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/modules/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/modules/ready.txt b/.devcontainer/modules/ready.txt new file mode 100644 index 000000000..ea5a1971c --- /dev/null +++ b/.devcontainer/modules/ready.txt @@ -0,0 +1,12 @@ +[devcontainer] ready: Gameplane · Modules + +Works here: + make module-validate + make build-go + make modules-push + +Ports: none +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? envtest, golangci-lint → profile "api" +Need something else? node → profile "web" diff --git a/.devcontainer/modules/setup.env b/.devcontainer/modules/setup.env new file mode 100644 index 000000000..711e3b7b1 --- /dev/null +++ b/.devcontainer/modules/setup.env @@ -0,0 +1,10 @@ +GO_MODULES="" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +ORAS_VERSION="1.2.0" +PROFILE="modules" +READY=".devcontainer/modules/ready.txt" +SPECIFY_VERSION="v1.0.3" +STEPS="submodules oras uv specify ai_clis" +SUBMODULES="modules" +WEBSITE_DIR="website" diff --git a/.devcontainer/operator/devcontainer-lock.json b/.devcontainer/operator/devcontainer-lock.json new file mode 100644 index 000000000..7dfc7c7e2 --- /dev/null +++ b/.devcontainer/operator/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/go:1": { + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "version": "1.3.4" + } + } +} diff --git a/.devcontainer/operator/devcontainer.json b/.devcontainer/operator/devcontainer.json new file mode 100644 index 000000000..ca05b19ab --- /dev/null +++ b/.devcontainer/operator/devcontainer.json @@ -0,0 +1,54 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "golang.go", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.26" + } + }, + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Operator", + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/operator/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/operator/ready.txt b/.devcontainer/operator/ready.txt new file mode 100644 index 000000000..6da136ca8 --- /dev/null +++ b/.devcontainer/operator/ready.txt @@ -0,0 +1,16 @@ +[devcontainer] ready: Gameplane · Operator + +Works here: + cd i18n && go build ./... + cd netguard && go build ./... + cd operator && go build ./... + make build-go + make generate + make manifests + make module-validate + +Ports: none +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? node → profile "web" +Need something else? oras → profile "modules" diff --git a/.devcontainer/operator/setup.env b/.devcontainer/operator/setup.env new file mode 100644 index 000000000..5486fdffa --- /dev/null +++ b/.devcontainer/operator/setup.env @@ -0,0 +1,12 @@ +ENVTEST_K8S_VERSION="1.36.2" +GOLANGCI_LINT_VERSION="v2.12.2" +GO_MODULES="i18n netguard operator" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +PROFILE="operator" +READY=".devcontainer/operator/ready.txt" +SETUP_ENVTEST_VERSION="release-0.24" +SPECIFY_VERSION="v1.0.3" +STEPS="golangci_lint envtest go_mod_download uv specify ai_clis" +SUBMODULES="" +WEBSITE_DIR="website" diff --git a/.devcontainer/tool-index.tsv b/.devcontainer/tool-index.tsv new file mode 100644 index 000000000..ad308bf57 --- /dev/null +++ b/.devcontainer/tool-index.tsv @@ -0,0 +1,15 @@ +ai-clis web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline +common-utils web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline +docker dashboard-api,control-plane,full deploy/kind +envtest api,operator,dashboard-api,control-plane,full api +gh web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline +git web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline +go api,operator,agent,go-services,modules,dashboard-api,control-plane,modules-gp,full agent +golangci-lint api,operator,agent,go-services,dashboard-api,control-plane,modules-gp,full agent +helm dashboard-api,control-plane,full charts/gameplane +kind dashboard-api,control-plane,full deploy/kind +kubectl dashboard-api,control-plane,full deploy/kind +node web,website,dashboard-api,full web +oras modules,dashboard-api,control-plane,modules-gp,full modules +specify web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline +uv web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline diff --git a/.devcontainer/web/devcontainer-lock.json b/.devcontainer/web/devcontainer-lock.json new file mode 100644 index 000000000..21798409d --- /dev/null +++ b/.devcontainer/web/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/node:1": { + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "version": "1.7.1" + } + } +} diff --git a/.devcontainer/web/devcontainer.json b/.devcontainer/web/devcontainer.json new file mode 100644 index 000000000..cb7508300 --- /dev/null +++ b/.devcontainer/web/devcontainer.json @@ -0,0 +1,64 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "24" + } + }, + "forwardPorts": [ + 5173 + ], + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Web dashboard", + "portsAttributes": { + "5173": { + "label": "Vite (web dev)", + "onAutoForward": "notify" + } + }, + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/web/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/web/ready.txt b/.devcontainer/web/ready.txt new file mode 100644 index 000000000..10dfd2906 --- /dev/null +++ b/.devcontainer/web/ready.txt @@ -0,0 +1,12 @@ +[devcontainer] ready: Gameplane · Web dashboard + +Works here: + cd web && npm ci && npx tsc --noEmit + make build-web + make web-dev + +Ports: 5173 Vite (web dev) +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? envtest, go, golangci-lint → profile "api" +Need something else? oras → profile "modules" diff --git a/.devcontainer/web/setup.env b/.devcontainer/web/setup.env new file mode 100644 index 000000000..366bf4cba --- /dev/null +++ b/.devcontainer/web/setup.env @@ -0,0 +1,9 @@ +GO_MODULES="" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +PROFILE="web" +READY=".devcontainer/web/ready.txt" +SPECIFY_VERSION="v1.0.3" +STEPS="npm_ci_web uv specify ai_clis" +SUBMODULES="" +WEBSITE_DIR="website" diff --git a/.devcontainer/website/devcontainer-lock.json b/.devcontainer/website/devcontainer-lock.json new file mode 100644 index 000000000..21798409d --- /dev/null +++ b/.devcontainer/website/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "version": "2.5.9" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "version": "1.1.0" + }, + "ghcr.io/devcontainers/features/node:1": { + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "version": "1.7.1" + } + } +} diff --git a/.devcontainer/website/devcontainer.json b/.devcontainer/website/devcontainer.json new file mode 100644 index 000000000..d587ec5c1 --- /dev/null +++ b/.devcontainer/website/devcontainer.json @@ -0,0 +1,63 @@ +{ + "containerEnv": { + "CI": "1", + "GOTOOLCHAIN": "local" + }, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "astro-build.astro-vscode", + "bradlc.vscode-tailwindcss", + "ms-vscode.makefile-tools", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ], + "settings": { + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "go.toolsManagement.checkForUpdates": "off", + "go.useLanguageServer": true, + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "workbench.browser.openLocalhostLinks": false + } + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": "false", + "installZsh": "true", + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "24" + } + }, + "forwardPorts": [ + 4321 + ], + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "name": "Gameplane \u00b7 Website", + "portsAttributes": { + "4321": { + "label": "Astro (website dev)", + "onAutoForward": "notify" + } + }, + "postCreateCommand": "bash .devcontainer/setup.sh .devcontainer/website/setup.env", + "remoteUser": "vscode", + "runArgs": [ + "--init" + ] +} diff --git a/.devcontainer/website/ready.txt b/.devcontainer/website/ready.txt new file mode 100644 index 000000000..3b10b42e0 --- /dev/null +++ b/.devcontainer/website/ready.txt @@ -0,0 +1,10 @@ +[devcontainer] ready: Gameplane · Website + +Works here: + cd website && npm ci && npm run check + +Ports: 4321 Astro (website dev) +Tests and lint run in CI (CLAUDE.md rule 8). +Need something else? docker, helm, kind, kubectl → profile "dashboard-api" +Need something else? envtest, go, golangci-lint → profile "api" +Need something else? oras → profile "modules" diff --git a/.devcontainer/website/setup.env b/.devcontainer/website/setup.env new file mode 100644 index 000000000..9359ce748 --- /dev/null +++ b/.devcontainer/website/setup.env @@ -0,0 +1,9 @@ +GO_MODULES="" +MODULES_DIR="modules" +OPTIONAL_STEPS="ai_clis" +PROFILE="website" +READY=".devcontainer/website/ready.txt" +SPECIFY_VERSION="v1.0.3" +STEPS="submodules npm_ci_website uv specify ai_clis" +SUBMODULES="website" +WEBSITE_DIR="website" From c6ffc9305afc783a350b7b6084e264b977fd99f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 07/19] ci: add dev container drift check and full profile build (spec 026 T014) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- .github/workflows/devcontainers.yaml | 113 +++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/devcontainers.yaml diff --git a/.github/workflows/devcontainers.yaml b/.github/workflows/devcontainers.yaml new file mode 100644 index 000000000..49660b45b --- /dev/null +++ b/.github/workflows/devcontainers.yaml @@ -0,0 +1,113 @@ +name: dev containers + +# Verifies the modular dev containers (spec 026). +# +# drift: regenerated files must match their inputs, and the generator's fixture +# tests must pass. +# build: builds the full profile on amd64 and arm64 and runs its checks inside +# the container. PR-A: full profile only; PR-B (T021) adds the +# affected-profile matrix. +on: + pull_request: + paths: + - '.devcontainer/**' + - '**/system-deps.toml' + - 'hack/gen-devcontainers.py' + - 'hack/test-gen-devcontainers.py' + - 'hack/need.sh' + - 'Makefile' + - 'go.work' + - 'web/package*.json' + - '.github/workflows/devcontainers.yaml' + push: + branches: [master] + paths: + - '.devcontainer/**' + - '**/system-deps.toml' + - 'hack/gen-devcontainers.py' + - 'hack/test-gen-devcontainers.py' + - 'hack/need.sh' + - 'Makefile' + - 'go.work' + - 'web/package*.json' + - '.github/workflows/devcontainers.yaml' + +permissions: + contents: read + +concurrency: + group: devcontainers-${{ github.ref }} + cancel-in-progress: true + +jobs: + drift: + name: drift + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + + - name: Verify generated dev container files + run: python3 hack/gen-devcontainers.py --check + + - name: Run generator fixture tests + run: make test-gen-devcontainers + + build: + name: build (${{ matrix.arch }}) + needs: drift + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + arch: [amd64, arm64] + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + + - name: Record start time + id: timer + run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" + + - name: Expand full profile checks + id: checks + run: | + set -euo pipefail + cmd="$(python3 hack/gen-devcontainers.py --checks full)" + echo "cmd=${cmd}" >> "$GITHUB_OUTPUT" + + - name: Build full profile and run its checks + uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + push: never + configFile: .devcontainer/devcontainer.json + runCmd: ${{ steps.checks.outputs.cmd }} + + - name: Write build summary + if: always() && steps.timer.outcome == 'success' + env: + STARTED: ${{ steps.timer.outputs.started }} + MATRIX_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + elapsed=$(( $(date +%s) - STARTED )) + { + echo "### Dev containers: full (${MATRIX_ARCH})" + echo "" + echo "- Setup seconds: ${elapsed}" + echo "- Workspace size (du -sx): $(du -sx "$GITHUB_WORKSPACE" | cut -f1) KiB" + echo "" + echo "Docker images:" + echo "" + echo '```' + docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" From 0270770086a1c95be8be0322caf914d5e0c929d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:23 +0000 Subject: [PATCH 08/19] docs(specs): spec 026 inventory brief and task progress Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- specs/026-modular-dev-containers/inventory.md | 333 ++++++++++++++++++ specs/026-modular-dev-containers/tasks.md | 38 +- 2 files changed, 352 insertions(+), 19 deletions(-) create mode 100644 specs/026-modular-dev-containers/inventory.md diff --git a/specs/026-modular-dev-containers/inventory.md b/specs/026-modular-dev-containers/inventory.md new file mode 100644 index 000000000..17e889ac1 --- /dev/null +++ b/specs/026-modular-dev-containers/inventory.md @@ -0,0 +1,333 @@ +# Spec 026: system dependency inventory (T001) + +Read-only scout. Nothing was built, installed, tested or committed. + +- Basis: working tree of branch `claude/project-thread-nv63wo` (clean at scout time). +- Submodules as checked out: `modules/` at `2835987` (`v0.3.0-24-g2835987`), `website/` at `ae95098` (`heads/main`), per `.gitmodules:1-4`. +- Paths are repo-relative. `Makefile:N` means the root `Makefile`. `GO_MODULES` is `Makefile:43`, `GO_UNIT_MODULES` is `Makefile:50`. +- Legend for targets: "covers" means the root Makefile loops over the folder, so the folder is built, tested or linted by that target. + +## 1. Today's dev container registry + +Source: `.devcontainer/devcontainer.json` and `.devcontainer/post-create.sh`. + +| Entry | Kind | Evidence (declared / invoked) | +|---|---|---| +| `mcr.microsoft.com/devcontainers/base:ubuntu-24.04` | base image | devcontainer.json:3 | +| `common-utils:2` (zsh, user `vscode`) | feature | devcontainer.json:5-9 | +| `go` 1.26 | feature | devcontainer.json:10-12; post-create.sh:16, 62-63, 71, 81 | +| `node` 24 | feature | devcontainer.json:13-15; post-create.sh:86, 107-113 | +| `docker-in-docker` latest (moby, compose v2) | feature | devcontainer.json:16-20 | +| `kubectl-helm-minikube` kubectl 1.31, helm `latest`, minikube none | feature | devcontainer.json:21-25 | +| `github-cli` | feature | devcontainer.json:26 (no Makefile or script call) | +| `git` | not declared (base image only) | post-create.sh:29-30 (`git submodule update`) | +| `sudo`, `curl`, `dpkg` | not declared (base image) | post-create.sh:15, 37-39, 47-50, 91, 113 | +| `kind` v0.32.0 (curl binary) | script step | post-create.sh:34-41 | +| `oras` 1.2.0 (GitHub release tarball) | script step | post-create.sh:44-52 | +| `golangci-lint` v2.12.2 (`go install`) | script step | post-create.sh:58-64 | +| `setup-envtest@latest` then K8s 1.36.2 assets | script step | post-create.sh:69-74 (`Makefile:171` pins `release-0.24`) | +| `go mod download` for 16 modules | script step | post-create.sh:79-82 (list omits `gp-module`, which is in `go.work:10`) | +| `npm ci` in `web/` | script step | post-create.sh:86 | +| `uv` (astral curl installer) | script step | post-create.sh:89-92 | +| `specify-cli` v1.0.3 via `uv tool install` | script step | post-create.sh:95-99 | +| AI CLIs: `@anthropic-ai/claude-code`, `opencode-ai`, `@google/gemini-cli`, `@openai/codex` (warn-only) | script step | post-create.sh:107-115 | +| submodule init (`modules`, `website`) | script step | post-create.sh:28-31 | +| env `GOTOOLCHAIN=local`, `GAMEPLANE_REG_HOST_PORT=5001`, `CI=1` | container env | devcontainer.json:29-32 | +| forwarded ports 5173, 8080, 8443, 5001, 30565 | container | devcontainer.json:35 | +| VS Code extensions and editor settings | customizations | devcontainer.json:44-68 | + +## 2. Components + +### 2.1 Go modules (`go.work:3-21`, 17 folders) + +Root-Makefile targets that cover every Go module: `build-go` (`Makefile:72-76`), `test-go` (`88-91`), `cover-go` (`107-112`, `go test -race`, which needs cgo), `cover-go-merge` (`124-132`, `go run` of gocovmerge), `cover-go-check` (`135-148`, `go run` of go-test-coverage), `lint-go` (`302-305`, `golangci-lint run`), `tidy` (`482-485`). `test/e2e` is covered by `test-go` only (`GO_UNIT_MODULES`, `Makefile:50`). + +| Folder | Module-specific root-Makefile targets (evidence) | External tools and services those targets call | Dockerfile | +|---|---|---|---| +| `agent` | `image-%` via `IMAGES` (`Makefile:313, 330`); `e2e-images` (`:189`) | go, docker | `agent/Dockerfile` | +| `api` | `cover-go-integration` (`:115-121`); `test-integration` (`:174-179`); `i18n-sync` (`:272`, `cd api && go run ./cmd/messages-export`); `check-i18n` (`:281`); `e2e-images` (`:188`); `IMAGES` (`:313`) | go, setup-envtest and K8s 1.36.2 envtest assets (`:166-171`), jq (`:282`) | `api/Dockerfile` | +| `audit-syslog-bridge` | `image-audit-syslog` (`:333-334`); `ALL_IMAGE_NAMES` (`:323`) | docker | `audit-syslog-bridge/Dockerfile` | +| `capture-sidecar` | `IMAGES` (`:313`) | docker | `capture-sidecar/Dockerfile`. CI-only: `libcap2-bin` and a setcap proof (`.github/workflows/ci.yaml:507-508`) | +| `gameaction` | none beyond the GO_MODULES loops | go | none | +| `gameproto` | none beyond the GO_MODULES loops | go | none | +| `gp-module` | `gp-module` builds `bin/gp-module` (`:383-385`); `module-new`, `module-validate`, `module-preview`, `module-package` (`:387-397`) | go | none | +| `i18n` | `i18n-sync` (`:272-273`, `cd i18n && go run ./cmd/i18n-check`); `check-i18n` (`:280-283`) | go, jq (`:282`) | none | +| `mcp-server` | `IMAGES` (`:313`) | docker | `mcp-server/Dockerfile` | +| `netguard` | none beyond the GO_MODULES loops | go | none | +| `operator` | `generate` (`:347-348`, `go run controller-gen`); `manifests` (`:350-362`, controller-gen, then `hack/sync-chart-crds.sh`); `cover-go-integration`; `test-integration`; `i18n-sync` (`:275`, `go run ./hack/condition-ids`); `check-i18n` (`:280`); `e2e-images` (`:187`); `IMAGES` | go, controller-gen (via `go run`, no install), setup-envtest, docker | `operator/Dockerfile` | +| `sentinel` | `IMAGES` (`:313`); image loaded by `deploy/kind/e2e.sh:215-216` | docker | `sentinel/Dockerfile` | +| `svcutil` | none beyond the GO_MODULES loops | go | none | +| `telemetryschema` | none beyond the GO_MODULES loops | go | none | +| `telemetry-receiver` | `IMAGES` (`:313`); `e2e-images` (`:190`) | docker | `telemetry-receiver/Dockerfile` | +| `test/e2e` | `test-go` (`:50`); `test-e2e` (`:193-203`); `test-e2e-keep` (`:206-208`); `test-e2e-bucket` (`:211-213`); `e2e-images` (`:185-191`, incl. `test/e2e/Dockerfile.fakeoidc`); `e2e-up`, `e2e-down` (`:215-221`) | go, kind, kubectl, helm, docker, bash (`buckets.sh`), `deploy/kind/e2e.sh` | `test/e2e/Dockerfile` (`golang:1.27-alpine`, `:13`), `test/e2e/Dockerfile.fakeoidc:11` | +| `tunnel` | `image-tunnel-frp`, `image-tunnel-tailscale`, `image-tunnel-playit` (`:336-343`); `ALL_IMAGE_NAMES` (`:323`) | docker | `tunnel/Dockerfile.frp`, `.tailscale`, `.playit` | + +Extra: `tunnel/frpc/go.mod` is a nested module that is not in `go.work`. + +Images: `IMAGES := operator api web agent telemetry-receiver sentinel mcp-server capture-sidecar` (`Makefile:313`). `images` builds them with `docker build` (`:326`, `:330`). + +#### In-repo `require` and `replace` targets (go.mod evidence) + +| Module (go.mod) | In-repo `require` | `replace` target | +|---|---|---| +| `agent` | `netguard` (go.mod:5), `gameaction` (:11) | `../netguard` (:9), `../gameaction` (:15) | +| `api` | `netguard` (:5), `gameaction` (:11), `gp-module` (:18), `telemetryschema` (:25), `i18n` (:37) | `../netguard` (:9), `../gameaction` (:16), `../gp-module` (:23), `../telemetryschema` (:30), `../i18n` (:34) | +| `operator` | `netguard` (:5), `i18n` (:23) | `../netguard` (:16), `../i18n` (:20) | +| `gp-module` | `i18n` (:10) | `../i18n` (:7) | +| `sentinel` | `gameproto` (:6) | `../gameproto` (:13) | +| `telemetry-receiver` | `telemetryschema` (:6) | `../telemetryschema` (:15) | +| `test/e2e` | `gp-module` (:5), `i18n` (:9), `telemetryschema` (:13), `gameproto` (:17) | `../../gp-module` (:7), `../../i18n` (:11), `../../telemetryschema` (:15), `../../gameproto` (:19) | +| `capture-sidecar`, `mcp-server`, `gameproto`, `telemetryschema`, `svcutil`, `audit-syslog-bridge`, `gameaction`, `netguard`, `i18n`, `tunnel` | none | none | + +Non-in-repo replace: `tunnel/frpc/go.mod:10` (yamux fork). This is outside `go.work`. + +### 2.2 `web/` (React, Vite, TypeScript) + +Root-Makefile targets: `build-web` (`Makefile:79-80`, `cd web && npm ci && npm run build`); `test-web` (`:94-95`, `npm test --if-present`); `cover-web` (`:151`); `lint-web` (`:308-309`); `web-dev` (`:477-478`); `test-web-e2e-mock` (`:230-231`, `npm ci && npx playwright install --with-deps chromium`); `test-web-e2e-live` (`:234-235`); `screenshots` (`:238-239`); `check-i18n` (`:279`, `npm run i18n:extract-only`); `IMAGES` (`:313`, docker build of `web/Dockerfile`). + +`web/package.json` scripts (`web/package.json:6-28`): + +| Script | Line | Tool or file invoked | +|---|---|---| +| `dev` | 7 | `vite` | +| `build` | 8 | `npm run i18n:compile && tsc -b && vite build` | +| `i18n:extract-only` | 9 | `formatjs extract` (`@formatjs/cli`) | +| `i18n:extract` | 10 | `node scripts/i18n-reference.mjs` | +| `i18n:compile` | 11 | `node scripts/i18n-compile.mjs` | +| `i18n:fixtures`, `i18n:pseudo` | 12-13 | `node scripts/i18n-fixtures.mjs`, `node scripts/i18n-pseudo.mjs` | +| `i18n:migrate` | 14 | `node scripts/i18n-migrate.ts` (needs Node type stripping) | +| `i18n:manifest` | 15 | `cd ../i18n && go run ./cmd/i18n-check --manifest ...` | +| `lint` | 17 | `eslint .` | +| `test`, `test:watch`, `test:cover` | 18-20 | vitest | +| `test:e2e:mock`, `test:e2e:live` | 21-22 | `playwright test` | +| `typecheck:e2e` | 23 | `tsc --noEmit -p e2e/tsconfig.json` | +| `screenshots*` | 24-26 | `playwright test` | +| `diff:screenshots` | 27 | `node scripts/compare-screenshots.mjs` | + +Dev-dependency tools that need native or external pieces: `@playwright/test` (`web/package.json:54`), `sharp` (`:78`), `typescript` (`:80`). The file has no `engines` field. Container bases in `web/Dockerfile`: `golang:1.27-alpine` (`:11`), `node:26-bookworm-slim` (`:21`), `nginxinc/nginx-unprivileged:1.31-alpine` (`:45`). + +### 2.3 `charts/gameplane/` (Helm chart) + +Root-Makefile targets: `dev-install` (`Makefile:443-460`, `helm upgrade --install`, helm at `:451`); `dev-down` (`:463-467`, `helm uninstall` at `:464` in remote mode); `manifests` (`:350-362`, `controller-gen` then `hack/sync-chart-crds.sh`, which writes `charts/gameplane/charts/gameplane-crds/crds/` and `charts/gameplane/crd-manifests/`); `e2e-up` through `deploy/kind/e2e.sh` (`:216-217`). + +CI (not local): `helm lint charts/gameplane` (`.github/workflows/ci.yaml:1170-1171`, `azure/setup-helm@v5.0.1` at `:1170`); `helm template` matrix (`ci.yaml:1230` onwards). + +Chart runtime images: the CRD apply hook uses `registry.k8s.io/kubectl:v1.36.3` (`charts/gameplane/values.yaml:46`, `templates/crd-apply-hook.yaml:220-223`). Chart dependency `gameplane-crds` 0.3.0 (`charts/gameplane/Chart.yaml:21-23`). + +`hack/sync-chart-crds.sh` needs bash 4 (`:38`) and `sha256sum` or `shasum` (`:45-49`). + +### 2.4 `deploy/kind/` (kind bootstrap scripts) + +| Script | Tools and services invoked (evidence) | +|---|---| +| `up.sh` | `need kind/kubectl/helm/docker` (`:195-199`, `type -P`); `kubectl()` wrapper (`:31`); `kind create cluster --config cluster.yaml` (`:207`); `docker run registry:2` as `kind-registry` on `localhost:5001` (`:19`, `:177-179`); `docker exec` containerd `hosts.toml` (`:220-223`); MetalLB `v0.14.9` (`:36`, applied `:51-52`, pools from `docker network inspect kind` at `:75-80`); ingress-nginx `controller-v1.15.1` (`:40`, applied `:245`, wait `:247`) | +| `e2e.sh` | `need()` (`:41`, `command -v`), `need kind/kubectl/helm/docker` (`:175-178`); MetalLB `v0.14.9` (`:45`, applied `:56-57`); `kind create cluster --wait 90s` (`:192`); `kind load docker-image` (`:225`, `:233`, `:245`); `docker build` (`:223`, `:243`); `helm upgrade --install gameplane` (`:384`) | +| `upgrade.sh` | `need` (`:40`), `need kind/kubectl/helm/docker` (`:44-47`); `kind create cluster` (`:54`); `docker build` (`:70`); `kind load` (`:72`); `helm install gameplane` (`:87`). Called by CI at `.github/workflows/ci.yaml:2077` | +| `down.sh` | `kind get clusters`, `kind delete cluster` (`:8`, `:13`). Not called by the Makefile or CI (`Makefile:466` deletes the cluster directly) | +| `cluster.yaml` | kind config (`:7-9`), `containerdConfigPatches` for `kind-registry:5000` (`:16-19`), node port mappings 8080, 8443, 30565 (`:28-39`) | +| `postgres.yaml` | applied by `e2e.sh` for the PostgreSQL variant (`e2e.sh:278`) | + +Required by the bootstrap scripts: kind, kubectl, helm, docker, plus the service images MetalLB, ingress-nginx and `registry:2`. + +### 2.5 `test/e2e/` (Go suite, separate module) + +Root-Makefile targets are listed in 2.1. Runtime binaries that the Go suite executes (evidence): + +| Binary | Evidence | +|---|---| +| `kubectl` | `test/e2e/env.go:167`, `:249`, `:392` | +| `helm` | `test/e2e/helm_install_e2e_test.go:359`, `:514`; `upgrade_e2e_test.go:119`, `:286`, `:307`, `:365`, `:521`, `:548`; `gatewayparity_e2e_test.go:235`, `:243`; `telemetry_e2e_test.go:664`; `telemetry_e2e_helpers_test.go:262` | +| `kind` | `test/e2e/multicluster_e2e_test.go:126` (`kind get kubeconfig`); bootstrap via `deploy/kind/e2e.sh` (`test/e2e/e2e_suite_test.go:69-71`) | +| `docker` | `test/e2e/gatewayparity_e2e_test.go:156`, `:170`; `multicluster_e2e_test.go:140` | +| `bash` | `test/e2e/ci_selection_e2e_test.go:64`, `:155` (`buckets.sh`) | +| `curl` | `test/e2e/helm_install_e2e_test.go` (one call) | + +Bucket definitions: `test/e2e/buckets.sh` (called as `./buckets.sh regex ` from `Makefile:213`). + +### 2.6 `website/` (Astro site, submodule) + +The root Makefile has no target that builds `website/`. It is read by `check-features-sync` (`Makefile:259-260`, `hack/check-features-sync.sh:36` reads `website/src/content/docs/features.mdx`). `hack/check-submodule-freshness.sh:32-37` checks the submodule gitlinks; CI runs it at `.github/workflows/ci.yaml:712-714`. + +`website/package.json`: +- engines: `"node": ">=22.12.0"` (`:7-9`). +- scripts (`:10-17`): `dev` (`astro dev`), `build` (`astro build`), `preview`, `check` (`astro check`), `lint` (`eslint .`), `astro`. +- dependencies: `astro` `^7.0.5` (`:23`), `@astrojs/mdx`, `@astrojs/sitemap`, `@fontsource-variable/*`. +- devDependencies: `eslint` (`:29`), `typescript-eslint` (`:33`), `@tailwindcss/vite`, `tailwindcss`, `@astrojs/check`. + +`website/.github/workflows/ci.yaml` (`check` job): `actions/checkout@v7` (`:16`), `actions/setup-node@v6` with `node-version: 22` and `cache: npm` (`:17-20`), `npm ci` (`:21`), `npm run lint` (`:22`), `npm run check` (`:23`), `npm run build` (`:24`). + +`website/.github/workflows/deploy.yaml`: `actions/configure-pages@v6` (`:23`), `withastro/action@v6` with no node version set (`:26`), `actions/deploy-pages@v5` (`:36`). + +No `.nvmrc`, `.node-version` or `packageManager` field exists in the website root. + +Website dev container has no tools beyond Node and npm (npm ci with `package-lock.json`). + +### 2.7 `modules/` (game module bundles, submodule) + +Root-Makefile targets: `modules-push` (`Makefile:472-473`, `./modules/build.sh push --registry $(MODULE_REGISTRY) --plain-http`); `module-schema` (`:364-366`, `python3 hack/gen-module-schema.py`); `module-pin` (`:368-376`, `python3 modules/validate.py --pin`, needs network); `dev-up` pushes modules to `localhost:5001` (`:427`). + +`modules/build.sh`: +- Requires oras >= 1.2.0 (`:19`); checks `command -v oras` (`:105`); runs `oras push` (`:170`) and `oras tag` (`:211`). +- `--sign` requires cosign >= 2.0 (`:20`, `:107-108`) and `COSIGN_PRIVATE_KEY`; cosign runs at `:202`. The Makefile never passes `--sign`. + +`modules/validate.py`: +- PyYAML imported at module load (`:103-109`), so it is required even for `--offline`. +- `--offline` runs `gp-module validate` (`:1319-1338`), with `gp-module` located on PATH or in `bin/` (`:1284-1294`, `shutil.which` at `:1285`). Missing binary exits 2 (`:1331`). +- Default run uses `curl` against registries (`:156`, `:202-207`, Docker Hub `:255-257`), which needs network. + +`modules/test-build-sh.sh` and `modules/test-validate-py.sh` run `python3 validate.py` on fixtures (`test-validate-py.sh:50`). + +`modules/.github/workflows/validate.yml` (module repo): `setup-python` 3.12 (`:32-34`); `pip install pyyaml` (`:36-37`); `python3 validate.py` (`:39-40`); `./test-build-sh.sh` (`:46-47`); `./test-validate-py.sh` (`:49-50`); `setup-go` 1.26 (`:52-54`); `go run github.com/GameplanePanel/gameplane/i18n/cmd/i18n-check@master` (`:63-64`). + +`modules/.github/workflows/refresh-pins.yml` (module repo): `setup-python` 3.12 (`:37-39`); `pip install --user pyyaml` (`:41-42`); `python3 validate.py --pin` (`:52`); `git`, `gh pr create` (`:108-109`). + +`modules/validate.py --pin` also needs network (registry reads). + +## 3. Makefile targets from research R6 (external tools, file:line) + +| Target | Lines | External tools invoked | +|---|---|---| +| `dev-up` (CLUSTER=kind) | `:418-429` | `deploy/kind/up.sh` (`:424`), `make images` (`:425`), `dev-load` (`:426`), `modules-push` (`:427`), `dev-install` (`:428`) | +| `dev-up` (CLUSTER=remote) | `:419-422` | `make images`, `dev-push` (`docker push`, `:437-440`), `modules-push`, `dev-install` | +| `dev-load` | `:432-434` | `kind load docker-image` (`:433`) | +| `dev-install` | `:443-460` | `helm upgrade --install` (`:451`); `KUBECONFIG` only when CLUSTER=remote (`:36`) | +| `modules-push` | `:472-473` | `modules/build.sh` → `oras` (`modules/build.sh:105`, `:170`, `:211`); `cosign` only with `--sign` (not passed) | +| `test-e2e` | `:193-203` | `docker build` via `e2e-images` (`:187-191`); `go test -tags=e2e` (`:198`, `:202`); the Go suite then runs kind, kubectl, helm, docker (2.5) | +| `test-e2e-bucket` | `:211-213` | `go test -tags=e2e`; `./buckets.sh regex` (`:213`) | +| `lint-go` | `:302-305` | `golangci-lint run` (`:304`) | +| `images` | `:326`, pattern `:330`, `:334`, `:337`, `:340`, `:343` | `docker build` | +| `web-dev` | `:477-478` | `npm run dev` → `vite` (`web/package.json:7`) | +| `build-web` | `:79-80` | `npm ci`, `npm run build` (`web/package.json:8`: formatjs-based i18n compile, `tsc -b`, `vite build`) | + +Also: `dev-up` and `dev-load` call `kind`; `up.sh` and `e2e.sh` require kind, kubectl, helm and docker (`up.sh:195-199`, `e2e.sh:175-178`). + +## 4. Scripts and per-step map of `post-create.sh` + +| Step | Lines | Requires | +|---|---|---| +| arch and PATH setup | 15-23 | `dpkg`, `go env GOPATH`, `$HOME/.local/bin` | +| submodule init | 28-31 | `git` | +| kind | 33-41 | `curl`, `sudo install` | +| oras | 43-52 | `curl`, `tar`, `sudo` | +| golangci-lint | 54-64 | `go install` | +| envtest | 66-74 | `go install ...setup-envtest@latest`, `setup-envtest use 1.36.2` | +| go mod download | 76-82 | `go` (16 modules; `gp-module` not listed) | +| npm ci web | 84-86 | `npm` | +| uv | 88-92 | `curl` (astral installer), `sh` | +| specify | 94-99 | `uv tool install` from GitHub git ref | +| AI CLIs | 101-115 | `npm install -g` (sudo fallback) | +| summary | 117-135 | `go`, `node`, `docker`, `kubectl`, `helm`, `kind`, `oras`, `golangci-lint`, `uv`, `specify`, AI CLIs (all `|| true`) | + +## 5. Services and pinned component images + +| Service | Pin and evidence | +|---|---| +| kind cluster | `kind` v0.32.0 (`post-create.sh:34`); cluster name `gameplane-dev` (`deploy/kind/cluster.yaml:9`); node image is not pinned (kind default) | +| MetalLB | `v0.14.9` (`deploy/kind/up.sh:36`, `e2e.sh:45`) | +| ingress-nginx | `controller-v1.15.1` (`deploy/kind/up.sh:40`) | +| local OCI registry | `registry:2`, container `kind-registry`, host port `5001` (`deploy/kind/up.sh:177-179`, `:18-19`) | +| K8s for envtest and e2e | 1.36.2 (`Makefile:166`; `post-create.sh:73-74`) | +| `setup-envtest` | `release-0.24` (`Makefile:171`), `@latest` in devcontainer (`post-create.sh:71`) | +| CRD apply hook image | `registry.k8s.io/kubectl:v1.36.3` (`charts/gameplane/values.yaml:46`) | +| `test/e2e` images | `golang:1.27-alpine`, `gcr.io/distroless/static:nonroot` (`test/e2e/Dockerfile:13`, `:37`) | +| `web` images | `golang:1.27-alpine` (`web/Dockerfile:11`), `node:26-bookworm-slim` (`:21`), `nginxinc/nginx-unprivileged:1.31-alpine` (`:45`) | + +Submodules: `modules` (`.gitmodules:1-2`, `GameplanePanel/module.git`), `website` (`.gitmodules:4-5`, `GameplanePanel/website.git`). + +## 6. Answers + +### (a) Command that validates modules without pushing + +- Recommended for the modules profile check: `make module-validate MODULE=` (`Makefile:390-391`). It runs `bin/gp-module validate modules/`. Build the binary first with `make gp-module` (`Makefile:383-385`, Go only, no Python). Needs: go (for the binary). No Python, PyYAML or jsonschema. +- Python wrapper: `python3 modules/validate.py --offline []` (`modules/validate.py:1319-1338`). Needs python3 and PyYAML (`modules/validate.py:103-109`, module-level import). The `gp-module` binary is also required (`:1331`). No jsonschema is used in `modules/`. +- Not offline: `python3 modules/validate.py` (no flag) queries registries over the network (`validate.py:156`, `:202-207`). CI runs this (`modules/.github/workflows/validate.yml:39-40`). Do not use `make modules-push` (`Makefile:472-473`) or `modules/build.sh push` for the check, since both push. +- `python3 hack/gen-module-schema.py` (`Makefile:365`) imports yaml unguarded (`hack/gen-module-schema.py:21`). + +### (b) Node version for the website + +- Required: `>=22.12.0` (`website/package.json:7-9`, engines). +- CI: Node 22 with npm cache (`website/.github/workflows/ci.yaml:17-19`). +- Deploy: `withastro/action@v6` sets no node version (`website/.github/workflows/deploy.yaml:26`). +- No `.nvmrc` or `.node-version` file in `website/`. +- Root devcontainer provides Node 24 (`.devcontainer/devcontainer.json:13-15`), which satisfies the engines range. +- Other Node versions in the repo for reference: `web` CI uses Node 24 (`.github/workflows/ci.yaml:996`, `:1139`), `web/Dockerfile` uses Node 26 (`:21`). `web/package.json` has no `engines` field. + +### (c) Registration needed for new `.github/workflows/devcontainers.yaml` and new `hack/` files + +No registration is required for a new workflow or new hack files in these checks: +- `actionlint` lints every workflow with no arguments (`.github/workflows/ci.yaml:1592-1608`), so the new workflow is discovered automatically. +- `zizmor` lints `inputs: .github` (`ci.yaml:1639-1644`). This is the spec 008 SHA-pin gate (comment `:1634-1636`, rules in `.github/zizmor.yml:7-9`). No list of hardened workflows exists, so T023 has nothing to edit. +- The `github` path filter `.github/workflows/**` (`ci.yaml:278-279`) already triggers the `workflow-lint` job (`:1582-1585`). +- `hack/check-ci-report-coverage.sh:40` reads only `.github/workflows/ci.yaml`. No change. +- `hack/check-publish-edge-paths.sh:46` reads only `publish-edge.yaml`. No change. +- `hack/auto_label.py`: `area` comes from `AREA_BY_DIR` (`:51-65`) with fallback `area: shared` (`:99`). Neither `.devcontainer/` nor `hack/` is mapped, so the fallback applies. No change needed. +- `hack/ci_scope.py`: `.devcontainer`, `hack`, `.github`, `website`, `docs` are in `NO_E2E_DIRS` (`:42-44`), and `is_unmapped` (`:111-117`) therefore does not force full scope for them. No change needed. +- `test/e2e/lint-gate-verify.sh` parses only the `lint:` job in `ci.yaml` and `go.work` and `.golangci.yml` (`:63-64`, `:187`). It does not read the Makefile or workflow files. No change needed. + +Required edits that do exist: +- `Makefile:299` (`lint:` prerequisites): add `check-devcontainers`. Add `.PHONY` entries for `devcontainers`, `check-devcontainers`, `test-gen-devcontainers` (T010). +- `docs/contributing.md` and `docs/dependencies.md` are in the fixed link and doc-version audit lists (`hack/check-links.sh:95-96`, `hack/check-doc-versions.sh:75`). Edits there (T030, T031) must keep anchors valid and doc-version markers consistent. + +Optional, only if the generator tests should also run from `ci.yaml`: add `hack/gen-devcontainers.py` and `hack/test-gen-devcontainers.py` to the `github` filter (`ci.yaml:278-291`), and add steps to `workflow-lint` (`:1582-1641`). The plan (R7) runs them from `devcontainers.yaml` instead, so this is not needed. + +### (d) Every `post-create.sh` reference repo-wide (excluding `specs/done_*`) + +Non-spec references: +- `.devcontainer/devcontainer.json:34` (`postCreateCommand`). +- `.devcontainer/post-create.sh:2` (file header). +- `docs/dependencies.md:27` (`` `.devcontainer/post-create.sh`. ``). +- `docs/dependencies.md:410` (golangci-lint "pinned in `.devcontainer/post-create.sh`"). + +Spec 026 references (expected; these are the work items): `specs/026-modular-dev-containers/spec.md:13`, `:15`; `plan.md:13`, `:73`; `research.md:28`, `:53`, `:68`; `contracts/setup-and-need.md:8`; `tasks.md:37`, `:52`, `:66`, `:145`, `:151`. + +Submodules: no references in `modules/` or `website/`. T033 covers `website/src/content/docs/local-development-setup.mdx`, which I did not grep because it is a submodule file the task asked to leave alone. + +Unrelated matches of the word "post-create" in code comments (`operator/internal/controller/gameserver_envtest_test.go:1344`, `web/src/routes/tabs/settings/Version.tsx:10`) are not references to the script. + +### (e) Makefile targets from R6 and the external tools they run + +See section 3. Summary with evidence: +- `dev-up`: `deploy/kind/up.sh` (`Makefile:424`), `docker build` (`:425` via `images`), `kind load` (`:426`), `modules/build.sh` (`:427`), `helm upgrade --install` (`:428`). +- `dev-load`: `kind load docker-image` (`Makefile:433`). +- `dev-install`: `helm upgrade --install` (`Makefile:451`). +- `modules-push`: `modules/build.sh push` (`Makefile:473`) → `oras` (`modules/build.sh:105`, `:170`, `:211`). +- `test-e2e`: `docker build` (`Makefile:187-191`), `go test` (`:198`, `:202`), then kind, kubectl, helm and docker at runtime (`test/e2e/env.go:167`, `helm_install_e2e_test.go:514`, `multicluster_e2e_test.go:126`). +- `test-e2e-bucket`: `go test` (`Makefile:212-213`), `buckets.sh` (`:213`). +- `lint-go`: `golangci-lint run` (`Makefile:304`). +- `images`: `docker build` (`Makefile:330`, `:334`, `:337`, `:340`, `:343`). +- `web-dev`: `npm run dev` → `vite` (`Makefile:478`, `web/package.json:7`). +- `build-web`: `npm ci`, `npm run build` (`Makefile:80`, `web/package.json:8`). + +### (f) Gaps + +Tools needed by a component that the registry (today's `devcontainer.json` or `post-create.sh`) does not provide. Nothing is installed here. + +| Tool | Needed by (evidence) | Status | +|---|---|---| +| `python3` | `Makefile:365`, `:375`, `:495`; `modules/validate.py`; `modules/test-*.sh`; `hack/*.py`; `.specify/scripts/bash/common.sh` | not declared; `post-create.sh` does not install it. Expected from the base image, not verified | +| PyYAML | `modules/validate.py:103-109`; `hack/gen-module-schema.py:21`; `modules/.github/workflows/validate.yml:36-37` | not declared, not installed | +| `jq` | `Makefile:282-283` (`check-i18n`) | not declared, not installed | +| `git` | `post-create.sh:29-30`; `Makefile` submodule flow | not declared; comes from the base image only | +| `gcc` (cgo toolchain) | `Makefile:110`, `:119` (`go test -race` needs cgo) | not declared | +| `cosign` (>= 2.0) | `modules/build.sh:20`, `:107-108`, `:202` (only with `--sign`) | not declared; release path only | +| Playwright browsers and their apt packages | `Makefile:231`, `:235` (`npx playwright install --with-deps chromium`) | not declared; needs apt and sudo | +| `bash` >= 4 | `hack/sync-chart-crds.sh:38` | base image (Ubuntu bash 5) | +| `sudo`, `curl`, `dpkg`, `sha256sum` | `post-create.sh:15`, `:37-50`, `:113`; `hack/sync-chart-crds.sh:45-49` | base image and common-utils; not declared as a dependency | +| `controller-gen`, `gocovmerge`, `go-test-coverage` | `Makefile:55-59`, `:145`, `:348`, `:351` (all via `go run`) | no install needed; fetched through the Go module cache | +| CI-only linters: `actionlint` v1.7.10, `hadolint` v2.14.0, `zizmor` v0.7.1, `govulncheck` v1.9.0, `trivy` | `.github/workflows/ci.yaml:754`, `:1592-1608`, `:1610-1628`, `:1639-1644`; `hack/trivy-scan-images.sh` | CI only; not required in the container | + +Registry items used by a component but not present in any profile list yet: none beyond the above. Registry items present but not used by any repo script: `github-cli` (`devcontainer.json:26`). The `gh` CLI is used by agents and CI scripts (`modules/.github/workflows/refresh-pins.yml:108-109`), not by a Makefile target. + +Discrepancies found while scouting (for the spec owner to decide, not changed here): +1. `post-create.sh:79-82` `go mod download` list omits `gp-module` (in `go.work:10`). +2. `kubectl` 1.31 (`devcontainer.json:22`) versus K8s 1.36.2 test assets and chart hook 1.36.3 (`values.yaml:46`). OD-5 covers this. +3. `helm` `latest` (`devcontainer.json:23`) is unpinned. OD-5 covers this. +4. `setup-envtest@latest` (`post-create.sh:71`) versus `release-0.24` (`Makefile:171`, `ci.yaml:917`, `:928`). OD-5 covers this. +5. `docs/dependencies.md:409` says `setup-envtest@release-0.19`, which is stale against `Makefile:171`. +6. `docs/dependencies.md:410` and `:467-469` say golangci-lint is not CI-enforced, but `ci.yaml:802-816` runs `golangci-lint-action` in the lint job. Stale doc. +7. Node versions differ across consumers: 24 (devcontainer and web CI), 22 (website CI and engines minimum 22.12), 26 (`web/Dockerfile:21`). No `.nvmrc` exists. +8. `test/e2e/Dockerfile` and `web/Dockerfile` use `golang:1.27-alpine` while `go.work:1` and every `go.mod` say 1.26.0. + +## 7. Method note + +Everything above was read with `grep` and `cat -n` on the working tree. No command was executed other than read-only inspection. No `.pen` file was read or touched. No submodule branch was changed. No test, lint or build was run. diff --git a/specs/026-modular-dev-containers/tasks.md b/specs/026-modular-dev-containers/tasks.md index 5b5495002..4db75150e 100644 --- a/specs/026-modular-dev-containers/tasks.md +++ b/specs/026-modular-dev-containers/tasks.md @@ -34,9 +34,9 @@ description: "Task list for spec 026 modular dev containers" **Purpose**: the hand-written inputs every profile is generated from. -- [ ] T001 Scout (one agent, `haiku`, read-only): build the system dependency inventory from research R9. For each folder in `go.work`, plus `web/`, `charts/gameplane/`, `deploy/kind/`, `test/e2e/`, `website/` and `modules/`, list every toolchain, CLI, OS package, service and submodule that its Makefile targets, scripts (`modules/build.sh`, `deploy/kind/*.sh`, `web/package.json` scripts, `website/package.json` scripts and `engines`) and today's `.devcontainer/post-create.sh` invoke. Give `file:line` evidence for each entry. Also list each Go module's in-repo `require`/`replace` targets. Write the brief to `specs/026-modular-dev-containers/inventory.md`. -- [ ] T002 Create `.devcontainer/versions.toml` with one `[tools]` key per tool from today's `.devcontainer/devcontainer.json` and `post-create.sh` (go 1.26, node 24, kind v0.32.0, oras 1.2.0, golangci_lint v2.12.2, envtest_k8s 1.36.2, specify v1.0.3, …). Apply OD-5: `kubectl = "1.36"`, `setup_envtest = "release-0.24"`, and `helm` pinned to the current stable Helm release (record the version and date in a comment). -- [ ] T003 Create `.devcontainer/profiles.toml` per [contracts/profiles-catalogue.md](contracts/profiles-catalogue.md) and data-model "Profile" and "Baseline". The baseline carries the common-utils, git, github-cli, uv, specify and AI CLI deps (optional), `anthropic.claude-code`, and today's shared editor settings and env (`GOTOOLCHAIN=local`, `CI=1`). `[full].personal` carries today's theme and other-language extensions (Lunar Pink, darkpinkpro, Rust, Vue, Gemini, Pencil, …). It defines the 11 profiles (`web`, `api`, `operator`, `agent`, `go-services`, `website`, `modules`, `dashboard-api`, `control-plane`, `modules-gp`, `full`) with ports, extensions, checks and submodules from the contract. +- [X] T001 Scout (one agent, `haiku`, read-only): build the system dependency inventory from research R9. For each folder in `go.work`, plus `web/`, `charts/gameplane/`, `deploy/kind/`, `test/e2e/`, `website/` and `modules/`, list every toolchain, CLI, OS package, service and submodule that its Makefile targets, scripts (`modules/build.sh`, `deploy/kind/*.sh`, `web/package.json` scripts, `website/package.json` scripts and `engines`) and today's `.devcontainer/post-create.sh` invoke. Give `file:line` evidence for each entry. Also list each Go module's in-repo `require`/`replace` targets. Write the brief to `specs/026-modular-dev-containers/inventory.md`. +- [X] T002 Create `.devcontainer/versions.toml` with one `[tools]` key per tool from today's `.devcontainer/devcontainer.json` and `post-create.sh` (go 1.26, node 24, kind v0.32.0, oras 1.2.0, golangci_lint v2.12.2, envtest_k8s 1.36.2, specify v1.0.3, …). Apply OD-5: `kubectl = "1.36"`, `setup_envtest = "release-0.24"`, and `helm` pinned to the current stable Helm release (record the version and date in a comment). +- [X] T003 Create `.devcontainer/profiles.toml` per [contracts/profiles-catalogue.md](contracts/profiles-catalogue.md) and data-model "Profile" and "Baseline". The baseline carries the common-utils, git, github-cli, uv, specify and AI CLI deps (optional), `anthropic.claude-code`, and today's shared editor settings and env (`GOTOOLCHAIN=local`, `CI=1`). `[full].personal` carries today's theme and other-language extensions (Lunar Pink, darkpinkpro, Rust, Vue, Gemini, Pencil, …). It defines the 11 profiles (`web`, `api`, `operator`, `agent`, `go-services`, `website`, `modules`, `dashboard-api`, `control-plane`, `modules-gp`, `full`) with ports, extensions, checks and submodules from the contract. --- @@ -44,13 +44,13 @@ description: "Task list for spec 026 modular dev containers" **Purpose**: the generator, its tests and the shared setup script. They block every story. -- [ ] T004 Implement input loading and validation in `hack/gen-devcontainers.py`: parse `versions.toml`, `profiles.toml` and every `system-deps.toml`, and enforce every rule in [contracts/system-deps-format.md](contracts/system-deps-format.md) and the error list in [contracts/generator-cli.md](contracts/generator-cli.md). Each error names the file and the profile. -- [ ] T005 Implement composition in `hack/gen-devcontainers.py`: derive each component profile's library folders from `go.mod` in-repo `require`/`replace` targets, build part profiles as unions (FR-005), build the full profile as the union of all plus `[full].personal`, and collapse duplicate deps (data-model invariant). -- [ ] T006 Implement output in `hack/gen-devcontainers.py`: write `devcontainer.json` (tab-indented, sorted, trailing newline, `name` = `Gameplane · `, `postCreateCommand` = `bash .devcontainer/setup.sh .devcontainer/<id>/setup.env`), the subset `devcontainer-lock.json` from the source lock (research R4), `setup.env`, `ready.txt` ([contracts/setup-and-need.md](contracts/setup-and-need.md)) and `.devcontainer/tool-index.tsv`. The full profile's JSON goes to `.devcontainer/devcontainer.json`, and its other files to `.devcontainer/full/`. -- [ ] T007 Implement `--check`, `--affected PATH...` and `--submodules` modes in `hack/gen-devcontainers.py` per [contracts/generator-cli.md](contracts/generator-cli.md), including the check that the `Makefile` `ENVTEST_K8S_VERSION` and `setup-envtest@` ref equal `versions.toml` (OD-5). -- [ ] T008 [P] Write fixture tests in `hack/test-gen-devcontainers.py` (stdlib `unittest`, temp-dir fixture trees). Cover: literal version rejected, unknown `kind`, empty `reason`, `none` together with deps, missing `system-deps.toml`, `go.work` module in no profile, unused version key, union rule for a part, duplicate collapse, hand-edited output detected by `--check`, `--affected` for a deps file and for `versions.toml`, and Makefile pin mismatch. -- [ ] T009 Create `.devcontainer/setup.sh` per [contracts/setup-and-need.md](contracts/setup-and-need.md): move each block of `.devcontainer/post-create.sh` into a step function with the same guards, read versions and `GO_MODULES` from `setup.env`, run only the listed steps, treat `ai_clis` as warn-only (FR-011), print versions of installed tools only, then print `ready.txt`. -- [ ] T010 Add Makefile targets `devcontainers` (runs `python3 hack/gen-devcontainers.py --submodules`) and `check-devcontainers` (`--check`), add `check-devcontainers` to the `lint:` prerequisites in `Makefile`, and add a `test-gen-devcontainers` target that runs the fixture tests (CI only). +- [X] T004 Implement input loading and validation in `hack/gen-devcontainers.py`: parse `versions.toml`, `profiles.toml` and every `system-deps.toml`, and enforce every rule in [contracts/system-deps-format.md](contracts/system-deps-format.md) and the error list in [contracts/generator-cli.md](contracts/generator-cli.md). Each error names the file and the profile. +- [X] T005 Implement composition in `hack/gen-devcontainers.py`: derive each component profile's library folders from `go.mod` in-repo `require`/`replace` targets, build part profiles as unions (FR-005), build the full profile as the union of all plus `[full].personal`, and collapse duplicate deps (data-model invariant). +- [X] T006 Implement output in `hack/gen-devcontainers.py`: write `devcontainer.json` (tab-indented, sorted, trailing newline, `name` = `Gameplane · <title>`, `postCreateCommand` = `bash .devcontainer/setup.sh .devcontainer/<id>/setup.env`), the subset `devcontainer-lock.json` from the source lock (research R4), `setup.env`, `ready.txt` ([contracts/setup-and-need.md](contracts/setup-and-need.md)) and `.devcontainer/tool-index.tsv`. The full profile's JSON goes to `.devcontainer/devcontainer.json`, and its other files to `.devcontainer/full/`. +- [X] T007 Implement `--check`, `--affected PATH...` and `--submodules` modes in `hack/gen-devcontainers.py` per [contracts/generator-cli.md](contracts/generator-cli.md), including the check that the `Makefile` `ENVTEST_K8S_VERSION` and `setup-envtest@` ref equal `versions.toml` (OD-5). +- [X] T008 [P] Write fixture tests in `hack/test-gen-devcontainers.py` (stdlib `unittest`, temp-dir fixture trees). Cover: literal version rejected, unknown `kind`, empty `reason`, `none` together with deps, missing `system-deps.toml`, `go.work` module in no profile, unused version key, union rule for a part, duplicate collapse, hand-edited output detected by `--check`, `--affected` for a deps file and for `versions.toml`, and Makefile pin mismatch. +- [X] T009 Create `.devcontainer/setup.sh` per [contracts/setup-and-need.md](contracts/setup-and-need.md): move each block of `.devcontainer/post-create.sh` into a step function with the same guards, read versions and `GO_MODULES` from `setup.env`, run only the listed steps, treat `ai_clis` as warn-only (FR-011), print versions of installed tools only, then print `ready.txt`. +- [X] T010 Add Makefile targets `devcontainers` (runs `python3 hack/gen-devcontainers.py --submodules`) and `check-devcontainers` (`--check`), add `check-devcontainers` to the `lint:` prerequisites in `Makefile`, and add a `test-gen-devcontainers` target that runs the fixture tests (CI only). **Checkpoint**: the generator runs and `--check` passes on the PR-A tree. @@ -62,8 +62,8 @@ description: "Task list for spec 026 modular dev containers" **Independent Test**: open any component folder and read its `system-deps.toml`. The `drift` CI job passes, and it fails when a tool is added to `profiles.toml` extras without a reason or when a list is removed. -- [ ] T011 [US6] Generate every main-repo `system-deps.toml` from the T001 brief with one deterministic script run by one `haiku` agent (rule 13 "scripts over fan-out"): `agent/`, `api/`, `audit-syslog-bridge/`, `capture-sidecar/`, `gameaction/`, `gameproto/`, `gp-module/`, `i18n/`, `mcp-server/`, `netguard/`, `operator/`, `sentinel/`, `svcutil/`, `telemetryschema/`, `telemetry-receiver/`, `test/e2e/`, `tunnel/`, `web/`, `charts/gameplane/`, `deploy/kind/`. Each entry uses a `versions.toml` key and a one-line reason that cites the brief's evidence. -- [ ] T012 [US6] Remove `.devcontainer/post-create.sh` (its steps now live in `.devcontainer/setup.sh`, T009) and update its references in `docs/dependencies.md`. The website page reference is changed in PR-D (T033). +- [X] T011 [US6] Generate every main-repo `system-deps.toml` from the T001 brief with one deterministic script run by one `haiku` agent (rule 13 "scripts over fan-out"): `agent/`, `api/`, `audit-syslog-bridge/`, `capture-sidecar/`, `gameaction/`, `gameproto/`, `gp-module/`, `i18n/`, `mcp-server/`, `netguard/`, `operator/`, `sentinel/`, `svcutil/`, `telemetryschema/`, `telemetry-receiver/`, `test/e2e/`, `tunnel/`, `web/`, `charts/gameplane/`, `deploy/kind/`. Each entry uses a `versions.toml` key and a one-line reason that cites the brief's evidence. +- [X] T012 [US6] Remove `.devcontainer/post-create.sh` (its steps now live in `.devcontainer/setup.sh`, T009) and update its references in `docs/dependencies.md`. The website page reference is changed in PR-D (T033). --- @@ -73,8 +73,8 @@ description: "Task list for spec 026 modular dev containers" **Independent Test**: quickstart §4. Diff the generated `.devcontainer/devcontainer.json` against `git show 8a67f2e:.devcontainer/devcontainer.json`. -- [ ] T013 [US3] Run `make devcontainers` and commit the generated `.devcontainer/devcontainer.json`, `.devcontainer/full/*` and `.devcontainer/tool-index.tsv`. In the PR body, include the diff against `8a67f2e`, showing that only the `postCreateCommand` path, the `name`, kubectl 1.36 and the pinned helm differ (setup-envtest lives in `setup.env`). -- [ ] T014 [US3] Add a temporary `full`-only build to `.github/workflows/devcontainers.yaml` (`drift` job plus one `build` job for `full` on amd64 and arm64, using `devcontainers/ci` pinned by SHA with `push: never`), so PR-A is verified before PR-B adds the matrix. PR-B (T021) extends this file rather than replacing it. +- [X] T013 [US3] Run `make devcontainers` and commit the generated `.devcontainer/devcontainer.json`, `.devcontainer/full/*` and `.devcontainer/tool-index.tsv`. In the PR body, include the diff against `8a67f2e`, showing that only the `postCreateCommand` path, the `name`, kubectl 1.36 and the pinned helm differ (setup-envtest lives in `setup.env`). +- [X] T014 [US3] Add a temporary `full`-only build to `.github/workflows/devcontainers.yaml` (`drift` job plus one `build` job for `full` on amd64 and arm64, using `devcontainers/ci` pinned by SHA with `push: never`), so PR-A is verified before PR-B adds the matrix. PR-B (T021) extends this file rather than replacing it. **Checkpoint (PR-A)**: `drift` green, `full` builds on both arches, `make lint` green. Open PR-A. @@ -86,10 +86,10 @@ description: "Task list for spec 026 modular dev containers" **Independent Test**: quickstart §2. Each component profile builds in CI and passes its checks, and tools outside its scope are absent. -- [ ] T015 [US1] Run `make devcontainers` and commit `.devcontainer/{web,api,operator,agent,go-services,website,modules}/*` (generated). +- [X] T015 [US1] Run `make devcontainers` and commit `.devcontainer/{web,api,operator,agent,go-services,website,modules}/*` (generated). - [ ] T016 [P] [US1] Create `hack/need.sh` per [contracts/setup-and-need.md](contracts/setup-and-need.md) (reads `.devcontainer/tool-index.tsv`, exits 127, falls back to a plain message when the index is absent). - [ ] T017 [US1] Call `hack/need.sh` as the first recipe line of the Makefile targets from research R6 (`dev-up`, `dev-load`, `dev-install`, `modules-push`, `test-e2e`, `test-e2e-bucket`, `lint-go`, `images`, `web-dev`, `build-web`), naming the tools each one runs. Recipe bodies stay otherwise unchanged. -- [ ] T018 [US1] In the `website` and `modules` component profiles, set submodule init to only that submodule (`SUBMODULES` in `setup.env`), and confirm the generated `setup.env` has no `go_mod_download` or `envtest` step (spec US1 scenarios 2 and 3). +- [X] T018 [US1] In the `website` and `modules` component profiles, set submodule init to only that submodule (`SUBMODULES` in `setup.env`), and confirm the generated `setup.env` has no `go_mod_download` or `envtest` step (spec US1 scenarios 2 and 3). --- @@ -99,8 +99,8 @@ description: "Task list for spec 026 modular dev containers" **Independent Test**: quickstart §3. `dashboard-api` runs `make dev-up` and `make web-dev`, and the dashboard shows the sample servers. -- [ ] T019 [US2] Run `make devcontainers` and commit `.devcontainer/{dashboard-api,control-plane,modules-gp}/*` (generated). Confirm `dashboard-api` has docker-in-docker, kind, kubectl and helm from its `extras` / `deploy/kind` and `charts/gameplane` lists, and forwards 5173, 8080, 8443 and 5001. -- [ ] T020 [US2] Make the `dashboard-api` ready message, generated from `profiles.toml` `checks` and notes, give the run order `make dev-up` → `make web-dev` → open the forwarded 5173 port. +- [X] T019 [US2] Run `make devcontainers` and commit `.devcontainer/{dashboard-api,control-plane,modules-gp}/*` (generated). Confirm `dashboard-api` has docker-in-docker, kind, kubectl and helm from its `extras` / `deploy/kind` and `charts/gameplane` lists, and forwards 5173, 8080, 8443 and 5001. +- [X] T020 [US2] Make the `dashboard-api` ready message, generated from `profiles.toml` `checks` and notes, give the run order `make dev-up` → `make web-dev` → open the forwarded 5173 port. --- @@ -112,7 +112,7 @@ description: "Task list for spec 026 modular dev containers" - [ ] T021 [US5] Extend `.github/workflows/devcontainers.yaml` per research R7: path triggers, a `profiles` job that runs `python3 hack/gen-devcontainers.py --affected` on the PR's changed files and emits a JSON matrix, and a `build` matrix of affected profile × {amd64 `ubuntu-latest`, arm64 `ubuntu-24.04-arm`} running each profile's `checks` with `devcontainers/ci` (SHA-pinned, `push: never`). Write setup seconds and `du -sx` of the workspace and image size to `$GITHUB_STEP_SUMMARY`. Every action is SHA-pinned with a version comment (spec 008 rules). Set `permissions: contents: read`. - [ ] T022 [US5] Add a `drift` step that runs `make test-gen-devcontainers` (fixture tests) next to `--check` in `.github/workflows/devcontainers.yaml`. -- [ ] T023 [US5] Add `.github/workflows/devcontainers.yaml` to whatever lists hardened workflows today (the spec 008 action-pin check), if such a list exists. The T001 scout confirms where it is. +- [X] T023 [US5] Add `.github/workflows/devcontainers.yaml` to whatever lists hardened workflows today (the spec 008 action-pin check), if such a list exists. The T001 scout confirms where it is. Done: nothing to register; zizmor and actionlint discover every workflow (ci.yaml:1592, :1639), see inventory.md Answers (c). **Checkpoint (PR-B)**: every profile green on both arches. Job summary shows SC-001 and SC-002 numbers. Open PR-B. From a9525f8f80e28c0e04faa6443eae4e76735112dd Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 25 Oct 2026 21:01:10 +0000 Subject: [PATCH 09/19] feat: name the dev container profile when a make target lacks a tool (spec 026 T016, T017) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- Makefile | 14 ++++++++++ hack/need.sh | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100755 hack/need.sh diff --git a/Makefile b/Makefile index 229fed7c8..73e359cc8 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,7 @@ build-go: ## Build all Go binaries .PHONY: build-web build-web: ## Build web dashboard + @hack/need.sh npm cd web && npm ci && npm run build # -------- test -------- @@ -192,6 +193,7 @@ e2e-images: ## Build operator/api/agent/fakeoidc images tagged for e2e .PHONY: test-e2e test-e2e: ## Run E2E tests (CLUSTER=kind spins an ephemeral cluster; CLUSTER=remote reuses REMOTE_KUBECONFIG) + @hack/need.sh $(if $(filter remote,$(CLUSTER)),go kubectl helm,go docker kind kubectl helm) ifeq ($(CLUSTER),remote) cd test/e2e && GAMEPLANE_E2E_REUSE_CLUSTER=1 \ GAMEPLANE_E2E_CONTEXT=$(REMOTE_CONTEXT) KUBECONFIG=$(REMOTE_KUBECONFIG) \ @@ -209,6 +211,7 @@ test-e2e-keep: ## Re-run E2E tests against an already-up cluster (skip create/de .PHONY: test-e2e-bucket test-e2e-bucket: ## Run one CI e2e bucket against an already-up cluster (BUCKET=operator|api-auth|api-roles|api-rbac|api-agent|api-mods|ratelimit|bot-fast|bot-heavy|multicluster|upgrade). BUCKET=multicluster additionally needs a second kind cluster up and GAMEPLANE_E2E_CLUSTER_B set to its name (default gameplane-e2e-b). + @hack/need.sh go kubectl helm cd test/e2e && GAMEPLANE_E2E_REUSE_CLUSTER=1 GAMEPLANE_E2E_CLUSTER=$(KIND_E2E_CLUSTER) \ go test -tags=e2e -timeout 50m -v -run "$$(./buckets.sh regex $(BUCKET))" ./... @@ -312,6 +315,7 @@ lint: check-doc-versions check-links check-features-sync check-specs check-telem .PHONY: lint-go lint-go: ## Run golangci-lint across all modules + @hack/need.sh golangci-lint @for m in $(GO_MODULES); do \ (cd $$m && golangci-lint run) || exit $$?; \ done @@ -339,19 +343,24 @@ images: $(addprefix image-,$(IMAGES)) image-audit-syslog image-tunnel-frp image- # Pattern rule for standard-shaped images image-%: ## Build container image + @hack/need.sh docker docker build -t $(REGISTRY)/$*:$(TAG) -f $*/Dockerfile . # Non-standard images (target name ≠ dir, or special Dockerfile paths) image-audit-syslog: ## Build audit-syslog-bridge image + @hack/need.sh docker docker build -t $(REGISTRY)/audit-syslog-bridge:$(TAG) -f audit-syslog-bridge/Dockerfile . image-tunnel-frp: ## Build tunnel-frp image + @hack/need.sh docker docker build -t $(REGISTRY)/tunnel-frp:$(TAG) -f tunnel/Dockerfile.frp . image-tunnel-tailscale: ## Build tunnel-tailscale image + @hack/need.sh docker docker build -t $(REGISTRY)/tunnel-tailscale:$(TAG) -f tunnel/Dockerfile.tailscale . image-tunnel-playit: ## Build tunnel-playit image + @hack/need.sh docker docker build -t $(REGISTRY)/tunnel-playit:$(TAG) -f tunnel/Dockerfile.playit . # -------- codegen -------- @@ -427,6 +436,7 @@ ADDRESS_MANAGER ?= metallb endif dev-up: ## Create/prepare cluster + install Gameplane (CLUSTER=kind|remote) + @hack/need.sh $(if $(filter remote,$(CLUSTER)),docker kubectl helm oras,docker kind kubectl helm oras) ifeq ($(CLUSTER),remote) $(MAKE) images TAG=$(TAG) $(MAKE) dev-push TAG=$(TAG) @@ -441,6 +451,7 @@ else endif dev-load: ## Load every image `make images` builds into the kind cluster (kind only) + @hack/need.sh docker kind @for img in $(ALL_IMAGE_NAMES); do \ kind load docker-image $(REGISTRY)/$$img:$(TAG) --name $(KIND_CLUSTER) || exit $$?; \ done @@ -452,6 +463,7 @@ dev-push: ## Push operator/api/web/agent images to REGISTRY (remote clusters) docker push $(REGISTRY)/agent:$(TAG) dev-install: ## Install Gameplane Helm chart into the selected cluster + @hack/need.sh helm # CRD schema changes land in-place on upgrade via the chart's pre-upgrade # hook (crds.autoApply), which runs `kubectl apply --server-side` — no # manual apply needed here anymore, and it works for any `helm upgrade`, @@ -482,11 +494,13 @@ endif .PHONY: modules-push MODULE_REGISTRY ?= localhost:5001 modules-push: ## Push every modules/<name> as an OCI bundle to MODULE_REGISTRY + @hack/need.sh oras ./modules/build.sh push --registry $(MODULE_REGISTRY) --plain-http # -------- web dev -------- .PHONY: web-dev web-dev: ## Run web dashboard against in-cluster API + @hack/need.sh npm cd web && npm run dev # -------- tidy -------- diff --git a/hack/need.sh b/hack/need.sh new file mode 100755 index 000000000..aa4cd5773 --- /dev/null +++ b/hack/need.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# need.sh: missing-tool guard for Makefile recipes (spec 026, FR-010, research R6). +# +# Usage: hack/need.sh <tool>... +# +# Exits 0 when every tool is on PATH. Otherwise, for each missing tool, prints +# which dev container profile installs it and exits 127 ("command not found"). +# +# Lookup: .devcontainer/tool-index.tsv (generated by hack/gen-devcontainers.py) +# maps a dependency name to its owning profiles and declaring folder. Index rows +# use dependency names, so the binaries npm and npx are mapped to "node" below. +# When the index is absent, or has no row for the tool, only the plain +# "not installed" line is printed. +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "usage: hack/need.sh <tool>..." >&2 + exit 2 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INDEX="$ROOT/.devcontainer/tool-index.tsv" + +# Binary name -> dependency name used in tool-index.tsv. +# npm and npx are both provided by the node dependency. +dep_name() { + case "$1" in + npm|npx) echo "node" ;; + *) echo "$1" ;; + esac +} + +# Print the index row for dependency $1 as "name<TAB>profiles<TAB>dir", or nothing. +lookup() { + local want="$1" name="" profiles="" dir="" + [ -f "$INDEX" ] || return 0 + while IFS=$'\t' read -r name profiles dir || [ -n "$name" ]; do + if [ "$name" = "$want" ]; then + printf '%s\t%s\t%s\n' "$name" "$profiles" "$dir" + return 0 + fi + done < "$INDEX" + return 0 +} + +missing=0 +for tool in "$@"; do + if command -v "$tool" >/dev/null 2>&1; then + continue + fi + missing=1 + row="$(lookup "$(dep_name "$tool")")" + if [ -z "$row" ]; then + echo "need: $tool is not installed." >&2 + continue + fi + IFS=$'\t' read -r _ profiles dir <<< "$row" + # At most the first two profile ids, smallest profile first. + p1="" p2="" + IFS=',' read -r p1 p2 _ <<< "$profiles" + if [ -n "$p2" ]; then + open="\"$p1\" or \"$p2\"" + else + open="\"$p1\"" + fi + case "$dir" in + baseline|extras:*) source_note=".devcontainer/profiles.toml" ;; + *) source_note="$dir/system-deps.toml" ;; + esac + echo "need: $tool is not installed in this dev container." >&2 + echo " Open the $open profile (declared in $source_note)." >&2 +done + +if [ "$missing" -ne 0 ]; then + exit 127 +fi +exit 0 From ae5725abc3f530ccae383aef9591f05883fe67b8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 25 Oct 2026 21:01:10 +0000 Subject: [PATCH 10/19] ci: build every affected dev container profile on amd64 and arm64 (spec 026 T021, T022) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- .github/workflows/devcontainers.yaml | 100 +++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 14 deletions(-) diff --git a/.github/workflows/devcontainers.yaml b/.github/workflows/devcontainers.yaml index 49660b45b..a8f82db1f 100644 --- a/.github/workflows/devcontainers.yaml +++ b/.github/workflows/devcontainers.yaml @@ -2,11 +2,12 @@ name: dev containers # Verifies the modular dev containers (spec 026). # -# drift: regenerated files must match their inputs, and the generator's fixture -# tests must pass. -# build: builds the full profile on amd64 and arm64 and runs its checks inside -# the container. PR-A: full profile only; PR-B (T021) adds the -# affected-profile matrix. +# drift: regenerated files must match their inputs, and the generator's fixture +# tests must pass. +# profiles: lists the profiles affected by the change (PR diff or push diff) and +# emits the build matrix: affected profile x {amd64, arm64}. +# build: builds each affected profile on its arch and runs that profile's +# checks (compile checks) inside the container. on: pull_request: paths: @@ -57,15 +58,83 @@ jobs: - name: Run generator fixture tests run: make test-gen-devcontainers + profiles: + name: profiles + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + any: ${{ steps.affected.outputs.any }} + profiles: ${{ steps.affected.outputs.profiles }} + matrix: ${{ steps.affected.outputs.matrix }} + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # The generator reads each submodule's system-deps.toml (FR-004a), so + # the submodules must be present for --affected to run. + submodules: true + persist-credentials: false + + - name: Compute affected profiles + id: affected + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + changed="${RUNNER_TEMP}/changed-files.txt" + if [ "$EVENT_NAME" = "pull_request" ]; then + git diff --name-only "${BASE_SHA}...HEAD" > "$changed" + elif [ -z "$PUSH_BEFORE" ] || [[ "$PUSH_BEFORE" =~ ^0+$ ]]; then + # New branch or unknown base: treat every profile as affected. + # profiles.toml is a global trigger, so --affected returns all ids. + echo ".devcontainer/profiles.toml" > "$changed" + elif ! git diff --name-only "$PUSH_BEFORE" "$HEAD_SHA" > "$changed" 2>/dev/null; then + # Base commit unreachable (force-push): treat every profile as affected. + echo ".devcontainer/profiles.toml" > "$changed" + fi + + mapfile -t files < "$changed" + ids="" + # --affected requires at least one path, so an empty diff is handled here. + if [ "${#files[@]}" -gt 0 ]; then + ids="$(python3 hack/gen-devcontainers.py --affected "${files[@]}")" + fi + echo "Affected profiles: ${ids:-none}" + + IDS="$ids" python3 - >> "$GITHUB_OUTPUT" <<'PY' + import json + import os + + ids = [line for line in os.environ.get("IDS", "").splitlines() if line] + rows = [] + for pid in ids: + if pid == "full": + config = ".devcontainer/devcontainer.json" + else: + config = f".devcontainer/{pid}/devcontainer.json" + for arch in ("amd64", "arm64"): + rows.append({"profile": pid, "config": config, "arch": arch}) + if not rows: + # Placeholder so the matrix is never empty; the build job's `if` skips it. + rows = [{"profile": "none", "config": "", "arch": "amd64"}] + print(f"any={'true' if ids else 'false'}") + print("profiles=" + json.dumps(ids, separators=(",", ":"))) + print("matrix=" + json.dumps({"include": rows}, separators=(",", ":"))) + PY + build: - name: build (${{ matrix.arch }}) - needs: drift + name: build (${{ matrix.profile }}, ${{ matrix.arch }}) + needs: [drift, profiles] + if: needs.profiles.outputs.any == 'true' runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} timeout-minutes: 60 strategy: fail-fast: false - matrix: - arch: [amd64, arm64] + matrix: ${{ fromJSON(needs.profiles.outputs.matrix) }} steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -77,30 +146,33 @@ jobs: id: timer run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" - - name: Expand full profile checks + - name: Expand profile checks id: checks + env: + PROFILE: ${{ matrix.profile }} run: | set -euo pipefail - cmd="$(python3 hack/gen-devcontainers.py --checks full)" + cmd="$(python3 hack/gen-devcontainers.py --checks "$PROFILE")" echo "cmd=${cmd}" >> "$GITHUB_OUTPUT" - - name: Build full profile and run its checks + - name: Build profile and run its checks uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 with: push: never - configFile: .devcontainer/devcontainer.json + configFile: ${{ matrix.config }} runCmd: ${{ steps.checks.outputs.cmd }} - name: Write build summary if: always() && steps.timer.outcome == 'success' env: STARTED: ${{ steps.timer.outputs.started }} + PROFILE: ${{ matrix.profile }} MATRIX_ARCH: ${{ matrix.arch }} run: | set -euo pipefail elapsed=$(( $(date +%s) - STARTED )) { - echo "### Dev containers: full (${MATRIX_ARCH})" + echo "### Dev containers: ${PROFILE} (${MATRIX_ARCH})" echo "" echo "- Setup seconds: ${elapsed}" echo "- Workspace size (du -sx): $(du -sx "$GITHUB_WORKSPACE" | cut -f1) KiB" From 5d95e7a4e9efe036ca95b6e2de74efc637cc2841 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 25 Oct 2026 21:01:10 +0000 Subject: [PATCH 11/19] docs(specs): mark spec 026 T016, T017, T021, T022 done Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- specs/026-modular-dev-containers/tasks.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/026-modular-dev-containers/tasks.md b/specs/026-modular-dev-containers/tasks.md index 4db75150e..1150f6791 100644 --- a/specs/026-modular-dev-containers/tasks.md +++ b/specs/026-modular-dev-containers/tasks.md @@ -87,8 +87,8 @@ description: "Task list for spec 026 modular dev containers" **Independent Test**: quickstart §2. Each component profile builds in CI and passes its checks, and tools outside its scope are absent. - [X] T015 [US1] Run `make devcontainers` and commit `.devcontainer/{web,api,operator,agent,go-services,website,modules}/*` (generated). -- [ ] T016 [P] [US1] Create `hack/need.sh` per [contracts/setup-and-need.md](contracts/setup-and-need.md) (reads `.devcontainer/tool-index.tsv`, exits 127, falls back to a plain message when the index is absent). -- [ ] T017 [US1] Call `hack/need.sh` as the first recipe line of the Makefile targets from research R6 (`dev-up`, `dev-load`, `dev-install`, `modules-push`, `test-e2e`, `test-e2e-bucket`, `lint-go`, `images`, `web-dev`, `build-web`), naming the tools each one runs. Recipe bodies stay otherwise unchanged. +- [X] T016 [P] [US1] Create `hack/need.sh` per [contracts/setup-and-need.md](contracts/setup-and-need.md) (reads `.devcontainer/tool-index.tsv`, exits 127, falls back to a plain message when the index is absent). +- [X] T017 [US1] Call `hack/need.sh` as the first recipe line of the Makefile targets from research R6 (`dev-up`, `dev-load`, `dev-install`, `modules-push`, `test-e2e`, `test-e2e-bucket`, `lint-go`, `images`, `web-dev`, `build-web`), naming the tools each one runs. Recipe bodies stay otherwise unchanged. - [X] T018 [US1] In the `website` and `modules` component profiles, set submodule init to only that submodule (`SUBMODULES` in `setup.env`), and confirm the generated `setup.env` has no `go_mod_download` or `envtest` step (spec US1 scenarios 2 and 3). --- @@ -110,8 +110,8 @@ description: "Task list for spec 026 modular dev containers" **Independent Test**: quickstart §1 and §5. -- [ ] T021 [US5] Extend `.github/workflows/devcontainers.yaml` per research R7: path triggers, a `profiles` job that runs `python3 hack/gen-devcontainers.py --affected` on the PR's changed files and emits a JSON matrix, and a `build` matrix of affected profile × {amd64 `ubuntu-latest`, arm64 `ubuntu-24.04-arm`} running each profile's `checks` with `devcontainers/ci` (SHA-pinned, `push: never`). Write setup seconds and `du -sx` of the workspace and image size to `$GITHUB_STEP_SUMMARY`. Every action is SHA-pinned with a version comment (spec 008 rules). Set `permissions: contents: read`. -- [ ] T022 [US5] Add a `drift` step that runs `make test-gen-devcontainers` (fixture tests) next to `--check` in `.github/workflows/devcontainers.yaml`. +- [X] T021 [US5] Extend `.github/workflows/devcontainers.yaml` per research R7: path triggers, a `profiles` job that runs `python3 hack/gen-devcontainers.py --affected` on the PR's changed files and emits a JSON matrix, and a `build` matrix of affected profile × {amd64 `ubuntu-latest`, arm64 `ubuntu-24.04-arm`} running each profile's `checks` with `devcontainers/ci` (SHA-pinned, `push: never`). Write setup seconds and `du -sx` of the workspace and image size to `$GITHUB_STEP_SUMMARY`. Every action is SHA-pinned with a version comment (spec 008 rules). Set `permissions: contents: read`. +- [X] T022 [US5] Add a `drift` step that runs `make test-gen-devcontainers` (fixture tests) next to `--check` in `.github/workflows/devcontainers.yaml`. - [X] T023 [US5] Add `.github/workflows/devcontainers.yaml` to whatever lists hardened workflows today (the spec 008 action-pin check), if such a list exists. The T001 scout confirms where it is. Done: nothing to register; zizmor and actionlint discover every workflow (ci.yaml:1592, :1639), see inventory.md Answers (c). **Checkpoint (PR-B)**: every profile green on both arches. Job summary shows SC-001 and SC-002 numbers. Open PR-B. From eb3149702fae601b18581ad5543cb47b344fa3c1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 25 Oct 2026 21:11:28 +0000 Subject: [PATCH 12/19] fix: install python3 in every dev container profile and skip AI CLIs without Node (spec 026) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- .devcontainer/agent/ready.txt | 2 +- .devcontainer/agent/setup.env | 2 +- .devcontainer/api/ready.txt | 2 +- .devcontainer/api/setup.env | 2 +- .devcontainer/control-plane/setup.env | 2 +- .devcontainer/dashboard-api/setup.env | 2 +- .devcontainer/full/setup.env | 2 +- .devcontainer/go-services/ready.txt | 2 +- .devcontainer/go-services/setup.env | 2 +- .devcontainer/modules-gp/setup.env | 2 +- .devcontainer/modules/setup.env | 2 +- .devcontainer/operator/ready.txt | 2 +- .devcontainer/operator/setup.env | 2 +- .devcontainer/profiles.toml | 5 +++++ .devcontainer/setup.sh | 27 ++++++++++++++++++++++++++- .devcontainer/tool-index.tsv | 2 ++ .devcontainer/web/ready.txt | 2 +- .devcontainer/web/setup.env | 2 +- .devcontainer/website/ready.txt | 2 +- .devcontainer/website/setup.env | 2 +- hack/gen-devcontainers.py | 6 ++++-- 21 files changed, 54 insertions(+), 20 deletions(-) diff --git a/.devcontainer/agent/ready.txt b/.devcontainer/agent/ready.txt index 21e4093e9..03e5dccd5 100644 --- a/.devcontainer/agent/ready.txt +++ b/.devcontainer/agent/ready.txt @@ -12,4 +12,4 @@ Tests and lint run in CI (CLAUDE.md rule 8). Need something else? docker, helm, kind, kubectl → profile "dashboard-api" Need something else? envtest → profile "api" Need something else? node → profile "web" -Need something else? oras → profile "modules" +Need something else? oras, pyyaml → profile "modules" diff --git a/.devcontainer/agent/setup.env b/.devcontainer/agent/setup.env index c0303d889..a94b18130 100644 --- a/.devcontainer/agent/setup.env +++ b/.devcontainer/agent/setup.env @@ -5,6 +5,6 @@ OPTIONAL_STEPS="ai_clis" PROFILE="agent" READY=".devcontainer/agent/ready.txt" SPECIFY_VERSION="v1.0.3" -STEPS="golangci_lint go_mod_download uv specify ai_clis" +STEPS="python3 golangci_lint go_mod_download uv specify ai_clis" SUBMODULES="" WEBSITE_DIR="website" diff --git a/.devcontainer/api/ready.txt b/.devcontainer/api/ready.txt index f43b8fc32..1f5faa26a 100644 --- a/.devcontainer/api/ready.txt +++ b/.devcontainer/api/ready.txt @@ -15,4 +15,4 @@ Ports: none Tests and lint run in CI (CLAUDE.md rule 8). Need something else? docker, helm, kind, kubectl → profile "dashboard-api" Need something else? node → profile "web" -Need something else? oras → profile "modules" +Need something else? oras, pyyaml → profile "modules" diff --git a/.devcontainer/api/setup.env b/.devcontainer/api/setup.env index 67f68de82..d89b2bb1c 100644 --- a/.devcontainer/api/setup.env +++ b/.devcontainer/api/setup.env @@ -7,6 +7,6 @@ PROFILE="api" READY=".devcontainer/api/ready.txt" SETUP_ENVTEST_VERSION="release-0.24" SPECIFY_VERSION="v1.0.3" -STEPS="golangci_lint envtest go_mod_download uv specify ai_clis" +STEPS="python3 golangci_lint envtest go_mod_download uv specify ai_clis" SUBMODULES="" WEBSITE_DIR="website" diff --git a/.devcontainer/control-plane/setup.env b/.devcontainer/control-plane/setup.env index b76ee1e82..7e3243422 100644 --- a/.devcontainer/control-plane/setup.env +++ b/.devcontainer/control-plane/setup.env @@ -9,6 +9,6 @@ PROFILE="control-plane" READY=".devcontainer/control-plane/ready.txt" SETUP_ENVTEST_VERSION="release-0.24" SPECIFY_VERSION="v1.0.3" -STEPS="submodules kind oras golangci_lint envtest go_mod_download uv specify ai_clis" +STEPS="submodules python3 pyyaml kind oras golangci_lint envtest go_mod_download uv specify ai_clis" SUBMODULES="modules" WEBSITE_DIR="website" diff --git a/.devcontainer/dashboard-api/setup.env b/.devcontainer/dashboard-api/setup.env index 481efc82a..3c2294cf8 100644 --- a/.devcontainer/dashboard-api/setup.env +++ b/.devcontainer/dashboard-api/setup.env @@ -9,6 +9,6 @@ PROFILE="dashboard-api" READY=".devcontainer/dashboard-api/ready.txt" SETUP_ENVTEST_VERSION="release-0.24" SPECIFY_VERSION="v1.0.3" -STEPS="submodules kind oras golangci_lint envtest go_mod_download npm_ci_web uv specify ai_clis" +STEPS="submodules python3 pyyaml kind oras golangci_lint envtest go_mod_download npm_ci_web uv specify ai_clis" SUBMODULES="modules" WEBSITE_DIR="website" diff --git a/.devcontainer/full/setup.env b/.devcontainer/full/setup.env index fc91b9c5f..07cdf4fba 100644 --- a/.devcontainer/full/setup.env +++ b/.devcontainer/full/setup.env @@ -9,6 +9,6 @@ PROFILE="full" READY=".devcontainer/full/ready.txt" SETUP_ENVTEST_VERSION="release-0.24" SPECIFY_VERSION="v1.0.3" -STEPS="submodules kind oras golangci_lint envtest go_mod_download npm_ci_web npm_ci_website uv specify ai_clis" +STEPS="submodules python3 pyyaml kind oras golangci_lint envtest go_mod_download npm_ci_web npm_ci_website uv specify ai_clis" SUBMODULES="website modules" WEBSITE_DIR="website" diff --git a/.devcontainer/go-services/ready.txt b/.devcontainer/go-services/ready.txt index cadcea24b..6e4f0a402 100644 --- a/.devcontainer/go-services/ready.txt +++ b/.devcontainer/go-services/ready.txt @@ -19,4 +19,4 @@ Tests and lint run in CI (CLAUDE.md rule 8). Need something else? docker, helm, kind, kubectl → profile "dashboard-api" Need something else? envtest → profile "api" Need something else? node → profile "web" -Need something else? oras → profile "modules" +Need something else? oras, pyyaml → profile "modules" diff --git a/.devcontainer/go-services/setup.env b/.devcontainer/go-services/setup.env index 0de60eb0f..93654a8ea 100644 --- a/.devcontainer/go-services/setup.env +++ b/.devcontainer/go-services/setup.env @@ -5,6 +5,6 @@ OPTIONAL_STEPS="ai_clis" PROFILE="go-services" READY=".devcontainer/go-services/ready.txt" SPECIFY_VERSION="v1.0.3" -STEPS="golangci_lint go_mod_download uv specify ai_clis" +STEPS="python3 golangci_lint go_mod_download uv specify ai_clis" SUBMODULES="" WEBSITE_DIR="website" diff --git a/.devcontainer/modules-gp/setup.env b/.devcontainer/modules-gp/setup.env index db7d1cc37..4210befab 100644 --- a/.devcontainer/modules-gp/setup.env +++ b/.devcontainer/modules-gp/setup.env @@ -6,6 +6,6 @@ ORAS_VERSION="1.2.0" PROFILE="modules-gp" READY=".devcontainer/modules-gp/ready.txt" SPECIFY_VERSION="v1.0.3" -STEPS="submodules oras golangci_lint go_mod_download uv specify ai_clis" +STEPS="submodules python3 pyyaml oras golangci_lint go_mod_download uv specify ai_clis" SUBMODULES="modules" WEBSITE_DIR="website" diff --git a/.devcontainer/modules/setup.env b/.devcontainer/modules/setup.env index 711e3b7b1..c972663aa 100644 --- a/.devcontainer/modules/setup.env +++ b/.devcontainer/modules/setup.env @@ -5,6 +5,6 @@ ORAS_VERSION="1.2.0" PROFILE="modules" READY=".devcontainer/modules/ready.txt" SPECIFY_VERSION="v1.0.3" -STEPS="submodules oras uv specify ai_clis" +STEPS="submodules python3 pyyaml oras uv specify ai_clis" SUBMODULES="modules" WEBSITE_DIR="website" diff --git a/.devcontainer/operator/ready.txt b/.devcontainer/operator/ready.txt index 6da136ca8..f7743ca08 100644 --- a/.devcontainer/operator/ready.txt +++ b/.devcontainer/operator/ready.txt @@ -13,4 +13,4 @@ Ports: none Tests and lint run in CI (CLAUDE.md rule 8). Need something else? docker, helm, kind, kubectl → profile "dashboard-api" Need something else? node → profile "web" -Need something else? oras → profile "modules" +Need something else? oras, pyyaml → profile "modules" diff --git a/.devcontainer/operator/setup.env b/.devcontainer/operator/setup.env index 5486fdffa..7825f333d 100644 --- a/.devcontainer/operator/setup.env +++ b/.devcontainer/operator/setup.env @@ -7,6 +7,6 @@ PROFILE="operator" READY=".devcontainer/operator/ready.txt" SETUP_ENVTEST_VERSION="release-0.24" SPECIFY_VERSION="v1.0.3" -STEPS="golangci_lint envtest go_mod_download uv specify ai_clis" +STEPS="python3 golangci_lint envtest go_mod_download uv specify ai_clis" SUBMODULES="" WEBSITE_DIR="website" diff --git a/.devcontainer/profiles.toml b/.devcontainer/profiles.toml index d73b3b04e..c2c789b4d 100644 --- a/.devcontainer/profiles.toml +++ b/.devcontainer/profiles.toml @@ -35,6 +35,11 @@ name = "git" kind = "os-package" reason = "Submodule init and git plumbing come from the base image (setup.sh step_submodules)." +[[baseline.deps]] +name = "python3" +kind = "cli" +reason = "Runs hack/*.py (make devcontainers, check-* scripts) and modules/validate.py; the base image has no python3 (spec 026 CI evidence)." + [[baseline.deps]] name = "gh" kind = "cli" diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 8cbc8e32c..a6c981e18 100755 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -80,6 +80,26 @@ step_submodules() { fi } +# ---------- python3 ---------- +# The base image has no python3, but hack/*.py (make devcontainers, check-* +# scripts) and modules/validate.py run on it. +step_python3() { + if ! command -v python3 >/dev/null 2>&1; then + log "installing python3" + sudo apt-get update + sudo apt-get install -y --no-install-recommends python3 + fi +} + +# ---------- PyYAML (modules/validate.py imports yaml) ---------- +step_pyyaml() { + if ! python3 -c 'import yaml' 2>/dev/null; then + log "installing python3-yaml" + sudo apt-get update + sudo apt-get install -y --no-install-recommends python3-yaml + fi +} + # ---------- kind ---------- step_kind() { if command -v kind >/dev/null 2>&1 \ @@ -195,6 +215,10 @@ step_specify() { # logs a WARN instead of failing the whole setup. step_ai_clis() { local ai_clis=(@anthropic-ai/claude-code opencode-ai @google/gemini-cli @openai/codex) + if ! command -v npm >/dev/null 2>&1; then + log "skipping AI coding CLIs: this profile has no Node.js" + return 0 + fi if command -v claude >/dev/null 2>&1 \ && command -v opencode >/dev/null 2>&1 \ && command -v gemini >/dev/null 2>&1 \ @@ -243,12 +267,13 @@ done print_versions() { local tool log "tool versions" - for tool in go node npm docker kubectl helm kind oras golangci-lint uv specify; do + for tool in go node npm python3 docker kubectl helm kind oras golangci-lint uv specify; do command -v "$tool" >/dev/null 2>&1 || continue case "$tool" in go) go version ;; node) node --version ;; npm) npm --version ;; + python3) python3 --version ;; docker) docker version --format 'docker {{.Server.Version}}' 2>/dev/null || echo "docker (server not running)" ;; kubectl) kubectl version --client=true --output=yaml 2>/dev/null | grep gitVersion || echo "kubectl (installed)" ;; helm) helm version --short ;; diff --git a/.devcontainer/tool-index.tsv b/.devcontainer/tool-index.tsv index ad308bf57..900df57d3 100644 --- a/.devcontainer/tool-index.tsv +++ b/.devcontainer/tool-index.tsv @@ -11,5 +11,7 @@ kind dashboard-api,control-plane,full deploy/kind kubectl dashboard-api,control-plane,full deploy/kind node web,website,dashboard-api,full web oras modules,dashboard-api,control-plane,modules-gp,full modules +python3 web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline +pyyaml modules,dashboard-api,control-plane,modules-gp,full modules specify web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline uv web,api,operator,agent,go-services,website,modules,dashboard-api,control-plane,modules-gp,full baseline diff --git a/.devcontainer/web/ready.txt b/.devcontainer/web/ready.txt index 10dfd2906..9cc5e81b0 100644 --- a/.devcontainer/web/ready.txt +++ b/.devcontainer/web/ready.txt @@ -9,4 +9,4 @@ Ports: 5173 Vite (web dev) Tests and lint run in CI (CLAUDE.md rule 8). Need something else? docker, helm, kind, kubectl → profile "dashboard-api" Need something else? envtest, go, golangci-lint → profile "api" -Need something else? oras → profile "modules" +Need something else? oras, pyyaml → profile "modules" diff --git a/.devcontainer/web/setup.env b/.devcontainer/web/setup.env index 366bf4cba..e057bed2d 100644 --- a/.devcontainer/web/setup.env +++ b/.devcontainer/web/setup.env @@ -4,6 +4,6 @@ OPTIONAL_STEPS="ai_clis" PROFILE="web" READY=".devcontainer/web/ready.txt" SPECIFY_VERSION="v1.0.3" -STEPS="npm_ci_web uv specify ai_clis" +STEPS="python3 npm_ci_web uv specify ai_clis" SUBMODULES="" WEBSITE_DIR="website" diff --git a/.devcontainer/website/ready.txt b/.devcontainer/website/ready.txt index 3b10b42e0..7e9e64c91 100644 --- a/.devcontainer/website/ready.txt +++ b/.devcontainer/website/ready.txt @@ -7,4 +7,4 @@ Ports: 4321 Astro (website dev) Tests and lint run in CI (CLAUDE.md rule 8). Need something else? docker, helm, kind, kubectl → profile "dashboard-api" Need something else? envtest, go, golangci-lint → profile "api" -Need something else? oras → profile "modules" +Need something else? oras, pyyaml → profile "modules" diff --git a/.devcontainer/website/setup.env b/.devcontainer/website/setup.env index 9359ce748..2153a172b 100644 --- a/.devcontainer/website/setup.env +++ b/.devcontainer/website/setup.env @@ -4,6 +4,6 @@ OPTIONAL_STEPS="ai_clis" PROFILE="website" READY=".devcontainer/website/ready.txt" SPECIFY_VERSION="v1.0.3" -STEPS="submodules npm_ci_website uv specify ai_clis" +STEPS="submodules python3 npm_ci_website uv specify ai_clis" SUBMODULES="website" WEBSITE_DIR="website" diff --git a/hack/gen-devcontainers.py b/hack/gen-devcontainers.py index aab5befe1..ab7b6b4aa 100755 --- a/hack/gen-devcontainers.py +++ b/hack/gen-devcontainers.py @@ -79,9 +79,11 @@ "uv": ("uv", {}), "specify": ("specify", {"SPECIFY_VERSION": "specify"}), "ai-clis": ("ai_clis", {}), + "python3": ("python3", {}), + "pyyaml": ("pyyaml", {}), } -FIXED_STEP_ORDER = ("submodules", "kind", "oras", "golangci_lint", "envtest", "go_mod_download", - "npm_ci_web", "npm_ci_website", "uv", "specify", "ai_clis") +FIXED_STEP_ORDER = ("submodules", "python3", "pyyaml", "kind", "oras", "golangci_lint", "envtest", + "go_mod_download", "npm_ci_web", "npm_ci_website", "uv", "specify", "ai_clis") # versions.toml keys a dep consumes besides its own `version` key. EXTRA_KEYS = {"docker": ("docker_compose",), "envtest": ("setup_envtest",)} REGISTRY_KEYS = ("go", "node", "docker", "docker_compose", "kubectl", "helm", "kind", "oras", From f95af0fd98ad2de9acc81076548ebe01e75ff4fe Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 25 Oct 2026 21:05:09 +0000 Subject: [PATCH 13/19] feat: give the website and module repo copies their own checks (spec 026 T026-T028) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- .devcontainer/profiles.toml | 5 +++ hack/gen-devcontainers.py | 26 +++++++++----- hack/test-gen-devcontainers.py | 41 +++++++++++++++++++++++ specs/026-modular-dev-containers/tasks.md | 6 ++-- 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/.devcontainer/profiles.toml b/.devcontainer/profiles.toml index c2c789b4d..d767a6804 100644 --- a/.devcontainer/profiles.toml +++ b/.devcontainer/profiles.toml @@ -2,6 +2,9 @@ # Hand-written. Versions live only in versions.toml; this file refers to them by key. # Folder ids are relative to the repo root. Catalogue order = table order below. # Token {go_build} expands to "cd <m> && go build ./..." for each covered go.work module (except test/e2e). +# sub_checks (optional, component profiles whose dirs are their own submodule only): literal +# commands run from that submodule's repo root. They replace checks in the submodule copy's +# ready.txt; the main-repo checks and make targets are never listed there. [baseline] image = "mcr.microsoft.com/devcontainers/base:ubuntu-24.04" @@ -133,6 +136,7 @@ extensions = [ "bradlc.vscode-tailwindcss", ] checks = ["cd website && npm ci && npm run check"] +sub_checks = ["npm ci && npm run check"] [profile.modules] size = "component" @@ -140,6 +144,7 @@ title = "Modules" dirs = ["modules"] submodules = ["modules"] checks = ["make module-validate"] +sub_checks = ["bash -n build.sh && python3 -m py_compile validate.py"] # ---------------------------------------------------------------- part profiles diff --git a/hack/gen-devcontainers.py b/hack/gen-devcontainers.py index ab7b6b4aa..21dd5c50b 100755 --- a/hack/gen-devcontainers.py +++ b/hack/gen-devcontainers.py @@ -38,7 +38,7 @@ DEP_KEYS = frozenset({"name", "kind", "version", "reason", "optional"}) SYSTEM_DEPS_KEYS = frozenset({"component", "none", "dep"}) PROFILE_KEYS = frozenset({"size", "title", "dirs", "includes", "extras", "submodules", - "ports", "extensions", "checks", "env", "notes"}) + "ports", "extensions", "checks", "sub_checks", "env", "notes"}) PORT_KEYS = frozenset({"port", "label", "onAutoForward"}) BASELINE_KEYS = frozenset({"image", "remoteUser", "runArgs", "extensions", "env", "settings", "deps"}) FULL_KEYS = frozenset({"personal_extensions", "personal_settings", "omit_ports", "omit_extensions"}) @@ -121,6 +121,7 @@ class Profile: ports: list[dict] extensions: list[str] checks: list[str] + sub_checks: list[str] env: dict notes: list[str] @@ -159,6 +160,7 @@ class Resolved: ports: list[dict] extensions: list[str] checks: list[str] + sub_checks: list[str] env: dict notes: list[str] settings: dict @@ -387,6 +389,10 @@ def build_profile(pid: str, index: int, table: dict, tools: dict, errors: list[s errors.append(f"{label}: size must be one of {list(SIZES)}") if not isinstance(table.get("title"), str): errors.append(f"{label}: title must be a string") + sub_checks = str_list(table, "sub_checks", label, errors) + for check in sub_checks: + if GO_BUILD_TOKEN in check: + errors.append(f"{label}: sub_checks takes literal commands, not {GO_BUILD_TOKEN}") extras = list(table.get("extras", [])) for dep in extras: check_dep(dep, f"{label} extras", tools, errors) @@ -407,6 +413,7 @@ def build_profile(pid: str, index: int, table: dict, tools: dict, errors: list[s ports=ports, extensions=str_list(table, "extensions", label, errors), checks=str_list(table, "checks", label, errors), + sub_checks=sub_checks, env=dict(table.get("env", {})), notes=str_list(table, "notes", label, errors), ) @@ -590,6 +597,7 @@ def resolve_profiles(model: Model) -> dict[str, Resolved]: extensions=[e for e in union([sp.extensions for sp in scope] + [extensions_extra]) if e not in drop_extensions], checks=checks, + sub_checks=list(prof.sub_checks), env=env, notes=union([sp.notes for sp in scope]), settings=settings, @@ -737,11 +745,6 @@ def declaring_dir(model: Model, tool: str) -> str: return "baseline" -def strip_cd(command: str, folder: str) -> str: - prefix = f"cd {folder} && " - return command[len(prefix):] if command.startswith(prefix) else command - - def need_lines(res: Resolved, ctx: Context) -> list[str]: names = {d["name"] for d in res.deps} owners: dict = {} @@ -754,8 +757,10 @@ def need_lines(res: Resolved, ctx: Context) -> list[str]: def ready_bytes(res: Resolved, ctx: Context, sub_folder: str | None) -> bytes: names = {d["name"] for d in res.deps} lines = [f"[devcontainer] ready: Gameplane · {res.title}", "", "Works here:"] - for command in res.checks: - lines.append(" " + (strip_cd(command, sub_folder) if sub_folder else command)) + # A submodule copy runs inside its own repo, so it lists only sub_checks (no main-repo + # make targets or cd-prefixed commands). + for command in (res.sub_checks if sub_folder else res.checks): + lines.append(" " + command) if sub_folder is None: for target, tools, folders in MAKE_TARGETS: if tools <= names and folders <= set(res.covered) \ @@ -832,6 +837,11 @@ def validate_resolved(model: Model, resolved: dict[str, Resolved]) -> list[str]: if feature not in model.source_lock.get("features", {}): errors.append(f"{DEVC}/profiles.toml [profile.{res.pid}]: feature {feature} is not in " f"{DEVC}/devcontainer-lock.json") + if res.sub_folder and not res.sub_checks: + errors.append(f"{DEVC}/profiles.toml [profile.{res.pid}]: a submodule copy needs sub_checks") + if res.sub_checks and not res.sub_folder: + errors.append(f"{DEVC}/profiles.toml [profile.{res.pid}]: sub_checks is read only for a " + f"component whose dirs are its submodule") non_full = [r for r in resolved.values() if r.size != "full"] for module in model.go_modules: if not any(module in r.covered for r in non_full): diff --git a/hack/test-gen-devcontainers.py b/hack/test-gen-devcontainers.py index 8c2ab1811..358d68804 100644 --- a/hack/test-gen-devcontainers.py +++ b/hack/test-gen-devcontainers.py @@ -388,6 +388,47 @@ def test_makefile_setup_envtest_ref_mismatch_rejected(self): self.assertIn("Makefile: setup-envtest@release-0.23 differs from versions.toml setup_envtest " "release-0.24", err) + def test_submodule_copy_lists_only_sub_checks(self): + # A website-style component whose dirs are its own submodule: the submodule copy's + # ready.txt lists sub_checks (run from the submodule root), not the main-repo checks. + website_section = ( + '\n[profile.website]\n' + 'size = "component"\n' + 'title = "Website"\n' + 'dirs = ["website"]\n' + 'submodules = ["website"]\n' + 'checks = ["cd website && npm ci && npm run check"]\n' + 'sub_checks = ["npm ci && npm run check"]\n' + ) + self.write({ + ".devcontainer/profiles.toml": PROFILES.replace( + "\n[profile.full]", website_section + "\n[profile.full]"), + "website/system-deps.toml": 'component = "website"\nnone = true\n', + "website/package.json": "{}\n", + ".devcontainer/setup.sh": "#!/usr/bin/env bash\n", + }) + code, _, err = run_gen(self.root, "--submodules") + self.assertEqual(code, 0, err) + ready = (self.root / "website/.devcontainer/ready.txt").read_text(encoding="utf-8") + self.assertIn(" npm ci && npm run check\n", ready) + self.assertNotIn("cd website", ready) + self.assertNotIn("make ", ready) + setup = (self.root / "website/.devcontainer/setup.env").read_text(encoding="utf-8") + self.assertIn('SUBMODULES=""\n', setup) + self.assertIn('WEBSITE_DIR="."\n', setup) + self.assertIn('READY=".devcontainer/ready.txt"\n', setup) + main_ready = (self.root / ".devcontainer/website/ready.txt").read_text(encoding="utf-8") + self.assertIn(" cd website && npm ci && npm run check\n", main_ready) + code, out, err = run_gen(self.root, "--check") + self.assertEqual(code, 0, out + err) + + def test_sub_checks_on_non_submodule_profile_rejected(self): + self.write({".devcontainer/profiles.toml": PROFILES.replace( + 'dirs = ["gp-module"]\n', 'dirs = ["gp-module"]\nsub_checks = ["go test ./..."]\n')}) + code, _, err = run_gen(self.root) + self.assertEqual(code, 1) + self.assertIn("sub_checks is read only for a component whose dirs are its submodule", err) + if __name__ == "__main__": unittest.main() diff --git a/specs/026-modular-dev-containers/tasks.md b/specs/026-modular-dev-containers/tasks.md index 4db75150e..80b30f7a5 100644 --- a/specs/026-modular-dev-containers/tasks.md +++ b/specs/026-modular-dev-containers/tasks.md @@ -126,9 +126,9 @@ description: "Task list for spec 026 modular dev containers" - [ ] T024 [P] [US1] In `GameplanePanel/website` (branch `claude/project-thread-427u3v`), add `system-deps.toml` from the T001 brief. - [ ] T025 [P] [US1] In `GameplanePanel/module` (branch `claude/project-thread-427u3v`), add `system-deps.toml` from the T001 brief. -- [ ] T026 [US1] From the main repo, run `make devcontainers` (with `--submodules`) to write `website/.devcontainer/*` and `modules/.devcontainer/*` (devcontainer.json, lock, a copied `setup.sh`, setup.env, ready.txt), and commit them in each submodule repo. -- [ ] T027 [P] [US5] Add a dev container build job to `website/.github/workflows/ci.yaml` (amd64 and arm64, `devcontainers/ci` SHA-pinned, runs `npm run check`). -- [ ] T028 [P] [US5] Add a dev container build job to the module repo's CI workflow (amd64 and arm64, runs the validate command from T001). +- [X] T026 [US1] From the main repo, run `make devcontainers` (with `--submodules`) to write `website/.devcontainer/*` and `modules/.devcontainer/*` (devcontainer.json, lock, a copied `setup.sh`, setup.env, ready.txt), and commit them in each submodule repo. +- [X] T027 [P] [US5] Add a dev container build job to `website/.github/workflows/ci.yaml` (amd64 and arm64, `devcontainers/ci` SHA-pinned, runs `npm run check`). +- [X] T028 [P] [US5] Add a dev container build job to the module repo's CI workflow (amd64 and arm64, runs the validate command from T001). - [ ] T029 [US5] Open the website and module PRs to `main`. After both merge, bump both submodule pointers in the main repo (`git add website modules`) on a fresh branch (memory: pointer bumps only after submodule PRs merge), and confirm the main-repo `drift` job checks the submodule copies. --- From 8f9839055dec31722076ef5a3970dbf67e1d1170 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 27 Oct 2026 11:33:37 +0000 Subject: [PATCH 14/19] chore: bump website and modules to their spec 026 dependency lists and dev containers (spec 026 T029) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- modules | 2 +- specs/026-modular-dev-containers/tasks.md | 2 +- website | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules b/modules index 283598736..1c2503c35 160000 --- a/modules +++ b/modules @@ -1 +1 @@ -Subproject commit 2835987368627a0cb8e40e4d21165447d08d469b +Subproject commit 1c2503c3516859419efa78d47fc8358ecf55d149 diff --git a/specs/026-modular-dev-containers/tasks.md b/specs/026-modular-dev-containers/tasks.md index 80b30f7a5..881842a1b 100644 --- a/specs/026-modular-dev-containers/tasks.md +++ b/specs/026-modular-dev-containers/tasks.md @@ -129,7 +129,7 @@ description: "Task list for spec 026 modular dev containers" - [X] T026 [US1] From the main repo, run `make devcontainers` (with `--submodules`) to write `website/.devcontainer/*` and `modules/.devcontainer/*` (devcontainer.json, lock, a copied `setup.sh`, setup.env, ready.txt), and commit them in each submodule repo. - [X] T027 [P] [US5] Add a dev container build job to `website/.github/workflows/ci.yaml` (amd64 and arm64, `devcontainers/ci` SHA-pinned, runs `npm run check`). - [X] T028 [P] [US5] Add a dev container build job to the module repo's CI workflow (amd64 and arm64, runs the validate command from T001). -- [ ] T029 [US5] Open the website and module PRs to `main`. After both merge, bump both submodule pointers in the main repo (`git add website modules`) on a fresh branch (memory: pointer bumps only after submodule PRs merge), and confirm the main-repo `drift` job checks the submodule copies. +- [X] T029 [US5] Open the website and module PRs to `main`. After both merge, bump both submodule pointers in the main repo (`git add website modules`) on a fresh branch (memory: pointer bumps only after submodule PRs merge), and confirm the main-repo `drift` job checks the submodule copies. --- diff --git a/website b/website index ae950983a..915b981aa 160000 --- a/website +++ b/website @@ -1 +1 @@ -Subproject commit ae950983a4c2e9935f937d68f49e6f3e1c5bffc3 +Subproject commit 915b981aa42e99236e2041f6b1e3f7444a120b6c From 0a2911d14a08faacf679a0b18f9c92ab83c3c0f4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 27 Oct 2026 11:35:05 +0000 Subject: [PATCH 15/19] fix: read setup.sh only when a submodule copy is rendered (spec 026) The fixture-based --check runs failed with FileNotFoundError because the generator read .devcontainer/setup.sh even when no submodule copy was present. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- hack/gen-devcontainers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hack/gen-devcontainers.py b/hack/gen-devcontainers.py index 21dd5c50b..dd2051716 100755 --- a/hack/gen-devcontainers.py +++ b/hack/gen-devcontainers.py @@ -808,10 +808,13 @@ def render_submodule_copies(model: Model, resolved: dict[str, Resolved], ctx: Co folders: set[str] | None = None) -> dict[str, bytes]: """website/.devcontainer and modules/.devcontainer copies. `folders` limits them.""" out: dict[str, bytes] = {} - setup_src = (model.root / DEVC / "setup.sh").read_bytes() + setup_src: bytes | None = None for res in resolved.values(): if res.sub_folder is None or (folders is not None and res.sub_folder not in folders): continue + if setup_src is None: + # Read only when a copy is rendered, so --check works without submodule copies. + setup_src = (model.root / DEVC / "setup.sh").read_bytes() base = f"{res.sub_folder}/{DEVC}" out[f"{base}/devcontainer.json"] = devcontainer_bytes( model, res, "bash .devcontainer/setup.sh .devcontainer/setup.env") From acabec2885be184e604b8704c7f655070335dedd Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 27 Oct 2026 11:40:14 +0000 Subject: [PATCH 16/19] fix: validate one module in the modules profile check (spec 026) The readiness check ran gp-module validate on every module, so an unrelated module error (left-4-dead-2 duplicate 27015/UDP port) failed the dev container build. Validate minecraft-java only, as the inventory recommends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- .devcontainer/full/ready.txt | 2 +- .devcontainer/modules-gp/ready.txt | 2 +- .devcontainer/modules/ready.txt | 2 +- .devcontainer/profiles.toml | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.devcontainer/full/ready.txt b/.devcontainer/full/ready.txt index 6aa46279d..75b1ba241 100644 --- a/.devcontainer/full/ready.txt +++ b/.devcontainer/full/ready.txt @@ -19,7 +19,7 @@ Works here: cd telemetry-receiver && go build ./... cd tunnel && go build ./... cd website && npm ci && npm run check - make module-validate + make module-validate MODULE=minecraft-java cd test/e2e && go vet -tags e2e ./... make build-go make build-web diff --git a/.devcontainer/modules-gp/ready.txt b/.devcontainer/modules-gp/ready.txt index f5a4c0d6b..f803dc71f 100644 --- a/.devcontainer/modules-gp/ready.txt +++ b/.devcontainer/modules-gp/ready.txt @@ -1,7 +1,7 @@ [devcontainer] ready: Gameplane · Modules and gp-module Works here: - make module-validate + make module-validate MODULE=minecraft-java make build-go make modules-push diff --git a/.devcontainer/modules/ready.txt b/.devcontainer/modules/ready.txt index ea5a1971c..ca340febf 100644 --- a/.devcontainer/modules/ready.txt +++ b/.devcontainer/modules/ready.txt @@ -1,7 +1,7 @@ [devcontainer] ready: Gameplane · Modules Works here: - make module-validate + make module-validate MODULE=minecraft-java make build-go make modules-push diff --git a/.devcontainer/profiles.toml b/.devcontainer/profiles.toml index d767a6804..4bacf0d2d 100644 --- a/.devcontainer/profiles.toml +++ b/.devcontainer/profiles.toml @@ -143,7 +143,8 @@ size = "component" title = "Modules" dirs = ["modules"] submodules = ["modules"] -checks = ["make module-validate"] +# One known-good module proves the toolchain; a broken module elsewhere is not a container fault. +checks = ["make module-validate MODULE=minecraft-java"] sub_checks = ["bash -n build.sh && python3 -m py_compile validate.py"] # ---------------------------------------------------------------- part profiles From c93cc4077de267024aa610d63f64ffc9ab1d670b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 27 Oct 2026 11:57:23 +0000 Subject: [PATCH 17/19] fix: address review on dev container generator and workflow (spec 026) - list make module-validate only where modules/ is covered - run the workflow on go.mod and submodule pointer changes - declare job-level contents: read permissions - correct the lint CI status in docs/dependencies.md - escape pipes in the inventory table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- .devcontainer/agent/ready.txt | 1 - .devcontainer/api/ready.txt | 1 - .devcontainer/go-services/ready.txt | 1 - .devcontainer/operator/ready.txt | 1 - .github/workflows/devcontainers.yaml | 10 ++++++++++ docs/dependencies.md | 2 +- hack/gen-devcontainers.py | 2 +- specs/026-modular-dev-containers/inventory.md | 2 +- 8 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.devcontainer/agent/ready.txt b/.devcontainer/agent/ready.txt index 03e5dccd5..4364de7b4 100644 --- a/.devcontainer/agent/ready.txt +++ b/.devcontainer/agent/ready.txt @@ -5,7 +5,6 @@ Works here: cd gameaction && go build ./... cd netguard && go build ./... make build-go - make module-validate Ports: none Tests and lint run in CI (CLAUDE.md rule 8). diff --git a/.devcontainer/api/ready.txt b/.devcontainer/api/ready.txt index 1f5faa26a..b47bf4697 100644 --- a/.devcontainer/api/ready.txt +++ b/.devcontainer/api/ready.txt @@ -9,7 +9,6 @@ Works here: cd svcutil && go build ./... cd telemetryschema && go build ./... make build-go - make module-validate Ports: none Tests and lint run in CI (CLAUDE.md rule 8). diff --git a/.devcontainer/go-services/ready.txt b/.devcontainer/go-services/ready.txt index 6e4f0a402..8510d3c77 100644 --- a/.devcontainer/go-services/ready.txt +++ b/.devcontainer/go-services/ready.txt @@ -12,7 +12,6 @@ Works here: cd telemetry-receiver && go build ./... cd tunnel && go build ./... make build-go - make module-validate Ports: none Tests and lint run in CI (CLAUDE.md rule 8). diff --git a/.devcontainer/operator/ready.txt b/.devcontainer/operator/ready.txt index f7743ca08..59e9adb3a 100644 --- a/.devcontainer/operator/ready.txt +++ b/.devcontainer/operator/ready.txt @@ -7,7 +7,6 @@ Works here: make build-go make generate make manifests - make module-validate Ports: none Tests and lint run in CI (CLAUDE.md rule 8). diff --git a/.github/workflows/devcontainers.yaml b/.github/workflows/devcontainers.yaml index 49660b45b..2d9a26a0a 100644 --- a/.github/workflows/devcontainers.yaml +++ b/.github/workflows/devcontainers.yaml @@ -17,6 +17,9 @@ on: - 'hack/need.sh' - 'Makefile' - 'go.work' + - '**/go.mod' + - 'modules' + - 'website' - 'web/package*.json' - '.github/workflows/devcontainers.yaml' push: @@ -29,6 +32,9 @@ on: - 'hack/need.sh' - 'Makefile' - 'go.work' + - '**/go.mod' + - 'modules' + - 'website' - 'web/package*.json' - '.github/workflows/devcontainers.yaml' @@ -42,6 +48,8 @@ concurrency: jobs: drift: name: drift + permissions: + contents: read runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -60,6 +68,8 @@ jobs: build: name: build (${{ matrix.arch }}) needs: drift + permissions: + contents: read runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} timeout-minutes: 60 strategy: diff --git a/docs/dependencies.md b/docs/dependencies.md index 13b92b6c5..44b282c40 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. <!-- doc-versions: dependency --> - **`sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.24`** — 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/versions.toml` (`golangci_lint`) and installed with `go install` by `.devcontainer/setup.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.12.2** — pinned in `.devcontainer/versions.toml` (`golangci_lint`) and installed with `go install` by `.devcontainer/setup.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). **Invoked by CI** — `ci.yaml` runs `golangci-lint-action` for the Go module matrix; `make lint-go` runs the same linter locally or in a dev container. The web job runs `npm run lint`, so ESLint is CI-gated too. - **`oras` CLI ≥ 1.2.0** (pinned `1.2.0` in `.devcontainer/versions.toml`; `oras-project/setup-oras@v2.0.1` action in `release.yaml`) — `modules/build.sh` pushes each `modules/<name>/` 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 (pinned in `.devcontainer/versions.toml`) / `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. <!-- doc-versions: dependency --> diff --git a/hack/gen-devcontainers.py b/hack/gen-devcontainers.py index dd2051716..02f2bd2a9 100755 --- a/hack/gen-devcontainers.py +++ b/hack/gen-devcontainers.py @@ -97,7 +97,7 @@ ("make web-dev", frozenset({"node"}), frozenset({"web"})), ("make generate", frozenset({"go"}), frozenset({"operator"})), ("make manifests", frozenset({"go"}), frozenset({"operator"})), - ("make module-validate", frozenset({"go"}), frozenset()), + ("make module-validate", frozenset({"go"}), frozenset({"modules"})), ("make modules-push", frozenset({"oras"}), frozenset({"modules"})), ("make dev-load", frozenset({"docker", "kind"}), frozenset({"deploy/kind"})), ("make e2e-up", frozenset({"docker", "kind", "kubectl", "helm"}), frozenset()), diff --git a/specs/026-modular-dev-containers/inventory.md b/specs/026-modular-dev-containers/inventory.md index 17e889ac1..5e03bebd5 100644 --- a/specs/026-modular-dev-containers/inventory.md +++ b/specs/026-modular-dev-containers/inventory.md @@ -216,7 +216,7 @@ Also: `dev-up` and `dev-load` call `kind`; `up.sh` and `e2e.sh` require kind, ku | uv | 88-92 | `curl` (astral installer), `sh` | | specify | 94-99 | `uv tool install` from GitHub git ref | | AI CLIs | 101-115 | `npm install -g` (sudo fallback) | -| summary | 117-135 | `go`, `node`, `docker`, `kubectl`, `helm`, `kind`, `oras`, `golangci-lint`, `uv`, `specify`, AI CLIs (all `|| true`) | +| summary | 117-135 | `go`, `node`, `docker`, `kubectl`, `helm`, `kind`, `oras`, `golangci-lint`, `uv`, `specify`, AI CLIs (all `\|\| true`) | ## 5. Services and pinned component images From c84cfc29eaebfd51fba5490057578661ec3c6e02 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 28 Oct 2026 00:18:44 +0000 Subject: [PATCH 18/19] chore: bump modules to Go 1.27.3 dev container copy (spec 026) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- modules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules b/modules index 1c2503c35..e5774ae14 160000 --- a/modules +++ b/modules @@ -1 +1 @@ -Subproject commit 1c2503c3516859419efa78d47fc8358ecf55d149 +Subproject commit e5774ae1454b3a6504dba6a4d5fb53bdfed8789d From 7522233ea2b00a250480a94a8162f1435048afb6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 28 Oct 2026 00:29:43 +0000 Subject: [PATCH 19/19] ci: cap dev container builds at 30 minutes (spec 026) The slowest profile build (full, amd64) took 10.7 minutes, so the repo's 30-minute default leaves room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com> --- .github/workflows/devcontainers.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/devcontainers.yaml b/.github/workflows/devcontainers.yaml index c7775fb1d..71616149c 100644 --- a/.github/workflows/devcontainers.yaml +++ b/.github/workflows/devcontainers.yaml @@ -143,7 +143,7 @@ jobs: permissions: contents: read runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} - timeout-minutes: 60 + timeout-minutes: 30 strategy: fail-fast: false matrix: ${{ fromJSON(needs.profiles.outputs.matrix) }}