From 6320ba91f735647d8a656f9faef553520085cf51 Mon Sep 17 00:00:00 2001 From: mannie-exe Date: Wed, 30 Sep 2026 19:06:50 -0700 Subject: [PATCH 1/4] feat: prepare chart 1.5.0 for cfgate alpha.6 - chart: target cfgate alpha.6 - chart: synchronize four CRD schemas - chart: align manager permissions - chart: disable Service environment links - chart: expose bounded controller settings - chart: preserve reused release values - chart: use the actual Pod namespace - schema: validate controller settings - docs: document alpha.5 to alpha.6 upgrades - docs: link historical migration notes - docs: remove stale candidate provenance - docs: clarify generated release notes - release: require published dependencies - release: verify operator provenance - release: recheck immutable sources - release: sign the pushed chart digest - release: record dependency digests - tests: cover publication guard failures - ci: update Helm and workflow actions - ci: render external resource settings - package: exclude local release output --- .github/scripts/release_guard.py | 101 +++++++++++++++++ .github/scripts/test_release_guard.py | 104 ++++++++++++++++++ .github/workflows/ci.yml | 18 ++- .github/workflows/release.yml | 99 +++++++++++------ .helmignore | 5 +- CONTRIBUTING.md | 41 ++++++- Chart.yaml | 4 +- README.md | 68 ++++++++++-- templates/NOTES.txt | 11 ++ templates/clusterrole.yaml | 9 ++ .../crds/cloudflareaccessapplication.yaml | 23 +++- templates/crds/cloudflareaccesspolicy.yaml | 29 ++++- templates/crds/cloudflaredns.yaml | 55 +++++---- templates/crds/cloudflaretunnel.yaml | 77 ++++++++++++- templates/deployment.yaml | 14 +++ values.schema.json | 56 +++++++++- values.yaml | 11 ++ 17 files changed, 628 insertions(+), 97 deletions(-) create mode 100644 .github/scripts/release_guard.py create mode 100644 .github/scripts/test_release_guard.py diff --git a/.github/scripts/release_guard.py b/.github/scripts/release_guard.py new file mode 100644 index 0000000..093fe35 --- /dev/null +++ b/.github/scripts/release_guard.py @@ -0,0 +1,101 @@ +"""Verify immutable chart source and published, attested operator dependencies.""" + +import base64 +import json +import os +from pathlib import Path +import re +import subprocess + + +VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?" + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def run(*args): + return subprocess.check_output(args, text=True).strip() + + +def api(path): + return json.loads(run("gh", "api", path)) + + +def scalar(chart, key): + values = re.findall(r"^" + key + r":\s*['\"]?([^'\"\s]+)['\"]?\s*$", chart, re.M) + require(len(values) == 1, f"expected one normalized {key}") + require(re.fullmatch(VERSION, values[0]), f"invalid {key}") + return values[0] + + +def published_release(repository, tag): + release = api(f"repos/{repository}/releases/tags/{tag}") + require(release.get("tag_name") == tag and release.get("draft") is False + and release.get("published_at"), f"{repository} {tag} is not published") + return release + + +def tag_commit(repository, tag): + obj = api(f"repos/{repository}/git/ref/tags/{tag}")["object"] + for _ in range(5): + if obj["type"] == "commit": + require(re.fullmatch(r"[a-f0-9]{40}", obj["sha"]), "invalid source SHA") + return obj["sha"] + require(obj["type"] == "tag", "release tag does not resolve to a commit") + obj = api(f"repos/{repository}/git/tags/{obj['sha']}")["object"] + raise ValueError("release tag nesting limit exceeded") + + +def image_digest(image): + digest = run("oras", "resolve", image) + require(re.fullmatch(r"sha256:[a-f0-9]{64}", digest), "invalid image digest") + return digest + + +def verify(tag, sha): + require(re.fullmatch("v" + VERSION, tag), "invalid chart release tag") + require(re.fullmatch(r"[a-f0-9]{40}", sha), "invalid chart source SHA") + require(run("git", "rev-parse", "HEAD") == sha, "checkout does not match event SHA") + require(run("git", "rev-parse", f"refs/tags/{tag}^{{commit}}") == sha, + "chart tag does not match event SHA") + remote = run("git", "ls-remote", "--tags", "origin", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}") + refs = dict(line.split()[::-1] for line in remote.splitlines()) + require(refs.get(f"refs/tags/{tag}^{{}}", refs.get(f"refs/tags/{tag}")) == sha, + "remote chart tag moved or disappeared") + metadata = run("helm", "show", "chart", ".") + version, app = scalar(metadata, "version"), scalar(metadata, "appVersion") + require(tag == "v" + version, "Chart.yaml version does not match tag") + published_release("cfgate/cfgate", "v" + app) + operator_sha = tag_commit("cfgate/cfgate", "v" + app) + image = "ghcr.io/cfgate/cfgate:" + app + digest = image_digest(image) + # These checks bind the available OCI digest to the released source, rather + # than trusting the mutable image tag or the release's target_commitish. + run("gh", "attestation", "verify", "oci://ghcr.io/cfgate/cfgate@" + digest, + "--bundle-from-oci", + "--repo", "cfgate/cfgate", "--signer-workflow", "cfgate/cfgate/.github/workflows/release.yml", + "--source-ref", "refs/tags/v" + app, "--source-digest", operator_sha) + source = api(f"repos/cfgate/cfgate/contents/internal/cloudflared/deployment.go?ref={operator_sha}") + require(source.get("encoding") == "base64", "unexpected source encoding") + code = base64.b64decode(source["content"]).decode() + matches = re.findall(r'DefaultImage\s*=\s*"(ghcr\.io/inherent-design/cloudflared:([0-9A-Za-z.-]+))"', code) + require(len(matches) == 1, "released operator must identify one versioned connector") + fork_image, fork_version = matches[0] + require(re.fullmatch(VERSION, fork_version), "invalid connector version") + published_release("inherent-design/cloudflared", "v" + fork_version) + fork_digest = image_digest(fork_image) + return {"version": version, "source": sha, "app_version": app, + "operator_source": operator_sha, "operator_image": image, "operator_digest": digest, + "connector_image": fork_image, "connector_digest": fork_digest} + + +if __name__ == "__main__": + evidence = verify(os.environ["RELEASE_TAG"], os.environ["RELEASE_SHA"]) + Path("dist").mkdir(exist_ok=True) + Path("dist/release-dependencies.json").write_text(json.dumps(evidence, indent=2) + "\n") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write("clean=" + evidence["version"] + "\n") + print(json.dumps(evidence, indent=2)) diff --git a/.github/scripts/test_release_guard.py b/.github/scripts/test_release_guard.py new file mode 100644 index 0000000..3cda044 --- /dev/null +++ b/.github/scripts/test_release_guard.py @@ -0,0 +1,104 @@ +import base64 +import copy +import unittest +from unittest.mock import patch + +import release_guard as guard + + +class ReleaseGuardTests(unittest.TestCase): + def setUp(self): + self.sha = "a" * 40 + self.operator_sha = "b" * 40 + self.digest = "sha256:" + "c" * 64 + self.responses = { + ("git", "rev-parse", "HEAD"): self.sha, + ("git", "rev-parse", "refs/tags/v1.5.0^{commit}"): self.sha, + ("git", "ls-remote", "--tags", "origin", "refs/tags/v1.5.0", "refs/tags/v1.5.0^{}"): self.sha + "\trefs/tags/v1.5.0", + ("helm", "show", "chart", "."): 'version: 1.5.0\nappVersion: "0.2.0-alpha.6"', + ("oras", "resolve", "ghcr.io/cfgate/cfgate:0.2.0-alpha.6"): self.digest, + ("oras", "resolve", "ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1"): self.digest, + } + self.release_path = "repos/cfgate/cfgate/releases/tags/v0.2.0-alpha.6" + self.source_path = f"repos/cfgate/cfgate/contents/internal/cloudflared/deployment.go?ref={self.operator_sha}" + self.api_responses = { + self.release_path: {"tag_name": "v0.2.0-alpha.6", "draft": False, "published_at": "2026-09-29T00:00:00Z", "prerelease": True}, + "repos/cfgate/cfgate/git/ref/tags/v0.2.0-alpha.6": {"object": {"type": "commit", "sha": self.operator_sha}}, + self.source_path: {"encoding": "base64", "content": base64.b64encode(b'DefaultImage = "ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1"').decode()}, + "repos/inherent-design/cloudflared/releases/tags/v2026.9.3-h2c.1": {"tag_name": "v2026.9.3-h2c.1", "draft": False, "published_at": "2026-09-29T00:00:00Z"}, + } + self.attestation_error = False + self.calls = [] + + def command(self, *args): + self.calls.append(args) + if args[:3] == ("gh", "attestation", "verify"): + if self.attestation_error: + raise RuntimeError("attestation verification failed") + self.assertIn(self.operator_sha, args) + self.assertIn("--bundle-from-oci", args) + self.assertIn("refs/tags/v0.2.0-alpha.6", args) + self.assertIn("cfgate/cfgate/.github/workflows/release.yml", args) + return "verified" + return self.responses[args] + + def verify(self, tag="v1.5.0", sha=None): + with patch.object(guard, "run", side_effect=self.command), patch.object(guard, "api", side_effect=lambda path: copy.deepcopy(self.api_responses[path])): + return guard.verify(tag, sha or self.sha) + + def test_valid_prerelease_dependency(self): + self.assertEqual(self.verify()["operator_source"], self.operator_sha) + + def test_annotated_tag(self): + key = ("git", "ls-remote", "--tags", "origin", "refs/tags/v1.5.0", "refs/tags/v1.5.0^{}") + self.responses[key] = "d" * 40 + "\trefs/tags/v1.5.0\n" + self.sha + "\trefs/tags/v1.5.0^{}" + self.verify() + + def test_invalid_tags_reject_before_commands(self): + for tag in ["v1.5.0\nclean=oops", "v1.5.0;touch /tmp/x", "--help", "1.5.0", "v01.5.0", "v1.5.0$(id)"]: + with self.subTest(tag=tag), self.assertRaises(ValueError): + self.verify(tag) + self.assertEqual(self.calls, []) + + def test_checkout_and_tag_mismatch(self): + for key in [("git", "rev-parse", "HEAD"), ("git", "rev-parse", "refs/tags/v1.5.0^{commit}"), ("git", "ls-remote", "--tags", "origin", "refs/tags/v1.5.0", "refs/tags/v1.5.0^{}")]: + original = self.responses[key] + self.responses[key] = "d" * 40 + ("\trefs/tags/v1.5.0" if "ls-remote" in key else "") + with self.subTest(key=key), self.assertRaises(ValueError): + self.verify() + self.responses[key] = original + + def test_chart_versions_reject(self): + for text in ['version: 1.4.0\nappVersion: 0.2.0-alpha.6', 'version: 1.5.0\nappVersion: latest', 'version: 1.5.0\nappVersion: "$(id)"']: + self.responses[("helm", "show", "chart", ".")] = text + with self.subTest(text=text), self.assertRaises(ValueError): + self.verify() + + def test_unpublished_dependencies_reject(self): + for path in [self.release_path, "repos/inherent-design/cloudflared/releases/tags/v2026.9.3-h2c.1"]: + original = copy.deepcopy(self.api_responses[path]) + for key, value in [("draft", True), ("published_at", None), ("tag_name", "v9.9.9")]: + self.api_responses[path] = {**original, key: value} + with self.subTest(path=path, key=key), self.assertRaises(ValueError): + self.verify() + self.api_responses[path] = original + + def test_missing_image_or_provenance_reject(self): + for key in [key for key in self.responses if key[:2] == ("oras", "resolve")]: + original = self.responses[key] + self.responses[key] = "not-a-digest" + with self.subTest(key=key), self.assertRaises(ValueError): + self.verify() + self.responses[key] = original + self.attestation_error = True + with self.assertRaises(RuntimeError): + self.verify() + + def test_source_connector_required(self): + self.api_responses[self.source_path]["content"] = base64.b64encode(b'DefaultImage = "cloudflare/cloudflared:latest"').decode() + with self.assertRaises(ValueError): + self.verify() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c1f5b9..322a55e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,11 @@ on: - "Chart.yaml" - "values.yaml" - "values.schema.json" + - ".helmignore" - "templates/**" - "ci/**" - ".github/workflows/**" + - ".github/scripts/**" - ".github/actionlint.yaml" - "cliff.toml" - "artifacthub-repo.yml" @@ -19,9 +21,11 @@ on: - "Chart.yaml" - "values.yaml" - "values.schema.json" + - ".helmignore" - "templates/**" - "ci/**" - ".github/workflows/**" + - ".github/scripts/**" - ".github/actionlint.yaml" - "cliff.toml" - "artifacthub-repo.yml" @@ -39,7 +43,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 - uses: rhysd/actionlint@v1.7.12 @@ -47,11 +51,11 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 - - uses: azure/setup-helm@v5.0.0 + - uses: azure/setup-helm@v5.0.1 with: - version: v4.1.4 + version: v4.3.0 - name: Lint chart run: helm lint . @@ -61,3 +65,9 @@ jobs: - name: Template with CI values run: helm template cfgate . -f ci/default-values.yaml + + - name: Template with external CRDs and RBAC + run: helm template cfgate . --set installCRDs=false --set rbac.create=false + + - name: Test release dependency guards + run: python3 -m unittest discover -s .github/scripts -p "test_*.py" -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0dc706..bca9790 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,13 @@ on: tags: - "v*" +permissions: + contents: read + +concurrency: + group: chart-release + cancel-in-progress: false + env: REGISTRY: ghcr.io CHART_REF: ghcr.io/cfgate/charts/cfgate @@ -19,58 +26,84 @@ jobs: id-token: write steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 + ref: ${{ github.sha }} + + - uses: azure/setup-helm@v5.0.1 + with: + version: v4.3.0 - - id: version - run: echo "clean=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + - uses: oras-project/setup-oras@v2.0.2 - - uses: azure/setup-helm@v5.0.0 + - uses: docker/login-action@v4.6.0 with: - version: v4.1.4 + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Validate chart version matches tag + - name: Verify published operator and connector dependencies + id: version + env: + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_SHA: ${{ github.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - CHART_VERSION="$(awk '/^version:/ {print $2; exit}' Chart.yaml)" - TAG_VERSION="${{ steps.version.outputs.clean }}" - if [ "$CHART_VERSION" != "$TAG_VERSION" ]; then - echo "::error::Chart.yaml version ($CHART_VERSION) must match release tag ($TAG_VERSION)" - exit 1 - fi + python3 .github/scripts/release_guard.py + cp dist/release-dependencies.json "$RUNNER_TEMP/chart-release-dependencies.json" + + - name: Test release dependency guards + run: python3 -m unittest discover -s .github/scripts -p "test_*.py" -v - name: Lint chart - run: helm lint . + run: | + helm lint . + helm template cfgate . > /dev/null + helm template cfgate . -f ci/default-values.yaml > /dev/null + helm template cfgate . --set installCRDs=false --set rbac.create=false > /dev/null - name: Package chart run: helm package . -d dist/ + - name: Recheck source and dependencies immediately before publication + env: + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_SHA: ${{ github.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 .github/scripts/release_guard.py + if ! cmp -s "$RUNNER_TEMP/chart-release-dependencies.json" dist/release-dependencies.json; then + echo "::error::Published dependencies changed during validation" + exit 1 + fi + - name: Push chart to GHCR id: push + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.clean }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin - OUTPUT=$(helm push dist/cfgate-*.tgz oci://ghcr.io/cfgate/charts 2>&1) - echo "$OUTPUT" - DIGEST=$(echo "$OUTPUT" | grep -oP 'sha256:[a-f0-9]+') + set -euo pipefail + printf '%s' "$GH_TOKEN" | helm registry login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + OUTPUT=$(helm push "dist/cfgate-$RELEASE_VERSION.tgz" oci://ghcr.io/cfgate/charts 2>&1) + printf '%s\n' "$OUTPUT" + DIGEST=$(printf '%s\n' "$OUTPUT" | sed -nE 's/^Digest: (sha256:[a-f0-9]{64})$/\1/p') + [[ "$DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] + test "$(oras resolve "$CHART_REF:$RELEASE_VERSION")" = "$DIGEST" echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v4.1.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: sigstore/cosign-installer@v4.1.2 - name: Sign chart - run: cosign sign --yes ${{ env.CHART_REF }}@${{ steps.push.outputs.digest }} - - - uses: oras-project/setup-oras@v2.0.0 + env: + CHART_DIGEST: ${{ steps.push.outputs.digest }} + run: cosign sign --yes "$CHART_REF@$CHART_DIGEST" - name: Tag latest - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | oras login ghcr.io -u ${{ github.actor }} --password-stdin - oras tag ${{ env.CHART_REF }}:${{ steps.version.outputs.clean }} latest + env: + CHART_DIGEST: ${{ steps.push.outputs.digest }} + run: oras tag "$CHART_REF@$CHART_DIGEST" latest - name: Generate release notes id: changelog @@ -81,15 +114,17 @@ jobs: env: GITHUB_REPO: ${{ github.repository }} - - uses: softprops/action-gh-release@v3.0.0 + - uses: softprops/action-gh-release@v3.0.3 with: body: ${{ steps.changelog.outputs.content }} - files: dist/cfgate-*.tgz + files: | + dist/cfgate-*.tgz + dist/release-dependencies.json env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Push Artifact Hub metadata run: | - oras push ${{ env.CHART_REF }}:artifacthub.io \ + oras push "$CHART_REF:artifacthub.io" \ --config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \ artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml diff --git a/.helmignore b/.helmignore index 9bcabc6..0268a1f 100644 --- a/.helmignore +++ b/.helmignore @@ -1,7 +1,7 @@ # Patterns to ignore when building packages. .DS_Store # Common VCS dirs -.git/ +.git .gitignore .bzr/ .bzrignore @@ -21,5 +21,8 @@ .vscode/ # CI/CD ci/ +.github/ +# Local release output must never be repackaged into the chart. +dist/ # Helm .helmignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4ee6c3..be649c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,14 @@ CRDs in `templates/crds/` are templated versions of the generated CRDs. {{- include "cfgate.labels" . | nindent 4 }} ``` -3. Write to `templates/crds/{resource-name}.yaml` +3. Escape literal template examples in generated descriptions before adding the + chart wrapper. Use the Helm raw-string expression in + `templates/crds/cloudflaredns.yaml` as the example; the rendered CRD must + still contain the exact literal `{{ .TunnelDomain }}`. + +4. Write to `templates/crds/{resource-name}.yaml` and compare the rendered `spec` + with the source. Keep all four matching schemas, including status fields used + for credential cleanup, ownership and Access publication receipts. ### Regenerating RBAC @@ -64,7 +71,10 @@ When `config/manager/manager.yaml` changes: 1. Compare container args, ports, probes, security context 2. Update `templates/deployment.yaml` accordingly -3. Update `values.yaml` if new configurable options added +3. Update `values.yaml`, `values.schema.json` and README when configurable options are added +4. Preserve the `POD_NAMESPACE` downward API and `enableServiceLinks: false` +5. Compare default and overridden flags with the matching manager source; do not + infer Kubernetes support from the chart admission floor or client-library version ### Updating NOTES.txt @@ -109,8 +119,7 @@ Update `Chart.yaml`: `CHANGELOG.md` is generated from git history via `git-cliff`. -- regenerate it locally when preparing chart releases or housekeeping updates -- do not hand-edit release entries +The release workflow generates GitHub release notes with `git-cliff --latest --strip header`. Keep upgrade instructions in README and link historical migration guidance to its release tag; do not duplicate those instructions as handwritten changelog entries. Regenerate `CHANGELOG.md` from tagged history when refreshing that tracked snapshot, rather than adding an unreleased version manually. ### CI Integration @@ -119,3 +128,27 @@ Chart changes should pass: - `helm lint` - `helm template` with default values - `helm template` with `ci/default-values.yaml` +- `helm template` with external CRDs/RBAC disabled +- `python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v` + +### Ordered publication + +The chart release workflow checks the immutable tag source and chart version, +then requires the exact `appVersion` to have a published, non-draft cfgate release. +Prerelease operators are permitted. The available operator OCI digest must have +a GitHub attestation from cfgate's release workflow for that source commit and tag. +Verification reads the attestation bundle from OCI, avoiding a cross-repository +attestation API permission requirement for the chart's workflow token. +The connector pin is read from this verified operator source; its fork release +and image must also exist before any chart push. Missing evidence stops release. +This records the fork image's available digest; it does not independently attest +the fork image's source. Source and dependencies are rechecked immediately before +publication. + +The workflow records these source commits and image digests in +`release-dependencies.json`, attached beside the chart package. It signs the +verified pushed chart digest and points `latest` at that digest. Chart publication +must follow the operator release, including any required operator review gate. +Local packaging excludes `dist/` to avoid including earlier release output. +The `.git` exclusion covers both a checkout's Git directory and a worktree's Git +pointer file. Inspect the packaged archive when changing `.helmignore`. diff --git a/Chart.yaml b/Chart.yaml index 1ee6993..07871de 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: cfgate description: Gateway API-native Kubernetes operator for Cloudflare Tunnel, DNS, and Access type: application -version: 1.4.0 -appVersion: "0.2.0-alpha.5" +version: 1.5.0 +appVersion: "0.2.0-alpha.6" kubeVersion: ">= 1.26.0-0" keywords: diff --git a/README.md b/README.md index c16b402..111f5fc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Installs the cfgate controller, a Gateway API-native Kubernetes operator for Cloudflare Tunnel, DNS, and Access management. -This chart currently targets cfgate `0.2.0-alpha.5`. +This chart targets the next cfgate release, `0.2.0-alpha.6`. Chart `1.5.0` must be published after that controller image is available. The current cfgate surface managed by this chart includes separate `CloudflareAccessApplication` and `CloudflareAccessPolicy` CRDs for Access application and policy lifecycle management. @@ -19,16 +19,18 @@ The chart deploys: ## Prerequisites -- Kubernetes 1.26+ +- Kubernetes compatible with the installed Gateway API bundle. The upstream standard bundle used below requires Kubernetes 1.30 or later; this API minimum is not a cfgate-tested support range. - Helm 3.x or 4.x - Gateway API CRDs installed: ```bash -kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.2/standard-install.yaml ``` Gateway API CRDs are a cluster-level prerequisite, not a chart dependency. They may already be installed if you run Istio, Cilium, Envoy Gateway, or another Gateway API implementation. +The chart's historical `kubeVersion: ">= 1.26.0-0"` controls Helm admission for its own templates; it does not certify the complete controller and Gateway API stack on Kubernetes 1.26. Upstream [Gateway API installation requirements](https://kubernetes.io/blog/2026/04/21/gateway-api-v1-5/) and cfgate's release validation are separate checks. The final chart release must record the exact Kubernetes, Gateway API, controller image, and CRD versions actually tested. + ## Install ```bash @@ -43,17 +45,31 @@ helm upgrade cfgate oci://ghcr.io/cfgate/charts/cfgate \ --namespace cfgate-system ``` -### Upgrade from 1.3.1 to 1.4.0 +### Upgrade from 1.4.0 to 1.5.0 + +Chart 1.4.0 → 1.5.0 upgrades cfgate `0.2.0-alpha.5` → `0.2.0-alpha.6`. The chart keeps existing values and template names compatible while adding controller configuration and CRD fields. The controller's stricter ownership and authorization rules require migration for existing resources that relied on implicit adoption or unrestricted cross-namespace references. This is not an unattended image-only upgrade. + +Before rollout: + +1. Inventory existing CR UIDs, connector owner references, remote tunnel IDs, DNS data and TXT ownership records. Stop previous writers for resources being migrated and preserve the installation namespace: its Kubernetes UID now forms part of persistent ownership. +2. Add explicit, narrowly scoped ReferenceGrants for cross-namespace credentials, Gateway/DNS/Access-to-Tunnel references, and HTTPRoute backends. Same-namespace references remain a namespace trust boundary. Keep infrastructure CRs, credential Secrets, ownership claims, connector images, and arguments under administrator control. +3. Verify generated connector resources and Access service-token Secrets have their expected controller owner UID. cfgate refuses to overwrite unowned or foreign objects. Deliberately migrate or recreate those objects after inspecting their current owner. +4. For an existing, verified, unclaimed remote tunnel, temporarily opt in with `cfgate.io/adopt-existing: "true"`. A foreign ownership claim still blocks adoption. Remove the opt-in after successful migration. +5. Migrate legacy DNS ownership deliberately after stopping the previous writer. `spec.ownership.ownerId` and `cleanupPolicy.onlyManaged: false` no longer authorize overwriting or deleting another owner's records. Inspect legacy TXT claims and data markers before any manual transition; never remove a claim while its owner is active. +6. Install the matching four CRD schemas before running the new controller, including when `installCRDs=false`. New status fields retain credential selection, installation ownership, and reconciliation state; old schemas must not prune them. +7. Verify remote configuration, connector readiness, DNS ownership, and application requests before resuming normal traffic. Review the supported HTTPRoute subset: unsupported restrictions are rejected, while invalid attached backends return matching failure responses instead of falling through to broader routes. + +See the [controller authorization and migration guide](https://github.com/cfgate/cfgate/blob/v0.2.0-alpha.6/docs/authorization-and-ownership.md) for exact grants, ownership rules, and coordination limits. Chart 1.5.0 remains downstream of the approved controller release; do not publish it before that image and its matching schemas are available. -Chart 1.4.0 installs cfgate `0.2.0-alpha.5` CRDs with stricter validation. -Before upgrading existing clusters: +Controller Pods disable Service environment injection (`enableServiceLinks: false`), preventing the metrics Service from injecting a conflicting `CFGATE_METRICS_PORT` URL. Existing explicit metrics and health flags remain supported. DNS CRD descriptions preserve the literal `{{ .TunnelDomain }}` variable. CRD schemas and RBAC must be synchronized with the final controller release; they are no longer unchanged from chart 1.4.0. -- Change any `CloudflareAccessApplication.spec.application.type` value other than `self_hosted` to `self_hosted`. -- Split any `CloudflareAccessPolicy` rule entry that sets multiple matcher types into separate rule entries. -- Replace `CloudflareAccessPolicy` `emailList.name` and `ipList.name` references with Cloudflare list IDs. -- Update alerts or automation keyed on old condition names: - - `CloudflareDNS`: use `ZonesResolved`, `RecordsSynced`, and `OwnershipVerified`. - - `CloudflareTunnel`: use `TunnelReady` and `CloudflaredDeployed`. +### Candidate provenance and validation limits + +The four rendered CRD specifications and manager RBAC must match the controller source being released. Repeat the normalized comparisons after code generation or dependency updates; record that source commit and the released controller image digest with release evidence. The bundled connector default is `ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1`. + +Helm lint, local rendering, JSON Schema validation, and normalized CRD/RBAC comparisons verify chart generation. They do not establish a tested Kubernetes range, an actual cluster upgrade, or live Cloudflare compatibility. Record exact cluster, external Gateway bundle, chart, controller, connector and CRD versions from final release tests before publishing. + +For installations older than chart 1.4.0, first follow the [historical 1.4.0 migration notes](https://github.com/cfgate/helm-chart/blob/v1.4.0/README.md#upgrade-from-131-to-140), then apply the upgrade above. ## Uninstall @@ -85,6 +101,30 @@ kubectl delete crd cloudflareaccesspolicies.cfgate.io | `fullnameOverride` | string | `""` | Override full release name | | `namespaceOverride` | string | `""` | Override release namespace | +### Controller limits and installation identity + +| Key | Type | Default | Manager flag | +|-----|------|---------|--------------| +| `controller.clusterDomain` | string | `cluster.local` | `--cluster-domain` | +| `controller.installationNamespace` | string | `""` (actual Pod namespace) | `--installation-namespace`, when set | +| `controller.cloudflareRequestTimeoutSeconds` | int | `30` | `--cloudflare-request-timeout=30s` | +| `controller.maxIngressRules` | int | `1000` | `--max-ingress-rules` | +| `controller.maxConfigurationBytes` | int | `1048576` | `--max-configuration-bytes` | + +Timeout values are positive whole seconds, with a maximum of 9223372036 seconds (the manager's duration representation limit). Rule and byte limits accept positive integers up to 2147483647. These are operator work limits, not Cloudflare service limits; exceeding them rejects configuration publication instead of truncating routes. The rule budget includes the fallback rule. The byte budget includes serialized ingress and origin settings. + +`clusterDomain` accepts a lowercase DNS suffix without a trailing dot. Use the suffix actually configured in the cluster; setting the value does not reconfigure cluster DNS. For example: + +```yaml +controller: + clusterDomain: cluster.internal + cloudflareRequestTimeoutSeconds: 20 + maxIngressRules: 2000 + maxConfigurationBytes: 2097152 +``` + +`POD_NAMESPACE` comes from the Pod's actual `metadata.namespace`, so `namespaceOverride` also selects the default installation namespace correctly. The namespace's Kubernetes UID and each resource UID participate in ownership. Preserve that namespace across upgrades. Setting `controller.installationNamespace` selects an existing namespace for identity and tunnel claims; it does not create one. Changing that value, deleting/recreating the namespace, or recreating owned CRs requires the documented ownership migration. + ### CRDs and RBAC | Key | Type | Default | Description | @@ -95,6 +135,10 @@ kubectl delete crd cloudflareaccesspolicies.cfgate.io | `serviceAccount.name` | string | `""` | ServiceAccount name (generated if empty) | | `serviceAccount.annotations` | object | `{}` | ServiceAccount annotations | +The manager ClusterRole includes `get/create/delete` on ConfigMaps for immutable installation tunnel claims, and `get/list/watch` on Pods for connector drain checks. These permissions are part of the matching controller RBAC; when `rbac.create=false`, provide equivalent administrator-managed rules. Do not disable the manager's service-account token: it needs Kubernetes API access. Connector Pods independently disable unused token mounting. + +Access-required routing remains an explicit per-HTTPRoute opt-in (`cfgate.io/access-required: namespace/name`), not a chart-wide setting. Its dependency receipts are included in the Tunnel CRD. The selected tunnel credential needs Access application/policy read permissions. See the [Access-required contract and limits](https://github.com/cfgate/cfgate/blob/v0.2.0-alpha.6/docs/access-required.md); edge configuration is asynchronous, and strict or gRPC authentication requires origin-side enforcement. + ### Metrics and Monitoring | Key | Type | Default | Description | diff --git a/templates/NOTES.txt b/templates/NOTES.txt index 3ca25fb..f15a66c 100644 --- a/templates/NOTES.txt +++ b/templates/NOTES.txt @@ -3,6 +3,11 @@ cfgate has been installed. Chart version: {{ .Chart.Version }} App version: {{ .Chart.AppVersion }} +Gateway API CRDs are an external prerequisite; this chart does not install them. +Before upgrading from 1.4.0, complete the ownership/grant migration in README.md. +Preserve the installation namespace and install all four matching CRD schemas +before the new controller runs, including Access dependency status fields. + {{- if .Values.installCRDs }} CRDs installed: @@ -21,6 +26,12 @@ deleted when the chart is uninstalled. To remove CRDs manually: {{- end }} +Installation identity defaults to the actual Pod namespace (POD_NAMESPACE). +Changing its namespace UID or controller.installationNamespace requires migration. +Cross-namespace credential and resource references require target ReferenceGrants. +The manager requires its service-account token for Kubernetes API access. +Access-required routing is opt-in; strict authentication still belongs at the origin. + To verify the controller is running: kubectl get pods -n {{ include "cfgate.namespace" . }} -l app.kubernetes.io/name={{ include "cfgate.name" . }} diff --git a/templates/clusterrole.yaml b/templates/clusterrole.yaml index bc2d489..e73fd57 100644 --- a/templates/clusterrole.yaml +++ b/templates/clusterrole.yaml @@ -6,10 +6,19 @@ metadata: labels: {{- include "cfgate.labels" . | nindent 4 }} rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - delete + - get - apiGroups: - "" resources: - namespaces + - pods - services verbs: - get diff --git a/templates/crds/cloudflareaccessapplication.yaml b/templates/crds/cloudflareaccessapplication.yaml index cc3bf8d..c281204 100644 --- a/templates/crds/cloudflareaccessapplication.yaml +++ b/templates/crds/cloudflareaccessapplication.yaml @@ -4,8 +4,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 helm.sh/resource-policy: keep + controller-gen.kubebuilder.io/version: v0.22.0 name: cloudflareaccessapplications.cfgate.io labels: {{- include "cfgate.labels" . | nindent 4 }} @@ -283,6 +283,17 @@ spec: namespace: description: Namespace of the secret (defaults to policy namespace). type: string + secretKeys: + description: SecretKeys selects credential data keys; omitted + keys use their defaults. + properties: + apiToken: + default: CLOUDFLARE_API_TOKEN + description: APIToken is the key name for the Cloudflare API + token. + maxLength: 253 + type: string + type: object required: - name type: object @@ -692,6 +703,16 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + credentialSecretKeys: + description: CredentialSecretKeys preserves the selected token key + for cleanup. + properties: + apiToken: + default: CLOUDFLARE_API_TOKEN + description: APIToken is the key name for the Cloudflare API token. + maxLength: 253 + type: string + type: object credentialSecretRef: description: |- CredentialSecretRef is the resolved credentials Secret used for cleanup. diff --git a/templates/crds/cloudflareaccesspolicy.yaml b/templates/crds/cloudflareaccesspolicy.yaml index 4cfc228..9dc7029 100644 --- a/templates/crds/cloudflareaccesspolicy.yaml +++ b/templates/crds/cloudflareaccesspolicy.yaml @@ -4,8 +4,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 helm.sh/resource-policy: keep + controller-gen.kubebuilder.io/version: v0.22.0 name: cloudflareaccesspolicies.cfgate.io labels: {{- include "cfgate.labels" . | nindent 4 }} @@ -135,6 +135,17 @@ spec: namespace: description: Namespace of the secret (defaults to policy namespace). type: string + secretKeys: + description: SecretKeys selects credential data keys; omitted + keys use their defaults. + properties: + apiToken: + default: CLOUDFLARE_API_TOKEN + description: APIToken is the key name for the Cloudflare API + token. + maxLength: 253 + type: string + type: object required: - name type: object @@ -160,7 +171,7 @@ spec: - P2 (Google Workspace): GSuiteGroup - P3 (not in current product scope): Certificate, CommonName, Group, GitHub, Azure, Okta, SAML, etc. - SDK types map directly to cloudflare-go v6 SDK: IPRule, IPListRule, CountryRule, + Selector types map to the Cloudflare API: IPRule, IPListRule, CountryRule, EveryoneRule, ServiceTokenRule, AnyValidServiceTokenRule, EmailRule, DomainRule, EmailListRule, AccessOIDCClaimRule, GSuiteGroupRule. properties: @@ -371,7 +382,7 @@ spec: - P2 (Google Workspace): GSuiteGroup - P3 (not in current product scope): Certificate, CommonName, Group, GitHub, Azure, Okta, SAML, etc. - SDK types map directly to cloudflare-go v6 SDK: IPRule, IPListRule, CountryRule, + Selector types map to the Cloudflare API: IPRule, IPListRule, CountryRule, EveryoneRule, ServiceTokenRule, AnyValidServiceTokenRule, EmailRule, DomainRule, EmailListRule, AccessOIDCClaimRule, GSuiteGroupRule. properties: @@ -597,7 +608,7 @@ spec: - P2 (Google Workspace): GSuiteGroup - P3 (not in current product scope): Certificate, CommonName, Group, GitHub, Azure, Okta, SAML, etc. - SDK types map directly to cloudflare-go v6 SDK: IPRule, IPListRule, CountryRule, + Selector types map to the Cloudflare API: IPRule, IPListRule, CountryRule, EveryoneRule, ServiceTokenRule, AnyValidServiceTokenRule, EmailRule, DomainRule, EmailListRule, AccessOIDCClaimRule, GSuiteGroupRule. properties: @@ -922,6 +933,16 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + credentialSecretKeys: + description: CredentialSecretKeys preserves selected token keys for + cleanup. + properties: + apiToken: + default: CLOUDFLARE_API_TOKEN + description: APIToken is the key name for the Cloudflare API token. + maxLength: 253 + type: string + type: object credentialSecretRef: description: |- CredentialSecretRef is the resolved credentials Secret used for cleanup. diff --git a/templates/crds/cloudflaredns.yaml b/templates/crds/cloudflaredns.yaml index cb6f77a..10c2e9f 100644 --- a/templates/crds/cloudflaredns.yaml +++ b/templates/crds/cloudflaredns.yaml @@ -4,8 +4,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 helm.sh/resource-policy: keep + controller-gen.kubebuilder.io/version: v0.22.0 name: cloudflarednses.cfgate.io labels: {{- include "cfgate.labels" . | nindent 4 }} @@ -99,8 +99,8 @@ spec: type: boolean onlyManaged: description: |- - OnlyManaged only deletes records that were created by cfgate (verified via ownership). - nil defaults to true. + OnlyManaged is retained for compatibility. Ownership verification is always required; + setting false cannot authorize deletion of foreign or unmarked records. type: boolean type: object cloudflare: @@ -178,16 +178,11 @@ spec: description: ExternalTarget specifies a non-tunnel DNS target. properties: type: - allOf: - - enum: - - CNAME - - A - - AAAA - - enum: - - CNAME - - A - - AAAA description: Type is the DNS record type. + enum: + - CNAME + - A + - AAAA type: string value: description: |- @@ -233,7 +228,7 @@ spec: Enabled enables comment-based ownership tracking. Deprecated: since v0.1.0-alpha.13. This field is ignored. The controller always - writes a "managed by cfgate" comment. Will be removed in a future cleanup release. + writes an exact resource owner marker. Will be removed in a future cleanup release. type: boolean template: default: managed by cfgate @@ -241,15 +236,15 @@ spec: Template is the comment template. Deprecated: since v0.1.0-alpha.13. This field is ignored. The controller always - uses "managed by cfgate" as the comment. Will be removed in a future cleanup release. + uses an exact resource owner marker. Will be removed in a future cleanup release. maxLength: 255 type: string type: object ownerId: description: |- - OwnerID is the cluster/installation identifier used in TXT ownership records. - Used to distinguish records created by different cfgate installations. - Defaults to the CloudflareDNS resource's namespace/name if not specified. + OwnerID is a deprecated legacy migration hint, not an ownership authority. + Since v0.2.0-alpha.6 the controller persists installation/resource UIDs in status.ownerId. + Setting this field cannot adopt another resource's records. maxLength: 253 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(/[a-z0-9]([-a-z0-9]*[a-z0-9])?)?$ type: string @@ -269,17 +264,12 @@ spec: type: object type: object policy: - allOf: - - enum: - - sync - - upsert-only - - create-only - - enum: - - sync - - upsert-only - - create-only default: sync description: Policy controls DNS record lifecycle. + enum: + - sync + - upsert-only + - create-only type: string source: description: Source defines where to get hostnames to sync. @@ -291,7 +281,7 @@ spec: DNSExplicitHostname defines an explicit hostname to sync with optional per-hostname configuration. DNSExplicitHostname provides direct specification of DNS hostnames without depending on - Gateway API route discovery. The Target field supports the {{ .TunnelDomain }} template + Gateway API route discovery. The Target field supports the {{ `{{ .TunnelDomain }}` }} template variable for dynamic resolution when using tunnelRef. properties: hostname: @@ -313,7 +303,7 @@ spec: target: description: |- Target overrides the resolved record target for this hostname. - Supports template variable {{ .TunnelDomain }} when tunnelRef is used. + Supports template variable {{ `{{ .TunnelDomain }}` }} when tunnelRef is used. Defaults to the resource-level resolved target when omitted. Max 253: RFC 1035 section 2.3.4 FQDN presentation-format limit. maxLength: 253 @@ -519,6 +509,10 @@ spec: controller. format: int64 type: integer + ownerId: + description: OwnerID persists the installation namespace UID and resource + UID for cleanup. + type: string pendingRecords: description: PendingRecords is the number of records pending sync. format: int32 @@ -531,7 +525,7 @@ spec: DNSRecordSyncStatus tracks individual DNS record state including the Cloudflare record ID, current configuration, and sync status. The Status field indicates: Synced (successfully - synchronized), Pending (awaiting sync), or Failed (sync failed, see Error field). + synchronized), Pending (awaiting sync), Skipped (policy prevented synchronization), or Failed (sync failed, see Error field). properties: error: description: Error contains the error message if status is Failed. @@ -546,7 +540,8 @@ spec: description: RecordID is the Cloudflare record ID. type: string status: - description: 'Status is the sync status: Synced, Pending, Failed.' + description: 'Status is the sync status: Synced, Pending, Skipped, + Failed.' type: string target: description: Target is the record target/content. diff --git a/templates/crds/cloudflaretunnel.yaml b/templates/crds/cloudflaretunnel.yaml index e9db72e..5ec4933 100644 --- a/templates/crds/cloudflaretunnel.yaml +++ b/templates/crds/cloudflaretunnel.yaml @@ -4,8 +4,8 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 helm.sh/resource-policy: keep + controller-gen.kubebuilder.io/version: v0.22.0 name: cloudflaretunnels.cfgate.io labels: {{- include "cfgate.labels" . | nindent 4 }} @@ -138,7 +138,7 @@ spec: maxItems: 20 type: array image: - default: ghcr.io/inherent-design/cloudflared:2026.5.0-h2c.1 + default: ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1 description: Image is the cloudflared container image. maxLength: 255 type: string @@ -156,7 +156,9 @@ spec: properties: enabled: default: true - description: Enabled enables the metrics endpoint. + description: Enabled declares the metrics container port for + scraping. Health probes and their shared listener remain + enabled. type: boolean port: default: 44483 @@ -374,7 +376,8 @@ spec: properties: name: description: |- - Name is the tunnel name in Cloudflare. If tunnel with this name exists, adopt it. + Name is the tunnel name in Cloudflare. Existing tunnels require explicit + cfgate.io/adopt-existing=true and an exclusive installation ownership claim. If not, create it. Tunnel ID is stored in status after resolution/creation. maxLength: 63 minLength: 1 @@ -395,6 +398,61 @@ spec: status, and reconciliation state. The TunnelDomain field provides the CNAME target ({tunnelId}.cfargotunnel.com) that CloudflareDNS uses for DNS record creation. properties: + accessDependencies: + description: |- + AccessDependencies track applications needed by current or possibly applied configurations. + Entries are cleared only after remote configuration withdrawal is confirmed. + items: + description: TunnelAccessDependency records possibly active application + and policy protection. + properties: + accountId: + type: string + hostnames: + items: + type: string + maxItems: 64 + type: array + name: + type: string + namespace: + type: string + pending: + description: Pending records an unconfirmed publication attempt; + protection cannot be removed until a later confirmed sync. + type: boolean + policies: + items: + description: TunnelAccessPolicyDependency retains policy identity + until remote withdrawal is verified. + properties: + name: + type: string + namespace: + type: string + policyId: + type: string + uid: + type: string + required: + - name + - namespace + type: object + maxItems: 64 + type: array + tunnelId: + type: string + uid: + type: string + required: + - accountId + - hostnames + - name + - namespace + - tunnelId + type: object + maxItems: 256 + type: array accountId: description: AccountID is the resolved Cloudflare account ID. type: string @@ -464,11 +522,22 @@ spec: to this tunnel. format: int32 type: integer + lastFullReconcileTime: + description: |- + LastFullReconcileTime is the last successful credentials, tunnel, deployment, and configuration reconciliation. + Configuration-only reconciliations do not advance this timestamp. + format: date-time + type: string lastSyncTime: description: LastSyncTime is the last time the configuration was synced to Cloudflare. format: date-time type: string + lifecycleDependencyHash: + description: |- + LifecycleDependencyHash identifies the Secret revisions and Deployment generation checked during the last full reconciliation. + It contains no Secret data. + type: string observedGeneration: description: ObservedGeneration is the generation observed by the controller. diff --git a/templates/deployment.yaml b/templates/deployment.yaml index 20ab7f3..eff8493 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -1,3 +1,4 @@ +{{- $controller := merge (deepCopy (.Values.controller | default dict)) (dict "clusterDomain" "cluster.local" "installationNamespace" "" "cloudflareRequestTimeoutSeconds" 30 "maxIngressRules" 1000 "maxConfigurationBytes" 1048576) -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -23,6 +24,7 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} spec: + enableServiceLinks: false {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} @@ -39,6 +41,18 @@ spec: - --leader-elect - --health-probe-bind-address=:{{ .Values.health.port }} - --metrics-bind-address=:{{ .Values.metrics.port }} + - {{ printf "--cluster-domain=%s" $controller.clusterDomain | quote }} + - {{ printf "--cloudflare-request-timeout=%ds" ($controller.cloudflareRequestTimeoutSeconds | int64) | quote }} + - {{ printf "--max-ingress-rules=%d" ($controller.maxIngressRules | int64) | quote }} + - {{ printf "--max-configuration-bytes=%d" ($controller.maxConfigurationBytes | int64) | quote }} + {{- with $controller.installationNamespace }} + - {{ printf "--installation-namespace=%s" . | quote }} + {{- end }} + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace ports: - name: metrics containerPort: {{ .Values.metrics.port }} diff --git a/values.schema.json b/values.schema.json index 648b180..ee3216e 100644 --- a/values.schema.json +++ b/values.schema.json @@ -1,7 +1,9 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": ["image"], + "required": [ + "image" + ], "properties": { "replicaCount": { "type": "integer", @@ -11,7 +13,9 @@ }, "image": { "type": "object", - "required": ["repository"], + "required": [ + "repository" + ], "properties": { "repository": { "type": "string", @@ -23,7 +27,11 @@ }, "pullPolicy": { "type": "string", - "enum": ["Always", "Never", "IfNotPresent"], + "enum": [ + "Always", + "Never", + "IfNotPresent" + ], "default": "IfNotPresent", "description": "Image pull policy" } @@ -248,6 +256,48 @@ }, "default": {}, "description": "Pod labels" + }, + "controller": { + "type": "object", + "description": "Additive manager settings; defaults preserve the controller defaults", + "properties": { + "clusterDomain": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "default": "cluster.local", + "description": "Canonical Kubernetes cluster DNS suffix without a trailing dot" + }, + "installationNamespace": { + "type": "string", + "maxLength": 63, + "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$", + "default": "", + "description": "Empty uses POD_NAMESPACE; changing this changes installation ownership identity" + }, + "cloudflareRequestTimeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036, + "default": 30, + "description": "Positive whole seconds per Cloudflare API attempt" + }, + "maxIngressRules": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "default": 1000, + "description": "Aggregate tunnel rule limit including fallback" + }, + "maxConfigurationBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "default": 1048576, + "description": "Maximum serialized tunnel configuration bytes" + } + } } } } diff --git a/values.yaml b/values.yaml index a410356..111d218 100644 --- a/values.yaml +++ b/values.yaml @@ -15,6 +15,17 @@ nameOverride: "" fullnameOverride: "" namespaceOverride: "" +# Controller settings; installation identity defaults to the actual Pod namespace. +controller: + clusterDomain: cluster.local + # Override only for a deliberate installation-identity migration. + installationNamespace: "" + # Positive whole seconds per Cloudflare API attempt. + cloudflareRequestTimeoutSeconds: 30 + # Aggregate limits include the fallback rule and serialized origin settings. + maxIngressRules: 1000 + maxConfigurationBytes: 1048576 + # Install CRDs with the chart # Set to false if CRDs are managed separately (e.g., via kubectl apply) installCRDs: true From 3b5a2dfa8f9270c15bc6bfba8328d3f2f14d5485 Mon Sep 17 00:00:00 2001 From: mannie-exe Date: Wed, 30 Sep 2026 23:49:58 -0700 Subject: [PATCH 2/4] feat: finalize chart alpha.6 upgrade guidance - docs: link the published alpha.6 release - docs: trim the chart migration checklist - docs: clarify the automatic startup fix - docs: call out explicit image tag updates - docs: retain external schema guidance - docs: retain external RBAC guidance - docs: link detailed ownership migration - docs: clarify Access-required opt-in - docs: record the tested operator source - docs: record the published image digest - docs: record final chart upgrade checks - docs: distinguish chart and live E2E checks - checks: compare all published CRD schemas - checks: verify reused ports and restart - checks: validate the packaged chart --- README.md | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 111f5fc..63a84df 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Installs the cfgate controller, a Gateway API-native Kubernetes operator for Cloudflare Tunnel, DNS, and Access management. -This chart targets the next cfgate release, `0.2.0-alpha.6`. Chart `1.5.0` must be published after that controller image is available. +Chart `1.5.0` installs cfgate [`0.2.0-alpha.6`](https://github.com/cfgate/cfgate/releases/tag/v0.2.0-alpha.6). The current cfgate surface managed by this chart includes separate `CloudflareAccessApplication` and `CloudflareAccessPolicy` CRDs for Access application and policy lifecycle management. @@ -29,7 +29,7 @@ kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/downloa Gateway API CRDs are a cluster-level prerequisite, not a chart dependency. They may already be installed if you run Istio, Cilium, Envoy Gateway, or another Gateway API implementation. -The chart's historical `kubeVersion: ">= 1.26.0-0"` controls Helm admission for its own templates; it does not certify the complete controller and Gateway API stack on Kubernetes 1.26. Upstream [Gateway API installation requirements](https://kubernetes.io/blog/2026/04/21/gateway-api-v1-5/) and cfgate's release validation are separate checks. The final chart release must record the exact Kubernetes, Gateway API, controller image, and CRD versions actually tested. +The chart's historical `kubeVersion: ">= 1.26.0-0"` controls Helm admission for its own templates; it does not certify the complete controller and Gateway API stack on Kubernetes 1.26. Upstream [Gateway API installation requirements](https://kubernetes.io/blog/2026/04/21/gateway-api-v1-5/) and the [tested configuration below](#release-validation) are separate checks. ## Install @@ -47,27 +47,24 @@ helm upgrade cfgate oci://ghcr.io/cfgate/charts/cfgate \ ### Upgrade from 1.4.0 to 1.5.0 -Chart 1.4.0 → 1.5.0 upgrades cfgate `0.2.0-alpha.5` → `0.2.0-alpha.6`. The chart keeps existing values and template names compatible while adding controller configuration and CRD fields. The controller's stricter ownership and authorization rules require migration for existing resources that relied on implicit adoption or unrestricted cross-namespace references. This is not an unattended image-only upgrade. +Chart 1.4.0 → 1.5.0 upgrades cfgate `0.2.0-alpha.5` → `0.2.0-alpha.6`. Existing chart values remain compatible. -Before rollout: +- no values change is needed for #85; the chart disables Service environment injection and the controller fixes configuration precedence +- if `image.tag` is explicitly set, update it to `0.2.0-alpha.6`; an empty tag follows the chart's appVersion +- if `installCRDs=false` or `rbac.create=false`, update the externally managed CRDs and RBAC to alpha.6 before rollout +- cross-namespace references now require the applicable `ReferenceGrant` +- existing tunnels, DNS records, and generated resources must satisfy stricter ownership checks; preserve the installation namespace and follow the migration guide before adopting legacy resources +- to hold routes closed until Access is ready, set `cfgate.io/access-required`; intentionally public routes need no change -1. Inventory existing CR UIDs, connector owner references, remote tunnel IDs, DNS data and TXT ownership records. Stop previous writers for resources being migrated and preserve the installation namespace: its Kubernetes UID now forms part of persistent ownership. -2. Add explicit, narrowly scoped ReferenceGrants for cross-namespace credentials, Gateway/DNS/Access-to-Tunnel references, and HTTPRoute backends. Same-namespace references remain a namespace trust boundary. Keep infrastructure CRs, credential Secrets, ownership claims, connector images, and arguments under administrator control. -3. Verify generated connector resources and Access service-token Secrets have their expected controller owner UID. cfgate refuses to overwrite unowned or foreign objects. Deliberately migrate or recreate those objects after inspecting their current owner. -4. For an existing, verified, unclaimed remote tunnel, temporarily opt in with `cfgate.io/adopt-existing: "true"`. A foreign ownership claim still blocks adoption. Remove the opt-in after successful migration. -5. Migrate legacy DNS ownership deliberately after stopping the previous writer. `spec.ownership.ownerId` and `cleanupPolicy.onlyManaged: false` no longer authorize overwriting or deleting another owner's records. Inspect legacy TXT claims and data markers before any manual transition; never remove a claim while its owner is active. -6. Install the matching four CRD schemas before running the new controller, including when `installCRDs=false`. New status fields retain credential selection, installation ownership, and reconciliation state; old schemas must not prune them. -7. Verify remote configuration, connector readiness, DNS ownership, and application requests before resuming normal traffic. Review the supported HTTPRoute subset: unsupported restrictions are rejected, while invalid attached backends return matching failure responses instead of falling through to broader routes. +See the [alpha.5 → alpha.6 migration guide](https://github.com/cfgate/cfgate/blob/v0.2.0-alpha.6/docs/authorization-and-ownership.md#upgrade-from-v020-alpha5-to-v020-alpha6) for adoption, DNS ownership, and authorization details. -See the [controller authorization and migration guide](https://github.com/cfgate/cfgate/blob/v0.2.0-alpha.6/docs/authorization-and-ownership.md) for exact grants, ownership rules, and coordination limits. Chart 1.5.0 remains downstream of the approved controller release; do not publish it before that image and its matching schemas are available. +### Release validation -Controller Pods disable Service environment injection (`enableServiceLinks: false`), preventing the metrics Service from injecting a conflicting `CFGATE_METRICS_PORT` URL. Existing explicit metrics and health flags remain supported. DNS CRD descriptions preserve the literal `{{ .TunnelDomain }}` variable. CRD schemas and RBAC must be synchronized with the final controller release; they are no longer unchanged from chart 1.4.0. +The four rendered CRD specifications and manager RBAC match released cfgate source `b8cb740`; the CRD specifications also match its published release assets. The published image's GitHub provenance verifies against `v0.2.0-alpha.6` and that source commit. The bundled connector default is `ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1`. -### Candidate provenance and validation limits +A disposable ARM64 kind cluster running Kubernetes 1.37.0 and Gateway API 1.6.2 passed a chart 1.4.0 → 1.5.0 upgrade using Helm 4.3.0 and `--reuse-values`. The old controller reproduced #85; the published alpha.6 image became Ready, preserved custom metrics/health ports, and passed a rollout restart. Installed CRD schemas, origin-setting validation, and the manager's service-account token were checked. -The four rendered CRD specifications and manager RBAC must match the controller source being released. Repeat the normalized comparisons after code generation or dependency updates; record that source commit and the released controller image digest with release evidence. The bundled connector default is `ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1`. - -Helm lint, local rendering, JSON Schema validation, and normalized CRD/RBAC comparisons verify chart generation. They do not establish a tested Kubernetes range, an actual cluster upgrade, or live Cloudflare compatibility. Record exact cluster, external Gateway bundle, chart, controller, connector and CRD versions from final release tests before publishing. +The tested operator index digest was `sha256:3d3eaeae0ae0a76f8b3bc5271f642cf06f42e6525c85cb16e1b778fb4d864d6a`. This chart check created no Cloudflare resources and does not establish a Kubernetes support range. The controller's separate [release run](https://github.com/cfgate/cfgate/actions/runs/36822793906) passed all 119 live E2E tests and both architecture scans. For installations older than chart 1.4.0, first follow the [historical 1.4.0 migration notes](https://github.com/cfgate/helm-chart/blob/v1.4.0/README.md#upgrade-from-131-to-140), then apply the upgrade above. From 15c6ece3f32a70d3a7d5e62aa7c779cc60e4204c Mon Sep 17 00:00:00 2001 From: mannie-exe Date: Thu, 1 Oct 2026 00:32:32 -0700 Subject: [PATCH 3/4] feat: pin verified chart images and test overrides - chart: record the verified operator digest - chart: render default images by digest - chart: preserve explicit tag overrides - chart: preserve custom image repositories - chart: support explicit digest overrides - chart: reject malformed image digests - chart: keep release pins with chart metadata - release: require the verified source pin - release: inspect packaged image selection - tests: assert controller override output - tests: assert namespace and account wiring - tests: reject unsafe image pin mismatches - tests: check reused and packaged values - docs: explain image selection precedence - checks: pass the pinned image upgrade --- .github/scripts/release_guard.py | 21 ++++- .github/scripts/test_chart.py | 120 ++++++++++++++++++++++++++ .github/scripts/test_release_guard.py | 33 ++++++- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 5 +- CONTRIBUTING.md | 10 +++ Chart.yaml | 1 + README.md | 21 ++++- templates/_helpers.tpl | 20 ++++- templates/deployment.yaml | 2 +- values.schema.json | 7 +- values.yaml | 4 +- 12 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 .github/scripts/test_chart.py diff --git a/.github/scripts/release_guard.py b/.github/scripts/release_guard.py index 093fe35..257afc1 100644 --- a/.github/scripts/release_guard.py +++ b/.github/scripts/release_guard.py @@ -1,5 +1,6 @@ """Verify immutable chart source and published, attested operator dependencies.""" +import argparse import base64 import json import os @@ -55,7 +56,17 @@ def image_digest(image): return digest -def verify(tag, sha): +def verify_image_pin(chart, digest): + metadata = run("helm", "show", "chart", chart) + pins = re.findall(r'^ cfgate\.io/operator-image-digest:\s*[\'"]?(sha256:[a-f0-9]{64})[\'"]?\s*$', metadata, re.M) + require(pins == [digest], "chart operator pin does not match verified release digest") + deployment = run("helm", "template", "cfgate", chart, "--show-only", "templates/deployment.yaml") + images = re.findall(r'^\s+image:\s*[\'"]?([^\s\'"]+)[\'"]?\s*$', deployment, re.M) + require(images == ["ghcr.io/cfgate/cfgate@" + digest], + "default Deployment must install the verified operator digest") + + +def verify(tag, sha, package=None): require(re.fullmatch("v" + VERSION, tag), "invalid chart release tag") require(re.fullmatch(r"[a-f0-9]{40}", sha), "invalid chart source SHA") require(run("git", "rev-parse", "HEAD") == sha, "checkout does not match event SHA") @@ -72,6 +83,9 @@ def verify(tag, sha): operator_sha = tag_commit("cfgate/cfgate", "v" + app) image = "ghcr.io/cfgate/cfgate:" + app digest = image_digest(image) + verify_image_pin(".", digest) + if package is not None: + verify_image_pin(package, digest) # These checks bind the available OCI digest to the released source, rather # than trusting the mutable image tag or the release's target_commitish. run("gh", "attestation", "verify", "oci://ghcr.io/cfgate/cfgate@" + digest, @@ -93,7 +107,10 @@ def verify(tag, sha): if __name__ == "__main__": - evidence = verify(os.environ["RELEASE_TAG"], os.environ["RELEASE_SHA"]) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", help="also verify the packaged chart's default image") + args = parser.parse_args() + evidence = verify(os.environ["RELEASE_TAG"], os.environ["RELEASE_SHA"], args.package) Path("dist").mkdir(exist_ok=True) Path("dist/release-dependencies.json").write_text(json.dumps(evidence, indent=2) + "\n") with open(os.environ["GITHUB_OUTPUT"], "a") as output: diff --git a/.github/scripts/test_chart.py b/.github/scripts/test_chart.py new file mode 100644 index 0000000..063f86e --- /dev/null +++ b/.github/scripts/test_chart.py @@ -0,0 +1,120 @@ +"""Exercise real Helm rendering and schema rejection without a cluster.""" + +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +DIGEST = "sha256:3d3eaeae0ae0a76f8b3bc5271f642cf06f42e6525c85cb16e1b778fb4d864d6a" + + +class ChartTests(unittest.TestCase): + def render(self, values=None, chart=".", deployment=True): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json") as f: + json.dump(values or {}, f) + f.flush() + args = ["helm", "template", "cfgate", chart, "--namespace", "manager", "-f", f.name] + if deployment: + args += ["--show-only", "templates/deployment.yaml"] + return subprocess.run(args, cwd=ROOT, capture_output=True, text=True) + + def image(self, values=None, chart="."): + result = self.render(values, chart) + self.assertEqual(result.returncode, 0, result.stderr) + images = re.findall(r'^\s+image: "([^"]+)"$', result.stdout, re.M) + self.assertEqual(len(images), 1) + return images[0] + + def test_image_selection(self): + custom = "sha256:" + "a" * 64 + for values, expected in [ + ({}, "ghcr.io/cfgate/cfgate@" + DIGEST), + ({"image": {"tag": "custom"}}, "ghcr.io/cfgate/cfgate:custom"), + ({"image": {"repository": "example.com/operator"}}, "example.com/operator:0.2.0-alpha.6"), + ({"image": {"repository": "example.com/operator", "tag": "custom"}}, "example.com/operator:custom"), + ({"image": {"digest": custom, "tag": "ignored"}}, "ghcr.io/cfgate/cfgate@" + custom), + ({"image": {"repository": "example.com/operator", "digest": custom}}, "example.com/operator@" + custom), + ]: + with self.subTest(values=values): + self.assertEqual(self.image(values), expected) + + def test_controller_namespace_and_service_account_overrides(self): + result = self.render({ + "installCRDs": False, + "namespaceOverride": "workloads", + "controller": {"clusterDomain": "cluster.internal", "installationNamespace": "claims", + "cloudflareRequestTimeoutSeconds": 20, "maxIngressRules": 2000, + "maxConfigurationBytes": 2097152}, + "serviceAccount": {"create": False, "name": "external-manager"}, + "metrics": {"port": 9090}, "health": {"port": 9091}, + }, deployment=False) + self.assertEqual(result.returncode, 0, result.stderr) + for expected in ['namespace: workloads', 'serviceAccountName: external-manager', + 'name: external-manager', 'fieldPath: metadata.namespace', 'enableServiceLinks: false', + '--cluster-domain=cluster.internal', '--installation-namespace=claims', + '--cloudflare-request-timeout=20s', '--max-ingress-rules=2000', + '--max-configuration-bytes=2097152', '--metrics-bind-address=:9090', + '--health-probe-bind-address=:9091']: + self.assertIn(expected, result.stdout) + self.assertNotIn('ServiceAccount', re.findall(r'^kind: (\S+)$', result.stdout, re.M)) + binding = next(doc for doc in result.stdout.split('---') if '\nkind: ClusterRoleBinding\n' in doc) + self.assertIn('name: external-manager\n namespace: workloads', binding) + + def test_old_values_and_packaged_defaults(self): + # Old releases lack controller and image.digest values. Reuse must still + # select this chart's own pin, not retain the old chart's release digest. + result = self.render({"controller": None, "image": {"tag": "", "digest": None}}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('ghcr.io/cfgate/cfgate@' + DIGEST, result.stdout) + self.assertIn('--cloudflare-request-timeout=30s', result.stdout) + self.assertNotIn('--installation-namespace=', result.stdout) + with tempfile.TemporaryDirectory() as directory: + subprocess.run(["helm", "package", ".", "-d", directory], cwd=ROOT, check=True, capture_output=True) + package, = Path(directory).glob('*.tgz') + self.assertEqual(self.image(chart=str(package)), 'ghcr.io/cfgate/cfgate@' + DIGEST) + + def test_chart_pin_changes_and_missing_pin_rejects(self): + with tempfile.TemporaryDirectory() as directory: + chart = Path(directory) + for name in ['Chart.yaml', 'values.yaml', 'values.schema.json']: + shutil.copyfile(ROOT / name, chart / name) + shutil.copytree(ROOT / 'templates', chart / 'templates') + metadata = (chart / 'Chart.yaml').read_text() + updated = 'sha256:' + 'b' * 64 + (chart / 'Chart.yaml').write_text(metadata.replace(DIGEST, updated)) + self.assertEqual(self.image({"image": {"tag": "", "digest": None}}, directory), + 'ghcr.io/cfgate/cfgate@' + updated) + for invalid in ['', 'sha256:bad']: + (chart / 'Chart.yaml').write_text(metadata.replace(DIGEST, invalid)) + result = self.render(chart=directory) + self.assertNotEqual(result.returncode, 0) + self.assertIn('digest', result.stderr) + + def test_external_crds_and_rbac(self): + result = self.render({"installCRDs": False, "rbac": {"create": False}}, deployment=False) + self.assertEqual(result.returncode, 0, result.stderr) + for kind in ['CustomResourceDefinition', 'ClusterRole', 'ClusterRoleBinding']: + self.assertNotIn('kind: ' + kind + '\n', result.stdout) + + def test_invalid_values_rejected(self): + for values in [ + {"image": {"digest": "latest"}}, {"image": {"digest": "sha256:abc"}}, + {"controller": {"cloudflareRequestTimeoutSeconds": 0}}, + {"controller": {"cloudflareRequestTimeoutSeconds": 9223372037}}, + {"controller": {"maxIngressRules": 0}}, {"controller": {"maxConfigurationBytes": 0}}, + {"controller": {"clusterDomain": "bad_domain"}}, + {"controller": {"installationNamespace": "Bad"}}, + ]: + with self.subTest(values=values): + result = self.render(values) + self.assertNotEqual(result.returncode, 0) + self.assertIn('schema', result.stderr.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_release_guard.py b/.github/scripts/test_release_guard.py index 3cda044..bccea82 100644 --- a/.github/scripts/test_release_guard.py +++ b/.github/scripts/test_release_guard.py @@ -15,7 +15,8 @@ def setUp(self): ("git", "rev-parse", "HEAD"): self.sha, ("git", "rev-parse", "refs/tags/v1.5.0^{commit}"): self.sha, ("git", "ls-remote", "--tags", "origin", "refs/tags/v1.5.0", "refs/tags/v1.5.0^{}"): self.sha + "\trefs/tags/v1.5.0", - ("helm", "show", "chart", "."): 'version: 1.5.0\nappVersion: "0.2.0-alpha.6"', + ("helm", "show", "chart", "."): 'version: 1.5.0\nappVersion: "0.2.0-alpha.6"\nannotations:\n cfgate.io/operator-image-digest: ' + self.digest, + ("helm", "template", "cfgate", ".", "--show-only", "templates/deployment.yaml"): ' image: "ghcr.io/cfgate/cfgate@' + self.digest + '"', ("oras", "resolve", "ghcr.io/cfgate/cfgate:0.2.0-alpha.6"): self.digest, ("oras", "resolve", "ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1"): self.digest, } @@ -42,9 +43,35 @@ def command(self, *args): return "verified" return self.responses[args] - def verify(self, tag="v1.5.0", sha=None): + def verify(self, tag="v1.5.0", sha=None, package=None): with patch.object(guard, "run", side_effect=self.command), patch.object(guard, "api", side_effect=lambda path: copy.deepcopy(self.api_responses[path])): - return guard.verify(tag, sha or self.sha) + return guard.verify(tag, sha or self.sha, package) + + def test_pin_and_render_must_match_verified_digest(self): + metadata = ("helm", "show", "chart", ".") + render = ("helm", "template", "cfgate", ".", "--show-only", "templates/deployment.yaml") + for key, value in [ + (metadata, 'version: 1.5.0\nappVersion: 0.2.0-alpha.6'), + (metadata, self.responses[metadata].replace(self.digest, "sha256:" + "d" * 64)), + (render, ' image: "ghcr.io/cfgate/cfgate:0.2.0-alpha.6"'), + (render, ' image: "ghcr.io/cfgate/cfgate@sha256:' + "d" * 64 + '"'), + ]: + original = self.responses[key] + self.responses[key] = value + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + self.verify() + self.responses[key] = original + + def test_packaged_image_is_checked(self): + package = "dist/cfgate-1.5.0.tgz" + metadata = ("helm", "show", "chart", package) + render = ("helm", "template", "cfgate", package, "--show-only", "templates/deployment.yaml") + self.responses[metadata] = self.responses[("helm", "show", "chart", ".")] + self.responses[render] = self.responses[("helm", "template", "cfgate", ".", "--show-only", "templates/deployment.yaml")] + self.verify(package=package) + self.responses[render] = ' image: "ghcr.io/cfgate/cfgate:unverified"' + with self.assertRaisesRegex(ValueError, "default Deployment"): + self.verify(package=package) def test_valid_prerelease_dependency(self): self.assertEqual(self.verify()["operator_source"], self.operator_sha) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 322a55e..784c00a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,5 +69,5 @@ jobs: - name: Template with external CRDs and RBAC run: helm template cfgate . --set installCRDs=false --set rbac.create=false - - name: Test release dependency guards + - name: Test chart contracts and release guards run: python3 -m unittest discover -s .github/scripts -p "test_*.py" -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bca9790..6aa61cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: python3 .github/scripts/release_guard.py cp dist/release-dependencies.json "$RUNNER_TEMP/chart-release-dependencies.json" - - name: Test release dependency guards + - name: Test chart contracts and release guards run: python3 -m unittest discover -s .github/scripts -p "test_*.py" -v - name: Lint chart @@ -68,11 +68,12 @@ jobs: - name: Recheck source and dependencies immediately before publication env: + RELEASE_VERSION: ${{ steps.version.outputs.clean }} RELEASE_TAG: ${{ github.ref_name }} RELEASE_SHA: ${{ github.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - python3 .github/scripts/release_guard.py + python3 .github/scripts/release_guard.py --package "dist/cfgate-$RELEASE_VERSION.tgz" if ! cmp -s "$RUNNER_TEMP/chart-release-dependencies.json" dist/release-dependencies.json; then echo "::error::Published dependencies changed during validation" exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be649c0..ee61042 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,12 +131,22 @@ Chart changes should pass: - `helm template` with external CRDs/RBAC disabled - `python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v` +The unittest suite invokes Helm to assert image selection (including packaged +defaults), controller/namespace/service-account overrides, compatibility with +older values, external CRDs/RBAC, and invalid settings. It also tests release +guard rejection paths. Both CI and release run this same suite. + ### Ordered publication The chart release workflow checks the immutable tag source and chart version, then requires the exact `appVersion` to have a published, non-draft cfgate release. Prerelease operators are permitted. The available operator OCI digest must have a GitHub attestation from cfgate's release workflow for that source commit and tag. +Set `cfgate.io/operator-image-digest` in `Chart.yaml` to that release's verified +multi-architecture index digest when changing `appVersion`. The guard rejects a +different published digest or a default Deployment that does not use the pin. +Immediately before publication, it also checks the packaged chart's default +image. Commit the pin in source; publication must not rewrite chart values. Verification reads the attestation bundle from OCI, avoiding a cross-repository attestation API permission requirement for the chart's workflow token. The connector pin is read from this verified operator source; its fork release diff --git a/Chart.yaml b/Chart.yaml index 07871de..d221626 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -28,6 +28,7 @@ maintainers: url: https://github.com/inherent-design annotations: + cfgate.io/operator-image-digest: "sha256:3d3eaeae0ae0a76f8b3bc5271f642cf06f42e6525c85cb16e1b778fb4d864d6a" artifacthub.io/license: Apache-2.0 artifacthub.io/category: networking artifacthub.io/operator: "true" diff --git a/README.md b/README.md index 63a84df..290f1b6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ helm upgrade cfgate oci://ghcr.io/cfgate/charts/cfgate \ Chart 1.4.0 → 1.5.0 upgrades cfgate `0.2.0-alpha.5` → `0.2.0-alpha.6`. Existing chart values remain compatible. - no values change is needed for #85; the chart disables Service environment injection and the controller fixes configuration precedence -- if `image.tag` is explicitly set, update it to `0.2.0-alpha.6`; an empty tag follows the chart's appVersion +- if `image.tag` is explicitly set, clear it to use the chart's verified image pin, or update it to `0.2.0-alpha.6` to keep tag-based selection - if `installCRDs=false` or `rbac.create=false`, update the externally managed CRDs and RBAC to alpha.6 before rollout - cross-namespace references now require the applicable `ReferenceGrant` - existing tunnels, DNS records, and generated resources must satisfy stricter ownership checks; preserve the installation namespace and follow the migration guide before adopting legacy resources @@ -64,6 +64,10 @@ The four rendered CRD specifications and manager RBAC match released cfgate sour A disposable ARM64 kind cluster running Kubernetes 1.37.0 and Gateway API 1.6.2 passed a chart 1.4.0 → 1.5.0 upgrade using Helm 4.3.0 and `--reuse-values`. The old controller reproduced #85; the published alpha.6 image became Ready, preserved custom metrics/health ports, and passed a rollout restart. Installed CRD schemas, origin-setting validation, and the manager's service-account token were checked. +After adding the default digest pin, the packaged chart passed another 1.4.0 → +1.5.0 upgrade on that configuration: reused values were unchanged, the Deployment +selected the pinned image, and the controller became Ready with the custom ports. + The tested operator index digest was `sha256:3d3eaeae0ae0a76f8b3bc5271f642cf06f42e6525c85cb16e1b778fb4d864d6a`. This chart check created no Cloudflare resources and does not establish a Kubernetes support range. The controller's separate [release run](https://github.com/cfgate/cfgate/actions/runs/36822793906) passed all 119 live E2E tests and both architecture scans. For installations older than chart 1.4.0, first follow the [historical 1.4.0 migration notes](https://github.com/cfgate/helm-chart/blob/v1.4.0/README.md#upgrade-from-131-to-140), then apply the upgrade above. @@ -91,13 +95,26 @@ kubectl delete crd cloudflareaccesspolicies.cfgate.io |-----|------|---------|-------------| | `replicaCount` | int | `2` | Number of controller replicas | | `image.repository` | string | `ghcr.io/cfgate/cfgate` | Container image repository | -| `image.tag` | string | Chart appVersion | Container image tag | +| `image.tag` | string | `""` | Explicit tag opts out of the chart's default digest pin | +| `image.digest` | string | `""` | Explicit SHA-256 digest; takes precedence over tag | | `image.pullPolicy` | string | `IfNotPresent` | Image pull policy | | `imagePullSecrets` | list | `[]` | Image pull secrets | | `nameOverride` | string | `""` | Override chart name | | `fullnameOverride` | string | `""` | Override full release name | | `namespaceOverride` | string | `""` | Override release namespace | +With the default repository and empty `image.tag`/`image.digest`, the Deployment +uses the verified multi-architecture digest recorded in +`Chart.yaml`'s `cfgate.io/operator-image-digest` annotation. This chart-owned pin +updates with the chart even when upgrading with `--reuse-values`. + +An explicit tag or custom repository preserves tag-based selection unless +`image.digest` is supplied. A custom repository with an empty tag uses +`appVersion`. Custom images are administrator choices and are not certified by +the chart's release guard. Set `image.digest: sha256:<64 lowercase hex characters>` +to pin a custom image; that digest takes precedence over any tag. An explicitly +set digest persists with reused values, so update or clear it during upgrades. + ### Controller limits and installation identity | Key | Type | Default | Manager flag | diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index ed1d6ef..5c6c87a 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -5,6 +5,25 @@ Expand the name of the chart. {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} +{{/* +Pin the default operator to the chart-owned digest, not a value retained by +--reuse-values. Explicit repository/tag overrides preserve existing behavior. +*/}} +{{- define "cfgate.image" -}} +{{- $digest := .Values.image.digest | default "" -}} +{{- if and (not $digest) (eq .Values.image.repository "ghcr.io/cfgate/cfgate") (not .Values.image.tag) -}} +{{- $digest = required "chart operator image digest is required" (index .Chart.Annotations "cfgate.io/operator-image-digest") -}} +{{- end -}} +{{- if $digest -}} +{{- if not (regexMatch "^sha256:[a-f0-9]{64}$" $digest) -}} +{{- fail "operator image digest must be sha256 followed by 64 lowercase hex characters" -}} +{{- end -}} +{{- printf "%s@%s" .Values.image.repository $digest -}} +{{- else -}} +{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) -}} +{{- end -}} +{{- end -}} + {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). @@ -73,4 +92,3 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} - diff --git a/templates/deployment.yaml b/templates/deployment.yaml index eff8493..c087c0a 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -35,7 +35,7 @@ spec: terminationGracePeriodSeconds: 10 containers: - name: manager - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + image: {{ include "cfgate.image" . | quote }} imagePullPolicy: {{ .Values.image.pullPolicy }} args: - --leader-elect diff --git a/values.schema.json b/values.schema.json index ee3216e..94d23cb 100644 --- a/values.schema.json +++ b/values.schema.json @@ -23,7 +23,12 @@ }, "tag": { "type": "string", - "description": "Container image tag (defaults to Chart.AppVersion)" + "description": "Explicit tag opts out of the chart default digest; custom repositories default to Chart.AppVersion" + }, + "digest": { + "type": "string", + "pattern": "^(sha256:[a-f0-9]{64})?$", + "description": "Optional image digest; takes precedence over tag" }, "pullPolicy": { "type": "string", diff --git a/values.yaml b/values.yaml index 111d218..7d108e3 100644 --- a/values.yaml +++ b/values.yaml @@ -7,8 +7,10 @@ replicaCount: 2 image: repository: ghcr.io/cfgate/cfgate pullPolicy: IfNotPresent - # Overrides the image tag whose default is the chart appVersion + # Explicit tag or repository overrides opt out of the chart's verified default pin. tag: "" + # Optional sha256 digest; takes precedence over tag, including for custom repositories. + digest: "" imagePullSecrets: [] nameOverride: "" From 84a902a90658a1db70c3a47746f4c36793e83cb0 Mon Sep 17 00:00:00 2001 From: mannie-exe Date: Thu, 1 Oct 2026 00:45:08 -0700 Subject: [PATCH 4/4] feat: scope chart claim permissions by namespace - chart: remove the broad ConfigMap grant - chart: add a namespaced claim Role - chart: bind claim access to the manager - chart: use the installation namespace - chart: preserve custom manager namespaces - chart: preserve custom service accounts - chart: honor externally managed RBAC - chart: retain the published alpha.6 image - tests: cover claim namespace defaults - tests: cover separate claim namespaces - tests: reject cluster-wide claim grants - tests: assert role binding subjects - docs: explain namespace-scoped permissions - docs: clarify external RBAC migration - checks: verify positive and negative access --- .github/scripts/test_chart.py | 25 ++++++++++++++++++++++++- CONTRIBUTING.md | 12 +++++++++--- README.md | 20 +++++++++++++++++--- templates/_helpers.tpl | 6 ++++++ templates/claim-role.yaml | 13 +++++++++++++ templates/claim-rolebinding.yaml | 17 +++++++++++++++++ templates/clusterrole.yaml | 8 -------- 7 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 templates/claim-role.yaml create mode 100644 templates/claim-rolebinding.yaml diff --git a/.github/scripts/test_chart.py b/.github/scripts/test_chart.py index 063f86e..05ca938 100644 --- a/.github/scripts/test_chart.py +++ b/.github/scripts/test_chart.py @@ -98,9 +98,32 @@ def test_chart_pin_changes_and_missing_pin_rejects(self): def test_external_crds_and_rbac(self): result = self.render({"installCRDs": False, "rbac": {"create": False}}, deployment=False) self.assertEqual(result.returncode, 0, result.stderr) - for kind in ['CustomResourceDefinition', 'ClusterRole', 'ClusterRoleBinding']: + for kind in ['CustomResourceDefinition', 'ClusterRole', 'ClusterRoleBinding', 'Role', 'RoleBinding']: self.assertNotIn('kind: ' + kind + '\n', result.stdout) + def test_claim_permissions_follow_installation_namespace(self): + for values, namespace, account_namespace, account in [ + ({}, 'manager', 'manager', 'cfgate'), + ({'namespaceOverride': 'workloads'}, 'workloads', 'workloads', 'cfgate'), + ({'controller': None}, 'manager', 'manager', 'cfgate'), + ({'namespaceOverride': 'workloads', 'controller': {'installationNamespace': 'claims'}, + 'serviceAccount': {'create': False, 'name': 'external-manager'}}, + 'claims', 'workloads', 'external-manager'), + ]: + with self.subTest(values=values): + result = self.render(values, deployment=False) + self.assertEqual(result.returncode, 0, result.stderr) + docs = {re.search(r'^kind: (\S+)$', doc, re.M)[1]: doc + for doc in result.stdout.split('---') if re.search(r'^kind: (\S+)$', doc, re.M)} + self.assertNotIn('configmaps', docs['ClusterRole']) + role, binding = docs['Role'], docs['RoleBinding'] + self.assertIn(' namespace: ' + namespace + '\n', role) + self.assertIn(' namespace: ' + namespace + '\n', binding) + self.assertIn('resources: ["configmaps"]', role) + self.assertIn('verbs: ["get", "create", "delete"]', role) + self.assertIn(' kind: Role\n name: cfgate-claims', binding) + self.assertIn('name: ' + account + '\n namespace: ' + account_namespace, binding) + def test_invalid_values_rejected(self): for values in [ {"image": {"digest": "latest"}}, {"image": {"digest": "sha256:abc"}}, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee61042..6474a8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ The Helm chart sources are derived from the [cfgate controller](https://github.c | Chart File | Upstream Source | Sync Trigger | |------------|-----------------|--------------| | `templates/crds/*.yaml` | `config/crd/bases/*.yaml` | CRD schema changes | -| `templates/clusterrole.yaml` | `config/rbac/role.yaml` | RBAC rule changes | +| `templates/clusterrole.yaml`, `templates/claim-role.yaml` | `config/rbac/role.yaml` | RBAC rule changes | | `templates/deployment.yaml` | `config/manager/manager.yaml` | Container args, ports, probes | | `values.yaml` | `cmd/manager/main.go` | New flags, defaults | | `templates/NOTES.txt` | Project docs, CRD names | New features, workflow changes | @@ -61,9 +61,15 @@ The ClusterRole is generated by controller-gen from kubebuilder markers. mise run codegen ``` -2. Compare `config/rbac/role.yaml` with `templates/clusterrole.yaml` +2. Compare the generated ClusterRole with `templates/clusterrole.yaml` and the + generated namespaced claim Role with `templates/claim-role.yaml`. Alpha.6's + published source still has the older cluster-wide ConfigMap grant; the chart + deliberately narrows its scope without changing controller behavior. -3. Update the `rules:` section, preserving Helm templating wrapper +3. Preserve the claim RoleBinding's namespace (the installation namespace) and + subject namespace (the actual manager namespace). Both claim templates must + honor `rbac.create=false`. Do not copy ConfigMap access back into the + ClusterRole when synchronizing an older controller release. ### Updating Deployment diff --git a/README.md b/README.md index 290f1b6..dcdcc02 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ See the [alpha.5 → alpha.6 migration guide](https://github.com/cfgate/cfgate/b ### Release validation -The four rendered CRD specifications and manager RBAC match released cfgate source `b8cb740`; the CRD specifications also match its published release assets. The published image's GitHub provenance verifies against `v0.2.0-alpha.6` and that source commit. The bundled connector default is `ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1`. +The four rendered CRD specifications match released cfgate source `b8cb740` and its published release assets. Manager RBAC retains the required operations while narrowing ConfigMap access to the installation namespace. The published image's GitHub provenance verifies against `v0.2.0-alpha.6` and that source commit. The bundled connector default is `ghcr.io/inherent-design/cloudflared:2026.9.3-h2c.1`. A disposable ARM64 kind cluster running Kubernetes 1.37.0 and Gateway API 1.6.2 passed a chart 1.4.0 → 1.5.0 upgrade using Helm 4.3.0 and `--reuse-values`. The old controller reproduced #85; the published alpha.6 image became Ready, preserved custom metrics/health ports, and passed a rollout restart. Installed CRD schemas, origin-setting validation, and the manager's service-account token were checked. @@ -144,12 +144,26 @@ controller: | Key | Type | Default | Description | |-----|------|---------|-------------| | `installCRDs` | bool | `true` | Install CRDs with the chart | -| `rbac.create` | bool | `true` | Create ClusterRole and ClusterRoleBinding | +| `rbac.create` | bool | `true` | Create manager ClusterRole/Binding and installation claim Role/Binding | | `serviceAccount.create` | bool | `true` | Create ServiceAccount | | `serviceAccount.name` | string | `""` | ServiceAccount name (generated if empty) | | `serviceAccount.annotations` | object | `{}` | ServiceAccount annotations | -The manager ClusterRole includes `get/create/delete` on ConfigMaps for immutable installation tunnel claims, and `get/list/watch` on Pods for connector drain checks. These permissions are part of the matching controller RBAC; when `rbac.create=false`, provide equivalent administrator-managed rules. Do not disable the manager's service-account token: it needs Kubernetes API access. Connector Pods independently disable unused token mounting. +The manager's namespaced claim Role grants `get/create/delete` on ConfigMaps in +`controller.installationNamespace`, or the actual manager namespace when unset. +Its RoleBinding targets the manager's service account, even when the claim +namespace differs. A custom installation namespace must already exist. The +ClusterRole retains `get/list/watch` on Pods for connector drain checks, but +grants no ConfigMap access. + +When `rbac.create=false`, supply both the cluster-wide manager permissions and +the namespaced claim permissions yourself. Remove any previous cluster-wide +ConfigMap grant; adding a namespaced Role alone does not revoke it. This scopes +ConfigMap access to a namespace, not individual claims, and leaves other required +manager permissions unchanged. No CRD or controller-image update is needed for +this RBAC restriction. Do not disable the manager's service-account token: it +needs Kubernetes API access. Connector Pods independently disable unused token +mounting. Access-required routing remains an explicit per-HTTPRoute opt-in (`cfgate.io/access-required: namespace/name`), not a chart-wide setting. Its dependency receipts are included in the Tunnel CRD. The selected tunnel credential needs Access application/policy read permissions. See the [Access-required contract and limits](https://github.com/cfgate/cfgate/blob/v0.2.0-alpha.6/docs/access-required.md); edge configuration is asynchronous, and strict or gRPC authentication requires origin-side enforcement. diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index 5c6c87a..14b41f7 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -60,6 +60,12 @@ Define namespace (supports override for multi-namespace deployments). {{- end }} {{- end }} +{{/* Keep claim permissions in the namespace selected by the manager. */}} +{{- define "cfgate.installationNamespace" -}} +{{- $controller := .Values.controller | default dict -}} +{{- default (include "cfgate.namespace" .) $controller.installationNamespace -}} +{{- end -}} + {{/* Common labels */}} diff --git a/templates/claim-role.yaml b/templates/claim-role.yaml new file mode 100644 index 0000000..89ca7c3 --- /dev/null +++ b/templates/claim-role.yaml @@ -0,0 +1,13 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "cfgate.fullname" . }}-claims + namespace: {{ include "cfgate.installationNamespace" . }} + labels: + {{- include "cfgate.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "create", "delete"] +{{- end }} diff --git a/templates/claim-rolebinding.yaml b/templates/claim-rolebinding.yaml new file mode 100644 index 0000000..f17570c --- /dev/null +++ b/templates/claim-rolebinding.yaml @@ -0,0 +1,17 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "cfgate.fullname" . }}-claims + namespace: {{ include "cfgate.installationNamespace" . }} + labels: + {{- include "cfgate.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "cfgate.fullname" . }}-claims +subjects: + - kind: ServiceAccount + name: {{ include "cfgate.serviceAccountName" . }} + namespace: {{ include "cfgate.namespace" . }} +{{- end }} diff --git a/templates/clusterrole.yaml b/templates/clusterrole.yaml index e73fd57..a4e4612 100644 --- a/templates/clusterrole.yaml +++ b/templates/clusterrole.yaml @@ -6,14 +6,6 @@ metadata: labels: {{- include "cfgate.labels" . | nindent 4 }} rules: -- apiGroups: - - "" - resources: - - configmaps - verbs: - - create - - delete - - get - apiGroups: - "" resources: