Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .claude/agents/crd-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,17 @@ make generate # regenerates operator/api/v1alpha1/zz_generated.deepcopy.go
make manifests # regenerates operator/config/crd/*.yaml, operator/config/rbac/*.yaml
```

**Critical:** The chart also syncs these. After `make manifests`, verify both locations are updated:
**Critical:** The chart also syncs these. After `make manifests`, verify all three locations are updated:
- `operator/config/crd/` (YAML source)
- `charts/gameplane/crds/` (chart copy for pre-upgrade hook)
- `charts/gameplane/charts/gameplane-crds/crds/` (native Helm CRDs, gated by `operator.enabled`)
- `charts/gameplane/crd-manifests/` (chart copy for pre-upgrade hook)

Do NOT manually edit any generated file. Never touch:
- `operator/api/v1alpha1/zz_generated.deepcopy.go`
- `operator/config/crd/*.yaml`
- `operator/config/rbac/*.yaml`
- `charts/gameplane/crds/*.yaml`
- `charts/gameplane/charts/gameplane-crds/crds/*.yaml`
- `charts/gameplane/crd-manifests/*.yaml`

### 3. Update the Reconciler

Expand Down Expand Up @@ -96,7 +98,8 @@ git add operator/api/v1alpha1/<kind>_types.go \
operator/api/v1alpha1/zz_generated.deepcopy.go \
operator/config/crd/*.yaml \
operator/config/rbac/*.yaml \
charts/gameplane/crds/*.yaml \
charts/gameplane/charts/gameplane-crds/crds/*.yaml \
charts/gameplane/crd-manifests/*.yaml \
operator/internal/controller/<kind>_controller.go \
operator/internal/controller/<kind>_envtest_test.go \
web/src/types.ts [web/src/routes/<changed-files>.tsx] # Replace with specific route files actually modified (e.g., web/src/routes/Dashboard.tsx web/src/routes/Config.tsx)
Expand Down
3 changes: 2 additions & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ reviews:
- operator/api/v1alpha1/zz_generated.deepcopy.go (regenerated)
- operator/config/crd/*.yaml (regenerated)
- operator/config/rbac/*.yaml (regenerated)
- charts/gameplane/crds/*.yaml (regenerated and synced)
- charts/gameplane/charts/gameplane-crds/crds/*.yaml (regenerated and synced)
- charts/gameplane/crd-manifests/*.yaml (regenerated hook copy)

If a CRD type changed but these regenerated files are absent, the PR is incomplete.
Run: make generate && make manifests
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ jobs:
- 'Makefile'
- 'hack/sync-chart-crds.sh'
- 'operator/config/**'
- 'charts/gameplane/crds/**'
- 'charts/gameplane/charts/gameplane-crds/**'
- 'charts/gameplane/crd-manifests/**'
- 'hack/check-codegen-drift_test.sh'
ci:
Expand Down Expand Up @@ -1267,14 +1267,16 @@ jobs:
trap 'cp /tmp/values.yaml.orig charts/gameplane/values.yaml' EXIT
cp charts/gameplane/values.yaml /tmp/values.yaml.orig
cp charts/gameplane/testdata/upgrade-from-v0.2.0-beta.8-values.yaml charts/gameplane/values.yaml
helm template gameplane charts/gameplane > /tmp/rendered-reuse-values.yaml
helm template gameplane charts/gameplane --include-crds > /tmp/rendered-reuse-values.yaml
python3 - <<'PY'
import yaml
docs = [d for d in yaml.safe_load_all(open('/tmp/rendered-reuse-values.yaml')) if d]
resources = {(d['kind'], d['metadata']['name']) for d in docs}
required = {('Deployment', 'gameplane-api'), ('Deployment', 'gameplane-web'),
('PersistentVolumeClaim', 'gameplane-api-data')}
assert required <= resources, f"reuse-values removed existing resources: {required - resources}"
crds = {name for kind, name in resources if kind == 'CustomResourceDefinition'}
assert len(crds) == 9, f"reuse-values disabled the bundled CRDs: {crds}"
assert ('Deployment', 'gameplane-gateway') not in resources
assert ('NetworkPolicy', 'allow-api-to-agent') in resources
assert ('NetworkPolicy', 'allow-api-to-capture') not in resources, "legacy values without capture enabled its ingress"
Expand Down Expand Up @@ -1310,7 +1312,7 @@ jobs:
# The CRD-apply hook serves CRDs from crd-manifests/ (crds/ isn't
# readable by Helm .Files). make manifests keeps both in sync; guard
# against a drift that would apply stale CRDs on upgrade.
run: diff -r charts/gameplane/crds charts/gameplane/crd-manifests
run: diff -r charts/gameplane/charts/gameplane-crds/crds charts/gameplane/crd-manifests
- name: chart CRDs carry the current bundle-hash stamp (F-218)
# crd-apply-hook.yaml fires on pre-install only when the live CRDs'
# gameplane.local/crd-bundle-sha256 differs from the chart's. A stale
Expand All @@ -1320,7 +1322,7 @@ jobs:
# difference.
run: |
hack/sync-chart-crds.sh
git diff --exit-code -- charts/gameplane/crds charts/gameplane/crd-manifests
git diff --exit-code -- charts/gameplane/charts/gameplane-crds/crds charts/gameplane/crd-manifests
- name: chart cosign key matches signing/cosign.pub
# values.yaml embeds a copy of the signing public key so the operator
# can verify official module bundles. Nothing regenerates it, so a key
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ reaches `1.0.0`. Pre-1.0 minor versions may contain breaking changes.

## [Unreleased]

### Fixed

- **Standalone Helm CRD isolation:** the central panel profile excludes bundled
native CRDs and their apply hook/RBAC without requiring `--skip-crds`.
Default operator installations keep Helm's native CRD lifecycle; existing
CRDs and their custom resources are retained when a profile omits them.

### Upgrade Notes

- **Standalone panel/API:** run the central panel with Docker Compose or the
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ After cloning: `git submodule update --init` (`modules/` required for `make dev-

- **Dev:** `make dev-up` (Kind + OCI registry :5001 + Helm), `make web-dev` (Vite, proxies in-cluster API), `make dev-load` (load built images; run `make images` first), `make dev-install` (Helm upgrade), `make dev-down`.
- **Build:** `make build` (all Go + web), `make build-go` (15 modules in GO_MODULES, all but test/e2e), `make build-web` (`npm ci && npm run build`), `make images`.
- **Codegen:** `make generate` (deepcopy), `make manifests` (CRD/RBAC YAML, synced to `charts/gameplane/crds/`), `make modules-push`, `make tidy`.
- **Codegen:** `make generate` (deepcopy), `make manifests` (CRD/RBAC YAML, synced to `charts/gameplane/charts/gameplane-crds/crds/` and `charts/gameplane/crd-manifests/`), `make modules-push`, `make tidy`.
- **Tests (CI only — never run locally, see Rule 8):** `make test`, `make test-integration` (envtest operator+api), `make test-e2e` (~10–20 min), `make test-e2e-bucket BUCKET=(operator|api-auth|api-roles|api-rbac|api-agent|api-mods|ratelimit|bot-fast|bot-heavy|multicluster|upgrade)`.
- **E2E conventions:** register new tests in `test/e2e/buckets.sh`; `t.Parallel()` + unique names; guard shared resources (`ociPushMu` for module pushes, `ensureResticRepo(t)` for shared backup repos). Rate limits per cluster: IP burst 10 (5/min), user burst 6 (3/min) — cap an API bucket at ~7 admin logins.
- **Lint:** `make lint` = gofmt, go vet, golangci-lint, ESLint, `check-specs`, `check-doc-versions`, `check-links`.
Expand All @@ -85,7 +85,7 @@ After cloning: `git submodule update --init` (`modules/` required for `make dev-
4. **Fix, don't silence:** no `//nolint`, `eslint-disable`, or loosened configs. Allowed: `_test.go` exempt from `errcheck`/`gosec`/`unparam`; `operator/internal/controller/` exempt from revive `exported:`.
5. **TypeScript:** strict; no unjustified `any` (explain in a comment if unavoidable); every promise `await`ed or `void`-prefixed.
6. **Go errors:** wrap with `%w`.
7. **CRD sync:** after editing `operator/api/v1alpha1/*_types.go` run `make generate && make manifests`; commit deepcopy, `operator/config/{crd,rbac}/*.yaml`, `charts/gameplane/crds/*.yaml`/`crd-manifests/` in the same changeset.
7. **CRD sync:** after editing `operator/api/v1alpha1/*_types.go` run `make generate && make manifests`; commit deepcopy, `operator/config/{crd,rbac}/*.yaml`, `charts/gameplane/charts/gameplane-crds/crds/*.yaml`, and `charts/gameplane/crd-manifests/*.yaml` in the same changeset.
8. **Verification:** locally only compile checks (`go build ./...`, `npx tsc --noEmit`); never test/lint suites (`make test|lint|cover`, `go test`, `npm test`, envtest, E2E). Push a feature branch; CI is the sole verification authority.
9. **Kubernetes primitives first** (StatefulSet, Service, PVC, Job, ConfigMap, Secret, CRDs) before custom abstractions.
10. **Operator authority:** business logic lives in reconcilers (`operator/internal/controller/`); the API is a UX gateway and never bypasses reconciliation.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ manifests: ## Regenerate CRDs + RBAC manifests (and sync chart CRD copies)
paths=./... \
output:crd:artifacts:config=config/crd \
output:rbac:artifacts:config=config/rbac
# Copy the CRDs into the chart's crds/ and crd-manifests/ (the same CRDs
# Copy the CRDs into the conditional CRD subchart and crd-manifests/ (the same CRDs
# where Helm's .Files can read them -- it can't read the special crds/
# dir -- so the crds.autoApply hook can apply them), stamping each copy
# with a bundle hash the hook compares against the live CRDs to tell
Expand Down
6 changes: 6 additions & 0 deletions charts/gameplane/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ keywords:
- control-panel
maintainers:
- name: Gameplane contributors
dependencies:
# Vendored locally so installs never fetch another chart. Helm resolves this
# condition before installing native CRDs, including on a fresh cluster.
- name: gameplane-crds
version: 0.3.0
condition: operator.enabled
5 changes: 5 additions & 0 deletions charts/gameplane/charts/gameplane-crds/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: v2
name: gameplane-crds
description: Native CRDs bundled with the Gameplane operator.
type: application
version: 0.3.0
4 changes: 2 additions & 2 deletions charts/gameplane/examples/standalone-panel-values.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Fresh central panel install; use helm install/upgrade --skip-crds.
# Helm processes crds/ before values/templates, so values alone cannot skip CRDs.
# Fresh central panel install. operator.enabled=false excludes the bundled
# native CRD subchart; api.standalone=true also skips the CRD apply hook/RBAC.
operator:
enabled: false
api:
Expand Down
4 changes: 2 additions & 2 deletions charts/gameplane/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ Back up the database and matching encryption key separately. SQLite uses the
API data PVC; generated keys use the separate API key PVC mounted at /keys.
If using api.panelKey.existingSecret, protect and back up that Secret instead.
Restrict access to key backups; do not include them in database backup jobs.
Always pass --skip-crds when installing this profile; Helm's native crds/
installation happens before chart values can disable resources.
operator.enabled=false excludes bundled native CRDs. Standalone mode also
skips the CRD apply hook and its RBAC; no --skip-crds argument is required.
{{- end }}
{{- $receiverDeployed := and (eq (include "gameplane.apiEnabled" .) "true") $r.enabled (not .Values.api.telemetry.endpoint) }}
{{- $persistence := $r.persistence | default dict }}
Expand Down
10 changes: 7 additions & 3 deletions charts/gameplane/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ image:
pullPolicy: IfNotPresent
pullSecrets: []

# CRD lifecycle. Helm installs the files under crds/ only on the FIRST install
# Native CRDs live in the bundled gameplane-crds subchart, enabled by
# operator.enabled. Dependency gating happens before Helm installs CRDs, so
# operator.enabled=false omits them from a standalone central panel install.
# CRD lifecycle. Helm installs the files under the subchart's crds/ on first install
# and NEVER updates them on `helm upgrade` (a documented Helm limitation). This
# hook closes that gap: a pre-UPGRADE Job runs `kubectl apply --server-side`
# over the CRDs, so a plain `helm upgrade` always brings the CRD schema up to
Expand All @@ -47,7 +50,7 @@ crds:
pullPolicy: IfNotPresent

operator:
# Disable all operator resources for a standalone central panel.
# Disable the operator and bundled native CRDs for a standalone central panel.
enabled: true
replicas: 1
logLevel: info # zap log level: debug|info|error (warn not supported by zap's --zap-log-level)
Expand Down Expand Up @@ -126,7 +129,8 @@ api:
# integrations on remote operator+gateway installs. Agent mTLS is preserved.
enabled: true
# Central panel only: SQL-backed settings and credentials, no local cluster.
# Requires operator.enabled=false and helm install --skip-crds.
# Requires operator.enabled=false, which excludes bundled native CRDs.
# Standalone mode also skips the CRD apply hook and its RBAC.
standalone: false
replicas: 1 # Keep at 1. SQLite is single-writer on a RWO volume; with db.driver=postgres the user-management lock and audit hash chain are still per-process
logLevel: info # API log verbosity (debug|info|warn|error), passed as GAMEPLANE_LOG_LEVEL
Expand Down
12 changes: 9 additions & 3 deletions charts/tests/check_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@
CHART = ROOT / "charts" / "gameplane"


def render(values=None, failure=None, upgrade=False, namespace="gameplane-system", release="gameplane"):
def render(values=None, failure=None, upgrade=False, namespace="gameplane-system", release="gameplane", include_crds=False, chart=CHART):
"""Return rendered objects, or assert the expected Helm validation failure.

Accept a source chart or packaged archive; native CRDs are opt-in to match
Helm template's default behavior.
"""
with tempfile.TemporaryDirectory() as directory:
values_path = Path(directory) / "values.yaml"
values_path.write_text(yaml.safe_dump(values or {}), encoding="utf-8")
result = subprocess.run(
["helm", "template", release, str(CHART), "--namespace", namespace, "--values", str(values_path)]
+ (["--is-upgrade"] if upgrade else []),
["helm", "template", release, str(chart), "--namespace", namespace, "--values", str(values_path)]
+ (["--is-upgrade"] if upgrade else [])
+ (["--include-crds"] if include_crds else []),
capture_output=True, text=True, check=False,
)
if failure:
Expand Down
61 changes: 55 additions & 6 deletions charts/tests/check_standalone.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,51 @@
"""CI-only checks for combined, standalone central, and remote deployment roles."""
from pathlib import Path
import subprocess
import tempfile

import yaml

from check_gateway import one, render
from check_gateway import CHART, one, render


EXPECTED_CRDS = {
"gameservers.gameplane.local", "gametemplates.gameplane.local",
"backups.gameplane.local", "backupschedules.gameplane.local",
"restores.gameplane.local", "modules.gameplane.local",
"modulesources.gameplane.local", "clusters.gameplane.local",
"networkcaptures.gameplane.local",
}


def assert_crds(objects, expected):
"""Require exactly the expected CRD names, with no duplicate definitions."""
names = [obj["metadata"]["name"] for obj in objects if obj["kind"] == "CustomResourceDefinition"]
assert set(names) == expected and len(names) == len(expected), names


def check_packaged_crds(example):
"""Verify vendored chart archives preserve CRD gating for each install role."""
# Package only vendored files. Dependency downloads/builds would hide a
# missing bundled CRD chart and require network access for installation.
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run(["helm", "package", str(CHART), "--destination", directory],
capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
packages = list(Path(directory).glob("gameplane-*.tgz"))
assert len(packages) == 1, packages
for upgrade in (False, True):
for values in ({}, {"api": {"enabled": False}}):
assert_crds(render(values, chart=packages[0], include_crds=True, upgrade=upgrade), EXPECTED_CRDS)
for values in ({"operator": {"enabled": False}, "api": {"standalone": True}}, example):
objects = render(values, chart=packages[0], include_crds=True, upgrade=upgrade)
assert_crds(objects, set())
assert not any("crd-apply" in obj["metadata"]["name"] for obj in objects), objects


def main():
defaults = render()
"""Check deployment roles, standalone key custody, and source/archive CRDs."""
defaults = render(include_crds=True)
assert_crds(defaults, EXPECTED_CRDS)
one(defaults, "Deployment", "gameplane-operator")
one(defaults, "Deployment", "gameplane-api")
one(defaults, "ModuleSource", "default")
Expand All @@ -22,7 +60,8 @@ def main():
"mcpServer": {"enabled": True}, "capture": {"enabled": True},
}
for upgrade in (False, True):
objects = render(values, upgrade=upgrade)
objects = render(values, upgrade=upgrade, include_crds=True)
assert_crds(objects, set())
for obj in objects:
assert obj["kind"] not in {"ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding", "Namespace", "ModuleSource", "NetworkPolicy", "PodMonitor", "PrometheusRule", "Job"}, obj
assert "operator" not in obj["metadata"]["name"], obj
Expand Down Expand Up @@ -87,19 +126,29 @@ def main():
args = one(objects, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["containers"][0]["args"]
assert "--remote-allowed-cidrs=10.20.0.0/16,fd12:3456::/48" in args

remote = render({"api": {"enabled": False}})
remote = render({"api": {"enabled": False}}, include_crds=True)
assert_crds(remote, EXPECTED_CRDS)
one(remote, "Deployment", "gameplane-operator")
one(remote, "Secret", "gameplane-agent-ca")
assert not any(obj["metadata"]["name"] in {"gameplane-api", "gameplane-web"} for obj in remote)
disabled = render({"operator": {"enabled": False}})
disabled = render({"operator": {"enabled": False}}, include_crds=True)
assert_crds(disabled, set())
one(disabled, "Job", "gameplane-crd-apply")
assert not any("operator" in obj["metadata"]["name"] for obj in disabled)
one(disabled, "Deployment", "gameplane-api")

render({"api": {"standalone": True}}, failure="api.standalone requires operator.enabled=false")
render({"operator": {"enabled": False}, "api": {"enabled": False, "standalone": True}}, failure="api.standalone requires api.enabled=true")
render({"operator": {"enabled": False}, "api": {"standalone": True, "replicas": 2}}, failure="api.standalone requires api.replicas=1")
example = yaml.safe_load((Path(__file__).parents[1] / "gameplane/examples/standalone-panel-values.yaml").read_text())
one(render(example), "Deployment", "gameplane-api")
for upgrade in (False, True):
objects = render(example, include_crds=True, upgrade=upgrade)
assert_crds(objects, set())
one(objects, "Deployment", "gameplane-api")
assert not any("crd-apply" in obj["metadata"]["name"] for obj in objects), objects
assert_crds(render(include_crds=True, upgrade=upgrade), EXPECTED_CRDS)
assert_crds(render({"api": {"enabled": False}}, include_crds=True, upgrade=upgrade), EXPECTED_CRDS)
check_packaged_crds(example)


if __name__ == "__main__":
Expand Down
Loading
Loading