diff --git a/.github/scripts/release_guard.py b/.github/scripts/release_guard.py new file mode 100644 index 0000000..257afc1 --- /dev/null +++ b/.github/scripts/release_guard.py @@ -0,0 +1,118 @@ +"""Verify immutable chart source and published, attested operator dependencies.""" + +import argparse +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_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") + 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) + 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, + "--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__": + 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: + output.write("clean=" + evidence["version"] + "\n") + print(json.dumps(evidence, indent=2)) diff --git a/.github/scripts/test_chart.py b/.github/scripts/test_chart.py new file mode 100644 index 0000000..05ca938 --- /dev/null +++ b/.github/scripts/test_chart.py @@ -0,0 +1,143 @@ +"""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', '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"}}, + {"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 new file mode 100644 index 0000000..bccea82 --- /dev/null +++ b/.github/scripts/test_release_guard.py @@ -0,0 +1,131 @@ +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"\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, + } + 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, 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, 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) + + 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..784c00a 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 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 c0dc706..6aa61cf 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,85 @@ 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 chart contracts and release 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_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 --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 + 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 +115,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..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 | @@ -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 @@ -54,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 @@ -64,7 +77,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 +125,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 +134,37 @@ 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` + +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 +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..d221626 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: @@ -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 c16b402..dcdcc02 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`. +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. @@ -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 the [tested configuration below](#release-validation) are separate checks. + ## Install ```bash @@ -43,17 +45,32 @@ 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`. 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, 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 +- to hold routes closed until Access is ready, set `cfgate.io/access-required`; intentionally public routes need no change + +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. + +### Release validation -Chart 1.4.0 installs cfgate `0.2.0-alpha.5` CRDs with stricter validation. -Before upgrading existing clusters: +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`. -- 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`. +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. ## Uninstall @@ -78,23 +95,78 @@ 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 | +|-----|------|---------|--------------| +| `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 | |-----|------|---------|-------------| | `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'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. + ### 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/_helpers.tpl b/templates/_helpers.tpl index ed1d6ef..14b41f7 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). @@ -41,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 */}} @@ -73,4 +98,3 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} - 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 bc2d489..a4e4612 100644 --- a/templates/clusterrole.yaml +++ b/templates/clusterrole.yaml @@ -10,6 +10,7 @@ rules: - "" 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..c087c0a 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 }} @@ -33,12 +35,24 @@ 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 - --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..94d23cb 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", @@ -19,11 +23,20 @@ }, "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", - "enum": ["Always", "Never", "IfNotPresent"], + "enum": [ + "Always", + "Never", + "IfNotPresent" + ], "default": "IfNotPresent", "description": "Image pull policy" } @@ -248,6 +261,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..7d108e3 100644 --- a/values.yaml +++ b/values.yaml @@ -7,14 +7,27 @@ 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: "" 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