diff --git a/.github/workflows/build-app-image.yaml b/.github/workflows/build-app-image.yaml index a1b92e9..3b049b1 100644 --- a/.github/workflows/build-app-image.yaml +++ b/.github/workflows/build-app-image.yaml @@ -1,3 +1,8 @@ +# Relevant from tier-1 onward (see README.md#tiers) — builds and publishes the app +# image tier-1+ HelmReleases reference. At tier-0, the (prose-only, undescribed +# in this repo) deployer tool optionally builds and tags the image itself; this +# workflow isn't part of that story, and tier-0's manifests reference a +# hand-built ":manual" tag that nothing here publishes. name: Build app image on: @@ -30,7 +35,7 @@ jobs: id: build uses: docker/build-push-action@v7.3.0 with: - context: apps-source + context: tier-4/apps-source push: true tags: ghcr.io/${{ github.repository_owner }}/apps/archetype-backend:${{ github.sha }} labels: | diff --git a/.github/workflows/publish-app-values.yaml b/.github/workflows/publish-app-values.yaml index 06077fd..b94facd 100644 --- a/.github/workflows/publish-app-values.yaml +++ b/.github/workflows/publish-app-values.yaml @@ -1,3 +1,6 @@ +# Relevant from tier-2 onward (see README.md#tiers) — publishes the values OCI +# artifact that tier-2+ ArtifactGenerators compose with the chart. Not needed +# for tier-0/tier-1, which have no values-publishing pipeline. name: Bump archetype-backend values on: @@ -21,12 +24,18 @@ jobs: - name: Checkout uses: actions/checkout@v7.0.1 + # Runs from tier-4/ so mise finds tier-4/mise.toml (flux2, yq) — there is + # no mise.toml at the repo root, so without this, tools not already + # preinstalled on the runner image (flux, unlike yq/helm) silently fail + # to install and later steps get "command not found". - name: Set up mise-managed tools uses: jdx/mise-action@v4.3.0 + with: + working_directory: tier-4 - - name: Set image tag in apps-source/values.yaml + - name: Set image tag in tier-4/apps-source/values.yaml run: | - yq -i '.image.tag = strenv(IMAGE_TAG)' apps-source/values.yaml + yq -i '.image.tag = strenv(IMAGE_TAG)' tier-4/apps-source/values.yaml env: IMAGE_TAG: ${{ inputs.image_tag }} @@ -42,9 +51,13 @@ jobs: # interval, no git commit against clusters/kind/ needed. - name: Push values artifact to GHCR id: push + # pipefail: without it, a failing `flux push` still lets `tee` + # succeed, silently producing an empty digest/repository for the + # next step instead of failing this one where the real error is. + shell: bash --noprofile --norc -eo pipefail {0} run: | flux push artifact "oci://ghcr.io/${{ github.repository_owner }}/apps/archetype-backend-values:latest" \ - --path="./apps-source" \ + --path="./tier-4/apps-source" \ --source="${{ github.server_url }}/${{ github.repository }}" \ --revision="${{ github.ref_name }}@sha1:${{ github.sha }}" \ --annotations="org.opencontainers.image.revision=${{ github.sha }}" \ diff --git a/.github/workflows/publish-chart.yaml b/.github/workflows/publish-chart.yaml index 95d9887..2d2c63d 100644 --- a/.github/workflows/publish-chart.yaml +++ b/.github/workflows/publish-chart.yaml @@ -1,10 +1,13 @@ +# Relevant from tier-1 onward (see README.md#tiers) — publishes the platform Helm +# chart tier-1+ OCIRepositories track. Not needed for tier-0, which has +# no Helm chart at all. name: Publish Helm chart on: workflow_dispatch: inputs: chart: - description: Chart to publish (directory name under charts/) + description: Chart to publish (directory name under tier-4/charts/) required: true type: choice options: @@ -27,8 +30,14 @@ jobs: - name: Checkout uses: actions/checkout@v7.0.1 + # Runs from tier-4/ so mise finds tier-4/mise.toml — there is no + # mise.toml at the repo root. This job happens to work without it today + # only because helm ships preinstalled on the runner image; pin it + # explicitly rather than relying on that. - name: Set up mise-managed tools uses: jdx/mise-action@v4.3.0 + with: + working_directory: tier-4 - name: Log in to GitHub Container Registry (Helm) run: | @@ -48,7 +57,7 @@ jobs: - name: Package chart run: | - helm package "charts/${{ inputs.chart }}" \ + helm package "tier-4/charts/${{ inputs.chart }}" \ --version "${{ inputs.version }}" \ --app-version "${{ inputs.version }}" \ --destination .dist diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7208180 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this repository is + +A demonstration of decoupled GitOps, presented as a progression of five isolated tiers +(`tier-0` … `tier-4`) rather than a single finished state. Each tier is a self-contained directory with +its own kind cluster / OpenTofu stack, peeling back capability from `tier-4` (platform-owned Helm charts +and cluster governance policies vs. application-owned source/values, composed at runtime by Flux) down +to `tier-0` (Flux already reconciling raw manifests that a custom deployer tool templates and commits — +the real-world baseline, not a from-scratch GitOps bootstrap). See the root README's +[Tiers](README.md#tiers) section for the full progression and what each tier introduces. + +There is no application runtime to build/lint/test in the traditional sense — most "correctness" here +means valid Terraform/OpenTofu, valid Kubernetes/Flux/Kyverno YAML, and a cluster that reconciles to +`Ready`. + +**Tiers are isolated by design** — nothing is shared or symlinked between `tier-N/` directories. If +you're changing something to fix a bug or improve clarity, check whether the same construct exists, +possibly in a stripped-down form, in adjacent tiers and needs the equivalent fix there too. Don't add +cross-tier abstractions (shared modules, common scripts) — duplication across tiers is intentional; it's +what lets each one be read and run independently. + +## Commands + +Tooling is version-pinned via `mise`, per tier (`tier-N/mise.toml` and `tier-N/kind-cluster/mise.toml`). +The five `kind-cluster/mise.toml` files are currently identical (`helm`, `kind`, `kubectl`, `opentofu`, +`yq`) — none are actually scoped down to what that specific tier needs, despite the isolation principle +above; if you add a tier-specific tool dependency, add it only where it's used rather than assuming the +others are already minimal. `tier-N/mise.toml` (the root-level one, tier-2+) does vary — tier-3/tier-4 +add `jq` for `scripts/spicedb-fixture.sh`. + +```sh +cd tier-N +mise install +``` + +Cluster lifecycle (always run from `tier-N/kind-cluster/`, via that tier's wrapper script — it manages +`KUBECONFIG` from OpenTofu output automatically, so don't export it manually): + +```sh +cd tier-N/kind-cluster +./cluster.sh up # tofu init + apply, then wait for readiness of whatever that tier bootstraps +./cluster.sh check # just the readiness wait + status dump +./cluster.sh down # tofu destroy +``` + +Each tier's `cluster.sh check` only waits on the components that tier actually installs (e.g. tier-0 +just waits on Flux readiness; tier-2 waits on Flux only; tier-4 waits on Flux, Kyverno, Policy Reporter, +and SpiceDB). Don't copy a later tier's `check` logic into an earlier tier — that's how tiers end up +silently depending on components they don't have. + +There's no separate `tofu plan`/`apply` workflow expected outside this script — treat `cluster.sh` as +the entry point rather than calling `tofu` directly. + +SpiceDB fixture testing (tier-3 and tier-4 only; requires +`kubectl port-forward -n authz svc/spicedb 8443:8443` first): + +```sh +cd tier-N +./scripts/spicedb-fixture.sh check # query /v1/permissions/check for the 'deploy' permission +./scripts/spicedb-fixture.sh apply # push fixtures/spicedb/schema.zed + relationships.txt +./scripts/spicedb-fixture.sh revoke # explicitly delete a relationship tuple +``` + +`apply` only ever upserts (`OPERATION_TOUCH`) — removing a tuple from `relationships.txt` and +re-applying does **not** revoke it. Use `revoke` for that. + +Policy Reporter dashboard (tier-4 only): +`kubectl port-forward -n policy-reporter svc/policy-reporter-ui 8080:8080`. + +## Architecture + +Within any given tier that has them, these concerns are deliberately kept separate and never edited by +the same actor in the same workflow: + +* **`tier-N/kind-cluster/`** — OpenTofu. Creates the kind cluster (named `tier-N`), then bootstraps + `flux-operator` (via a `FluxInstance` CR) plus whatever subset of Kyverno, Policy Reporter, and (tier-3+) + the SpiceDB Operator (`spicedb-operator.tf`) that tier needs, as Flux-reconciled `OCIRepository`/`HelmRelease` + pairs or, for the SpiceDB Operator specifically, a `GitRepository`/`Kustomization` pair applied directly + via `kubectl_manifest` rather than through the git-synced manifests — its CRDs must exist independently + of whether that git-synced `Kustomization` (which includes a `SpiceDBCluster` resource of that CRD) has + succeeded, or the two would deadlock each other on a cold cluster. Past cluster creation, resources + are applied as `kubectl_manifest` YAML blocks, not native Terraform K8s resources — read the file + before assuming a resource is a first-class `kubernetes_*`/`helm_release` type. `flux_git_path` points + at that tier's own manifest directory (e.g. `tier-2/clusters/kind`), never another tier's. +* **`tier-N/charts/archetype-backend/`** (tier-1+) — the platform team's base Helm chart, published to + `oci://ghcr.io/magnusp/charts/archetype-backend:`. From tier-4 onward it also includes a + namespaced Kyverno `Policy` (`templates/policy.yaml`) that runs at admission time. +* **`tier-N/apps-source/`** — the simulated application repo: `Dockerfile`, static app content, and + (tier-1+) `values.yaml`. From tier-2 onward, the app team never commits to `clusters/kind/`; they + publish OCI artifacts instead (image + values), and Flux does the rest. At tier-0, a custom deployer + tool (not implemented in this repo — described only in `tier-0/README.md`) templates manifests and + commits them on the app team's behalf; there's no chart yet, so there's nothing to publish to a + registry. +* **`tier-N/clusters/kind/`** (tier-1+; raw manifests live in `tier-0/manifests/` instead) — the GitOps + manifests Flux reconciles. From tier-2 onward, `OCIRepository`/`ArtifactGenerator` objects compose the + platform chart with the app-owned values into an `ExternalArtifact`, which feeds a `HelmRelease`. + Cluster-wide `ClusterPolicy` objects (tier-3+) also live here, as opposed to the chart-packaged + namespaced `Policy` (tier-4+). + +**Key event chain to keep in mind when tracing a deploy (tier-2+)**: app team publishes +`ghcr.io/magnusp/apps/archetype-backend:` (image) and +`ghcr.io/magnusp/apps/archetype-backend-values:latest` (values) → `source-watcher`'s `ArtifactGenerator` +notices the values digest change and merges it with the platform base chart into an `ExternalArtifact` → +`helm-controller` reconciles the `HelmRelease` immediately (event-driven, not poll-interval-driven). At +tier-1, there's no `ArtifactGenerator` yet — the `HelmRelease` sources the chart's `OCIRepository` +directly and carries values inline, so it upgrades on Flux's normal poll interval instead. At tier-0, +there's no chart or `OCIRepository` at all — Flux reconciles whatever raw manifests the deployer tool +committed, on its normal poll interval. + +**Governance (tier-3/tier-4) differs in scoping between the two tiers**: tier-3's +`clusterpolicy-spicedb-authz.yaml` is hardcoded to the `apps` namespace +(`match.any[].resources.namespaces: [apps]`). Tier-4 retrofits it — and adds +`clusterpolicy-disallow-manual-image-revision.yaml` and +`clusterpolicy-verify-image-nginx-ancestor.yaml` alongside it — to match on the +`governance.platform.io/managed: "true"` namespace label instead, so a single policy set applies to any +number of opt-in application workspaces. Don't backport tier-4's label-scoping into tier-3 or vice +versa; the difference between them is the point of that tier boundary. + +When changing anything under a tier's `clusters/kind/` or `charts/archetype-backend/`, check whether the +change crosses that tier's platform/app boundary or (tier-3+) governance-scoping boundary described +above — that separation is the point of the tier, not an incidental detail. + +## CI/CD workflows (`.github/workflows/`) + +Workflows live at the repository root (a GitHub Actions requirement — they can't live per-tier and still +run), but each references `tier-4/` paths specifically and is only meaningful once you've reached the +tier that introduces the artifact it publishes (each workflow file has a comment noting this): + +* `publish-chart.yaml` — platform chart → GHCR OCI, SemVer tag. Relevant from tier-1 onward. +* `build-app-image.yaml` — app image → GHCR, tagged with commit SHA, stamps + `org.opencontainers.image.revision`/`vendor` and `dev.authz.app.deployer` labels. Relevant from tier-1 + onward. +* `publish-app-values.yaml` — bumps `image.tag` in `tier-4/apps-source/values.yaml` and pushes it as a + `:latest` OCI artifact with deployer provenance annotations. Relevant from tier-2 onward. + +These are manually triggered in sequence per the runbook in `tier-4/README.md`, not chained +automatically — don't assume merging to main alone deploys anything. If you add a new tier or renumber +existing ones, update these workflows' hardcoded `tier-4/...` paths accordingly. diff --git a/README.md b/README.md index 934d374..9ffa072 100644 --- a/README.md +++ b/README.md @@ -1,240 +1,105 @@ # Declarative Deploys Showcase -A demonstration of decoupled platform engineering and application development workflows using Flux CD, OCI artifacts, and Kyverno on a local kind cluster. +A demonstration of decoupled platform engineering and application development workflows using Flux CD, +OCI artifacts, and Kyverno on a local kind cluster — presented as a progression of five isolated tiers, +each adding one capability on top of the last. -## Overview - -This repository demonstrates a separation of concerns between **platform teams** and **application teams**: - -* **Platform Engineering**: Owns archetype Helm charts (`charts/`) and cluster-wide governance policies. Charts are packaged and published to GitHub Container Registry (GHCR) with SemVer tags. -* **Application Development**: Owns application source code and deployment parameters (`apps-source/values.yaml`). Application teams deploy by publishing container images and `values.yaml` artifacts to GHCR using a mutable `latest` tag without making Git commits to the cluster repository. -* **Cluster Infrastructure**: Provisions a local kind cluster and bootstraps Flux CD and Kyverno using OpenTofu (`kind-cluster/`). -* **Reconciliation & Composition**: Flux `source-watcher` composes the platform base chart and developer values into an `ExternalArtifact`, triggering immediate event-driven upgrades in `helm-controller` (`clusters/kind/`). - -``` -┌────────────────────────────────────────────────────────┐ -│ Platform Concern │ -│ Base Chart (GHCR: oci://.../archetype-backend:0.1.1) │ -└──────────────────────────┬─────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────┐ -│ Flux Artifact Composition (source-watcher) │ -│ ArtifactGenerator ───► ExternalArtifact (Merged Chart) │ -└──────────────────────────▲─────────────────────────────┘ - │ -┌──────────────────────────┴─────────────────────────────┐ -│ Developer Concern │ -│ App Values (GHCR: oci://.../values:latest) │ -└────────────────────────────────────────────────────────┘ -``` - ---- - -## Directory Structure - -* [`kind-cluster/`](kind-cluster/): OpenTofu configuration that creates the kind cluster and installs the `flux-operator` and Kyverno. -* [`charts/`](charts/): Platform-owned Helm charts consumed by application teams. -* [`clusters/kind/`](clusters/kind/): Flux manifests defining the continuous delivery pipeline: `OCIRepository`, `ArtifactGenerator`, `HelmRelease`, and governance policies. -* [`apps-source/`](apps-source/): Simulated application repository containing the container build files and environment values (`values.yaml`). -* [`fixtures/spicedb/`](fixtures/spicedb/): Human-readable SpiceDB schema (`schema.zed`) and relationship tuples (`relationships.txt`). -* [`scripts/`](scripts/): Developer CLI utilities, including `spicedb-fixture.sh` for testing permissions and updating fixtures. -* [`.github/workflows/`](.github/workflows/): GitHub Actions workflows for publishing charts, images, and values artifacts with build provenance attestations. - ---- - -## Getting Started +## Tiers -### Prerequisites +This repository demonstrates the platform/application split, GitOps delivery, and supply-chain +governance as a progression rather than a single finished state. Each tier is a fully isolated +directory — its own kind cluster, its own OpenTofu stack, its own manifests — that peels back +capability from the final tier (`tier-4`, which is the complete showcase). Read them in order; each +tier's `README.md` ends with a "Progressing to tier-(N+1)" section explaining exactly what changes and +why. -Ensure you have installed the required CLI tools managed by `mise`: +The throughline: Flux and a deployer tool already exist, but with no platform/app split (tier 0) → +separating platform and application ownership (tier 1) → decoupling application deploys from git +commits (tier 2) → authorizing who can deploy (tier 3) → verifying what gets deployed (tier 4). -```sh -mise install -``` - -This installs: -* `kind` -* `opentofu` -* `helm` -* `kubectl` -* `yq` -* `flux` - -### Cluster Lifecycle +| Tier | Name | Introduces | +| :--- | :--- | :--- | +| [tier-0](tier-0/README.md) | Flux + deployer tool | Raw manifests, git-committed by a deployer tool, Flux-reconciled — the real-world baseline. | +| [tier-1](tier-1/README.md) | Helm chart split | Platform-owned chart vs. app-owned values; the core platform/app lesson. | +| [tier-2](tier-2/README.md) | OCI-published values | App team deploys by publishing an OCI artifact, no git commit required. | +| [tier-3](tier-3/README.md) | SpiceDB ReBAC | Admission-time authorization: who is allowed to deploy. | +| [tier-4](tier-4/README.md) | Full governance | Opt-in policy scoping, image-revision integrity, base-image attestation, Policy Reporter. | -All cluster lifecycle commands are managed by [`kind-cluster/cluster.sh`](kind-cluster/cluster.sh): +Start at `tier-0` and work upward, or jump straight to `tier-4` if you want the full, final architecture. -```sh -cd kind-cluster +## Overview -./cluster.sh up # Create the kind cluster, bootstrap Flux and Kyverno, and verify health -./cluster.sh check # Check pod readiness and print component status -./cluster.sh down # Destroy the OpenTofu stack and delete the kind cluster +At its most complete (`tier-4`), this repository demonstrates a separation of concerns between +**platform teams** and **application teams**: + +* **Platform engineering**: owns archetype Helm charts (`tier-N/charts/`) and cluster-wide governance + policies. Charts are packaged and published to GitHub Container Registry (GHCR) with SemVer tags. +* **Application development**: owns application source code and deployment parameters + (`tier-N/apps-source/values.yaml`). From tier 2 onward, application teams deploy by publishing + container images and `values.yaml` artifacts to GHCR using a mutable `latest` tag without making git + commits to the cluster repository. +* **Cluster infrastructure**: each tier provisions its own local kind cluster and bootstraps whatever + subset of Flux CD / Kyverno that tier needs, using OpenTofu (`tier-N/kind-cluster/`). +* **Reconciliation & composition** (tier 2+): Flux `source-watcher` composes the platform base chart and + developer values into an `ExternalArtifact`, triggering immediate event-driven upgrades in + `helm-controller` (`tier-N/clusters/kind/`). + +```mermaid +flowchart TB + chart["Platform concern
Base chart (GHCR: oci://.../archetype-backend:0.1.1)"] + values["Developer concern
App values (GHCR: oci://.../values:latest)"] + compose["Flux artifact composition (source-watcher)
ArtifactGenerator → ExternalArtifact (merged chart)"] + + chart --> compose + values --> compose ``` -> **Note**: Both `up` and `check` automatically configure `KUBECONFIG` from the OpenTofu state. You do not need to export `KUBECONFIG` manually. - ---- - -## Application Delivery Workflow - -The application team delivers updates independently of the platform GitOps repository. - -### Workflows - -1. **Publish Helm chart** (`.github/workflows/publish-chart.yaml`): - * Packages the platform chart (`charts/archetype-backend`) with a given SemVer version. - * Pushes `oci://ghcr.io/magnusp/charts/archetype-backend:`. - * Generates a GitHub build provenance attestation. -2. **Build app image** (`.github/workflows/build-app-image.yaml`): - * Builds the container image from `apps-source/`. - * Pushes `ghcr.io/magnusp/apps/archetype-backend:`. - * Stamps the OCI config labels `org.opencontainers.image.revision`, `org.opencontainers.image.vendor`, and `dev.authz.app.deployer`. - * Generates a GitHub build provenance attestation. -3. **Bump archetype-backend values** (`.github/workflows/publish-app-values.yaml`): - * Updates `image.tag` in `apps-source/values.yaml` to the target commit SHA. - * Pushes `apps-source/` as an OCI artifact to `ghcr.io/magnusp/apps/archetype-backend-values:latest` with deployer provenance annotations. - * Generates a GitHub build provenance attestation. - -### Releasing an Application Update (Runbook) - -Follow these steps to deploy an application change: - -1. **Merge changes** to the main application branch. -2. **Trigger `Build app image`**: - * Navigate to **Actions** > **Build app image** and run the workflow on your target commit. -3. **Trigger `Bump archetype-backend values`**: - * Run the workflow with the `image_tag` input set to the commit SHA built in Step 2. -4. **Verify Deployment**: - * Flux automatically detects the new values artifact digest, generates a new `ExternalArtifact`, and reconciles the `HelmRelease`: - - ```sh - kubectl get helmrelease -n flux-system archetype-backend-demo - kubectl get pods -n apps -l app.kubernetes.io/instance=archetype-backend-demo - kubectl get deploy -n apps apps-archetype-backend-demo \ - -o jsonpath='{.items[0].spec.template.spec.containers[0].image}' - ``` +This diagram reflects tier 2 and above — see the [Tiers](#tiers) section above for what's present at +earlier tiers. --- -## Platform Governance & Kyverno Policies - -This repository separates policy enforcement into two layers and scopes governance policies to opt-in application workspaces labeled `governance.platform.io/managed: "true"` (e.g. `namespace/apps`): +## Directory structure -1. **Platform Validation Policy** ([`clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml`](clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml)): - * Enforces across managed workspaces that developers and incoming Helm charts cannot manually set or forge the `example.com/image-revision` annotation on `Deployment` templates. -2. **Archetype Mutation Policy** ([`charts/archetype-backend/templates/policy.yaml`](charts/archetype-backend/templates/policy.yaml)): - * A namespaced Kyverno `Policy` packaged with the archetype chart. - * At admission time, it queries the OCI registry for the container image configuration, extracts `org.opencontainers.image.revision`, and injects it into `spec.template.metadata.annotations`. -3. **Image Base Ancestor & Layer Policy** ([`clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml`](clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml)): - * Inspects the container image filesystem configuration at admission time using Kyverno's `imageRegistry` context. - * Iterates across root filesystem layer hashes (`imageData.configData.rootfs.diff_ids`) to cryptographically assert that the container image is derived from an approved `nginx:1.27` base image (`apps-source/Dockerfile`), regardless of intermediate build steps. +* [`tier-0/`](tier-0/) through [`tier-4/`](tier-4/): isolated tier directories, each with its own + `kind-cluster/` (OpenTofu), application/chart/policy manifests, and `README.md` explainer. See + [Tiers](#tiers) above. +* [`.github/workflows/`](.github/workflows/): GitHub Actions workflows for publishing charts, images, + and values artifacts with build provenance attestations. Each workflow notes the tier it becomes + relevant at. -To verify that the verified image revision was stamped on the running workload: - -```sh -kubectl get deploy -n apps apps-archetype-backend-demo \ - -o jsonpath='{.spec.template.metadata.annotations}' -``` - -### Policy Reporter & Dashboard - -Policy Reporter is installed as a Flux `HelmRelease` (`kind-cluster/policy-reporter.tf`) and persists policy execution history and violation reports in an embedded SQLite database backed by a persistent volume (`policy-reporter-sqlite-pvc`). - -To access the interactive Policy Reporter web dashboard: - -```sh -kubectl port-forward -n policy-reporter svc/policy-reporter-ui 8080:8080 -``` - -Open `http://localhost:8080` in your browser to view real-time Kyverno policy reports, audit logs, and compliance metrics. - -### SpiceDB ReBAC Authorization +--- -The cluster includes an ephemeral SpiceDB instance managed by the **SpiceDB Operator** (`clusters/kind/spicedb-operator.yaml` & `clusters/kind/spicedb-cluster.yaml`) and an admission gate policy ([`clusters/kind/clusterpolicy-spicedb-authz.yaml`](clusters/kind/clusterpolicy-spicedb-authz.yaml)): +## Prerequisites -1. **OCI Deployer Metadata**: When workflows build container images and package values artifacts, they embed the triggering actor (`dev.authz.app.deployer`) in the OCI labels. -2. **Admission Gate Check**: When Flux reconciles a deployment, Kyverno extracts the deployer identity and queries SpiceDB's `/v1/permissions/check` API to verify if the actor has `deploy` permissions on the service. -3. **Human-Readable Fixtures (`fixtures/spicedb/`)**: - * [`fixtures/spicedb/schema.zed`](fixtures/spicedb/schema.zed): Standard SpiceDB schema definition using `.zed` syntax. - * [`fixtures/spicedb/relationships.txt`](fixtures/spicedb/relationships.txt): Line-delimited relationship tuples (`resource#relation@subject`). -4. **Experimenting & Testing Permissions**: +Each tier's tooling is version-pinned via `mise` (see that tier's `mise.toml` and +`kind-cluster/mise.toml`): ```sh -# Port-forward SpiceDB HTTP API -kubectl port-forward -n authz svc/spicedb 8443:8443 - -# Check if user 'magnusp' has deploy permission -./scripts/spicedb-fixture.sh check magnusp - -# Check an unauthorized user -./scripts/spicedb-fixture.sh check unauthorized-dev - -# Edit fixtures/spicedb/relationships.txt or schema.zed, then apply: -./scripts/spicedb-fixture.sh apply +cd tier-N +mise install ``` ---- - -## Attestation & Provenance Verification +## Cluster lifecycle -All published OCI artifacts (charts, images, and values) include GitHub Actions build provenance attestations. - -To verify that an artifact was produced by an authentic repository workflow using the GitHub CLI: +Every tier's cluster lifecycle is managed by its own `tier-N/kind-cluster/cluster.sh`: ```sh -# Verify platform Helm chart -gh attestation verify oci://ghcr.io/magnusp/charts/: --owner magnusp - -# Verify application container image -gh attestation verify oci://ghcr.io/magnusp/apps/archetype-backend: --owner magnusp +cd tier-N/kind-cluster -# Verify application values artifact -gh attestation verify oci://ghcr.io/magnusp/apps/archetype-backend-values:latest --owner magnusp +./cluster.sh up # Create the tier's kind cluster and bring up whatever that tier needs +./cluster.sh check # Check readiness and print component status +./cluster.sh down # Tear the tier's cluster down ``` ---- - -## Bare-Metal & Alternative Delivery Options +> **Note**: `up` and `check` automatically configure `KUBECONFIG` from that tier's OpenTofu state. You +> do not need to export `KUBECONFIG` manually. Tiers use distinct kind cluster names (`tier-0` … +> `tier-4`), so multiple tiers could in principle run side by side. -While this repository demonstrates GitHub Actions with GitHub OIDC, the same Kyverno and Flux architecture adapts directly to **bare-metal / on-premises clusters** using modern Identity Providers (Entra ID, Google Workspace, GitHub, Okta, Keycloak) without requiring cloud-hosted Kubernetes (EKS/GKE/AKS) or cloud KMS: +For the deep-dive on any specific tier's application delivery workflow, governance policies, +attestation verification, or SpiceDB ReBAC setup, see that tier's own `README.md` — most of that detail +now lives in [`tier-4/README.md`](tier-4/README.md), since it's the tier where all of it is present. -| Pattern | Signing & Identity Mechanism | Kyverno Verification Mechanism | -| :--- | :--- | :--- | -| **Developer Workstation CLI** | Cosign with Corporate OIDC (Microsoft Entra ID, Google Workspace, GitHub) + Public Sigstore Rekor | `verifyImages` keyless rule matching corporate issuer (e.g. `login.microsoftonline.com`, `accounts.google.com`) and user email regex. | -| **Self-Hosted CI Runners** | Bare-metal runners (GitLab CI, Jenkins, Drone) signing via HashiCorp Vault Transit Engine or local Cosign keys | `verifyImages` rule checking static public keys stored in a Kubernetes `Secret` or fetched from on-prem Vault. | -| **ChatOps / Webhooks** | Slack / Mattermost webhook $\rightarrow$ Flux `Receiver` carrying triggering user email | Kyverno `apiCall` querying in-cluster SpiceDB to verify if the user has `deploy` permissions on the service. | -| **Direct `kubectl` Access** | Entra ID / Google / Keycloak OIDC Kubeconfig | Kyverno validation evaluating `request.userInfo.username` against SpiceDB ReBAC; blocks direct production edits in favor of GitOps. | -| **Automated Dependency Bots** | Renovate / Dependabot with dedicated bot keypair | Public key verification + OpenVEX / in-toto vulnerability scan conditions. | - -### Example: Keyless Sigstore with Microsoft Entra ID / Google Workspace -```yaml -apiVersion: kyverno.io/v1 -kind: ClusterPolicy -metadata: - name: verify-corporate-oidc-attestations -spec: - validationFailureAction: Enforce - rules: - - name: verify-developer-identity - match: - any: - - resources: - kinds: [Deployment] - namespaceSelector: - matchLabels: - governance.platform.io/managed: "true" - verifyImages: - - imageReferences: ["ghcr.io/magnusp/apps/*"] - attestations: - - type: "https://slsa.dev/provenance/v1" - attestors: - - entries: - - keyless: - # Microsoft Entra ID, Google Workspace, or GitHub - issuer: "https://login.microsoftonline.com//v2.0" - subjectRegExp: ".*@company.com" - rekor: - url: "https://rekor.sigstore.dev" -``` +CI workflows in [`.github/workflows/`](.github/workflows/) live at the repository root (a GitHub Actions +requirement) but are only meaningful once you've reached the tier that introduces them — each workflow +file notes the tier it becomes relevant at. diff --git a/TIMELINE.md b/TIMELINE.md index ed7e020..3c4296a 100644 --- a/TIMELINE.md +++ b/TIMELINE.md @@ -48,7 +48,7 @@ This document records the chronological development, architectural trade-offs, a * **Result**: Zero polling lag and native event-driven upgrades whenever either the chart or values artifact updates in GHCR. * **[PR #11](https://github.com/magnusp/declarative-deploys/pull/11)**: *Remove cert-manager* * **Context**: Evaluated cluster dependencies. The showcase workloads only use `Deployment` and `Service` without ingress or certificates. - * **Decision**: Removed `cert-manager` from OpenTofu, Flux, and cluster health checks, speeding up cluster standup time to under 90 seconds. + * **Decision**: Removed `cert-manager` from OpenTofu, Flux, and cluster health checks, reducing the number of components a cold cluster has to bring up. (No standup-time measurement was recorded for this change; treat any specific figure as unverified.) --- @@ -82,7 +82,112 @@ This document records the chronological development, architectural trade-offs, a 3. **SpiceDB Operator via Flux**: Installed `authzed/spicedb-operator` using Flux `GitRepository` + `Kustomization` with explicit RBAC extensions (`spicedb-operator-rbac.yaml`). 4. **In-Cluster Ephemeral SpiceDB & Human-Readable Fixtures**: Deployed a `SpiceDBCluster` resource in namespace `authz` and an automated initialization `Job` that seeds schema (`schema.zed`) and relationship tuples (`relationships.txt`) from a ConfigMap. 5. **Kyverno Admission Policy**: Added `ClusterPolicy/spicedb-attested-deploy-authz` querying SpiceDB's `/v1/permissions/check` API to assert that the actor has `deploy` permissions before admitting the workload. - 6. **Cryptographic Base Image Lineage Policy**: Added `ClusterPolicy/verify-app-image-nginx-ancestor` using Kyverno's `verifyImages` to cryptographically verify GitHub SLSA v1 build provenance and enforce that deployed application containers are derived from an approved `nginx` base image. +* **[PR #18](https://github.com/magnusp/declarative-deploys/pull/18)**: *Add Kyverno verifyImages policy enforcing SLSA-attested nginx base image lineage in apps workspace* + * **Decision**: Added `ClusterPolicy/verify-app-image-nginx-ancestor` using Kyverno's `verifyImages` with keyless Sigstore/Rekor attestors, cryptographically verifying GitHub SLSA v1 build provenance to enforce that deployed application containers are derived from an approved `nginx` base image. (This mechanism was replaced in PR #20 — see below.) + +--- + +### Interim: Version Bumps and a Mechanism Replacement + +* **[PR #19](https://github.com/magnusp/declarative-deploys/pull/19)**: *Add bare-metal and alternative delivery options with modern identity providers* — README-only, documenting non-GitHub-Actions delivery patterns. +* **[PR #20](https://github.com/magnusp/declarative-deploys/pull/20)**: *Bump tool versions, OpenTofu providers, SpiceDB, and verify image base layer ancestry* + * **Decision**: Bumped Flux to `2.9.4` (from the `2.7.5` set in PR #8) and other pinned tool/provider versions. + * **Also replaced `verify-app-image-nginx-ancestor`'s mechanism**: dropped the `verifyImages` + + keyless Sigstore/Rekor attestor approach from PR #18 in favor of an `imageRegistry` context plus a + JMESPath `deny` rule comparing `imageData.configData.rootfs.diff_ids` against two hardcoded base + layer digests. **This is a materially different, weaker guarantee** — a layer-hash allowlist, not + a signature or attestation check — and the policy's own header comment plus this file's Phase 5 + entry above were not updated to reflect the change until a later verification pass caught the + discrepancy (see the entry below). + +--- + +### Phase 6: Tiered Progression Restructure + +* **Scale down into a tiered showcase**: *Restructure into a tiered progression (tier-0 through tier-5)* + * **Problem**: The repository demonstrated its full final architecture (chart/app split, OCI artifact + composition, SpiceDB ReBAC, two-layer Kyverno governance, Policy Reporter) as a single indivisible + state. There was no way to learn or demo the platform/app split without also standing up every + governance and authorization layer at once. + * **Solution**: Split the repository into six self-contained tiers (`tier-0` … `tier-5`), each with + its own kind cluster, OpenTofu stack, and manifests, peeling back one capability at a time from + the original final state (now `tier-5`, moved via `git mv` to preserve history): + 1. **tier-0**: raw manifests, `kubectl apply`, no reconciler, no Helm. + 2. **tier-1**: same raw manifests, now reconciled by Flux. + 3. **tier-2**: introduces the platform/app split via a Helm chart (`chartRef` directly to the + chart `OCIRepository`, inline values, no `ArtifactGenerator` yet). + 4. **tier-3**: adds the app-published values OCI artifact and `ArtifactGenerator`/`ExternalArtifact` + composition, decoupling app deploys from git commits. + 5. **tier-4**: adds SpiceDB ReBAC, scoped directly to the `apps` namespace (no label opt-in yet). + 6. **tier-5**: unchanged final state — retrofits the SpiceDB policy to the + `governance.platform.io/managed` label scheme and adds both image-integrity `ClusterPolicy` + objects plus Policy Reporter. + * **Decision**: CI workflows stay at the repository root (a GitHub Actions requirement) but are + hardcoded to `tier-5/` paths, since only tier-5 has the full publishing pipeline; each workflow + notes the tier it becomes relevant at. The root `README.md` absorbed the tier index (formerly a + separate `TIERS.md`) as its "Tiers" section, so there is a single entry point into the repository. +* **Collapse tier-0 into tier-1 to match a real baseline**: *Renumber to a five-tier progression + (tier-0 through tier-4)* + * **Problem**: The six-tier progression above started from a "worst case" of raw, hand-applied + manifests with no Flux at all. That doesn't match the environment this showcase is meant to + prepare people for: Flux is already reconciling a cluster repository there, and a custom + in-house deployer tool already automates getting manifests into it — triggered by a merge to an + app repo's default branch, it optionally builds/tags a Docker image and optionally templates + manifests and git-commits them into the Flux-managed repo. Starting from raw `kubectl apply` + taught a lesson (GitOps reconciliation) that this audience has already internalized. + * **Solution**: Collapsed the old `tier-0` (raw manifests, hand-applied) and old `tier-1` (the + same manifests, now Flux-reconciled) into a single new `tier-0` that starts from "Flux + a + deployer tool already exist, but there's no platform/app split yet" — narrated entirely in + `tier-0/README.md`; the deployer tool itself is described in prose, not implemented in the repo. + Every subsequent tier renumbered down by one (old `tier-2`→`tier-1`, `tier-3`→`tier-2`, + `tier-4`→`tier-3`, `tier-5`→`tier-4`), so the progression is now five tiers, not six, and the + final governance tier lives at `tier-4/` instead of `tier-5/`. + +--- + +### Phase 7: Verification Pass and Defect Remediation + +* **Cross-checking every technical claim in the repo against upstream sources and live cluster + behavior surfaced that the SpiceDB admission gate had never actually worked**, in any tier, at any + point since PR #17 introduced it — masked because a precondition silently skipped the whole rule + whenever an image lacked a `dev.authz.app.deployer` label, and every image tested against it up to + this point lacked one (the values artifact published to GHCR pinned an image built before that label + existed). Underneath that mask were two more independent defects: + 1. `apiCall.urlPath` was used to address SpiceDB, an in-cluster HTTP service — `urlPath` only ever + addresses the Kubernetes API server and is mutually exclusive with `apiCall.service.url`, which + is what an external/in-cluster call requires. + 2. No `Authorization` header was sent, despite SpiceDB requiring the preshared key configured on its + `secretName`. + * **Decision**: Fixed the `apiCall` to use `service.url` + the bearer token, and replaced the + skip-precondition with an explicit deny condition so a missing deployer identity is now denied + (fail-closed) rather than silently admitted (fail-open) — matching what the tier-3/4 READMEs had + claimed the gate did all along. Also added a `revoke` subcommand to `spicedb-fixture.sh`, since + `apply` only ever upserts relationships and the READMEs' own demonstration instructions ("edit + relationships.txt to remove a tuple, re-apply") could never have worked. Verified end to end on + both tier-3 and tier-4: an unauthorized deploy is now genuinely rejected at admission, and + recovers correctly once permission is restored. +* **Policy Reporter's Kyverno plugin was never actually installed**: `policy-reporter.tf` set + `kyvernoPlugin.enabled`, which isn't a key in policy-reporter chart 3.10.0 (the real path is + `plugin.kyverno.enabled`); Helm silently ignores unknown top-level keys. Fixed and confirmed the + plugin pod now starts. +* **`publish-app-values.yaml` and `publish-chart.yaml` ran `mise install` from the repository root**, + which has no `mise.toml`, so `flux` (unlike `yq`/`helm`, which happen to be preinstalled on GitHub's + runner image) was never actually installed; the resulting `flux: command not found` was masked by an + unpiped `flux push | tee`, surfacing two steps later as an opaque "subject-digest must be provided" + failure in the attestation step instead of where it actually happened. Fixed both workflows to run + `mise` from `tier-4/`, and added `pipefail` to the push step. +* **`spec.validationFailureAction`** on all four `ClusterPolicy` objects was migrated to + `spec.rules[].validate.failureAction`, per Kyverno's own deprecation notice (the old field still + functioned, but is being removed). +* Corrected several stale or false documentation claims discovered in the same pass: a false claim + that Flux natively supports canary rollouts (that's Flagger, a separate project); the + `clusterpolicy-verify-image-nginx-ancestor.yaml` header comment and multiple READMEs still describing + the PR #18 `verifyImages`/SLSA mechanism that PR #20 replaced; the deployer label being credited to + `publish-app-values.yaml` instead of `build-app-image.yaml` (the label the policy actually reads); + stale references to `spicedb-operator.yaml`/`spicedb-operator-rbac.yaml`, which had moved into + `kind-cluster/spicedb-operator.tf`; a broken `-l app.kubernetes.io/instance=` selector and a stray + `.items[0]` in a single-resource `kubectl get -o jsonpath`; and a few leftover "tier 3" references + from the tier-0/tier-1 renumbering. --- @@ -96,5 +201,6 @@ This document records the chronological development, architectural trade-offs, a | **Policy Scope** | Single static `ClusterPolicy` in GitOps | Split: Platform Validation (`ClusterPolicy`) + Chart Mutation (`Policy`) | Guarantees tamper-resistance while making archetype charts self-contained. | | **Policy Reporting** | None (in-memory reports only) | Policy Reporter + Persistent SQLite (PVC) + Web UI | Persists policy reports and audit logs locally with zero external database dependencies. | | **Deployment Authorization** | Kubernetes RBAC on Flux machine account | Provenance Deployer Identity + SpiceDB ReBAC check | Enforces decentralized zero-trust access control without giving developers cluster credentials. | -| **Base Image Lineage** | Unverified container base layers | Kyverno `verifyImages` with SLSA v1 Attestation | Cryptographically guarantees that all admitted workloads derive from trusted golden base images. | -| **In-Cluster TLS** | `cert-manager` installed via Flux | Removed | Reduced cluster surface area and cut standup time in half. | +| **Base Image Lineage** | Unverified container base layers | Kyverno `imageRegistry` context + hardcoded rootfs layer-digest allowlist (`clusterpolicy-verify-image-nginx-ancestor.yaml`) | Asserts a specific base layer digest is present. **Not** a signature or attestation check (PR #18's original `verifyImages` + SLSA approach was replaced by PR #20 without updating this row at the time) — carries no cryptographic guarantee about who built the image, and goes stale whenever the `nginx:1.27` base is rebuilt upstream. | +| **In-Cluster TLS** | `cert-manager` installed via Flux | Removed | Reduced cluster surface area; no verified standup-time figure exists for this change. | +| **Repository Structure** | Single flat directory tree at the final architecture | Five isolated `tier-N/` directories (`tier-0`…`tier-4`, after the tier-0/tier-1 collapse), each a complete standalone stack | Lets the platform/app split, GitOps, and governance concerns be learned and demoed incrementally instead of all at once. | diff --git a/charts/archetype-backend/Chart.yaml b/charts/archetype-backend/Chart.yaml deleted file mode 100644 index 8e96e97..0000000 --- a/charts/archetype-backend/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: archetype-backend -description: Baseline backend service chart (Deployment + Service) maintained by platform engineering -type: application -version: 0.1.0 -appVersion: "1.0.0" diff --git a/clusters/kind/spicedb-operator-rbac.yaml b/clusters/kind/spicedb-operator-rbac.yaml deleted file mode 100644 index a911cc2..0000000 --- a/clusters/kind/spicedb-operator-rbac.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# RBAC ClusterRole extension granting spicedb-operator permissions to manage PodDisruptionBudgets -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: spicedb-operator-pdb-extension -rules: - - apiGroups: - - policy - resources: - - poddisruptionbudgets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - - apiGroups: - - discovery.k8s.io - resources: - - endpointslices - verbs: - - create - - delete - - get - - list - - patch - - update - - watch ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: spicedb-operator-pdb-extension -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: spicedb-operator-pdb-extension -subjects: - - kind: ServiceAccount - name: spicedb-operator - namespace: spicedb-operator diff --git a/clusters/kind/spicedb-operator.yaml b/clusters/kind/spicedb-operator.yaml deleted file mode 100644 index 42c28ef..0000000 --- a/clusters/kind/spicedb-operator.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Tracks the upstream authzed/spicedb-operator git repository. -apiVersion: source.toolkit.fluxcd.io/v1 -kind: GitRepository -metadata: - name: spicedb-operator - namespace: flux-system -spec: - interval: 2h - url: https://github.com/authzed/spicedb-operator - ref: - tag: v1.26.0 ---- -# Reconciles the SpiceDB Operator CRDs and controller with Server-Side Apply. -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: spicedb-operator - namespace: flux-system -spec: - interval: 2h - path: ./config - prune: true - wait: true - sourceRef: - kind: GitRepository - name: spicedb-operator diff --git a/docs/team-conflicts-infographic.html b/docs/team-conflicts-infographic.html new file mode 100644 index 0000000..ab0393c --- /dev/null +++ b/docs/team-conflicts-infographic.html @@ -0,0 +1,210 @@ + + + + + +Resolving developer / platform / security conflicts across tiers + + + +
+

Where developers, platform teams, and security clash — and where each tier resolves it

+

+ Each row is a recurring source of friction between the three roles in this repo's tiered + GitOps progression. Each column is a tier. Status shows whether that friction is still + open, partially addressed, or resolved by the mechanisms introduced at that tier. +

+ +
+
+
Unresolved
+
Emerging risk
+
Partially resolved
+
Resolved
+
Not applicable
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Hover a status for the mechanism that changed it at that tier.
Tier 0Flux + deployer toolTier 1+ Helm chart splitTier 2+ OCI values artifactTier 3+ SpiceDB ReBACTier 4+ Kyverno governance
Config drift & audit trailDeveloper vs. Platform✓ resolved✓ resolved✓ resolved✓ resolved✓ resolved
Ownership boundaryDeveloper vs. Platform✕ critical✓ resolved✓ resolved✓ resolved✓ resolved
Release cadence couplingDeveloper vs. Platform✕ critical△ emerging✓ resolved✓ resolved✓ resolved
Deploy authorizationDeveloper vs. Security✕ critical✕ critical△ emerging◐ partial✓ resolved
Image / supply-chain trustPlatform vs. Security✕ critical△ emerging△ emerging△ emerging✓ resolved
Policy blast radiusPlatform vs. Developern/an/an/a◐ partial✓ resolved
Source: this repo's tier-0 → tier-4 progression (see the root README's Tiers section). "Developer" = application team, "Platform" = platform engineering, "Security" = the governance/authorization layer (Kyverno + SpiceDB).
+
+
+ + diff --git a/tier-0/README.md b/tier-0/README.md new file mode 100644 index 0000000..809dfad --- /dev/null +++ b/tier-0/README.md @@ -0,0 +1,76 @@ +# Tier 0 — Flux + a deployer tool, raw manifests + +This is the starting point for this progression, and it's meant to match a real baseline many +organizations already have: Flux is already reconciling a cluster repository, and a custom **deployer +tool** — not something in this repo, just described here — already automates getting manifests into +that repository. Nobody hand-edits YAML or runs `kubectl apply` by hand; the gap this progression closes +from here on is the platform/app split, not GitOps itself. + +## What this tier demonstrates + +* **The deployer tool** (described here, not implemented in this repo): triggered by a merge to an + application repository's default branch, it optionally builds and tags a Docker image with the + merge-derived version, and optionally deploys by templating Kubernetes manifests and git-committing + them into this Flux-managed cluster repository (`manifests/`). Both steps are opt-in per app team, and + neither publishes anything to an OCI registry — the only record of "what's deployed" is the commit the + tool makes here. +* **`flux-operator` + `FluxInstance`** (`kind-cluster/flux.tf`): installs Flux via its OCI-hosted + operator chart, then a `FluxInstance` CR tells `flux-operator` which controllers to run and which git + repository/path to sync (`tier-0/manifests`, this GitHub repo, `main` branch). `flux-operator` + translates that `sync` block into a `GitRepository` + `Kustomization` automatically. +* **Raw manifests, no Helm** (`manifests/namespace-apps.yaml`, `manifests/deployment-archetype-backend.yaml`, + `manifests/service-archetype-backend.yaml`): whatever the deployer tool templates still lands here as + plain `Deployment`/`Service` objects, not a chart. There's no platform/app ownership split yet — one + set of manifests, templated and committed by one tool, covers the whole workload's shape and its + environment-specific values together. +* **The lesson this tier sets up**: even with Flux and a deployer tool already automating the commit, + "deploy" still means "a git commit to this repo," and the workload's shape (container, probes, + resources) and its per-app values (image tag, replica count) are still the same undifferentiated + templated output. Tier 1 splits those two concerns apart with a Helm chart. + +## Directory layout + +* `kind-cluster/` — OpenTofu stack: `kind_cluster` + `flux-operator`/`FluxInstance` only, no Kyverno. +* `manifests/` — plain Kubernetes objects, standing in for what the deployer tool would template and + commit here, plus a `kustomization.yaml` so Flux's `Kustomization` controller can apply them as a unit. +* `apps-source/` — `Dockerfile` and static content for the demo app, standing in for the app repo the + deployer tool's image-build step would run against. + +## Getting started + +```sh +cd tier-0/kind-cluster +mise install +./cluster.sh up # Create the kind cluster (named tier-0), bootstrap Flux, verify health +./cluster.sh check +./cluster.sh down +``` + +### Verify + +```sh +kubectl get kustomization -n flux-system +kubectl get pods -n apps +``` + +Simulate what the deployer tool would do on a merge — edit `manifests/deployment-archetype-backend.yaml` +(e.g. bump the image tag), commit and push, then watch Flux pick it up: + +```sh +flux reconcile kustomization flux-system -n flux-system --with-source +kubectl get deploy -n apps archetype-backend-demo -o jsonpath='{.spec.template.spec.containers[0].image}' +``` + +## Progressing to tier 1 + +Tier 1 replaces the raw manifests with a Helm chart, introducing the platform/app split: + +1. Platform engineering authors `charts/archetype-backend/` and publishes it to GHCR by SemVer — + independently of anything the deployer tool does. +2. `manifests/deployment-archetype-backend.yaml` and `manifests/service-archetype-backend.yaml` are + gone — the chart is now the only source of the workload's shape. +3. A `HelmRelease` sourced from that chart (via `chartRef: {kind: OCIRepository}`) replaces the raw + objects in the synced directory, with the app's values inline in the `HelmRelease` for now. + +See [`tier-1/README.md`](../tier-1/README.md) for the full detail, and the root +[README](../README.md#tiers) for the overall progression. diff --git a/apps-source/Dockerfile b/tier-0/apps-source/Dockerfile similarity index 100% rename from apps-source/Dockerfile rename to tier-0/apps-source/Dockerfile diff --git a/apps-source/index.html b/tier-0/apps-source/index.html similarity index 100% rename from apps-source/index.html rename to tier-0/apps-source/index.html diff --git a/kind-cluster/.gitignore b/tier-0/kind-cluster/.gitignore similarity index 87% rename from kind-cluster/.gitignore rename to tier-0/kind-cluster/.gitignore index 48b5da0..13be894 100644 --- a/kind-cluster/.gitignore +++ b/tier-0/kind-cluster/.gitignore @@ -5,3 +5,4 @@ terraform.tfstate.backup *.tfstate.* *.tfvars kind-config +tier-*-config diff --git a/kind-cluster/.terraform.lock.hcl b/tier-0/kind-cluster/.terraform.lock.hcl similarity index 100% rename from kind-cluster/.terraform.lock.hcl rename to tier-0/kind-cluster/.terraform.lock.hcl diff --git a/tier-0/kind-cluster/cluster.sh b/tier-0/kind-cluster/cluster.sh new file mode 100755 index 0000000..0b1200c --- /dev/null +++ b/tier-0/kind-cluster/cluster.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Stands up or tears down the local kind cluster and Flux bootstrap via OpenTofu. +# +# Usage: +# ./cluster.sh up Create the cluster and apply the OpenTofu stack. +# ./cluster.sh down Destroy the OpenTofu stack and the cluster. +# ./cluster.sh check Wait for Flux to become ready and report status. +# +# The repository is public, so Flux needs no credentials to clone it and +# sync tier-0/manifests/. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +usage() { + echo "Usage: $0 {up|down|check}" >&2 + exit 1 +} + +kubeconfig_env() { + KUBECONFIG=$(mktemp) + export KUBECONFIG + mise exec -- tofu output -raw kubeconfig > "$KUBECONFIG" +} + +check() { + kubeconfig_env + + echo "Waiting for flux-system pods..." + mise exec -- kubectl wait --for=condition=Ready pods --all -n flux-system --timeout=180s + + echo "Waiting for FluxInstance to become Ready..." + mise exec -- kubectl wait --for=condition=Ready fluxinstance/flux -n flux-system --timeout=180s + + echo + echo "--- flux-system pods ---" + mise exec -- kubectl get pods -n flux-system + echo + echo "Cluster and Flux bootstrap are healthy." +} + +[ $# -eq 1 ] || usage + +case "$1" in + up) + mise install + mise exec -- tofu init + mise exec -- tofu apply -auto-approve + check + ;; + down) + mise exec -- tofu destroy -auto-approve + ;; + check) + check + ;; + *) + usage + ;; +esac diff --git a/tier-0/kind-cluster/flux.tf b/tier-0/kind-cluster/flux.tf new file mode 100644 index 0000000..9a2f07d --- /dev/null +++ b/tier-0/kind-cluster/flux.tf @@ -0,0 +1,57 @@ +# Flux is installed via the flux-operator Helm chart, pulled directly from its +# OCI registry (oci://ghcr.io/controlplane/charts/flux-operator) rather than a +# traditional Helm repo, since we standardize on OCI-hosted charts. +resource "helm_release" "flux_operator" { + name = "flux-operator" + namespace = "flux-system" + repository = "oci://ghcr.io/controlplaneio-fluxcd/charts" + chart = "flux-operator" + version = var.flux_operator_version + + create_namespace = true + + depends_on = [kind_cluster.this] +} + +# The FluxInstance CR tells flux-operator which Flux controllers to run and, +# optionally, which git repository to sync. Source controllers for both +# OCIRepository and GitRepository are enabled so charts can be pulled from +# OCI registries by default, with git sync available when configured. +resource "kubectl_manifest" "flux_instance" { + yaml_body = yamlencode({ + apiVersion = "fluxcd.controlplane.io/v1" + kind = "FluxInstance" + metadata = { + name = "flux" + namespace = "flux-system" + } + spec = { + distribution = { + version = var.flux_version + registry = "ghcr.io/fluxcd" + } + components = [ + "source-controller", + "kustomize-controller", + "helm-controller", + "notification-controller", + "source-watcher", + ] + cluster = { + multitenant = false + networkPolicy = true + } + # The repository is public, so no pullSecret is needed to clone it. + # This syncs flux_git_path (tier-0/manifests) as a Kustomization — + # plain Deployment/Service/Namespace YAML, no Helm involved yet. + sync = var.flux_git_repository == "" ? null : { + kind = "GitRepository" + url = var.flux_git_repository + ref = "refs/heads/${var.flux_git_branch}" + path = var.flux_git_path + } + } + }) + + depends_on = [helm_release.flux_operator] +} diff --git a/kind-cluster/kind.tf b/tier-0/kind-cluster/kind.tf similarity index 100% rename from kind-cluster/kind.tf rename to tier-0/kind-cluster/kind.tf diff --git a/kind-cluster/mise.toml b/tier-0/kind-cluster/mise.toml similarity index 100% rename from kind-cluster/mise.toml rename to tier-0/kind-cluster/mise.toml diff --git a/kind-cluster/outputs.tf b/tier-0/kind-cluster/outputs.tf similarity index 100% rename from kind-cluster/outputs.tf rename to tier-0/kind-cluster/outputs.tf diff --git a/kind-cluster/providers.tf b/tier-0/kind-cluster/providers.tf similarity index 100% rename from kind-cluster/providers.tf rename to tier-0/kind-cluster/providers.tf diff --git a/tier-0/kind-cluster/variables.tf b/tier-0/kind-cluster/variables.tf new file mode 100644 index 0000000..4f1d4c0 --- /dev/null +++ b/tier-0/kind-cluster/variables.tf @@ -0,0 +1,41 @@ +variable "cluster_name" { + description = "Name of the kind cluster." + type = string + default = "tier-0" +} + +variable "kubernetes_version" { + description = "Kind node image tag (kubernetes version) to run. Leave null for the kind default." + type = string + default = null +} + +variable "flux_operator_version" { + description = "Version of the controlplane/flux-operator Helm chart to install from OCI." + type = string + default = "0.58.1" +} + +variable "flux_version" { + description = "Version of Flux the FluxInstance should reconcile." + type = string + default = "2.9.4" +} + +variable "flux_git_repository" { + description = "Optional git repository URL to bootstrap Flux against. Leave empty to manage sync purely via OCIRepository/HelmRelease objects applied later." + type = string + default = "https://github.com/magnusp/declarative-deploys" +} + +variable "flux_git_branch" { + description = "Git branch Flux should reconcile when flux_git_repository is set." + type = string + default = "main" +} + +variable "flux_git_path" { + description = "Path within the git repository containing the cluster's Flux manifests." + type = string + default = "tier-0/manifests" +} diff --git a/kind-cluster/versions.tf b/tier-0/kind-cluster/versions.tf similarity index 100% rename from kind-cluster/versions.tf rename to tier-0/kind-cluster/versions.tf diff --git a/tier-0/manifests/deployment-archetype-backend.yaml b/tier-0/manifests/deployment-archetype-backend.yaml new file mode 100644 index 0000000..34f4e54 --- /dev/null +++ b/tier-0/manifests/deployment-archetype-backend.yaml @@ -0,0 +1,27 @@ +# Plain Deployment for the archetype-backend demo app. No Helm chart, no +# templating — everything a change requires is a hand-edit of this file. +# The image is built by hand from apps-source/ and pushed manually; there is +# no CI publishing pipeline at this tier. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: archetype-backend-demo + namespace: apps + labels: + app.kubernetes.io/name: archetype-backend-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: archetype-backend-demo + template: + metadata: + labels: + app.kubernetes.io/name: archetype-backend-demo + spec: + containers: + - name: archetype-backend-demo + image: ghcr.io/magnusp/apps/archetype-backend:manual + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 diff --git a/tier-0/manifests/kustomization.yaml b/tier-0/manifests/kustomization.yaml new file mode 100644 index 0000000..167b1fa --- /dev/null +++ b/tier-0/manifests/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace-apps.yaml + - deployment-archetype-backend.yaml + - service-archetype-backend.yaml diff --git a/tier-0/manifests/namespace-apps.yaml b/tier-0/manifests/namespace-apps.yaml new file mode 100644 index 0000000..745ba23 --- /dev/null +++ b/tier-0/manifests/namespace-apps.yaml @@ -0,0 +1,5 @@ +# Dedicated application workspace namespace for showcase workloads. +apiVersion: v1 +kind: Namespace +metadata: + name: apps diff --git a/tier-0/manifests/service-archetype-backend.yaml b/tier-0/manifests/service-archetype-backend.yaml new file mode 100644 index 0000000..828a0fd --- /dev/null +++ b/tier-0/manifests/service-archetype-backend.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: archetype-backend-demo + namespace: apps +spec: + selector: + app.kubernetes.io/name: archetype-backend-demo + ports: + - port: 8080 + targetPort: 80 diff --git a/tier-1/README.md b/tier-1/README.md new file mode 100644 index 0000000..ca5cd24 --- /dev/null +++ b/tier-1/README.md @@ -0,0 +1,78 @@ +# Tier 1 — Helm chart and the platform/app split + +Builds on [tier 0](../tier-0/README.md) (Flux + a deployer tool, raw manifests) by introducing the core +lesson of this whole progression: **chart-authoring and values-authoring are different jobs**, decoupled +by publishing the chart to an OCI registry. + +## What this tier demonstrates + +* **Platform engineering owns a Helm chart** (`charts/archetype-backend/`): a `Deployment` + `Service` + template, published to `oci://ghcr.io/magnusp/charts/archetype-backend:` by + `.github/workflows/publish-chart.yaml`. +* **The chart is tracked by an `OCIRepository`** (`clusters/kind/ocirepository-archetype-backend.yaml`), + polled on Flux's normal interval — no event-driven composition yet, that's tier 2. +* **Application team owns deployment parameters, but not yet a publishing pipeline for them**: the + `HelmRelease` (`clusters/kind/helmrelease-archetype-backend.yaml`) sources its chart directly via + `chartRef: {kind: OCIRepository, name: archetype-backend}` and carries the app's values inline under + `spec.values`. `apps-source/values.yaml` still documents what those values should be, but at this tier + it's a reference a human copies from, not something CI publishes. +* **What's gone from tier 0**: the raw `Deployment`/`Service` manifests and the deployer tool's role in + templating them. The chart is now the only source of the workload's shape; the app team (or the + deployer tool acting on its behalf) can no longer diverge from the platform's container/probe/resource + conventions baked into the chart template. + +## Directory layout + +* `kind-cluster/` — OpenTofu stack (kind cluster named `tier-1` + Flux, no Kyverno). +* `charts/archetype-backend/` — the platform-owned chart. +* `clusters/kind/` — `namespace-apps.yaml`, `ocirepository-archetype-backend.yaml`, + `helmrelease-archetype-backend.yaml`. +* `apps-source/` — `Dockerfile`, static content, and `values.yaml` (reference only at this tier). + +## Getting started + +```sh +cd tier-1 +mise install + +cd kind-cluster +./cluster.sh up # Create the kind cluster (named tier-1), bootstrap Flux, verify health +./cluster.sh check +./cluster.sh down +``` + +### Verify + +```sh +kubectl get ocirepository -n flux-system archetype-backend +kubectl get helmrelease -n flux-system archetype-backend-demo +kubectl get deploy -n apps apps-archetype-backend-demo +``` + +No workflow publishes an image tagged `:latest` — `build-app-image.yaml` only ever pushes +`:${{ github.sha }}` — so `helmrelease-archetype-backend.yaml`'s inline `image.tag: "latest"` will sit +in `ImagePullBackOff` on a fresh cluster unless you build and load one yourself: + +```sh +docker build -t ghcr.io/magnusp/apps/archetype-backend:latest ../apps-source +kind load docker-image ghcr.io/magnusp/apps/archetype-backend:latest --name tier-1 +``` + +To roll out a new chart version, run `.github/workflows/publish-chart.yaml` with a bumped SemVer tag, +then bump `clusters/kind/ocirepository-archetype-backend.yaml`'s `spec.ref.tag` to match and commit it; +Flux picks up the new chart on its next poll (or `flux reconcile source oci archetype-backend`). + +## Progressing to tier 2 + +Tier 2 removes the need for the app team to hand-copy values or wait for a poll interval: + +1. `apps-source/values.yaml` becomes something CI publishes — `.github/workflows/publish-app-values.yaml` + pushes it to `oci://ghcr.io/magnusp/apps/archetype-backend-values:latest` after bumping `image.tag`. +2. `clusters/kind/ocirepository-archetype-backend-values.yaml` tracks that artifact. +3. `clusters/kind/artifactgenerator-archetype-backend.yaml` composes the chart and the published values + into an `ExternalArtifact`, and the `HelmRelease`'s `chartRef` switches from the raw chart + `OCIRepository` to that `ExternalArtifact` — giving immediate, event-driven reconciliation whenever + either changes, and letting the app team deploy without a single git commit to this repository. + +See [`tier-2/README.md`](../tier-2/README.md) for the full detail, and the root +[README](../README.md#tiers) for the overall progression. diff --git a/tier-1/apps-source/Dockerfile b/tier-1/apps-source/Dockerfile new file mode 100644 index 0000000..36c21c4 --- /dev/null +++ b/tier-1/apps-source/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.27 +COPY --chmod=0644 index.html /usr/share/nginx/html/index.html diff --git a/tier-1/apps-source/index.html b/tier-1/apps-source/index.html new file mode 100644 index 0000000..15f0063 --- /dev/null +++ b/tier-1/apps-source/index.html @@ -0,0 +1,11 @@ + + + + + archetype-backend + + +

archetype-backend

+

Simulated application, built on nginx, deployed via archetype-backend.

+ + diff --git a/tier-1/apps-source/values.yaml b/tier-1/apps-source/values.yaml new file mode 100644 index 0000000..4d35c6f --- /dev/null +++ b/tier-1/apps-source/values.yaml @@ -0,0 +1,13 @@ +# archetype-backend chart values for this application. At this tier these are +# hand-copied into clusters/kind/helmrelease-archetype-backend.yaml's inline +# spec.values — there is no publishing pipeline for values yet (see tier 2). +replicas: 1 + +image: + repository: ghcr.io/magnusp/apps/archetype-backend + tag: "latest" + pullPolicy: IfNotPresent + +service: + port: 8080 + targetPort: 80 diff --git a/tier-1/charts/archetype-backend/Chart.yaml b/tier-1/charts/archetype-backend/Chart.yaml new file mode 100644 index 0000000..c260771 --- /dev/null +++ b/tier-1/charts/archetype-backend/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: archetype-backend +description: Baseline backend service chart (Deployment + Service) maintained by platform engineering +type: application +# publish-chart.yaml overrides this with --version/--app-version at publish +# time; the OCIRepository tag Flux tracks (e.g. 0.1.1, 0.1.2) reflects what was +# actually published, not this file's version. +version: 0.1.0 +appVersion: "1.0.0" diff --git a/charts/archetype-backend/templates/_helpers.tpl b/tier-1/charts/archetype-backend/templates/_helpers.tpl similarity index 100% rename from charts/archetype-backend/templates/_helpers.tpl rename to tier-1/charts/archetype-backend/templates/_helpers.tpl diff --git a/charts/archetype-backend/templates/deployment.yaml b/tier-1/charts/archetype-backend/templates/deployment.yaml similarity index 100% rename from charts/archetype-backend/templates/deployment.yaml rename to tier-1/charts/archetype-backend/templates/deployment.yaml diff --git a/charts/archetype-backend/templates/service.yaml b/tier-1/charts/archetype-backend/templates/service.yaml similarity index 100% rename from charts/archetype-backend/templates/service.yaml rename to tier-1/charts/archetype-backend/templates/service.yaml diff --git a/charts/archetype-backend/values.schema.json b/tier-1/charts/archetype-backend/values.schema.json similarity index 100% rename from charts/archetype-backend/values.schema.json rename to tier-1/charts/archetype-backend/values.schema.json diff --git a/charts/archetype-backend/values.yaml b/tier-1/charts/archetype-backend/values.yaml similarity index 100% rename from charts/archetype-backend/values.yaml rename to tier-1/charts/archetype-backend/values.yaml diff --git a/tier-1/clusters/kind/helmrelease-archetype-backend.yaml b/tier-1/clusters/kind/helmrelease-archetype-backend.yaml new file mode 100644 index 0000000..4c83d3f --- /dev/null +++ b/tier-1/clusters/kind/helmrelease-archetype-backend.yaml @@ -0,0 +1,36 @@ +# Instance of the archetype-backend chart for the apps-source/ simulated +# application. The chart comes straight from GHCR (OCIRepository/archetype-backend); +# values are static and inline here, mirroring apps-source/values.yaml by hand. +# There is no OCI values artifact or composition step yet — that arrives in tier 2. +# +# Reconciliation happens on Flux's poll interval (10m) rather than being event-driven, +# since there's no ArtifactGenerator/ExternalArtifact watching for changes. +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: archetype-backend-demo + namespace: flux-system +spec: + targetNamespace: apps + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + strategy: rollback + remediateLastFailure: true + interval: 10m + chartRef: + kind: OCIRepository + name: archetype-backend + values: + replicas: 1 + image: + repository: ghcr.io/magnusp/apps/archetype-backend + tag: "latest" + pullPolicy: IfNotPresent + service: + port: 8080 + targetPort: 80 diff --git a/tier-1/clusters/kind/kustomization.yaml b/tier-1/clusters/kind/kustomization.yaml new file mode 100644 index 0000000..418d05a --- /dev/null +++ b/tier-1/clusters/kind/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace-apps.yaml + - ocirepository-archetype-backend.yaml + - helmrelease-archetype-backend.yaml diff --git a/tier-1/clusters/kind/namespace-apps.yaml b/tier-1/clusters/kind/namespace-apps.yaml new file mode 100644 index 0000000..745ba23 --- /dev/null +++ b/tier-1/clusters/kind/namespace-apps.yaml @@ -0,0 +1,5 @@ +# Dedicated application workspace namespace for showcase workloads. +apiVersion: v1 +kind: Namespace +metadata: + name: apps diff --git a/clusters/kind/ocirepository-archetype-backend.yaml b/tier-1/clusters/kind/ocirepository-archetype-backend.yaml similarity index 100% rename from clusters/kind/ocirepository-archetype-backend.yaml rename to tier-1/clusters/kind/ocirepository-archetype-backend.yaml diff --git a/tier-1/kind-cluster/.gitignore b/tier-1/kind-cluster/.gitignore new file mode 100644 index 0000000..13be894 --- /dev/null +++ b/tier-1/kind-cluster/.gitignore @@ -0,0 +1,8 @@ +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfstate +*.tfstate.* +*.tfvars +kind-config +tier-*-config diff --git a/tier-1/kind-cluster/.terraform.lock.hcl b/tier-1/kind-cluster/.terraform.lock.hcl new file mode 100644 index 0000000..8237ab3 --- /dev/null +++ b/tier-1/kind-cluster/.terraform.lock.hcl @@ -0,0 +1,131 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/gavinbunney/kubectl" { + version = "1.19.0" + constraints = "~> 1.19" + hashes = [ + "h1:/KiSv1EYOBNWL2Op7BqTmqnc/G2W/WvXIhUZlhnK0to=", + "h1:3E8E5zdLYTLsP9h+Ibvez8m+S0Qqa7QULHvcq8PcnWg=", + "h1:9QkxPjp0x5FZFfJbE+B7hBOoads9gmdfj9aYu5N4Sfc=", + "h1:DJC655V2Lw7zER1Gi+yN1ijW3A+bUAI3MucdYVhGxFY=", + "h1:EL1HfCDfY/pabIJ2kXuppTvqhgY9hfLu2o4TonJheT8=", + "h1:aQUITuBBjv8WJ+WGI0bOZXXYi5s09pMEyyJmlFnuUbE=", + "h1:gwnJt0UeVYnD2u8S0nGbQHvrUccNL3jeCqf9xu0X13I=", + "h1:hzxgobCLhmbGcgC2KD6aYleH17hiblT9vIHyEQKb1VA=", + "h1:iV249UVwKvuNaFHcvba4SitTyWpUNGvqdyy6SatjjH4=", + "h1:jTkF6nmuZvbXvl08L6iwe2RfV4PkPdy2Cnn+7aWkgu8=", + "h1:kevkHUKdNWcl+ET894COOV9i18hXeACWfu4AJXct/b0=", + "h1:ovkjqc3GnFlb5mVY51ZFV3VwrSbxQQtMTXYvEmBs2xg=", + "h1:quymfa/OKEfWI5JXFEwGbUY2aAy0vet3rA9JWJam+3k=", + "h1:tm5Tw1sfVURwtKGB34mUqekBxgo4HqANIV+uyrucYjA=", + "zh:1dec8766336ac5b00b3d8f62e3fff6390f5f60699c9299920fc9861a76f00c71", + "zh:43f101b56b58d7fead6a511728b4e09f7c41dc2e3963f59cf1c146c4767c6cb7", + "zh:4c4fbaa44f60e722f25cc05ee11dfaec282893c5c0ffa27bc88c382dbfbaa35c", + "zh:51dd23238b7b677b8a1abbfcc7deec53ffa5ec79e58e3b54d6be334d3d01bc0e", + "zh:5afc2ebc75b9d708730dbabdc8f94dd559d7f2fc5a31c5101358bd8d016916ba", + "zh:6be6e72d4663776390a82a37e34f7359f726d0120df622f4a2b46619338a168e", + "zh:72642d5fcf1e3febb6e5d4ae7b592bb9ff3cb220af041dbda893588e4bf30c0c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a1da03e3239867b35812ee031a1060fed6e8d8e458e2eaca48b5dd51b35f56f7", + "zh:b98b6a6728fe277fcd133bdfa7237bd733eae233f09653523f14460f608f8ba2", + "zh:bb8b071d0437f4767695c6158a3cb70df9f52e377c67019971d888b99147511f", + "zh:dc89ce4b63bfef708ec29c17e85ad0232a1794336dc54dd88c3ba0b77e764f71", + "zh:dd7dd18f1f8218c6cd19592288fde32dccc743cde05b9feeb2883f37c2ff4b4e", + "zh:ec4bd5ab3872dedb39fe528319b4bba609306e12ee90971495f109e142d66310", + "zh:f610ead42f724c82f5463e0e71fa735a11ffb6101880665d93f48b4a67b9ad82", + ] +} + +provider "registry.opentofu.org/hashicorp/helm" { + version = "3.2.0" + constraints = "~> 3.2.0" + hashes = [ + "h1:/TdLShnQEtyQRij46dZJobeA8clDnuncSdc2KRkc0B0=", + "h1:8j4dgSY+8Y9tukMTo0WuKPyJx0JKUeZsYx9ltTUmEuo=", + "h1:AOxR+hQy92GqWcp4rnD3ZBrDlip7M7fOB4zL4aw8AU4=", + "h1:IytRFhZXOMPVB36GainacnNoBtiibAdVMdezG2DPVag=", + "h1:XpXkXaOaDyICkDLyrRNBzxqUc8zrKp6Y7sw17FGD+Xg=", + "h1:c1NQPYp6/iIg0PG/HFodq0oyDVI/FAYZF32eEOliWaw=", + "h1:dBAYwgWCv9/ulFx7uxbkNzPYL9TI1aqUF8E7EBJbIRk=", + "h1:h05ap1OKMOtgqspVfdc1GmkX9vJJHquxn5g26Ik7+t4=", + "h1:h21YEXSmcV7Xe64kptnt0WQYEh6m99jVR8sec2o9stk=", + "h1:ikX+6kgc+kYNDAiNghhOhhY15T1Jt6omoWoHlh8CEsY=", + "h1:jvnisz9lHFn+5sjbuZwGCqqCvbddTeqWvrYtK4Wcbxw=", + "h1:nK2+CnIIaNh3zl4nRRhpxrA5Z7sAMrB3Jmzjcm9hw/4=", + "h1:r8rm1wyokhuaFvRDptDm2G1BsvrV6ukGiPoZRGaVmbc=", + "h1:thNSoWm4pdgTEO1XBi1n5V2nwsdSA6EVEXP3qPZiFcA=", + "h1:zVNhod3npW/4gn5QTcQzmlTSdAP7Ttjs59KW6VVyaoo=", + "zh:1a214581dee54ec4e9afa4050e54f6c187aed4b51b2d2ac929c58706b65e1159", + "zh:2f8ba94af93011768ed1fffd4d25b980cd764f2d49c9f13475512ec48464da0b", + "zh:36373bca4f374e95f654def79e0b12df0c8f2e01c634db80ff2737ded29062e3", + "zh:3a26b5c3e47b2bbc01faa0fa9fe816ea4f74f1f8f4555dfad7f62ee38266aaff", + "zh:51bc637700f13cfc1f7c6a8c03a4b5b5755338419912d945dbfe5ccb0ddbf614", + "zh:53f32f91afcb682209de124d8a994e591687023ae7c0dfed6184c5511778b16b", + "zh:6f4434327ed466b2b5be0d5f4aa537bca71ebd7f7fe05468065aeda52dfc8896", + "zh:7394e8c6f5027fa21699e46c9a45bee1ce87fd8dd98f2e89e8d414ec70c8e4e1", + "zh:d90b855a0990e3aa6445afe66de730c2f4651f39699c6b6345ca02f9af2a1a08", + "zh:d9b7246e6af0f75155ed532855014f888e9e5613242c89e321b4e0f9b54f5726", + "zh:dabd399ca36172c15d176a24cf199ac886ef191f7131e806d76a8dc209e5beb8", + "zh:e57987397be46dc123365f16c3bec4dd0615453c8bfdcfc1b7c9f3202a09c516", + "zh:ec88430b833b943b38d02f70b33250c72bb6ffa3b3d22360990a41390161a2b2", + "zh:f8ea01b57982e9ed9ad3a750b1caba261a478f5b0ddcac78a4502d886fd2fc74", + "zh:fb63819037158205ebf42b649c3a8ec308234ffe987a5dbd4444e1e0683f1170", + ] +} + +provider "registry.opentofu.org/hashicorp/kubernetes" { + version = "3.2.1" + constraints = "~> 3.2.1" + hashes = [ + "h1:+VM77HEd3CgzxwQ0PtfOc5tQrRQ2FZ9YJj1ShiMTsoI=", + "h1:32YYVAK/LUIheXfaxxnwL4qzf6QgWaMVkByneQH8EpA=", + "h1:8WnHwjdwYmtpTJg5reAs9VGtKq67E3dQdIrcjDW1Xao=", + "h1:8pNFJAX4IXSaPr+f02FsD6bUlZrMT5rkPPnA5sm2szI=", + "h1:Ad9kERqM2KFLkW/beJ6VsV3QxD05o98ZjC/epjpDcnI=", + "h1:EtS/ztrc03Vy+u9Q6eflu+HYVMMhQIufMaHS+vlbprY=", + "h1:GROcvqLewzybvZa6LjWjU7EWE7aOttp9ovBkT1b8lhc=", + "h1:OR1GGSnvGgFNKA8ilnttn1cy5wT8N0qzGpbqWOZ+yG8=", + "h1:PJBBJz/9kOjaGjGXCQpVslstEbeTxQBx8zu/ZAvRHak=", + "h1:QzzyadpGU0iHlOKsXX/ge6/apcwRXmzVlJxSzVclM/I=", + "h1:Un9WHqrP13fUt64c7VcO60VfvJty0tnrX88b8wy/4SU=", + "h1:VDyHxveT5+lt75fY3oOpa1D/08i1+AC6Af5XoQXEK7E=", + "h1:glcBsCYHGLC1os31lZ22X0se9EyTjQO/1kVMeHj5uJ8=", + "h1:qGon41gIBlkXIbSpsovc5UJr4okWcjaw7KkgPFxhNlU=", + "h1:tsq7mekr30obQVOK83ZNAiRb7rzRSZnWxQLdjD1Rvo0=", + "zh:018e382285ab0ca5a68e4e4116e1cfc3a20c57194f4e678bd87654f5aecf5b14", + "zh:0e8d233199b6896a6b9379b2f41bfdcdbb42b80f5f1c4ed7d87825ce53bae639", + "zh:112221d98fae795d89e6a0adc526fbe2a115316bd052fe10433d93061183b5e2", + "zh:1487c229ad673ddbd6c46ac511985d9a7ce86120ccf1c929acacb3761f3f1f9c", + "zh:2b73e4087cfbf20049240914a02f1344e43eb96a3c7d734454e4c49875aba1df", + "zh:3de5de4e50e58228f612fa158577f623c3a679b18918f62e6b0a925b23e4ec04", + "zh:6501c980ecb830728929798848105b27ce1cba1b604745fc609ecc54a80e0bd4", + "zh:67db18b88e93520eb052c5fae1f0dbedefe8433692b98ebdd3e29c3824ef08eb", + "zh:6aa9bada1a35dd1eb1a37e0233a031d8ecc1ae76ead3e851419bc49ce0dd67ba", + "zh:80e499f1971e2f5dfa7bbb4c40fa2bb303d6c0752b3d04f9a6869b7051071913", + "zh:93d63aee4fbacd28eef5330d517f37da2ead51dbd9c1d89e3e2a19a158c15a5d", + "zh:97c9d6971915400300f78300b807470c00793068b2b90506463da96cf5067092", + "zh:9a626f4c13f32317e8b891950bfd34eb69970599b9e8f62a5faa565e8c60f962", + "zh:d233fc2f7173db3ce5de21dc17e37b7757956b48783be3a81b8d92a6893a4fbc", + "zh:dfc6282c33ab4747d899915027de314687c6f8b49b7c95cafa8544bc45a0d15a", + ] +} + +provider "registry.opentofu.org/tehcyx/kind" { + version = "0.11.0" + constraints = "~> 0.11" + hashes = [ + "h1:+Q1amcdec50yH5urYvojIkQZdCXJwV/6wG7rFpIgfaY=", + "h1:5YkI2sD2PSilFvCvUL2dtyRkrTvxO7PLOgbIZXMbelA=", + "h1:6ahtXT8czMx6a3cfQRhrCkKGotFkSHtfvnGOHw6mH0E=", + "h1:B7hSI3H98Qr6Hkp8CtJm6nVrnWqxbGUgj7jWW/QAIzo=", + "h1:BOGuvXDo/Qes3RrTdYR/LY9tqQucpsIkvE2tfRaXTzE=", + "h1:w1vPgnfTycZq2MXQoS4sJ+URsXMwIv8u2drC9BE8DPA=", + "zh:10cf5f11ed1b24bcc2a64ddfe529dbe240ac72c075100039eb8a182abd5a25d8", + "zh:1c652afcea840545f9e21cf42369560966eafe52986d578c31a35247624442bf", + "zh:8ed94e1387970e7b885c7a68579b17a662d769d04dd3a0917d6c795741d0b97c", + "zh:97e3591b821b8a7cd1d0bc6322c1cbeed882ca26ff357cdcfab8dc0b17279090", + "zh:d5789b07c0a76d086d19acf948246875cb20bea0826a164f4f2c36b8fe527385", + "zh:e5a1117080f6b51e836bf41576e62ee1ea738a5d5fb2f5b0fccb25c942dfb557", + ] +} diff --git a/tier-1/kind-cluster/cluster.sh b/tier-1/kind-cluster/cluster.sh new file mode 100755 index 0000000..ae1eb4c --- /dev/null +++ b/tier-1/kind-cluster/cluster.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Stands up or tears down the local kind cluster and Flux bootstrap via OpenTofu. +# +# Usage: +# ./cluster.sh up Create the cluster and apply the OpenTofu stack. +# ./cluster.sh down Destroy the OpenTofu stack and the cluster. +# ./cluster.sh check Wait for Flux to become ready and report status. +# +# The repository is public, and the chart OCIRepository sources under +# clusters/kind/ assume their GHCR packages are also public, so Flux needs +# no credentials for either. If a source is added that isn't public, apply +# its pull secret manually with `kubectl create secret ... -n flux-system` +# and reference it via that source's secretRef. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +usage() { + echo "Usage: $0 {up|down|check}" >&2 + exit 1 +} + +kubeconfig_env() { + KUBECONFIG=$(mktemp) + export KUBECONFIG + mise exec -- tofu output -raw kubeconfig > "$KUBECONFIG" +} + +check() { + kubeconfig_env + + echo "Waiting for flux-system pods..." + mise exec -- kubectl wait --for=condition=Ready pods --all -n flux-system --timeout=180s + + echo "Waiting for FluxInstance to become Ready..." + mise exec -- kubectl wait --for=condition=Ready fluxinstance/flux -n flux-system --timeout=180s + + echo + echo "--- flux-system pods ---" + mise exec -- kubectl get pods -n flux-system + echo + echo "Cluster and Flux bootstrap are healthy." +} + +[ $# -eq 1 ] || usage + +case "$1" in + up) + mise install + mise exec -- tofu init + mise exec -- tofu apply -auto-approve + check + ;; + down) + mise exec -- tofu destroy -auto-approve + ;; + check) + check + ;; + *) + usage + ;; +esac diff --git a/kind-cluster/flux.tf b/tier-1/kind-cluster/flux.tf similarity index 100% rename from kind-cluster/flux.tf rename to tier-1/kind-cluster/flux.tf diff --git a/tier-1/kind-cluster/kind.tf b/tier-1/kind-cluster/kind.tf new file mode 100644 index 0000000..08c4433 --- /dev/null +++ b/tier-1/kind-cluster/kind.tf @@ -0,0 +1,21 @@ +provider "kind" {} + +resource "kind_cluster" "this" { + name = var.cluster_name + wait_for_ready = true + + kind_config { + kind = "Cluster" + api_version = "kind.x-k8s.io/v1alpha4" + + node { + role = "control-plane" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + + node { + role = "worker" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + } +} diff --git a/tier-1/kind-cluster/mise.toml b/tier-1/kind-cluster/mise.toml new file mode 100644 index 0000000..bfed49c --- /dev/null +++ b/tier-1/kind-cluster/mise.toml @@ -0,0 +1,6 @@ +[tools] +helm = "latest" +kind = "latest" +kubectl = "latest" +opentofu = "latest" +yq = "latest" diff --git a/tier-1/kind-cluster/outputs.tf b/tier-1/kind-cluster/outputs.tf new file mode 100644 index 0000000..534130d --- /dev/null +++ b/tier-1/kind-cluster/outputs.tf @@ -0,0 +1,10 @@ +output "kubeconfig" { + description = "Kubeconfig for the kind cluster." + value = kind_cluster.this.kubeconfig + sensitive = true +} + +output "cluster_endpoint" { + description = "API server endpoint of the kind cluster." + value = kind_cluster.this.endpoint +} diff --git a/tier-1/kind-cluster/providers.tf b/tier-1/kind-cluster/providers.tf new file mode 100644 index 0000000..3f41ef0 --- /dev/null +++ b/tier-1/kind-cluster/providers.tf @@ -0,0 +1,23 @@ +provider "helm" { + kubernetes = { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + } +} + +provider "kubernetes" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate +} + +provider "kubectl" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + load_config_file = false +} diff --git a/tier-1/kind-cluster/variables.tf b/tier-1/kind-cluster/variables.tf new file mode 100644 index 0000000..1adea7b --- /dev/null +++ b/tier-1/kind-cluster/variables.tf @@ -0,0 +1,41 @@ +variable "cluster_name" { + description = "Name of the kind cluster." + type = string + default = "tier-1" +} + +variable "kubernetes_version" { + description = "Kind node image tag (kubernetes version) to run. Leave null for the kind default." + type = string + default = null +} + +variable "flux_operator_version" { + description = "Version of the controlplane/flux-operator Helm chart to install from OCI." + type = string + default = "0.58.1" +} + +variable "flux_version" { + description = "Version of Flux the FluxInstance should reconcile." + type = string + default = "2.9.4" +} + +variable "flux_git_repository" { + description = "Optional git repository URL to bootstrap Flux against. Leave empty to manage sync purely via OCIRepository/HelmRelease objects applied later." + type = string + default = "https://github.com/magnusp/declarative-deploys" +} + +variable "flux_git_branch" { + description = "Git branch Flux should reconcile when flux_git_repository is set." + type = string + default = "main" +} + +variable "flux_git_path" { + description = "Path within the git repository containing the cluster's Flux manifests." + type = string + default = "tier-1/clusters/kind" +} diff --git a/tier-1/kind-cluster/versions.tf b/tier-1/kind-cluster/versions.tf new file mode 100644 index 0000000..6fecaec --- /dev/null +++ b/tier-1/kind-cluster/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + kind = { + source = "tehcyx/kind" + version = "~> 0.11" + } + helm = { + source = "hashicorp/helm" + version = "~> 3.2.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 3.2.1" + } + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.19" + } + } +} diff --git a/mise.toml b/tier-1/mise.toml similarity index 100% rename from mise.toml rename to tier-1/mise.toml diff --git a/tier-2/README.md b/tier-2/README.md new file mode 100644 index 0000000..a9162d9 --- /dev/null +++ b/tier-2/README.md @@ -0,0 +1,141 @@ +# Tier 2 — App-published values via OCI + +Builds on [tier 1](../tier-1/README.md) (Helm chart + platform/app split) by letting the application +team deploy without ever committing to this cluster repository: they publish a values OCI artifact, and +Flux composes it with the platform chart automatically. + +## What this tier demonstrates + +* **App values become an OCI artifact**: `apps-source/values.yaml` is no longer committed into + `clusters/kind/` (as it was, inline, in tier 1). Instead it's published as + `oci://ghcr.io/magnusp/apps/archetype-backend-values:latest` by + `.github/workflows/publish-app-values.yaml`, which also bumps `image.tag` to the newly built commit + SHA. +* **Composition via `ArtifactGenerator`** + (`clusters/kind/artifactgenerator-archetype-backend.yaml`): Flux's `source-watcher` merges the + platform chart (`OCIRepository/archetype-backend`) with the app's published values + (`OCIRepository/archetype-backend-values`) into a single `ExternalArtifact`. +* **Event-driven reconciliation, mostly**: the `HelmRelease` (`clusters/kind/helmrelease-archetype-backend.yaml`) + now sources its chart from `chartRef: {kind: ExternalArtifact, ...}` instead of the OCI chart + directly. `ArtifactGenerator` itself has no polling interval — it watches its sources — and + helm-controller reacts to a new `ExternalArtifact` revision immediately, bypassing the `HelmRelease`'s + own 10m interval. But the upstream `OCIRepository` for the `:latest` values tag still polls the + registry on its own interval to notice a new digest, so the whole chain isn't push-triggered + end-to-end — there's still up to one polling interval of latency between publishing and Flux noticing. + +## ⚠️ Known risk introduced at this tier: no independent check on the change + +Frameworks like ISO/IEC 27001 (via Annex A controls such as **A.8.32 Change management** and +**A.5.3 Segregation of duties**) treat this as two separate obligations: a change needs some +independent check before it reaches production, and the act of deploying it needs to be authorized and +logged. Neither obligation actually requires a human to click "approve" on every change — the standard +is risk-based, and "independent check" can be satisfied by automated gates as long as they're +appropriately designed and the person who authored the change isn't also the one who can bypass them. +Tiers 0 and 1 happen to satisfy the first obligation via git-commit/PR review (someone other than the +author reviews the diff before it merges), but that's one implementation of the control, not the +control itself. + +**This tier removes whatever independent check existed upstream, without replacing it with +anything** — that's the actual gap, not "no human review" specifically. Deploy-time authorization and +logging stay fine even here: whoever triggers `publish-app-values.yaml` is an authenticated, logged +GitHub actor. What's missing is any check — human or automated — on *what* changed before +`oci://ghcr.io/magnusp/apps/archetype-backend-values:latest` (an OCI artifact — the same +container-registry mechanism used for images, here just holding a `values.yaml` instead) gets +published and Flux deploys it. + +This is an acceptable trade-off for a demo focused on decoupling app deploys from git commits, but +it's a real compliance gap, not a cosmetic one. It stays open later in this progression too: + +* **Tier 3 adds Kyverno, a Kubernetes *admission controller*** — software that sits in front of the + Kubernetes API and inspects every object before it's allowed to be created or updated ("admitted"), + with the power to approve, block, or modify it. Its SpiceDB-backed check strengthens **deploy-time + authorization** ("is this identity allowed to deploy this service") and gives it an audit trail — a + different control than a change check, and it doesn't substitute for one. +* **Tier 4's Kyverno policies verify image provenance** (base-image lineage, unforged revision + annotations) — a fact about the artifact's *build history*, not about whether the values change was + checked before it shipped. + +**Mitigations that fit within this tier's own toolset, without borrowing Kyverno or SpiceDB from +later tiers, roughly in order of how well they preserve multiple-deploys-a-day velocity:** + +* **Automated policy/schema checks as the gate.** `charts/archetype-backend/values.schema.json` + already constrains what's structurally valid — extend that idea with a CI step in + `publish-app-values.yaml` that runs policy-as-code checks (tools like `conftest`/Open Policy Agent + evaluate a machine-readable policy against a file and fail the pipeline if it doesn't comply) against + the rendered values before publishing. This is a deterministic, previously-approved check standing in + for per-change human review — appropriate for routine, low-risk value bumps. +* **Machine-enforced segregation of duties.** Make sure the identity that performs the actual + `flux push artifact` is a pipeline/service identity, never a human's standing credentials — so no + individual author can single-handedly both write and ship a change outside the workflow, even + without a per-change reviewer. +* **Progressive delivery with automated rollback as a compensating control.** Canary a rollout — shift + traffic to the new version gradually and automatically complete or revert it based on health checks — + using [Flagger](https://fluxcd.io/flagger/), a separate CNCF/Flux-family project (`HelmRelease` itself + only supports install/upgrade remediation with retries and rollback on failure, not canary traffic + shifting; Flagger additionally needs a service mesh or ingress controller to actually split traffic). + This is a widely accepted substitute for pre-deploy human review in high-velocity continuous + deployment, since it bounds the blast radius of an unreviewed bad change instead of trying to prevent + it from ever shipping. +* **Human review, synchronous or sampled.** Route the values bump through a real PR with a required + approving review (gates every change), or a GitHub Actions **environment with required reviewers** + (a GitHub-native setting that pauses a workflow job after it's triggered until a designated person + approves it — added via `environment: production` on the job that runs `flux push artifact`) — or, + for lower-risk changes, review a statistically meaningful sample after the fact rather than gating + every single one. This is the right tool when a change is high-risk or irreversible enough that + automated gates and rollback aren't sufficient on their own — not the default answer for every + deploy. + +This repo doesn't implement any of these (each has real operational cost to maintain), but picking +one — matched to how risky your actual changes are — is the natural next step if you're adapting this +tier's pattern for real use. + +## Directory layout + +Same as tier 1, plus: + +* `clusters/kind/ocirepository-archetype-backend-values.yaml` — tracks the `:latest` values artifact. +* `clusters/kind/artifactgenerator-archetype-backend.yaml` — the chart+values composition. + +`apps-source/values.yaml` remains in the repo as the source the app team edits before running +`publish-app-values.yaml`, but it is no longer referenced directly by any `clusters/kind/` manifest. + +## Getting started + +```sh +cd tier-2 +mise install + +cd kind-cluster +./cluster.sh up # Create the kind cluster (named tier-2), bootstrap Flux, verify health +./cluster.sh check +./cluster.sh down +``` + +### Simulate an application release + +1. Run `.github/workflows/build-app-image.yaml` on your target commit. +2. Run `.github/workflows/publish-app-values.yaml` with `image_tag` set to that commit SHA. +3. Watch Flux pick it up without any commit to this repository: + + ```sh + kubectl get ocirepository -n flux-system archetype-backend-values + kubectl get externalartifact -n flux-system archetype-backend-demo + kubectl get helmrelease -n flux-system archetype-backend-demo + kubectl get deploy -n apps apps-archetype-backend-demo \ + -o jsonpath='{.spec.template.spec.containers[0].image}' + ``` + +## Progressing to tier 3 + +Tier 3 adds a SpiceDB ReBAC admission gate: + +1. **Kyverno** (`kind-cluster/kyverno.tf`) is introduced as the admission controller. +2. **SpiceDB** (`kind-cluster/spicedb-operator.tf`, `clusters/kind/spicedb-cluster.yaml`) runs + ephemerally in-cluster, seeded from human-readable fixtures (`fixtures/spicedb/`). +3. `clusters/kind/clusterpolicy-spicedb-authz.yaml` checks, at admission time, whether the actor who + built the app image (`dev.authz.app.deployer` label, stamped by `build-app-image.yaml` — already + present on every image since tier 1, just unused until now) has `deploy` permission on the target + service. + +See [`tier-3/README.md`](../tier-3/README.md) for the full detail, and the root +[README](../README.md#tiers) for the overall progression. diff --git a/tier-2/apps-source/Dockerfile b/tier-2/apps-source/Dockerfile new file mode 100644 index 0000000..36c21c4 --- /dev/null +++ b/tier-2/apps-source/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.27 +COPY --chmod=0644 index.html /usr/share/nginx/html/index.html diff --git a/tier-2/apps-source/index.html b/tier-2/apps-source/index.html new file mode 100644 index 0000000..15f0063 --- /dev/null +++ b/tier-2/apps-source/index.html @@ -0,0 +1,11 @@ + + + + + archetype-backend + + +

archetype-backend

+

Simulated application, built on nginx, deployed via archetype-backend.

+ + diff --git a/apps-source/values.yaml b/tier-2/apps-source/values.yaml similarity index 82% rename from apps-source/values.yaml rename to tier-2/apps-source/values.yaml index f1b7534..08fb993 100644 --- a/apps-source/values.yaml +++ b/tier-2/apps-source/values.yaml @@ -1,7 +1,7 @@ # archetype-backend chart values for this application. Published as an OCI # artifact by .github/workflows/publish-app-values.yaml, whenever the # application image is rolled out — Flux picks it up via the -# archetype-backend-values OCIRepository/Kustomization in clusters/kind/. +# archetype-backend-values OCIRepository/ArtifactGenerator in clusters/kind/. replicas: 1 image: diff --git a/tier-2/charts/archetype-backend/Chart.yaml b/tier-2/charts/archetype-backend/Chart.yaml new file mode 100644 index 0000000..c260771 --- /dev/null +++ b/tier-2/charts/archetype-backend/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: archetype-backend +description: Baseline backend service chart (Deployment + Service) maintained by platform engineering +type: application +# publish-chart.yaml overrides this with --version/--app-version at publish +# time; the OCIRepository tag Flux tracks (e.g. 0.1.1, 0.1.2) reflects what was +# actually published, not this file's version. +version: 0.1.0 +appVersion: "1.0.0" diff --git a/tier-2/charts/archetype-backend/templates/_helpers.tpl b/tier-2/charts/archetype-backend/templates/_helpers.tpl new file mode 100644 index 0000000..2a318b2 --- /dev/null +++ b/tier-2/charts/archetype-backend/templates/_helpers.tpl @@ -0,0 +1,20 @@ +{{- define "archetype-backend.name" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "archetype-backend.labels" -}} +app.kubernetes.io/name: {{ include "archetype-backend.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{/* +Chart.Version can carry OCI build metadata (e.g. "0.1.0+") when +installed via chartRef from an OCIRepository — "+" isn't a valid label +value character, so it's replaced with "_". +*/}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "archetype-backend.selectorLabels" -}} +app.kubernetes.io/name: {{ include "archetype-backend.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/tier-2/charts/archetype-backend/templates/deployment.yaml b/tier-2/charts/archetype-backend/templates/deployment.yaml new file mode 100644 index 0000000..eb34d2d --- /dev/null +++ b/tier-2/charts/archetype-backend/templates/deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "archetype-backend.name" . }} + labels: + {{- include "archetype-backend.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "archetype-backend.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "archetype-backend.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: {{ include "archetype-backend.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} diff --git a/tier-2/charts/archetype-backend/templates/service.yaml b/tier-2/charts/archetype-backend/templates/service.yaml new file mode 100644 index 0000000..de66e64 --- /dev/null +++ b/tier-2/charts/archetype-backend/templates/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "archetype-backend.name" . }} + labels: + {{- include "archetype-backend.labels" . | nindent 4 }} +spec: + selector: + {{- include "archetype-backend.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/tier-2/charts/archetype-backend/values.schema.json b/tier-2/charts/archetype-backend/values.schema.json new file mode 100644 index 0000000..690c2ab --- /dev/null +++ b/tier-2/charts/archetype-backend/values.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "archetype-backend values", + "type": "object", + "required": ["image"], + "properties": { + "replicas": { + "type": "integer", + "minimum": 1 + }, + "image": { + "type": "object", + "required": ["repository", "tag"], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "pullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"] + } + } + }, + "service": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "targetPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + } + } +} diff --git a/tier-2/charts/archetype-backend/values.yaml b/tier-2/charts/archetype-backend/values.yaml new file mode 100644 index 0000000..fae374f --- /dev/null +++ b/tier-2/charts/archetype-backend/values.yaml @@ -0,0 +1,10 @@ +replicas: 1 + +image: + repository: "" + tag: "" + pullPolicy: IfNotPresent + +service: + port: 8080 + targetPort: 8080 diff --git a/clusters/kind/artifactgenerator-archetype-backend.yaml b/tier-2/clusters/kind/artifactgenerator-archetype-backend.yaml similarity index 100% rename from clusters/kind/artifactgenerator-archetype-backend.yaml rename to tier-2/clusters/kind/artifactgenerator-archetype-backend.yaml diff --git a/clusters/kind/helmrelease-archetype-backend.yaml b/tier-2/clusters/kind/helmrelease-archetype-backend.yaml similarity index 100% rename from clusters/kind/helmrelease-archetype-backend.yaml rename to tier-2/clusters/kind/helmrelease-archetype-backend.yaml diff --git a/tier-2/clusters/kind/kustomization.yaml b/tier-2/clusters/kind/kustomization.yaml new file mode 100644 index 0000000..d9e156f --- /dev/null +++ b/tier-2/clusters/kind/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace-apps.yaml + - ocirepository-archetype-backend.yaml + - ocirepository-archetype-backend-values.yaml + - artifactgenerator-archetype-backend.yaml + - helmrelease-archetype-backend.yaml diff --git a/tier-2/clusters/kind/namespace-apps.yaml b/tier-2/clusters/kind/namespace-apps.yaml new file mode 100644 index 0000000..745ba23 --- /dev/null +++ b/tier-2/clusters/kind/namespace-apps.yaml @@ -0,0 +1,5 @@ +# Dedicated application workspace namespace for showcase workloads. +apiVersion: v1 +kind: Namespace +metadata: + name: apps diff --git a/clusters/kind/ocirepository-archetype-backend-values.yaml b/tier-2/clusters/kind/ocirepository-archetype-backend-values.yaml similarity index 100% rename from clusters/kind/ocirepository-archetype-backend-values.yaml rename to tier-2/clusters/kind/ocirepository-archetype-backend-values.yaml diff --git a/tier-2/clusters/kind/ocirepository-archetype-backend.yaml b/tier-2/clusters/kind/ocirepository-archetype-backend.yaml new file mode 100644 index 0000000..0d1861c --- /dev/null +++ b/tier-2/clusters/kind/ocirepository-archetype-backend.yaml @@ -0,0 +1,18 @@ +# Tracks the archetype-backend chart published to GHCR by +# .github/workflows/publish-chart.yaml. Bump `ref.tag` after publishing a new +# chart version. +# +# No secretRef: this assumes the ghcr.io/magnusp/charts/archetype-backend +# package is set to public visibility on GHCR (a separate setting from repo +# visibility, only available after the first version is published). Until +# that's done, reconciliation will fail with an auth error. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: archetype-backend + namespace: flux-system +spec: + interval: 10m + url: oci://ghcr.io/magnusp/charts/archetype-backend + ref: + tag: 0.1.1 diff --git a/tier-2/kind-cluster/.gitignore b/tier-2/kind-cluster/.gitignore new file mode 100644 index 0000000..13be894 --- /dev/null +++ b/tier-2/kind-cluster/.gitignore @@ -0,0 +1,8 @@ +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfstate +*.tfstate.* +*.tfvars +kind-config +tier-*-config diff --git a/tier-2/kind-cluster/.terraform.lock.hcl b/tier-2/kind-cluster/.terraform.lock.hcl new file mode 100644 index 0000000..8237ab3 --- /dev/null +++ b/tier-2/kind-cluster/.terraform.lock.hcl @@ -0,0 +1,131 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/gavinbunney/kubectl" { + version = "1.19.0" + constraints = "~> 1.19" + hashes = [ + "h1:/KiSv1EYOBNWL2Op7BqTmqnc/G2W/WvXIhUZlhnK0to=", + "h1:3E8E5zdLYTLsP9h+Ibvez8m+S0Qqa7QULHvcq8PcnWg=", + "h1:9QkxPjp0x5FZFfJbE+B7hBOoads9gmdfj9aYu5N4Sfc=", + "h1:DJC655V2Lw7zER1Gi+yN1ijW3A+bUAI3MucdYVhGxFY=", + "h1:EL1HfCDfY/pabIJ2kXuppTvqhgY9hfLu2o4TonJheT8=", + "h1:aQUITuBBjv8WJ+WGI0bOZXXYi5s09pMEyyJmlFnuUbE=", + "h1:gwnJt0UeVYnD2u8S0nGbQHvrUccNL3jeCqf9xu0X13I=", + "h1:hzxgobCLhmbGcgC2KD6aYleH17hiblT9vIHyEQKb1VA=", + "h1:iV249UVwKvuNaFHcvba4SitTyWpUNGvqdyy6SatjjH4=", + "h1:jTkF6nmuZvbXvl08L6iwe2RfV4PkPdy2Cnn+7aWkgu8=", + "h1:kevkHUKdNWcl+ET894COOV9i18hXeACWfu4AJXct/b0=", + "h1:ovkjqc3GnFlb5mVY51ZFV3VwrSbxQQtMTXYvEmBs2xg=", + "h1:quymfa/OKEfWI5JXFEwGbUY2aAy0vet3rA9JWJam+3k=", + "h1:tm5Tw1sfVURwtKGB34mUqekBxgo4HqANIV+uyrucYjA=", + "zh:1dec8766336ac5b00b3d8f62e3fff6390f5f60699c9299920fc9861a76f00c71", + "zh:43f101b56b58d7fead6a511728b4e09f7c41dc2e3963f59cf1c146c4767c6cb7", + "zh:4c4fbaa44f60e722f25cc05ee11dfaec282893c5c0ffa27bc88c382dbfbaa35c", + "zh:51dd23238b7b677b8a1abbfcc7deec53ffa5ec79e58e3b54d6be334d3d01bc0e", + "zh:5afc2ebc75b9d708730dbabdc8f94dd559d7f2fc5a31c5101358bd8d016916ba", + "zh:6be6e72d4663776390a82a37e34f7359f726d0120df622f4a2b46619338a168e", + "zh:72642d5fcf1e3febb6e5d4ae7b592bb9ff3cb220af041dbda893588e4bf30c0c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a1da03e3239867b35812ee031a1060fed6e8d8e458e2eaca48b5dd51b35f56f7", + "zh:b98b6a6728fe277fcd133bdfa7237bd733eae233f09653523f14460f608f8ba2", + "zh:bb8b071d0437f4767695c6158a3cb70df9f52e377c67019971d888b99147511f", + "zh:dc89ce4b63bfef708ec29c17e85ad0232a1794336dc54dd88c3ba0b77e764f71", + "zh:dd7dd18f1f8218c6cd19592288fde32dccc743cde05b9feeb2883f37c2ff4b4e", + "zh:ec4bd5ab3872dedb39fe528319b4bba609306e12ee90971495f109e142d66310", + "zh:f610ead42f724c82f5463e0e71fa735a11ffb6101880665d93f48b4a67b9ad82", + ] +} + +provider "registry.opentofu.org/hashicorp/helm" { + version = "3.2.0" + constraints = "~> 3.2.0" + hashes = [ + "h1:/TdLShnQEtyQRij46dZJobeA8clDnuncSdc2KRkc0B0=", + "h1:8j4dgSY+8Y9tukMTo0WuKPyJx0JKUeZsYx9ltTUmEuo=", + "h1:AOxR+hQy92GqWcp4rnD3ZBrDlip7M7fOB4zL4aw8AU4=", + "h1:IytRFhZXOMPVB36GainacnNoBtiibAdVMdezG2DPVag=", + "h1:XpXkXaOaDyICkDLyrRNBzxqUc8zrKp6Y7sw17FGD+Xg=", + "h1:c1NQPYp6/iIg0PG/HFodq0oyDVI/FAYZF32eEOliWaw=", + "h1:dBAYwgWCv9/ulFx7uxbkNzPYL9TI1aqUF8E7EBJbIRk=", + "h1:h05ap1OKMOtgqspVfdc1GmkX9vJJHquxn5g26Ik7+t4=", + "h1:h21YEXSmcV7Xe64kptnt0WQYEh6m99jVR8sec2o9stk=", + "h1:ikX+6kgc+kYNDAiNghhOhhY15T1Jt6omoWoHlh8CEsY=", + "h1:jvnisz9lHFn+5sjbuZwGCqqCvbddTeqWvrYtK4Wcbxw=", + "h1:nK2+CnIIaNh3zl4nRRhpxrA5Z7sAMrB3Jmzjcm9hw/4=", + "h1:r8rm1wyokhuaFvRDptDm2G1BsvrV6ukGiPoZRGaVmbc=", + "h1:thNSoWm4pdgTEO1XBi1n5V2nwsdSA6EVEXP3qPZiFcA=", + "h1:zVNhod3npW/4gn5QTcQzmlTSdAP7Ttjs59KW6VVyaoo=", + "zh:1a214581dee54ec4e9afa4050e54f6c187aed4b51b2d2ac929c58706b65e1159", + "zh:2f8ba94af93011768ed1fffd4d25b980cd764f2d49c9f13475512ec48464da0b", + "zh:36373bca4f374e95f654def79e0b12df0c8f2e01c634db80ff2737ded29062e3", + "zh:3a26b5c3e47b2bbc01faa0fa9fe816ea4f74f1f8f4555dfad7f62ee38266aaff", + "zh:51bc637700f13cfc1f7c6a8c03a4b5b5755338419912d945dbfe5ccb0ddbf614", + "zh:53f32f91afcb682209de124d8a994e591687023ae7c0dfed6184c5511778b16b", + "zh:6f4434327ed466b2b5be0d5f4aa537bca71ebd7f7fe05468065aeda52dfc8896", + "zh:7394e8c6f5027fa21699e46c9a45bee1ce87fd8dd98f2e89e8d414ec70c8e4e1", + "zh:d90b855a0990e3aa6445afe66de730c2f4651f39699c6b6345ca02f9af2a1a08", + "zh:d9b7246e6af0f75155ed532855014f888e9e5613242c89e321b4e0f9b54f5726", + "zh:dabd399ca36172c15d176a24cf199ac886ef191f7131e806d76a8dc209e5beb8", + "zh:e57987397be46dc123365f16c3bec4dd0615453c8bfdcfc1b7c9f3202a09c516", + "zh:ec88430b833b943b38d02f70b33250c72bb6ffa3b3d22360990a41390161a2b2", + "zh:f8ea01b57982e9ed9ad3a750b1caba261a478f5b0ddcac78a4502d886fd2fc74", + "zh:fb63819037158205ebf42b649c3a8ec308234ffe987a5dbd4444e1e0683f1170", + ] +} + +provider "registry.opentofu.org/hashicorp/kubernetes" { + version = "3.2.1" + constraints = "~> 3.2.1" + hashes = [ + "h1:+VM77HEd3CgzxwQ0PtfOc5tQrRQ2FZ9YJj1ShiMTsoI=", + "h1:32YYVAK/LUIheXfaxxnwL4qzf6QgWaMVkByneQH8EpA=", + "h1:8WnHwjdwYmtpTJg5reAs9VGtKq67E3dQdIrcjDW1Xao=", + "h1:8pNFJAX4IXSaPr+f02FsD6bUlZrMT5rkPPnA5sm2szI=", + "h1:Ad9kERqM2KFLkW/beJ6VsV3QxD05o98ZjC/epjpDcnI=", + "h1:EtS/ztrc03Vy+u9Q6eflu+HYVMMhQIufMaHS+vlbprY=", + "h1:GROcvqLewzybvZa6LjWjU7EWE7aOttp9ovBkT1b8lhc=", + "h1:OR1GGSnvGgFNKA8ilnttn1cy5wT8N0qzGpbqWOZ+yG8=", + "h1:PJBBJz/9kOjaGjGXCQpVslstEbeTxQBx8zu/ZAvRHak=", + "h1:QzzyadpGU0iHlOKsXX/ge6/apcwRXmzVlJxSzVclM/I=", + "h1:Un9WHqrP13fUt64c7VcO60VfvJty0tnrX88b8wy/4SU=", + "h1:VDyHxveT5+lt75fY3oOpa1D/08i1+AC6Af5XoQXEK7E=", + "h1:glcBsCYHGLC1os31lZ22X0se9EyTjQO/1kVMeHj5uJ8=", + "h1:qGon41gIBlkXIbSpsovc5UJr4okWcjaw7KkgPFxhNlU=", + "h1:tsq7mekr30obQVOK83ZNAiRb7rzRSZnWxQLdjD1Rvo0=", + "zh:018e382285ab0ca5a68e4e4116e1cfc3a20c57194f4e678bd87654f5aecf5b14", + "zh:0e8d233199b6896a6b9379b2f41bfdcdbb42b80f5f1c4ed7d87825ce53bae639", + "zh:112221d98fae795d89e6a0adc526fbe2a115316bd052fe10433d93061183b5e2", + "zh:1487c229ad673ddbd6c46ac511985d9a7ce86120ccf1c929acacb3761f3f1f9c", + "zh:2b73e4087cfbf20049240914a02f1344e43eb96a3c7d734454e4c49875aba1df", + "zh:3de5de4e50e58228f612fa158577f623c3a679b18918f62e6b0a925b23e4ec04", + "zh:6501c980ecb830728929798848105b27ce1cba1b604745fc609ecc54a80e0bd4", + "zh:67db18b88e93520eb052c5fae1f0dbedefe8433692b98ebdd3e29c3824ef08eb", + "zh:6aa9bada1a35dd1eb1a37e0233a031d8ecc1ae76ead3e851419bc49ce0dd67ba", + "zh:80e499f1971e2f5dfa7bbb4c40fa2bb303d6c0752b3d04f9a6869b7051071913", + "zh:93d63aee4fbacd28eef5330d517f37da2ead51dbd9c1d89e3e2a19a158c15a5d", + "zh:97c9d6971915400300f78300b807470c00793068b2b90506463da96cf5067092", + "zh:9a626f4c13f32317e8b891950bfd34eb69970599b9e8f62a5faa565e8c60f962", + "zh:d233fc2f7173db3ce5de21dc17e37b7757956b48783be3a81b8d92a6893a4fbc", + "zh:dfc6282c33ab4747d899915027de314687c6f8b49b7c95cafa8544bc45a0d15a", + ] +} + +provider "registry.opentofu.org/tehcyx/kind" { + version = "0.11.0" + constraints = "~> 0.11" + hashes = [ + "h1:+Q1amcdec50yH5urYvojIkQZdCXJwV/6wG7rFpIgfaY=", + "h1:5YkI2sD2PSilFvCvUL2dtyRkrTvxO7PLOgbIZXMbelA=", + "h1:6ahtXT8czMx6a3cfQRhrCkKGotFkSHtfvnGOHw6mH0E=", + "h1:B7hSI3H98Qr6Hkp8CtJm6nVrnWqxbGUgj7jWW/QAIzo=", + "h1:BOGuvXDo/Qes3RrTdYR/LY9tqQucpsIkvE2tfRaXTzE=", + "h1:w1vPgnfTycZq2MXQoS4sJ+URsXMwIv8u2drC9BE8DPA=", + "zh:10cf5f11ed1b24bcc2a64ddfe529dbe240ac72c075100039eb8a182abd5a25d8", + "zh:1c652afcea840545f9e21cf42369560966eafe52986d578c31a35247624442bf", + "zh:8ed94e1387970e7b885c7a68579b17a662d769d04dd3a0917d6c795741d0b97c", + "zh:97e3591b821b8a7cd1d0bc6322c1cbeed882ca26ff357cdcfab8dc0b17279090", + "zh:d5789b07c0a76d086d19acf948246875cb20bea0826a164f4f2c36b8fe527385", + "zh:e5a1117080f6b51e836bf41576e62ee1ea738a5d5fb2f5b0fccb25c942dfb557", + ] +} diff --git a/tier-2/kind-cluster/cluster.sh b/tier-2/kind-cluster/cluster.sh new file mode 100755 index 0000000..ae1eb4c --- /dev/null +++ b/tier-2/kind-cluster/cluster.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Stands up or tears down the local kind cluster and Flux bootstrap via OpenTofu. +# +# Usage: +# ./cluster.sh up Create the cluster and apply the OpenTofu stack. +# ./cluster.sh down Destroy the OpenTofu stack and the cluster. +# ./cluster.sh check Wait for Flux to become ready and report status. +# +# The repository is public, and the chart OCIRepository sources under +# clusters/kind/ assume their GHCR packages are also public, so Flux needs +# no credentials for either. If a source is added that isn't public, apply +# its pull secret manually with `kubectl create secret ... -n flux-system` +# and reference it via that source's secretRef. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +usage() { + echo "Usage: $0 {up|down|check}" >&2 + exit 1 +} + +kubeconfig_env() { + KUBECONFIG=$(mktemp) + export KUBECONFIG + mise exec -- tofu output -raw kubeconfig > "$KUBECONFIG" +} + +check() { + kubeconfig_env + + echo "Waiting for flux-system pods..." + mise exec -- kubectl wait --for=condition=Ready pods --all -n flux-system --timeout=180s + + echo "Waiting for FluxInstance to become Ready..." + mise exec -- kubectl wait --for=condition=Ready fluxinstance/flux -n flux-system --timeout=180s + + echo + echo "--- flux-system pods ---" + mise exec -- kubectl get pods -n flux-system + echo + echo "Cluster and Flux bootstrap are healthy." +} + +[ $# -eq 1 ] || usage + +case "$1" in + up) + mise install + mise exec -- tofu init + mise exec -- tofu apply -auto-approve + check + ;; + down) + mise exec -- tofu destroy -auto-approve + ;; + check) + check + ;; + *) + usage + ;; +esac diff --git a/tier-2/kind-cluster/flux.tf b/tier-2/kind-cluster/flux.tf new file mode 100644 index 0000000..7becf0d --- /dev/null +++ b/tier-2/kind-cluster/flux.tf @@ -0,0 +1,57 @@ +# Flux is installed via the flux-operator Helm chart, pulled directly from its +# OCI registry (oci://ghcr.io/controlplane/charts/flux-operator) rather than a +# traditional Helm repo, since we standardize on OCI-hosted charts. +resource "helm_release" "flux_operator" { + name = "flux-operator" + namespace = "flux-system" + repository = "oci://ghcr.io/controlplaneio-fluxcd/charts" + chart = "flux-operator" + version = var.flux_operator_version + + create_namespace = true + + depends_on = [kind_cluster.this] +} + +# The FluxInstance CR tells flux-operator which Flux controllers to run and, +# optionally, which git repository to sync. Source controllers for both +# OCIRepository and GitRepository are enabled so charts can be pulled from +# OCI registries by default, with git sync available when configured. +resource "kubectl_manifest" "flux_instance" { + yaml_body = yamlencode({ + apiVersion = "fluxcd.controlplane.io/v1" + kind = "FluxInstance" + metadata = { + name = "flux" + namespace = "flux-system" + } + spec = { + distribution = { + version = var.flux_version + registry = "ghcr.io/fluxcd" + } + components = [ + "source-controller", + "kustomize-controller", + "helm-controller", + "notification-controller", + "source-watcher", + ] + cluster = { + multitenant = false + networkPolicy = true + } + # The repository is public, so no pullSecret is needed to clone it. + # GHCR chart pulls (OCIRepository sources) are a separate concern — + # see clusters/kind/ocirepository-archetype-backend.yaml. + sync = var.flux_git_repository == "" ? null : { + kind = "GitRepository" + url = var.flux_git_repository + ref = "refs/heads/${var.flux_git_branch}" + path = var.flux_git_path + } + } + }) + + depends_on = [helm_release.flux_operator] +} diff --git a/tier-2/kind-cluster/kind.tf b/tier-2/kind-cluster/kind.tf new file mode 100644 index 0000000..08c4433 --- /dev/null +++ b/tier-2/kind-cluster/kind.tf @@ -0,0 +1,21 @@ +provider "kind" {} + +resource "kind_cluster" "this" { + name = var.cluster_name + wait_for_ready = true + + kind_config { + kind = "Cluster" + api_version = "kind.x-k8s.io/v1alpha4" + + node { + role = "control-plane" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + + node { + role = "worker" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + } +} diff --git a/tier-2/kind-cluster/mise.toml b/tier-2/kind-cluster/mise.toml new file mode 100644 index 0000000..bfed49c --- /dev/null +++ b/tier-2/kind-cluster/mise.toml @@ -0,0 +1,6 @@ +[tools] +helm = "latest" +kind = "latest" +kubectl = "latest" +opentofu = "latest" +yq = "latest" diff --git a/tier-2/kind-cluster/outputs.tf b/tier-2/kind-cluster/outputs.tf new file mode 100644 index 0000000..534130d --- /dev/null +++ b/tier-2/kind-cluster/outputs.tf @@ -0,0 +1,10 @@ +output "kubeconfig" { + description = "Kubeconfig for the kind cluster." + value = kind_cluster.this.kubeconfig + sensitive = true +} + +output "cluster_endpoint" { + description = "API server endpoint of the kind cluster." + value = kind_cluster.this.endpoint +} diff --git a/tier-2/kind-cluster/providers.tf b/tier-2/kind-cluster/providers.tf new file mode 100644 index 0000000..3f41ef0 --- /dev/null +++ b/tier-2/kind-cluster/providers.tf @@ -0,0 +1,23 @@ +provider "helm" { + kubernetes = { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + } +} + +provider "kubernetes" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate +} + +provider "kubectl" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + load_config_file = false +} diff --git a/tier-2/kind-cluster/variables.tf b/tier-2/kind-cluster/variables.tf new file mode 100644 index 0000000..0994d35 --- /dev/null +++ b/tier-2/kind-cluster/variables.tf @@ -0,0 +1,41 @@ +variable "cluster_name" { + description = "Name of the kind cluster." + type = string + default = "tier-2" +} + +variable "kubernetes_version" { + description = "Kind node image tag (kubernetes version) to run. Leave null for the kind default." + type = string + default = null +} + +variable "flux_operator_version" { + description = "Version of the controlplane/flux-operator Helm chart to install from OCI." + type = string + default = "0.58.1" +} + +variable "flux_version" { + description = "Version of Flux the FluxInstance should reconcile." + type = string + default = "2.9.4" +} + +variable "flux_git_repository" { + description = "Optional git repository URL to bootstrap Flux against. Leave empty to manage sync purely via OCIRepository/HelmRelease objects applied later." + type = string + default = "https://github.com/magnusp/declarative-deploys" +} + +variable "flux_git_branch" { + description = "Git branch Flux should reconcile when flux_git_repository is set." + type = string + default = "main" +} + +variable "flux_git_path" { + description = "Path within the git repository containing the cluster's Flux manifests." + type = string + default = "tier-2/clusters/kind" +} diff --git a/tier-2/kind-cluster/versions.tf b/tier-2/kind-cluster/versions.tf new file mode 100644 index 0000000..6fecaec --- /dev/null +++ b/tier-2/kind-cluster/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + kind = { + source = "tehcyx/kind" + version = "~> 0.11" + } + helm = { + source = "hashicorp/helm" + version = "~> 3.2.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 3.2.1" + } + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.19" + } + } +} diff --git a/tier-2/mise.toml b/tier-2/mise.toml new file mode 100644 index 0000000..14fa908 --- /dev/null +++ b/tier-2/mise.toml @@ -0,0 +1,4 @@ +[tools] +helm = "latest" +yq = "latest" +flux2 = "latest" diff --git a/tier-3/README.md b/tier-3/README.md new file mode 100644 index 0000000..43b62bb --- /dev/null +++ b/tier-3/README.md @@ -0,0 +1,129 @@ +# Tier 3 — SpiceDB ReBAC admission gate + +Builds on [tier 2](../tier-2/README.md) (app-published values via OCI) by adding an authorization check +at admission time: only actors with a `deploy` relationship on the target service, according to a +SpiceDB ReBAC graph, are allowed to have their Deployment admitted. + +## What this tier demonstrates + +* **Kyverno enters the picture** (`kind-cluster/kyverno.tf`): a Flux-managed `HelmRelease` sourced from + Kyverno's OCI chart. This is the first tier with an admission controller. +* **Ephemeral in-cluster SpiceDB Operator bootstrap** (`kind-cluster/spicedb-operator.tf`): applied + directly via OpenTofu, not the git-synced Flux `Kustomization` — the operator's `GitRepository` and + its own `Kustomization` install the `SpiceDBCluster` CRD, which `clusters/kind/spicedb-cluster.yaml` + (a resource of that CRD) depends on existing. Bundling both in the same git-synced `Kustomization` + would deadlock on a cold cluster: Flux validates the whole manifest set together, so the CR's failed + dry-run (no CRD yet) would block the operator's own bootstrap objects from ever being created. +* **Human-readable fixtures** (`fixtures/spicedb/schema.zed`, `fixtures/spicedb/relationships.txt`): + a `Job` (`clusters/kind/spicedb-fixture-job.yaml`) loads these into SpiceDB on cluster bring-up. + `scripts/spicedb-fixture.sh` lets you check permissions directly, add/update relationships (`apply`), + or explicitly revoke one (`revoke`) — `apply` only ever upserts, so editing a tuple out of + `relationships.txt` and re-applying does **not** remove it. +* **The admission gate** (`clusters/kind/clusterpolicy-spicedb-authz.yaml`): a Kyverno `ClusterPolicy` + that reads the `dev.authz.app.deployer` label off the Deployment's container image — stamped by + `.github/workflows/build-app-image.yaml` when the image is built, not by `publish-app-values.yaml` + (which stamps an identically-named OCI artifact *annotation* on the values artifact, which this + policy never reads) — and calls SpiceDB's `/v1/permissions/check` API over `apiCall.service` (an + in-cluster HTTP service call; Kyverno's `apiCall.urlPath` addresses only the Kubernetes API server + and can't be used here). If the deployer doesn't have `deploy` permission on the target service, or + the image carries no deployer identity at all, the Deployment is rejected — the gate fails closed. +* **Deliberately not yet generalized**: this policy is scoped directly to the `apps` namespace + (`namespaces: [apps]`) rather than an opt-in label. Tier 4 introduces + `governance.platform.io/managed` label scoping and retrofits this policy to use it, once there's a + second governance concern to justify generalizing. + +## This tier strengthens deploy authorization, not the change check + +Tier 2's README distinguishes two obligations: an independent check on *the change* before it's +published (which can be automated, sampled, or human — see tier 2 for the options), and authorization +plus logging of *the deploy action*. Kyverno — a Kubernetes *admission controller*, meaning it inspects +every object Kubernetes is about to create or update and can allow, block, or modify it before that +happens — enters the picture here, but it only strengthens the second obligation, not the first. + +Specifically: the `clusterpolicy-spicedb-authz.yaml` `ClusterPolicy` runs at *admission time* (the +moment a `Deployment` is submitted, before Kubernetes persists it) and calls out to **SpiceDB**, a +*relationship-based access control (ReBAC)* system — instead of a fixed list of roles, it stores a +graph of relationships (e.g. "`magnusp` can `deploy` `archetype-backend`") and answers permission +questions by querying that graph. The policy asks SpiceDB **"is this identity allowed to deploy this +service,"** and Kyverno logs the answer — that's deploy-time authorization and auditability. It has no +way to answer **"was the values change checked before `magnusp` published it,"** because that's a fact +about the change, not the actor, and nothing in this pipeline records it. A fully authorized deployer +can still publish arbitrary, unchecked values and have them admitted — SpiceDB has no opinion on +content, only on who pushed it. + +## Directory layout + +Same as tier 2, plus: + +* `kind-cluster/kyverno.tf` — Kyverno bootstrap. +* `kind-cluster/spicedb-operator.tf` — SpiceDB Operator bootstrap (`GitRepository` + `Kustomization` + + RBAC extension), applied via OpenTofu rather than the git-synced manifests, for the ordering reason + above. +* `clusters/kind/spicedb-cluster.yaml`, `spicedb-fixture-job.yaml`, `clusterpolicy-spicedb-authz.yaml` — + the rest of the ReBAC stack. +* `fixtures/spicedb/` and `scripts/spicedb-fixture.sh`. + +## Getting started + +```sh +cd tier-3 +mise install + +cd kind-cluster +./cluster.sh up # Create the kind cluster (named tier-3), bootstrap Flux and Kyverno, verify health +./cluster.sh check +./cluster.sh down +``` + +### Verify the admission gate + +```sh +# Port-forward SpiceDB's HTTP API +kubectl port-forward -n authz svc/spicedb 8443:8443 + +# magnusp is granted deploy permission by the seeded fixtures +../scripts/spicedb-fixture.sh check magnusp + +# unauthorized-dev has no relationship to the service and will be denied +../scripts/spicedb-fixture.sh check unauthorized-dev + +# Inspect the running deployment +kubectl get deploy -n apps apps-archetype-backend-demo +``` + +To see the gate actually reject a deploy, revoke `magnusp`'s permission — `apply` only upserts, so +removing a line from `relationships.txt` and re-applying has no effect: + +```sh +../scripts/spicedb-fixture.sh revoke magnusp +kubectl delete deployment apps-archetype-backend-demo -n apps +``` + +The delete itself gets blocked at admission with the SpiceDB rejection message. Restore access and +recover with: + +```sh +../scripts/spicedb-fixture.sh apply +flux reconcile helmrelease archetype-backend-demo -n flux-system --force +``` + +## Progressing to tier 4 + +Tier 4 adds: + +1. **Governance label scoping**: `namespace-apps.yaml` gains the `governance.platform.io/managed: "true"` + label, and this tier's `clusterpolicy-spicedb-authz.yaml` is retrofitted to match on that label + (`namespaceSelector`) instead of a hardcoded namespace name — making the policy reusable across any + number of opt-in application workspaces. +2. **Image revision integrity**: `clusterpolicy-disallow-manual-image-revision.yaml` (blocks forged + annotations) plus a namespaced Kyverno `Policy` packaged inside the chart itself + (`charts/archetype-backend/templates/policy.yaml`) that injects the real + `org.opencontainers.image.revision` at admission time. +3. **Image base ancestor check**: `clusterpolicy-verify-image-nginx-ancestor.yaml` compares the + deployed image's rootfs layer digests against a known-good `nginx:1.27` base layer digest — a + layer-hash allowlist, not a signature or attestation check (see tier-4's README for the distinction + and a real attestation-based alternative). +4. **Policy Reporter**: a dashboard and SQLite-backed audit trail for all Kyverno policy decisions. + +See [`tier-4/README.md`](../tier-4/README.md) for the full detail, and the root +[README](../README.md#tiers) for the overall progression. diff --git a/tier-3/apps-source/Dockerfile b/tier-3/apps-source/Dockerfile new file mode 100644 index 0000000..36c21c4 --- /dev/null +++ b/tier-3/apps-source/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.27 +COPY --chmod=0644 index.html /usr/share/nginx/html/index.html diff --git a/tier-3/apps-source/index.html b/tier-3/apps-source/index.html new file mode 100644 index 0000000..15f0063 --- /dev/null +++ b/tier-3/apps-source/index.html @@ -0,0 +1,11 @@ + + + + + archetype-backend + + +

archetype-backend

+

Simulated application, built on nginx, deployed via archetype-backend.

+ + diff --git a/tier-3/apps-source/values.yaml b/tier-3/apps-source/values.yaml new file mode 100644 index 0000000..08fb993 --- /dev/null +++ b/tier-3/apps-source/values.yaml @@ -0,0 +1,14 @@ +# archetype-backend chart values for this application. Published as an OCI +# artifact by .github/workflows/publish-app-values.yaml, whenever the +# application image is rolled out — Flux picks it up via the +# archetype-backend-values OCIRepository/ArtifactGenerator in clusters/kind/. +replicas: 1 + +image: + repository: ghcr.io/magnusp/apps/archetype-backend + tag: "latest" + pullPolicy: IfNotPresent + +service: + port: 8080 + targetPort: 80 diff --git a/tier-3/charts/archetype-backend/Chart.yaml b/tier-3/charts/archetype-backend/Chart.yaml new file mode 100644 index 0000000..c260771 --- /dev/null +++ b/tier-3/charts/archetype-backend/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: archetype-backend +description: Baseline backend service chart (Deployment + Service) maintained by platform engineering +type: application +# publish-chart.yaml overrides this with --version/--app-version at publish +# time; the OCIRepository tag Flux tracks (e.g. 0.1.1, 0.1.2) reflects what was +# actually published, not this file's version. +version: 0.1.0 +appVersion: "1.0.0" diff --git a/tier-3/charts/archetype-backend/templates/_helpers.tpl b/tier-3/charts/archetype-backend/templates/_helpers.tpl new file mode 100644 index 0000000..2a318b2 --- /dev/null +++ b/tier-3/charts/archetype-backend/templates/_helpers.tpl @@ -0,0 +1,20 @@ +{{- define "archetype-backend.name" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "archetype-backend.labels" -}} +app.kubernetes.io/name: {{ include "archetype-backend.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{/* +Chart.Version can carry OCI build metadata (e.g. "0.1.0+") when +installed via chartRef from an OCIRepository — "+" isn't a valid label +value character, so it's replaced with "_". +*/}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "archetype-backend.selectorLabels" -}} +app.kubernetes.io/name: {{ include "archetype-backend.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/tier-3/charts/archetype-backend/templates/deployment.yaml b/tier-3/charts/archetype-backend/templates/deployment.yaml new file mode 100644 index 0000000..eb34d2d --- /dev/null +++ b/tier-3/charts/archetype-backend/templates/deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "archetype-backend.name" . }} + labels: + {{- include "archetype-backend.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "archetype-backend.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "archetype-backend.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: {{ include "archetype-backend.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} diff --git a/tier-3/charts/archetype-backend/templates/service.yaml b/tier-3/charts/archetype-backend/templates/service.yaml new file mode 100644 index 0000000..de66e64 --- /dev/null +++ b/tier-3/charts/archetype-backend/templates/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "archetype-backend.name" . }} + labels: + {{- include "archetype-backend.labels" . | nindent 4 }} +spec: + selector: + {{- include "archetype-backend.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/tier-3/charts/archetype-backend/values.schema.json b/tier-3/charts/archetype-backend/values.schema.json new file mode 100644 index 0000000..690c2ab --- /dev/null +++ b/tier-3/charts/archetype-backend/values.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "archetype-backend values", + "type": "object", + "required": ["image"], + "properties": { + "replicas": { + "type": "integer", + "minimum": 1 + }, + "image": { + "type": "object", + "required": ["repository", "tag"], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "pullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"] + } + } + }, + "service": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "targetPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + } + } +} diff --git a/tier-3/charts/archetype-backend/values.yaml b/tier-3/charts/archetype-backend/values.yaml new file mode 100644 index 0000000..fae374f --- /dev/null +++ b/tier-3/charts/archetype-backend/values.yaml @@ -0,0 +1,10 @@ +replicas: 1 + +image: + repository: "" + tag: "" + pullPolicy: IfNotPresent + +service: + port: 8080 + targetPort: 8080 diff --git a/tier-3/clusters/kind/artifactgenerator-archetype-backend.yaml b/tier-3/clusters/kind/artifactgenerator-archetype-backend.yaml new file mode 100644 index 0000000..a04c10b --- /dev/null +++ b/tier-3/clusters/kind/artifactgenerator-archetype-backend.yaml @@ -0,0 +1,25 @@ +# Composes the archetype-backend base Helm chart and the application's +# values.yaml (from archetype-backend-values) into a single ExternalArtifact. +# Whenever either the chart or the values OCI artifact updates, this triggers +# an immediate reconciliation on the HelmRelease. +apiVersion: source.extensions.fluxcd.io/v1beta1 +kind: ArtifactGenerator +metadata: + name: archetype-backend-demo + namespace: flux-system +spec: + sources: + - alias: chart + kind: OCIRepository + name: archetype-backend + - alias: values + kind: OCIRepository + name: archetype-backend-values + artifacts: + - name: archetype-backend-demo + copy: + - from: "@chart/**" + to: "@artifact/" + - from: "@values/values.yaml" + to: "@artifact/archetype-backend/values.yaml" + strategy: Merge diff --git a/tier-3/clusters/kind/clusterpolicy-spicedb-authz.yaml b/tier-3/clusters/kind/clusterpolicy-spicedb-authz.yaml new file mode 100644 index 0000000..022685d --- /dev/null +++ b/tier-3/clusters/kind/clusterpolicy-spicedb-authz.yaml @@ -0,0 +1,72 @@ +# Kyverno platform policy: verifies that the actor who pushed/built the +# container image or values artifact has 'deploy' permission on the target +# workload according to SpiceDB ReBAC relations. +# +# Scoped directly to the 'apps' namespace for now. Tier 4 retrofits this to +# match on the governance.platform.io/managed label instead, so the policy +# generalizes to any number of opt-in application workspaces. +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: spicedb-attested-deploy-authz +spec: + background: false + rules: + - name: check-spicedb-deployer-permission + match: + any: + - resources: + kinds: + - Deployment + namespaces: + - apps + context: + - name: imageData + imageRegistry: + reference: "{{ request.object.spec.template.spec.containers[0].image }}" + # Read the deployer actor from the OCI container image labels + - name: deployer + variable: + jmesPath: "imageData.configData.config.Labels.\"dev.authz.app.deployer\" || imageData.configData.config.Labels.\"org.opencontainers.image.vendor\" || ''" + - name: spiceDbCheck + apiCall: + # apiCall.urlPath only ever addresses the Kubernetes API server; + # calling an arbitrary in-cluster service requires service.url. + service: + url: "http://spicedb.authz.svc.cluster.local:8443/v1/permissions/check" + # SpiceDB requires the preshared key configured on the + # SpiceDBCluster's secretName (spicedb-preshared-key). + headers: + - key: Authorization + value: "Bearer showcase-authz-key" + method: POST + data: + - key: consistency + value: + fullyConsistent: true + - key: resource + value: + objectType: "service" + objectId: "{{ request.object.metadata.name }}" + - key: permission + value: "deploy" + - key: subject + value: + object: + objectType: "user" + objectId: "{{ deployer }}" + validate: + failureAction: Enforce + message: "Deployment Rejected by SpiceDB: Actor '{{ deployer }}' does not have 'deploy' permissions on service '{{ request.object.metadata.name }}'." + deny: + conditions: + # Fail closed: deny both when no deployer identity is present on + # the image (nothing to authorize) and when SpiceDB doesn't grant + # 'deploy' permission to whatever identity is present. + any: + - key: "{{ deployer }}" + operator: Equals + value: "" + - key: "{{ spiceDbCheck.permissionship }}" + operator: NotEquals + value: "PERMISSIONSHIP_HAS_PERMISSION" diff --git a/tier-3/clusters/kind/helmrelease-archetype-backend.yaml b/tier-3/clusters/kind/helmrelease-archetype-backend.yaml new file mode 100644 index 0000000..2df584e --- /dev/null +++ b/tier-3/clusters/kind/helmrelease-archetype-backend.yaml @@ -0,0 +1,30 @@ +# Instance of the archetype-backend chart for the apps-source/ simulated +# application. Its chart and values are composed directly into an ExternalArtifact +# by source-watcher (ArtifactGenerator), providing immediate event-driven reconciliation +# whenever either the chart version or the values artifact changes. +# +# Remediation strategy: bounded retries (3) with automatic rollback on upgrade failure. +# When a new valid values/chart artifact is published, source-watcher generates +# a new ExternalArtifact revision, which automatically resets failure state and +# triggers a new release upgrade. +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: archetype-backend-demo + namespace: flux-system +spec: + targetNamespace: apps + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + strategy: rollback + remediateLastFailure: true + interval: 10m + chartRef: + kind: ExternalArtifact + name: archetype-backend-demo + diff --git a/tier-3/clusters/kind/kustomization.yaml b/tier-3/clusters/kind/kustomization.yaml new file mode 100644 index 0000000..67d4c2a --- /dev/null +++ b/tier-3/clusters/kind/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace-apps.yaml + - ocirepository-archetype-backend.yaml + - ocirepository-archetype-backend-values.yaml + - artifactgenerator-archetype-backend.yaml + - helmrelease-archetype-backend.yaml + - spicedb-cluster.yaml + - spicedb-fixture-job.yaml + - clusterpolicy-spicedb-authz.yaml diff --git a/tier-3/clusters/kind/namespace-apps.yaml b/tier-3/clusters/kind/namespace-apps.yaml new file mode 100644 index 0000000..e4a9dd6 --- /dev/null +++ b/tier-3/clusters/kind/namespace-apps.yaml @@ -0,0 +1,7 @@ +# Dedicated application workspace namespace for showcase workloads. +# Not yet label-scoped for governance opt-in — clusterpolicy-spicedb-authz.yaml +# is hardcoded to this namespace name. Label-based opt-in arrives in tier 4. +apiVersion: v1 +kind: Namespace +metadata: + name: apps diff --git a/tier-3/clusters/kind/ocirepository-archetype-backend-values.yaml b/tier-3/clusters/kind/ocirepository-archetype-backend-values.yaml new file mode 100644 index 0000000..b751fdd --- /dev/null +++ b/tier-3/clusters/kind/ocirepository-archetype-backend-values.yaml @@ -0,0 +1,14 @@ +# Tracks the apps-source/ payload published by +# .github/workflows/publish-app-values.yaml containing values.yaml. The mutable +# `latest` tag plus source-watcher artifact generation is what makes this a "gitops version +# bump": publishing a new artifact is the deploy, no git commit required. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: archetype-backend-values + namespace: flux-system +spec: + interval: 1m + url: oci://ghcr.io/magnusp/apps/archetype-backend-values + ref: + tag: latest diff --git a/tier-3/clusters/kind/ocirepository-archetype-backend.yaml b/tier-3/clusters/kind/ocirepository-archetype-backend.yaml new file mode 100644 index 0000000..0d1861c --- /dev/null +++ b/tier-3/clusters/kind/ocirepository-archetype-backend.yaml @@ -0,0 +1,18 @@ +# Tracks the archetype-backend chart published to GHCR by +# .github/workflows/publish-chart.yaml. Bump `ref.tag` after publishing a new +# chart version. +# +# No secretRef: this assumes the ghcr.io/magnusp/charts/archetype-backend +# package is set to public visibility on GHCR (a separate setting from repo +# visibility, only available after the first version is published). Until +# that's done, reconciliation will fail with an auth error. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: archetype-backend + namespace: flux-system +spec: + interval: 10m + url: oci://ghcr.io/magnusp/charts/archetype-backend + ref: + tag: 0.1.1 diff --git a/clusters/kind/spicedb-cluster.yaml b/tier-3/clusters/kind/spicedb-cluster.yaml similarity index 100% rename from clusters/kind/spicedb-cluster.yaml rename to tier-3/clusters/kind/spicedb-cluster.yaml diff --git a/clusters/kind/spicedb-fixture-job.yaml b/tier-3/clusters/kind/spicedb-fixture-job.yaml similarity index 100% rename from clusters/kind/spicedb-fixture-job.yaml rename to tier-3/clusters/kind/spicedb-fixture-job.yaml diff --git a/fixtures/spicedb/relationships.txt b/tier-3/fixtures/spicedb/relationships.txt similarity index 100% rename from fixtures/spicedb/relationships.txt rename to tier-3/fixtures/spicedb/relationships.txt diff --git a/fixtures/spicedb/schema.zed b/tier-3/fixtures/spicedb/schema.zed similarity index 100% rename from fixtures/spicedb/schema.zed rename to tier-3/fixtures/spicedb/schema.zed diff --git a/tier-3/kind-cluster/.gitignore b/tier-3/kind-cluster/.gitignore new file mode 100644 index 0000000..13be894 --- /dev/null +++ b/tier-3/kind-cluster/.gitignore @@ -0,0 +1,8 @@ +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfstate +*.tfstate.* +*.tfvars +kind-config +tier-*-config diff --git a/tier-3/kind-cluster/.terraform.lock.hcl b/tier-3/kind-cluster/.terraform.lock.hcl new file mode 100644 index 0000000..8237ab3 --- /dev/null +++ b/tier-3/kind-cluster/.terraform.lock.hcl @@ -0,0 +1,131 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/gavinbunney/kubectl" { + version = "1.19.0" + constraints = "~> 1.19" + hashes = [ + "h1:/KiSv1EYOBNWL2Op7BqTmqnc/G2W/WvXIhUZlhnK0to=", + "h1:3E8E5zdLYTLsP9h+Ibvez8m+S0Qqa7QULHvcq8PcnWg=", + "h1:9QkxPjp0x5FZFfJbE+B7hBOoads9gmdfj9aYu5N4Sfc=", + "h1:DJC655V2Lw7zER1Gi+yN1ijW3A+bUAI3MucdYVhGxFY=", + "h1:EL1HfCDfY/pabIJ2kXuppTvqhgY9hfLu2o4TonJheT8=", + "h1:aQUITuBBjv8WJ+WGI0bOZXXYi5s09pMEyyJmlFnuUbE=", + "h1:gwnJt0UeVYnD2u8S0nGbQHvrUccNL3jeCqf9xu0X13I=", + "h1:hzxgobCLhmbGcgC2KD6aYleH17hiblT9vIHyEQKb1VA=", + "h1:iV249UVwKvuNaFHcvba4SitTyWpUNGvqdyy6SatjjH4=", + "h1:jTkF6nmuZvbXvl08L6iwe2RfV4PkPdy2Cnn+7aWkgu8=", + "h1:kevkHUKdNWcl+ET894COOV9i18hXeACWfu4AJXct/b0=", + "h1:ovkjqc3GnFlb5mVY51ZFV3VwrSbxQQtMTXYvEmBs2xg=", + "h1:quymfa/OKEfWI5JXFEwGbUY2aAy0vet3rA9JWJam+3k=", + "h1:tm5Tw1sfVURwtKGB34mUqekBxgo4HqANIV+uyrucYjA=", + "zh:1dec8766336ac5b00b3d8f62e3fff6390f5f60699c9299920fc9861a76f00c71", + "zh:43f101b56b58d7fead6a511728b4e09f7c41dc2e3963f59cf1c146c4767c6cb7", + "zh:4c4fbaa44f60e722f25cc05ee11dfaec282893c5c0ffa27bc88c382dbfbaa35c", + "zh:51dd23238b7b677b8a1abbfcc7deec53ffa5ec79e58e3b54d6be334d3d01bc0e", + "zh:5afc2ebc75b9d708730dbabdc8f94dd559d7f2fc5a31c5101358bd8d016916ba", + "zh:6be6e72d4663776390a82a37e34f7359f726d0120df622f4a2b46619338a168e", + "zh:72642d5fcf1e3febb6e5d4ae7b592bb9ff3cb220af041dbda893588e4bf30c0c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a1da03e3239867b35812ee031a1060fed6e8d8e458e2eaca48b5dd51b35f56f7", + "zh:b98b6a6728fe277fcd133bdfa7237bd733eae233f09653523f14460f608f8ba2", + "zh:bb8b071d0437f4767695c6158a3cb70df9f52e377c67019971d888b99147511f", + "zh:dc89ce4b63bfef708ec29c17e85ad0232a1794336dc54dd88c3ba0b77e764f71", + "zh:dd7dd18f1f8218c6cd19592288fde32dccc743cde05b9feeb2883f37c2ff4b4e", + "zh:ec4bd5ab3872dedb39fe528319b4bba609306e12ee90971495f109e142d66310", + "zh:f610ead42f724c82f5463e0e71fa735a11ffb6101880665d93f48b4a67b9ad82", + ] +} + +provider "registry.opentofu.org/hashicorp/helm" { + version = "3.2.0" + constraints = "~> 3.2.0" + hashes = [ + "h1:/TdLShnQEtyQRij46dZJobeA8clDnuncSdc2KRkc0B0=", + "h1:8j4dgSY+8Y9tukMTo0WuKPyJx0JKUeZsYx9ltTUmEuo=", + "h1:AOxR+hQy92GqWcp4rnD3ZBrDlip7M7fOB4zL4aw8AU4=", + "h1:IytRFhZXOMPVB36GainacnNoBtiibAdVMdezG2DPVag=", + "h1:XpXkXaOaDyICkDLyrRNBzxqUc8zrKp6Y7sw17FGD+Xg=", + "h1:c1NQPYp6/iIg0PG/HFodq0oyDVI/FAYZF32eEOliWaw=", + "h1:dBAYwgWCv9/ulFx7uxbkNzPYL9TI1aqUF8E7EBJbIRk=", + "h1:h05ap1OKMOtgqspVfdc1GmkX9vJJHquxn5g26Ik7+t4=", + "h1:h21YEXSmcV7Xe64kptnt0WQYEh6m99jVR8sec2o9stk=", + "h1:ikX+6kgc+kYNDAiNghhOhhY15T1Jt6omoWoHlh8CEsY=", + "h1:jvnisz9lHFn+5sjbuZwGCqqCvbddTeqWvrYtK4Wcbxw=", + "h1:nK2+CnIIaNh3zl4nRRhpxrA5Z7sAMrB3Jmzjcm9hw/4=", + "h1:r8rm1wyokhuaFvRDptDm2G1BsvrV6ukGiPoZRGaVmbc=", + "h1:thNSoWm4pdgTEO1XBi1n5V2nwsdSA6EVEXP3qPZiFcA=", + "h1:zVNhod3npW/4gn5QTcQzmlTSdAP7Ttjs59KW6VVyaoo=", + "zh:1a214581dee54ec4e9afa4050e54f6c187aed4b51b2d2ac929c58706b65e1159", + "zh:2f8ba94af93011768ed1fffd4d25b980cd764f2d49c9f13475512ec48464da0b", + "zh:36373bca4f374e95f654def79e0b12df0c8f2e01c634db80ff2737ded29062e3", + "zh:3a26b5c3e47b2bbc01faa0fa9fe816ea4f74f1f8f4555dfad7f62ee38266aaff", + "zh:51bc637700f13cfc1f7c6a8c03a4b5b5755338419912d945dbfe5ccb0ddbf614", + "zh:53f32f91afcb682209de124d8a994e591687023ae7c0dfed6184c5511778b16b", + "zh:6f4434327ed466b2b5be0d5f4aa537bca71ebd7f7fe05468065aeda52dfc8896", + "zh:7394e8c6f5027fa21699e46c9a45bee1ce87fd8dd98f2e89e8d414ec70c8e4e1", + "zh:d90b855a0990e3aa6445afe66de730c2f4651f39699c6b6345ca02f9af2a1a08", + "zh:d9b7246e6af0f75155ed532855014f888e9e5613242c89e321b4e0f9b54f5726", + "zh:dabd399ca36172c15d176a24cf199ac886ef191f7131e806d76a8dc209e5beb8", + "zh:e57987397be46dc123365f16c3bec4dd0615453c8bfdcfc1b7c9f3202a09c516", + "zh:ec88430b833b943b38d02f70b33250c72bb6ffa3b3d22360990a41390161a2b2", + "zh:f8ea01b57982e9ed9ad3a750b1caba261a478f5b0ddcac78a4502d886fd2fc74", + "zh:fb63819037158205ebf42b649c3a8ec308234ffe987a5dbd4444e1e0683f1170", + ] +} + +provider "registry.opentofu.org/hashicorp/kubernetes" { + version = "3.2.1" + constraints = "~> 3.2.1" + hashes = [ + "h1:+VM77HEd3CgzxwQ0PtfOc5tQrRQ2FZ9YJj1ShiMTsoI=", + "h1:32YYVAK/LUIheXfaxxnwL4qzf6QgWaMVkByneQH8EpA=", + "h1:8WnHwjdwYmtpTJg5reAs9VGtKq67E3dQdIrcjDW1Xao=", + "h1:8pNFJAX4IXSaPr+f02FsD6bUlZrMT5rkPPnA5sm2szI=", + "h1:Ad9kERqM2KFLkW/beJ6VsV3QxD05o98ZjC/epjpDcnI=", + "h1:EtS/ztrc03Vy+u9Q6eflu+HYVMMhQIufMaHS+vlbprY=", + "h1:GROcvqLewzybvZa6LjWjU7EWE7aOttp9ovBkT1b8lhc=", + "h1:OR1GGSnvGgFNKA8ilnttn1cy5wT8N0qzGpbqWOZ+yG8=", + "h1:PJBBJz/9kOjaGjGXCQpVslstEbeTxQBx8zu/ZAvRHak=", + "h1:QzzyadpGU0iHlOKsXX/ge6/apcwRXmzVlJxSzVclM/I=", + "h1:Un9WHqrP13fUt64c7VcO60VfvJty0tnrX88b8wy/4SU=", + "h1:VDyHxveT5+lt75fY3oOpa1D/08i1+AC6Af5XoQXEK7E=", + "h1:glcBsCYHGLC1os31lZ22X0se9EyTjQO/1kVMeHj5uJ8=", + "h1:qGon41gIBlkXIbSpsovc5UJr4okWcjaw7KkgPFxhNlU=", + "h1:tsq7mekr30obQVOK83ZNAiRb7rzRSZnWxQLdjD1Rvo0=", + "zh:018e382285ab0ca5a68e4e4116e1cfc3a20c57194f4e678bd87654f5aecf5b14", + "zh:0e8d233199b6896a6b9379b2f41bfdcdbb42b80f5f1c4ed7d87825ce53bae639", + "zh:112221d98fae795d89e6a0adc526fbe2a115316bd052fe10433d93061183b5e2", + "zh:1487c229ad673ddbd6c46ac511985d9a7ce86120ccf1c929acacb3761f3f1f9c", + "zh:2b73e4087cfbf20049240914a02f1344e43eb96a3c7d734454e4c49875aba1df", + "zh:3de5de4e50e58228f612fa158577f623c3a679b18918f62e6b0a925b23e4ec04", + "zh:6501c980ecb830728929798848105b27ce1cba1b604745fc609ecc54a80e0bd4", + "zh:67db18b88e93520eb052c5fae1f0dbedefe8433692b98ebdd3e29c3824ef08eb", + "zh:6aa9bada1a35dd1eb1a37e0233a031d8ecc1ae76ead3e851419bc49ce0dd67ba", + "zh:80e499f1971e2f5dfa7bbb4c40fa2bb303d6c0752b3d04f9a6869b7051071913", + "zh:93d63aee4fbacd28eef5330d517f37da2ead51dbd9c1d89e3e2a19a158c15a5d", + "zh:97c9d6971915400300f78300b807470c00793068b2b90506463da96cf5067092", + "zh:9a626f4c13f32317e8b891950bfd34eb69970599b9e8f62a5faa565e8c60f962", + "zh:d233fc2f7173db3ce5de21dc17e37b7757956b48783be3a81b8d92a6893a4fbc", + "zh:dfc6282c33ab4747d899915027de314687c6f8b49b7c95cafa8544bc45a0d15a", + ] +} + +provider "registry.opentofu.org/tehcyx/kind" { + version = "0.11.0" + constraints = "~> 0.11" + hashes = [ + "h1:+Q1amcdec50yH5urYvojIkQZdCXJwV/6wG7rFpIgfaY=", + "h1:5YkI2sD2PSilFvCvUL2dtyRkrTvxO7PLOgbIZXMbelA=", + "h1:6ahtXT8czMx6a3cfQRhrCkKGotFkSHtfvnGOHw6mH0E=", + "h1:B7hSI3H98Qr6Hkp8CtJm6nVrnWqxbGUgj7jWW/QAIzo=", + "h1:BOGuvXDo/Qes3RrTdYR/LY9tqQucpsIkvE2tfRaXTzE=", + "h1:w1vPgnfTycZq2MXQoS4sJ+URsXMwIv8u2drC9BE8DPA=", + "zh:10cf5f11ed1b24bcc2a64ddfe529dbe240ac72c075100039eb8a182abd5a25d8", + "zh:1c652afcea840545f9e21cf42369560966eafe52986d578c31a35247624442bf", + "zh:8ed94e1387970e7b885c7a68579b17a662d769d04dd3a0917d6c795741d0b97c", + "zh:97e3591b821b8a7cd1d0bc6322c1cbeed882ca26ff357cdcfab8dc0b17279090", + "zh:d5789b07c0a76d086d19acf948246875cb20bea0826a164f4f2c36b8fe527385", + "zh:e5a1117080f6b51e836bf41576e62ee1ea738a5d5fb2f5b0fccb25c942dfb557", + ] +} diff --git a/tier-3/kind-cluster/cluster.sh b/tier-3/kind-cluster/cluster.sh new file mode 100755 index 0000000..4fdd6fd --- /dev/null +++ b/tier-3/kind-cluster/cluster.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Stands up or tears down the local kind cluster and Flux bootstrap via OpenTofu. +# +# Usage: +# ./cluster.sh up Create the cluster and apply the OpenTofu stack. +# ./cluster.sh down Destroy the OpenTofu stack and the cluster. +# ./cluster.sh check Wait for Flux and Kyverno to become ready and report status. +# +# The repository is public, and the chart OCIRepository sources under +# clusters/kind/ assume their GHCR packages are also public, so Flux needs +# no credentials for either. If a source is added that isn't public, apply +# its pull secret manually with `kubectl create secret ... -n flux-system` +# and reference it via that source's secretRef. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +usage() { + echo "Usage: $0 {up|down|check}" >&2 + exit 1 +} + +kubeconfig_env() { + KUBECONFIG=$(mktemp) + export KUBECONFIG + mise exec -- tofu output -raw kubeconfig > "$KUBECONFIG" +} + +check() { + kubeconfig_env + + echo "Waiting for flux-system pods..." + mise exec -- kubectl wait --for=condition=Ready pods --all -n flux-system --timeout=180s + + # The SpiceDB Operator's CRDs (notably SpiceDBCluster) are applied directly via + # OpenTofu (spicedb-operator.tf), independently of the git-synced flux-system + # Kustomization, precisely so the operator can install its CRDs without waiting + # on that Kustomization to succeed first — clusters/kind/spicedb-cluster.yaml + # instantiates a SpiceDBCluster, which can't be applied until those CRDs exist. + # On a cold cluster the git-synced Kustomization's first attempt will still fail + # for that reason; wait for the operator here, then force one reconciliation + # instead of waiting out kustomize-controller's own retry backoff. + echo "Waiting for the SpiceDB Operator bootstrap to become Ready..." + mise exec -- kubectl wait --for=condition=Ready kustomization/spicedb-operator -n flux-system --timeout=180s + mise exec -- flux reconcile kustomization flux-system --with-source --timeout=180s + + echo "Waiting for FluxInstance to become Ready..." + mise exec -- kubectl wait --for=condition=Ready fluxinstance/flux -n flux-system --timeout=180s + + echo "Waiting for Kyverno HelmRelease to become Ready..." + mise exec -- kubectl wait --for=condition=Ready helmrelease/kyverno -n flux-system --timeout=180s + + echo "Waiting for Kyverno pods..." + mise exec -- kubectl wait --for=condition=Ready pods --all -n kyverno --timeout=180s + + if mise exec -- kubectl get namespace authz > /dev/null 2>&1; then + echo "Waiting for SpiceDB deployment in authz namespace..." + mise exec -- kubectl wait --for=condition=Available deployment/spicedb-spicedb -n authz --timeout=180s || true + fi + + echo + echo "--- flux-system pods ---" + mise exec -- kubectl get pods -n flux-system + echo + echo "--- kyverno pods ---" + mise exec -- kubectl get pods -n kyverno + if mise exec -- kubectl get namespace authz > /dev/null 2>&1; then + echo + echo "--- authz (spicedb) pods ---" + mise exec -- kubectl get pods -n authz + fi + echo + echo "Cluster and Flux bootstrap are healthy." +} + +[ $# -eq 1 ] || usage + +case "$1" in + up) + mise install + mise exec -- tofu init + mise exec -- tofu apply -auto-approve + check + ;; + down) + mise exec -- tofu destroy -auto-approve + ;; + check) + check + ;; + *) + usage + ;; +esac diff --git a/tier-3/kind-cluster/flux.tf b/tier-3/kind-cluster/flux.tf new file mode 100644 index 0000000..7becf0d --- /dev/null +++ b/tier-3/kind-cluster/flux.tf @@ -0,0 +1,57 @@ +# Flux is installed via the flux-operator Helm chart, pulled directly from its +# OCI registry (oci://ghcr.io/controlplane/charts/flux-operator) rather than a +# traditional Helm repo, since we standardize on OCI-hosted charts. +resource "helm_release" "flux_operator" { + name = "flux-operator" + namespace = "flux-system" + repository = "oci://ghcr.io/controlplaneio-fluxcd/charts" + chart = "flux-operator" + version = var.flux_operator_version + + create_namespace = true + + depends_on = [kind_cluster.this] +} + +# The FluxInstance CR tells flux-operator which Flux controllers to run and, +# optionally, which git repository to sync. Source controllers for both +# OCIRepository and GitRepository are enabled so charts can be pulled from +# OCI registries by default, with git sync available when configured. +resource "kubectl_manifest" "flux_instance" { + yaml_body = yamlencode({ + apiVersion = "fluxcd.controlplane.io/v1" + kind = "FluxInstance" + metadata = { + name = "flux" + namespace = "flux-system" + } + spec = { + distribution = { + version = var.flux_version + registry = "ghcr.io/fluxcd" + } + components = [ + "source-controller", + "kustomize-controller", + "helm-controller", + "notification-controller", + "source-watcher", + ] + cluster = { + multitenant = false + networkPolicy = true + } + # The repository is public, so no pullSecret is needed to clone it. + # GHCR chart pulls (OCIRepository sources) are a separate concern — + # see clusters/kind/ocirepository-archetype-backend.yaml. + sync = var.flux_git_repository == "" ? null : { + kind = "GitRepository" + url = var.flux_git_repository + ref = "refs/heads/${var.flux_git_branch}" + path = var.flux_git_path + } + } + }) + + depends_on = [helm_release.flux_operator] +} diff --git a/tier-3/kind-cluster/kind.tf b/tier-3/kind-cluster/kind.tf new file mode 100644 index 0000000..08c4433 --- /dev/null +++ b/tier-3/kind-cluster/kind.tf @@ -0,0 +1,21 @@ +provider "kind" {} + +resource "kind_cluster" "this" { + name = var.cluster_name + wait_for_ready = true + + kind_config { + kind = "Cluster" + api_version = "kind.x-k8s.io/v1alpha4" + + node { + role = "control-plane" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + + node { + role = "worker" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + } +} diff --git a/kind-cluster/kyverno.tf b/tier-3/kind-cluster/kyverno.tf similarity index 100% rename from kind-cluster/kyverno.tf rename to tier-3/kind-cluster/kyverno.tf diff --git a/tier-3/kind-cluster/mise.toml b/tier-3/kind-cluster/mise.toml new file mode 100644 index 0000000..bfed49c --- /dev/null +++ b/tier-3/kind-cluster/mise.toml @@ -0,0 +1,6 @@ +[tools] +helm = "latest" +kind = "latest" +kubectl = "latest" +opentofu = "latest" +yq = "latest" diff --git a/tier-3/kind-cluster/outputs.tf b/tier-3/kind-cluster/outputs.tf new file mode 100644 index 0000000..534130d --- /dev/null +++ b/tier-3/kind-cluster/outputs.tf @@ -0,0 +1,10 @@ +output "kubeconfig" { + description = "Kubeconfig for the kind cluster." + value = kind_cluster.this.kubeconfig + sensitive = true +} + +output "cluster_endpoint" { + description = "API server endpoint of the kind cluster." + value = kind_cluster.this.endpoint +} diff --git a/tier-3/kind-cluster/providers.tf b/tier-3/kind-cluster/providers.tf new file mode 100644 index 0000000..3f41ef0 --- /dev/null +++ b/tier-3/kind-cluster/providers.tf @@ -0,0 +1,23 @@ +provider "helm" { + kubernetes = { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + } +} + +provider "kubernetes" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate +} + +provider "kubectl" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + load_config_file = false +} diff --git a/tier-3/kind-cluster/spicedb-operator.tf b/tier-3/kind-cluster/spicedb-operator.tf new file mode 100644 index 0000000..3fa41df --- /dev/null +++ b/tier-3/kind-cluster/spicedb-operator.tf @@ -0,0 +1,106 @@ +# The SpiceDB Operator's CRDs (notably SpiceDBCluster) must exist before Flux's +# git-synced Kustomization can apply clusters/kind/spicedb-cluster.yaml, which +# instantiates a SpiceDBCluster. If the operator's GitRepository/Kustomization +# were themselves resources inside that same git-synced Kustomization, the +# whole apply would deadlock on a cold cluster: kustomize-controller validates +# the entire manifest set together, so the SpiceDBCluster CR's failed dry-run +# (no CRD yet) blocks the operator's own GitRepository/Kustomization from ever +# being created, and the CRD never gets installed. Applying the operator +# bootstrap directly via OpenTofu — the same pattern kyverno.tf uses — breaks +# that cycle: it's guaranteed to exist and start reconciling independently of +# whether the git-synced Kustomization has succeeded yet. + +resource "kubectl_manifest" "spicedb_operator_git_repository" { + yaml_body = yamlencode({ + apiVersion = "source.toolkit.fluxcd.io/v1" + kind = "GitRepository" + metadata = { + name = "spicedb-operator" + namespace = "flux-system" + } + spec = { + interval = "2h" + url = "https://github.com/authzed/spicedb-operator" + ref = { + tag = "v1.26.0" + } + } + }) + + depends_on = [kubectl_manifest.flux_instance] +} + +resource "kubectl_manifest" "spicedb_operator_kustomization" { + yaml_body = yamlencode({ + apiVersion = "kustomize.toolkit.fluxcd.io/v1" + kind = "Kustomization" + metadata = { + name = "spicedb-operator" + namespace = "flux-system" + } + spec = { + interval = "2h" + path = "./config" + prune = true + wait = true + sourceRef = { + kind = "GitRepository" + name = "spicedb-operator" + } + } + }) + + depends_on = [kubectl_manifest.spicedb_operator_git_repository] +} + +# RBAC extension granting spicedb-operator permissions to manage +# PodDisruptionBudgets and EndpointSlices. Kept alongside the operator's own +# bootstrap for the same reason: it must exist before the operator reconciles +# SpiceDBCluster resources, independent of the git-synced Kustomization. +resource "kubectl_manifest" "spicedb_operator_pdb_extension_role" { + yaml_body = yamlencode({ + apiVersion = "rbac.authorization.k8s.io/v1" + kind = "ClusterRole" + metadata = { + name = "spicedb-operator-pdb-extension" + } + rules = [ + { + apiGroups = ["policy"] + resources = ["poddisruptionbudgets"] + verbs = ["create", "delete", "get", "list", "patch", "update", "watch"] + }, + { + apiGroups = ["discovery.k8s.io"] + resources = ["endpointslices"] + verbs = ["create", "delete", "get", "list", "patch", "update", "watch"] + }, + ] + }) + + depends_on = [kubectl_manifest.flux_instance] +} + +resource "kubectl_manifest" "spicedb_operator_pdb_extension_role_binding" { + yaml_body = yamlencode({ + apiVersion = "rbac.authorization.k8s.io/v1" + kind = "ClusterRoleBinding" + metadata = { + name = "spicedb-operator-pdb-extension" + } + roleRef = { + apiGroup = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = "spicedb-operator-pdb-extension" + } + subjects = [ + { + kind = "ServiceAccount" + name = "spicedb-operator" + namespace = "spicedb-operator" + }, + ] + }) + + depends_on = [kubectl_manifest.spicedb_operator_pdb_extension_role] +} diff --git a/tier-3/kind-cluster/variables.tf b/tier-3/kind-cluster/variables.tf new file mode 100644 index 0000000..24a36d8 --- /dev/null +++ b/tier-3/kind-cluster/variables.tf @@ -0,0 +1,47 @@ +variable "cluster_name" { + description = "Name of the kind cluster." + type = string + default = "tier-3" +} + +variable "kubernetes_version" { + description = "Kind node image tag (kubernetes version) to run. Leave null for the kind default." + type = string + default = null +} + +variable "flux_operator_version" { + description = "Version of the controlplane/flux-operator Helm chart to install from OCI." + type = string + default = "0.58.1" +} + +variable "flux_version" { + description = "Version of Flux the FluxInstance should reconcile." + type = string + default = "2.9.4" +} + +variable "flux_git_repository" { + description = "Optional git repository URL to bootstrap Flux against. Leave empty to manage sync purely via OCIRepository/HelmRelease objects applied later." + type = string + default = "https://github.com/magnusp/declarative-deploys" +} + +variable "flux_git_branch" { + description = "Git branch Flux should reconcile when flux_git_repository is set." + type = string + default = "main" +} + +variable "flux_git_path" { + description = "Path within the git repository containing the cluster's Flux manifests." + type = string + default = "tier-3/clusters/kind" +} + +variable "kyverno_version" { + description = "Kyverno chart version (OCI tag) for Flux to reconcile." + type = string + default = "3.9.0" +} diff --git a/tier-3/kind-cluster/versions.tf b/tier-3/kind-cluster/versions.tf new file mode 100644 index 0000000..6fecaec --- /dev/null +++ b/tier-3/kind-cluster/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + kind = { + source = "tehcyx/kind" + version = "~> 0.11" + } + helm = { + source = "hashicorp/helm" + version = "~> 3.2.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 3.2.1" + } + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.19" + } + } +} diff --git a/tier-3/mise.toml b/tier-3/mise.toml new file mode 100644 index 0000000..8c4a255 --- /dev/null +++ b/tier-3/mise.toml @@ -0,0 +1,5 @@ +[tools] +helm = "latest" +yq = "latest" +flux2 = "latest" +jq = "latest" diff --git a/scripts/spicedb-fixture.sh b/tier-3/scripts/spicedb-fixture.sh similarity index 74% rename from scripts/spicedb-fixture.sh rename to tier-3/scripts/spicedb-fixture.sh index b5b40ed..9806c6c 100755 --- a/scripts/spicedb-fixture.sh +++ b/tier-3/scripts/spicedb-fixture.sh @@ -97,6 +97,33 @@ apply_fixtures() { write_relationships "${2:-${FIXTURES_DIR}/relationships.txt}" } +revoke_relationship() { + # write_relationships only ever issues OPERATION_TOUCH (upsert) for lines + # present in relationships.txt — deleting a line and re-applying does NOT + # revoke the tuple, since TOUCH never removes anything not mentioned. To + # actually revoke a relationship, issue an explicit OPERATION_DELETE. + local subject_id="$1" + local relation="${2:-member}" + local resource_type="${3:-team}" + local resource_id="${4:-backend_core}" + local subject_type="${5:-user}" + echo "==> Revoking ${resource_type}:${resource_id}#${relation}@${subject_type}:${subject_id}..." + curl -s -f -X POST "${SPICEDB_ENDPOINT}/v1/relationships/write" \ + -H "Content-Type: application/json" \ + -H "${AUTH_HEADER}" \ + -d "{ + \"updates\": [{ + \"operation\": \"OPERATION_DELETE\", + \"relationship\": { + \"resource\": { \"objectType\": \"${resource_type}\", \"objectId\": \"${resource_id}\" }, + \"relation\": \"${relation}\", + \"subject\": { \"object\": { \"objectType\": \"${subject_type}\", \"objectId\": \"${subject_id}\" } } + } + }] + }" | jq . + echo "✔ Relationship revoked." +} + check_permission() { local user="$1" local service="${2:-apps-archetype-backend-demo}" @@ -113,12 +140,16 @@ check_permission() { } usage() { - echo "Usage: $0 {apply [schema_path] [relationships_path]|schema [schema_path]|relationships [relationships_path]|check [service]}" + echo "Usage: $0 {apply [schema_path] [relationships_path]|schema [schema_path]|relationships [relationships_path]|check [service]|revoke [relation] [resource_type] [resource_id] [subject_type]}" echo "" echo "Examples:" echo " $0 apply # Apply default schema.zed and relationships.txt" echo " $0 check magnusp # Check if user magnusp can deploy apps-archetype-backend-demo" echo " $0 check unauthorized-dev # Check unauthorized user" + echo " $0 revoke magnusp # Revoke magnusp's team:backend_core#member relationship" + echo "" + echo "Note: 'apply' only ever upserts (OPERATION_TOUCH) — editing a tuple out" + echo "of relationships.txt and re-applying does NOT revoke it. Use 'revoke'." exit 1 } @@ -136,6 +167,10 @@ case "${1:-}" in [ $# -ge 2 ] || usage check_permission "$2" "${3:-apps-archetype-backend-demo}" ;; + revoke) + [ $# -ge 2 ] || usage + revoke_relationship "$2" "${3:-}" "${4:-}" "${5:-}" "${6:-}" + ;; *) usage ;; diff --git a/tier-4/README.md b/tier-4/README.md new file mode 100644 index 0000000..e340acf --- /dev/null +++ b/tier-4/README.md @@ -0,0 +1,258 @@ +# Tier 4 — Full governance and supply-chain verification + +This is the final tier and matches the complete showcase: the platform/app split (tier 1), OCI-published +app values (tier 2), and SpiceDB ReBAC authorization (tier 3), now layered with opt-in governance +scoping, image-provenance verification, and a Policy Reporter dashboard. + +## What this tier demonstrates + +* **Opt-in governance scoping**: Kyverno policies only apply to namespaces labelled + `governance.platform.io/managed: "true"` (see `clusters/kind/namespace-apps.yaml`), rather than being + hardcoded to a single namespace as in tier 3. This is what makes governance policies reusable across + an arbitrary number of application workspaces. +* **Two-layered image revision integrity**: + 1. [`clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml`](clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml) — + blocks developers and incoming Helm charts from forging the `example.com/image-revision` + annotation on `Deployment` templates. + 2. [`charts/archetype-backend/templates/policy.yaml`](charts/archetype-backend/templates/policy.yaml) — + a namespaced Kyverno `Policy` packaged with the archetype chart itself. At admission time it queries + the OCI registry for the container image configuration, extracts + `org.opencontainers.image.revision`, and injects it into `spec.template.metadata.annotations`. +* **Image base ancestor & layer check** + ([`clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml`](clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml)): + inspects the container image filesystem configuration at admission time using Kyverno's + `imageRegistry` context, comparing root filesystem layer hashes (`imageData.configData.rootfs.diff_ids`) + against a hardcoded, known-good `nginx:1.27` base layer digest. **This is a layer-hash allowlist, not + a signature or attestation check** — it carries no cryptographic guarantee about who built the image + or whether it was tampered with, only that one of its layers matches a digest recorded in the policy, + and it goes stale whenever the `nginx:1.27` base image is rebuilt upstream. A real attestation-based + equivalent would use Kyverno's `verifyImages` feature against a signed SLSA provenance attestation — + see the bare-metal example near the bottom of this README for that shape. (An earlier version of this + policy did exactly that; it was later simplified to the digest-comparison approach in this repo's + history, and this description was updated to match what the policy actually does today.) +* **One caveat that applies to all three policies above**: each only inspects + `spec.template.spec.containers[0]` — a second container in the pod spec is entirely unchecked. +* **SpiceDB ReBAC, now label-scoped**: the admission gate introduced in tier 3 + (`clusterpolicy-spicedb-authz.yaml`) is retrofitted to match on the same + `governance.platform.io/managed` label instead of a hardcoded `apps` namespace, consistent with the + other policies in this tier. It fails closed: a Deployment whose image carries no + `dev.authz.app.deployer` label (stamped by `build-app-image.yaml`), or whose deployer lacks `deploy` + permission in SpiceDB, is rejected. +* **Policy Reporter** (`kind-cluster/policy-reporter.tf`): a Flux-managed `HelmRelease` that persists + policy execution history and violation reports in an embedded SQLite database backed by a persistent + volume (`policy-reporter-sqlite-pvc`), giving governance decisions a queryable audit trail and web + dashboard. + +## Still no check on the change itself — and what would actually close it + +Even at full governance, this tier doesn't add the control that's actually missing: an independent +check on the values change before it's published (see tier 2 for why that doesn't have to mean human +review). The two image-integrity policies here check **provenance of the image** — that its base +layers descend from an approved `nginx:1.27`, and that its `image-revision` annotation genuinely +matches the OCI config — not the *content* of the values artifact a deployer publishes. SpiceDB +(tier 3) strengthens deploy-time authorization and logging; these policies strengthen build-time +provenance (a documented, verifiable trail of how an artifact was built and from what). None of them +check whether the change was reviewed, tested against policy, or covered by a rollback plan before it +shipped, because no fact like that exists anywhere in this pipeline for Kyverno — the *admission +controller* running these checks, meaning it inspects an object and can allow, block, or modify it +right before Kubernetes accepts it — to verify. + +A real closing mechanism has to produce that fact first, then enforce it — the same two-step pattern +this tier already uses for build provenance, and it works the same way regardless of which mitigation +from tier 2 you actually adopt: + +1. **Produce a verifiable attestation that the chosen check passed.** An *attestation* is a signed, + tamper-evident statement about an artifact — the same concept the app image's build already uses + (`build-app-image.yaml`'s `actions/attest-build-provenance` step), just applied to a different + claim. Whether the check you adopted from tier 2 is an automated policy-as-code gate, a + canary/rollback health check, or a human-reviewed PR, have that step sign a custom attestation + (using a tool like `cosign attest`, or GitHub's `actions/attest` action with a custom + `predicate-type`/`predicate` — note this is a different action from `attest-build-provenance`, which + only produces SLSA provenance) — e.g. "passed policy gate `` in run ``" — and attach it to + the published OCI artifact. +2. **Enforce it at admission.** Add a `ClusterPolicy` using Kyverno's `verifyImages` feature (which + checks that a required attestation exists and is validly signed before letting the image through) + for that specific attestation type — see the bare-metal keyless-Sigstore example near the bottom of + this README for the shape of a real `verifyImages` rule. Note this repo's own + `clusterpolicy-verify-image-nginx-ancestor.yaml` does **not** use `verifyImages` today (see above) — + it would need to be rewritten, not just extended, to enforce this attestation. + +Kyverno can only enforce facts that were actually produced and signed somewhere upstream; it can't +retroactively supply a check that never happened, automated or human. This repo doesn't implement +either step — picking a check (from tier 2's options) and wiring this attestation loop around it is +the natural next tier if you're adapting this pattern for real use. + +## Directory layout + +* [`kind-cluster/`](kind-cluster/): OpenTofu configuration that creates the kind cluster (named + `tier-4`) and bootstraps `flux-operator`, Kyverno, Policy Reporter, and (`spicedb-operator.tf`) the + SpiceDB Operator — applied directly via OpenTofu rather than the git-synced manifests, so its CRDs + exist independently of whether the git-synced `Kustomization` (which includes a `SpiceDBCluster` + resource of that CRD) has succeeded yet. +* [`charts/`](charts/): Platform-owned Helm chart, including the chart-packaged image-revision policy. +* [`clusters/kind/`](clusters/kind/): Flux manifests — `OCIRepository`, `ArtifactGenerator`, + `HelmRelease`, SpiceDB resources, and all governance `ClusterPolicy` objects. +* [`apps-source/`](apps-source/): Simulated application repository (container build files and + `values.yaml`). +* [`fixtures/spicedb/`](fixtures/spicedb/): Human-readable SpiceDB schema (`schema.zed`) and + relationship tuples (`relationships.txt`). +* [`scripts/`](scripts/): `spicedb-fixture.sh` for testing permissions and updating fixtures. + +## Getting started + +### Prerequisites + +```sh +cd tier-4 +mise install +``` + +This installs `helm`, `yq`, `flux`, and `jq` (for `scripts/spicedb-fixture.sh`). `kind`, `opentofu`, and +`kubectl` come from `tier-4/kind-cluster/mise.toml` instead — `./cluster.sh up` runs `mise install` +there too, so you don't need a separate step for them. + +### Cluster lifecycle + +```sh +cd tier-4/kind-cluster +./cluster.sh up # Create the kind cluster, bootstrap Flux, Kyverno and Policy Reporter, and verify health +./cluster.sh check # Check pod readiness and print component status +./cluster.sh down # Destroy the OpenTofu stack and delete the kind cluster +``` + +> **Note**: Both `up` and `check` automatically configure `KUBECONFIG` from the OpenTofu state. You do +> not need to export `KUBECONFIG` manually. + +--- + +## Application delivery workflow + +Same as tier 2/3: the application team delivers updates independently of the platform GitOps repository +by publishing OCI artifacts. See the repository root [`.github/workflows/`](../.github/workflows/) for +`publish-chart.yaml`, `build-app-image.yaml`, and `publish-app-values.yaml`. + +### Releasing an application update (runbook) + +1. **Merge changes** to the main application branch. +2. **Trigger `Build app image`**: Navigate to **Actions** > **Build app image** and run the workflow on + your target commit. +3. **Trigger `Bump archetype-backend values`**: Run the workflow with the `image_tag` input set to the + commit SHA built in step 2. +4. **Verify deployment**: + + ```sh + kubectl get helmrelease -n flux-system archetype-backend-demo + kubectl get pods -n apps -l app.kubernetes.io/instance=apps-archetype-backend-demo + kubectl get deploy -n apps apps-archetype-backend-demo \ + -o jsonpath='{.spec.template.spec.containers[0].image}' + ``` + +To verify that the verified image revision was stamped on the running workload: + +```sh +kubectl get deploy -n apps apps-archetype-backend-demo \ + -o jsonpath='{.spec.template.metadata.annotations}' +``` + +### Policy Reporter dashboard + +```sh +kubectl port-forward -n policy-reporter svc/policy-reporter-ui 8080:8080 +``` + +Open `http://localhost:8080` in your browser to view real-time Kyverno policy reports, audit logs, and +compliance metrics. + +### SpiceDB ReBAC authorization + +```sh +# Port-forward SpiceDB HTTP API +kubectl port-forward -n authz svc/spicedb 8443:8443 + +# Check if user 'magnusp' has deploy permission +./scripts/spicedb-fixture.sh check magnusp + +# Check an unauthorized user +./scripts/spicedb-fixture.sh check unauthorized-dev + +# Edit fixtures/spicedb/relationships.txt or schema.zed, then apply (upserts only): +./scripts/spicedb-fixture.sh apply + +# To actually revoke access (apply never removes a tuple, only adds/updates one): +./scripts/spicedb-fixture.sh revoke magnusp +``` + +--- + +## Attestation & provenance verification + +All published OCI artifacts (charts, images, and values) include GitHub Actions build provenance +attestations. + +```sh +# Verify platform Helm chart +gh attestation verify oci://ghcr.io/magnusp/charts/: --owner magnusp + +# Verify application container image +gh attestation verify oci://ghcr.io/magnusp/apps/archetype-backend: --owner magnusp + +# Verify application values artifact +gh attestation verify oci://ghcr.io/magnusp/apps/archetype-backend-values:latest --owner magnusp +``` + +--- + +## Bare-metal & alternative delivery options + +While this repository demonstrates GitHub Actions with GitHub OIDC, the same Kyverno and Flux +architecture adapts directly to **bare-metal / on-premises clusters** using modern identity providers +(Entra ID, Google Workspace, GitHub, Okta, Keycloak) without requiring cloud-hosted Kubernetes +(EKS/GKE/AKS) or cloud KMS: + +| Pattern | Signing & identity mechanism | Kyverno verification mechanism | +| :--- | :--- | :--- | +| **Developer workstation CLI** | Cosign with corporate OIDC (Microsoft Entra ID, Google Workspace, GitHub) + public Sigstore Rekor | `verifyImages` keyless rule matching corporate issuer (e.g. `login.microsoftonline.com`, `accounts.google.com`) and user email regex. | +| **Self-hosted CI runners** | Bare-metal runners (GitLab CI, Jenkins, Drone) signing via HashiCorp Vault Transit Engine or local Cosign keys | `verifyImages` rule checking static public keys stored in a Kubernetes `Secret` or fetched from on-prem Vault. | +| **ChatOps / webhooks** | Slack / Mattermost webhook → Flux `Receiver` carrying triggering user email | Kyverno `apiCall` querying in-cluster SpiceDB to verify if the user has `deploy` permissions on the service. | +| **Direct `kubectl` access** | Entra ID / Google / Keycloak OIDC kubeconfig | Kyverno validation evaluating `request.userInfo.username` against SpiceDB ReBAC; blocks direct production edits in favor of GitOps. | +| **Automated dependency bots** | Renovate / Dependabot with dedicated bot keypair | Public key verification + OpenVEX / in-toto vulnerability scan conditions. | + +### Example: keyless Sigstore with Microsoft Entra ID / Google Workspace + +```yaml +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: verify-corporate-oidc-attestations +spec: + validationFailureAction: Enforce + rules: + - name: verify-developer-identity + match: + any: + - resources: + kinds: [Deployment] + namespaceSelector: + matchLabels: + governance.platform.io/managed: "true" + verifyImages: + - imageReferences: ["ghcr.io/magnusp/apps/*"] + attestations: + - type: "https://slsa.dev/provenance/v1" + attestors: + - entries: + - keyless: + # Microsoft Entra ID, Google Workspace, or GitHub + issuer: "https://login.microsoftonline.com//v2.0" + subjectRegExp: ".*@company.com" + rekor: + url: "https://rekor.sigstore.dev" +``` + +--- + +## This is the final tier + +There is no tier 5. If you're arriving here from [`tier-3`](../tier-3/README.md), see the "What this +tier demonstrates" section above for the delta. For the overall progression, see +the root README's [Tiers](../README.md#tiers) section. diff --git a/tier-4/apps-source/Dockerfile b/tier-4/apps-source/Dockerfile new file mode 100644 index 0000000..36c21c4 --- /dev/null +++ b/tier-4/apps-source/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.27 +COPY --chmod=0644 index.html /usr/share/nginx/html/index.html diff --git a/tier-4/apps-source/index.html b/tier-4/apps-source/index.html new file mode 100644 index 0000000..15f0063 --- /dev/null +++ b/tier-4/apps-source/index.html @@ -0,0 +1,11 @@ + + + + + archetype-backend + + +

archetype-backend

+

Simulated application, built on nginx, deployed via archetype-backend.

+ + diff --git a/tier-4/apps-source/values.yaml b/tier-4/apps-source/values.yaml new file mode 100644 index 0000000..08fb993 --- /dev/null +++ b/tier-4/apps-source/values.yaml @@ -0,0 +1,14 @@ +# archetype-backend chart values for this application. Published as an OCI +# artifact by .github/workflows/publish-app-values.yaml, whenever the +# application image is rolled out — Flux picks it up via the +# archetype-backend-values OCIRepository/ArtifactGenerator in clusters/kind/. +replicas: 1 + +image: + repository: ghcr.io/magnusp/apps/archetype-backend + tag: "latest" + pullPolicy: IfNotPresent + +service: + port: 8080 + targetPort: 80 diff --git a/tier-4/charts/archetype-backend/Chart.yaml b/tier-4/charts/archetype-backend/Chart.yaml new file mode 100644 index 0000000..c260771 --- /dev/null +++ b/tier-4/charts/archetype-backend/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: archetype-backend +description: Baseline backend service chart (Deployment + Service) maintained by platform engineering +type: application +# publish-chart.yaml overrides this with --version/--app-version at publish +# time; the OCIRepository tag Flux tracks (e.g. 0.1.1, 0.1.2) reflects what was +# actually published, not this file's version. +version: 0.1.0 +appVersion: "1.0.0" diff --git a/tier-4/charts/archetype-backend/templates/_helpers.tpl b/tier-4/charts/archetype-backend/templates/_helpers.tpl new file mode 100644 index 0000000..2a318b2 --- /dev/null +++ b/tier-4/charts/archetype-backend/templates/_helpers.tpl @@ -0,0 +1,20 @@ +{{- define "archetype-backend.name" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "archetype-backend.labels" -}} +app.kubernetes.io/name: {{ include "archetype-backend.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{/* +Chart.Version can carry OCI build metadata (e.g. "0.1.0+") when +installed via chartRef from an OCIRepository — "+" isn't a valid label +value character, so it's replaced with "_". +*/}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "archetype-backend.selectorLabels" -}} +app.kubernetes.io/name: {{ include "archetype-backend.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/tier-4/charts/archetype-backend/templates/deployment.yaml b/tier-4/charts/archetype-backend/templates/deployment.yaml new file mode 100644 index 0000000..eb34d2d --- /dev/null +++ b/tier-4/charts/archetype-backend/templates/deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "archetype-backend.name" . }} + labels: + {{- include "archetype-backend.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "archetype-backend.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "archetype-backend.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: {{ include "archetype-backend.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} diff --git a/charts/archetype-backend/templates/policy.yaml b/tier-4/charts/archetype-backend/templates/policy.yaml similarity index 100% rename from charts/archetype-backend/templates/policy.yaml rename to tier-4/charts/archetype-backend/templates/policy.yaml diff --git a/tier-4/charts/archetype-backend/templates/service.yaml b/tier-4/charts/archetype-backend/templates/service.yaml new file mode 100644 index 0000000..de66e64 --- /dev/null +++ b/tier-4/charts/archetype-backend/templates/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "archetype-backend.name" . }} + labels: + {{- include "archetype-backend.labels" . | nindent 4 }} +spec: + selector: + {{- include "archetype-backend.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/tier-4/charts/archetype-backend/values.schema.json b/tier-4/charts/archetype-backend/values.schema.json new file mode 100644 index 0000000..690c2ab --- /dev/null +++ b/tier-4/charts/archetype-backend/values.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "archetype-backend values", + "type": "object", + "required": ["image"], + "properties": { + "replicas": { + "type": "integer", + "minimum": 1 + }, + "image": { + "type": "object", + "required": ["repository", "tag"], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "pullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"] + } + } + }, + "service": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "targetPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + } + } +} diff --git a/tier-4/charts/archetype-backend/values.yaml b/tier-4/charts/archetype-backend/values.yaml new file mode 100644 index 0000000..fae374f --- /dev/null +++ b/tier-4/charts/archetype-backend/values.yaml @@ -0,0 +1,10 @@ +replicas: 1 + +image: + repository: "" + tag: "" + pullPolicy: IfNotPresent + +service: + port: 8080 + targetPort: 8080 diff --git a/tier-4/clusters/kind/artifactgenerator-archetype-backend.yaml b/tier-4/clusters/kind/artifactgenerator-archetype-backend.yaml new file mode 100644 index 0000000..a04c10b --- /dev/null +++ b/tier-4/clusters/kind/artifactgenerator-archetype-backend.yaml @@ -0,0 +1,25 @@ +# Composes the archetype-backend base Helm chart and the application's +# values.yaml (from archetype-backend-values) into a single ExternalArtifact. +# Whenever either the chart or the values OCI artifact updates, this triggers +# an immediate reconciliation on the HelmRelease. +apiVersion: source.extensions.fluxcd.io/v1beta1 +kind: ArtifactGenerator +metadata: + name: archetype-backend-demo + namespace: flux-system +spec: + sources: + - alias: chart + kind: OCIRepository + name: archetype-backend + - alias: values + kind: OCIRepository + name: archetype-backend-values + artifacts: + - name: archetype-backend-demo + copy: + - from: "@chart/**" + to: "@artifact/" + - from: "@values/values.yaml" + to: "@artifact/archetype-backend/values.yaml" + strategy: Merge diff --git a/clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml b/tier-4/clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml similarity index 97% rename from clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml rename to tier-4/clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml index 373116f..54f15ec 100644 --- a/clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml +++ b/tier-4/clusters/kind/clusterpolicy-disallow-manual-image-revision.yaml @@ -5,7 +5,6 @@ kind: ClusterPolicy metadata: name: disallow-manual-image-revision spec: - validationFailureAction: Enforce background: false rules: - name: check-image-revision-authenticity @@ -27,6 +26,7 @@ spec: imageRegistry: reference: "{{ request.object.spec.template.spec.containers[0].image }}" validate: + failureAction: Enforce message: "The example.com/image-revision annotation is managed exclusively by platform policies and cannot be forged." pattern: spec: diff --git a/clusters/kind/clusterpolicy-spicedb-authz.yaml b/tier-4/clusters/kind/clusterpolicy-spicedb-authz.yaml similarity index 68% rename from clusters/kind/clusterpolicy-spicedb-authz.yaml rename to tier-4/clusters/kind/clusterpolicy-spicedb-authz.yaml index 0e4620b..a5b7f5a 100644 --- a/clusters/kind/clusterpolicy-spicedb-authz.yaml +++ b/tier-4/clusters/kind/clusterpolicy-spicedb-authz.yaml @@ -6,7 +6,6 @@ kind: ClusterPolicy metadata: name: spicedb-attested-deploy-authz spec: - validationFailureAction: Enforce background: false rules: - name: check-spicedb-deployer-permission @@ -28,7 +27,15 @@ spec: jmesPath: "imageData.configData.config.Labels.\"dev.authz.app.deployer\" || imageData.configData.config.Labels.\"org.opencontainers.image.vendor\" || ''" - name: spiceDbCheck apiCall: - urlPath: "http://spicedb.authz.svc.cluster.local:8443/v1/permissions/check" + # apiCall.urlPath only ever addresses the Kubernetes API server; + # calling an arbitrary in-cluster service requires service.url. + service: + url: "http://spicedb.authz.svc.cluster.local:8443/v1/permissions/check" + # SpiceDB requires the preshared key configured on the + # SpiceDBCluster's secretName (spicedb-preshared-key). + headers: + - key: Authorization + value: "Bearer showcase-authz-key" method: POST data: - key: consistency @@ -45,17 +52,18 @@ spec: object: objectType: "user" objectId: "{{ deployer }}" - preconditions: - all: - # Only enforce when the workload image contains our deployer metadata - - key: "{{ deployer }}" - operator: NotEquals - value: "" validate: + failureAction: Enforce message: "Deployment Rejected by SpiceDB: Actor '{{ deployer }}' does not have 'deploy' permissions on service '{{ request.object.metadata.name }}'." deny: conditions: - all: + # Fail closed: deny both when no deployer identity is present on + # the image (nothing to authorize) and when SpiceDB doesn't grant + # 'deploy' permission to whatever identity is present. + any: + - key: "{{ deployer }}" + operator: Equals + value: "" - key: "{{ spiceDbCheck.permissionship }}" operator: NotEquals value: "PERMISSIONSHIP_HAS_PERMISSION" diff --git a/clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml b/tier-4/clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml similarity index 62% rename from clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml rename to tier-4/clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml index 2590dcd..a496aec 100644 --- a/clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml +++ b/tier-4/clusters/kind/clusterpolicy-verify-image-nginx-ancestor.yaml @@ -1,12 +1,17 @@ -# Platform policy: verifies container images in managed application workspaces -# using SLSA v1 build provenance attestations. Enforces that the image was built -# from an authentic GitHub workflow and is derived from an approved nginx base image. +# Platform policy: verifies that a container image in a managed application +# workspace descends from an approved nginx:1.27 base image, by comparing its +# rootfs layer digests (fetched live from the registry via imageRegistry) +# against known-good nginx:1.27 base layer digests. This is a layer-hash +# allowlist, not a signature or attestation check — it carries no cryptographic +# guarantee about who built the image, only that one of its layers matches a +# digest recorded here, and it goes stale whenever the nginx:1.27 base image is +# rebuilt upstream. See tier-4/README.md for a real attestation-based +# alternative (Kyverno verifyImages + SLSA provenance). apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: verify-app-image-nginx-ancestor spec: - validationFailureAction: Enforce webhookTimeoutSeconds: 30 background: false rules: @@ -24,6 +29,7 @@ spec: imageRegistry: reference: "{{ request.object.spec.template.spec.containers[0].image }}" validate: + failureAction: Enforce message: "Deployment rejected: image layers do not contain an approved 'nginx:1.27' base image rootfs layer." deny: conditions: diff --git a/tier-4/clusters/kind/helmrelease-archetype-backend.yaml b/tier-4/clusters/kind/helmrelease-archetype-backend.yaml new file mode 100644 index 0000000..2df584e --- /dev/null +++ b/tier-4/clusters/kind/helmrelease-archetype-backend.yaml @@ -0,0 +1,30 @@ +# Instance of the archetype-backend chart for the apps-source/ simulated +# application. Its chart and values are composed directly into an ExternalArtifact +# by source-watcher (ArtifactGenerator), providing immediate event-driven reconciliation +# whenever either the chart version or the values artifact changes. +# +# Remediation strategy: bounded retries (3) with automatic rollback on upgrade failure. +# When a new valid values/chart artifact is published, source-watcher generates +# a new ExternalArtifact revision, which automatically resets failure state and +# triggers a new release upgrade. +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: archetype-backend-demo + namespace: flux-system +spec: + targetNamespace: apps + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + strategy: rollback + remediateLastFailure: true + interval: 10m + chartRef: + kind: ExternalArtifact + name: archetype-backend-demo + diff --git a/clusters/kind/kustomization.yaml b/tier-4/clusters/kind/kustomization.yaml similarity index 89% rename from clusters/kind/kustomization.yaml rename to tier-4/clusters/kind/kustomization.yaml index da9f5f1..d00d5d9 100644 --- a/clusters/kind/kustomization.yaml +++ b/tier-4/clusters/kind/kustomization.yaml @@ -7,8 +7,6 @@ resources: - artifactgenerator-archetype-backend.yaml - helmrelease-archetype-backend.yaml - clusterpolicy-disallow-manual-image-revision.yaml - - spicedb-operator.yaml - - spicedb-operator-rbac.yaml - spicedb-cluster.yaml - spicedb-fixture-job.yaml - clusterpolicy-spicedb-authz.yaml diff --git a/clusters/kind/namespace-apps.yaml b/tier-4/clusters/kind/namespace-apps.yaml similarity index 100% rename from clusters/kind/namespace-apps.yaml rename to tier-4/clusters/kind/namespace-apps.yaml diff --git a/tier-4/clusters/kind/ocirepository-archetype-backend-values.yaml b/tier-4/clusters/kind/ocirepository-archetype-backend-values.yaml new file mode 100644 index 0000000..b751fdd --- /dev/null +++ b/tier-4/clusters/kind/ocirepository-archetype-backend-values.yaml @@ -0,0 +1,14 @@ +# Tracks the apps-source/ payload published by +# .github/workflows/publish-app-values.yaml containing values.yaml. The mutable +# `latest` tag plus source-watcher artifact generation is what makes this a "gitops version +# bump": publishing a new artifact is the deploy, no git commit required. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: archetype-backend-values + namespace: flux-system +spec: + interval: 1m + url: oci://ghcr.io/magnusp/apps/archetype-backend-values + ref: + tag: latest diff --git a/tier-4/clusters/kind/ocirepository-archetype-backend.yaml b/tier-4/clusters/kind/ocirepository-archetype-backend.yaml new file mode 100644 index 0000000..5453f9a --- /dev/null +++ b/tier-4/clusters/kind/ocirepository-archetype-backend.yaml @@ -0,0 +1,18 @@ +# Tracks the archetype-backend chart published to GHCR by +# .github/workflows/publish-chart.yaml. Bump `ref.tag` after publishing a new +# chart version. +# +# No secretRef: this assumes the ghcr.io/magnusp/charts/archetype-backend +# package is set to public visibility on GHCR (a separate setting from repo +# visibility, only available after the first version is published). Until +# that's done, reconciliation will fail with an auth error. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: archetype-backend + namespace: flux-system +spec: + interval: 10m + url: oci://ghcr.io/magnusp/charts/archetype-backend + ref: + tag: 0.1.2 diff --git a/tier-4/clusters/kind/spicedb-cluster.yaml b/tier-4/clusters/kind/spicedb-cluster.yaml new file mode 100644 index 0000000..d790613 --- /dev/null +++ b/tier-4/clusters/kind/spicedb-cluster.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: authz +--- +apiVersion: v1 +kind: Secret +metadata: + name: spicedb-preshared-key + namespace: authz +type: Opaque +stringData: + preshared_key: "showcase-authz-key" +--- +# Ephemeral in-cluster SpiceDB cluster managed by the SpiceDB Operator +apiVersion: authzed.com/v1alpha1 +kind: SpiceDBCluster +metadata: + name: spicedb + namespace: authz +spec: + config: + image: authzed/spicedb:v1.56.1 + datastoreEngine: memory + httpEnabled: "true" + httpAddr: ":8443" + grpcAddr: ":50051" + secretName: spicedb-preshared-key diff --git a/tier-4/clusters/kind/spicedb-fixture-job.yaml b/tier-4/clusters/kind/spicedb-fixture-job.yaml new file mode 100644 index 0000000..ea0c712 --- /dev/null +++ b/tier-4/clusters/kind/spicedb-fixture-job.yaml @@ -0,0 +1,125 @@ +# In-cluster ConfigMap containing the human-editable SpiceDB schema and relationship fixtures. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spicedb-fixtures + namespace: authz +data: + schema.zed: | + definition user {} + + definition team { + relation member: user + } + + definition environment { + relation admin: user + } + + definition service { + relation owner: team | team#member + relation target_env: environment + + permission deploy = owner->member + target_env->admin + } + + relationships.txt: | + # Team backend_core owns the demo service in apps workspace + service:apps-archetype-backend-demo#owner@team:backend_core#member + + # magnusp is authorized to deploy + team:backend_core#member@user:magnusp +--- +# In-cluster Job that applies the ConfigMap fixtures to SpiceDB via curl + jq +apiVersion: batch/v1 +kind: Job +metadata: + name: spicedb-init-fixture + namespace: authz +spec: + ttlSecondsAfterFinished: 120 + template: + spec: + restartPolicy: OnFailure + volumes: + - name: fixtures + configMap: + name: spicedb-fixtures + containers: + - name: init-fixture + image: badouralix/curl-jq:latest + volumeMounts: + - name: fixtures + mountPath: /fixtures + command: + - /bin/sh + - -c + - | + set -e + echo "Waiting for SpiceDB HTTP API..." + until curl -s -X POST http://spicedb.authz.svc.cluster.local:8443/v1/schema/read \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer showcase-authz-key" \ + -d '{}' | grep -Eq '(schemaText|message)'; do + echo "SpiceDB not ready yet, retrying in 3s..." + sleep 3 + done + + echo "Applying schema from /fixtures/schema.zed..." + SCHEMA_JSON=$(jq -n --rawfile schema /fixtures/schema.zed '{schema: $schema}') + curl -s -f -X POST http://spicedb.authz.svc.cluster.local:8443/v1/schema/write \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer showcase-authz-key" \ + -d "$SCHEMA_JSON" + + echo "Applying relationships from /fixtures/relationships.txt..." + UPDATES="[]" + while IFS= read -r line || [ -n "$line" ]; do + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + case "$line" in + \#*|"") continue ;; + esac + + if echo "$line" | grep -Eq '^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$'; then + RES_TYPE=$(echo "$line" | sed -E 's/^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$/\1/') + RES_ID=$(echo "$line" | sed -E 's/^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$/\2/') + REL=$(echo "$line" | sed -E 's/^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$/\3/') + SUBJ_TYPE=$(echo "$line" | sed -E 's/^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$/\4/') + SUBJ_ID=$(echo "$line" | sed -E 's/^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$/\5/') + SUBJ_REL=$(echo "$line" | sed -E 's/^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$/\7/') + + if [ -n "$SUBJ_REL" ]; then + TUPLE_JSON=$(jq -n \ + --arg rt "$RES_TYPE" --arg ri "$RES_ID" --arg rel "$REL" \ + --arg st "$SUBJ_TYPE" --arg si "$SUBJ_ID" --arg sr "$SUBJ_REL" \ + '{ + operation: "OPERATION_TOUCH", + relationship: { + resource: { objectType: $rt, objectId: $ri }, + relation: $rel, + subject: { object: { objectType: $st, objectId: $si }, optionalRelation: $sr } + } + }') + else + TUPLE_JSON=$(jq -n \ + --arg rt "$RES_TYPE" --arg ri "$RES_ID" --arg rel "$REL" \ + --arg st "$SUBJ_TYPE" --arg si "$SUBJ_ID" \ + '{ + operation: "OPERATION_TOUCH", + relationship: { + resource: { objectType: $rt, objectId: $ri }, + relation: $rel, + subject: { object: { objectType: $st, objectId: $si } } + } + }') + fi + UPDATES=$(echo "$UPDATES" | jq --argjson u "$TUPLE_JSON" '. += [$u]') + fi + done < /fixtures/relationships.txt + + curl -s -f -X POST http://spicedb.authz.svc.cluster.local:8443/v1/relationships/write \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer showcase-authz-key" \ + -d "{\"updates\": $UPDATES}" + + echo "SpiceDB initialized successfully!" diff --git a/tier-4/fixtures/spicedb/relationships.txt b/tier-4/fixtures/spicedb/relationships.txt new file mode 100644 index 0000000..7ae52c1 --- /dev/null +++ b/tier-4/fixtures/spicedb/relationships.txt @@ -0,0 +1,12 @@ +# Initial ReBAC relationship tuples for the showcase. +# Format: resource_type:resource_id#relation@subject_type:subject_id +# Lines starting with '#' are ignored. + +# Team backend_core owns the demo service in apps workspace +service:apps-archetype-backend-demo#owner@team:backend_core#member + +# magnusp is a member of team backend_core (Authorized to deploy) +team:backend_core#member@user:magnusp + +# Optional: alice is also a team member +team:backend_core#member@user:alice diff --git a/tier-4/fixtures/spicedb/schema.zed b/tier-4/fixtures/spicedb/schema.zed new file mode 100644 index 0000000..c4cf83f --- /dev/null +++ b/tier-4/fixtures/spicedb/schema.zed @@ -0,0 +1,16 @@ +// SpiceDB Schema for GitOps Workload Authorization +definition user {} + +definition team { + relation member: user +} + +definition environment { + relation admin: user +} + +definition service { + relation owner: team | team#member + relation target_env: environment + permission deploy = owner->member + target_env->admin +} diff --git a/tier-4/kind-cluster/.gitignore b/tier-4/kind-cluster/.gitignore new file mode 100644 index 0000000..13be894 --- /dev/null +++ b/tier-4/kind-cluster/.gitignore @@ -0,0 +1,8 @@ +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfstate +*.tfstate.* +*.tfvars +kind-config +tier-*-config diff --git a/tier-4/kind-cluster/.terraform.lock.hcl b/tier-4/kind-cluster/.terraform.lock.hcl new file mode 100644 index 0000000..8237ab3 --- /dev/null +++ b/tier-4/kind-cluster/.terraform.lock.hcl @@ -0,0 +1,131 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/gavinbunney/kubectl" { + version = "1.19.0" + constraints = "~> 1.19" + hashes = [ + "h1:/KiSv1EYOBNWL2Op7BqTmqnc/G2W/WvXIhUZlhnK0to=", + "h1:3E8E5zdLYTLsP9h+Ibvez8m+S0Qqa7QULHvcq8PcnWg=", + "h1:9QkxPjp0x5FZFfJbE+B7hBOoads9gmdfj9aYu5N4Sfc=", + "h1:DJC655V2Lw7zER1Gi+yN1ijW3A+bUAI3MucdYVhGxFY=", + "h1:EL1HfCDfY/pabIJ2kXuppTvqhgY9hfLu2o4TonJheT8=", + "h1:aQUITuBBjv8WJ+WGI0bOZXXYi5s09pMEyyJmlFnuUbE=", + "h1:gwnJt0UeVYnD2u8S0nGbQHvrUccNL3jeCqf9xu0X13I=", + "h1:hzxgobCLhmbGcgC2KD6aYleH17hiblT9vIHyEQKb1VA=", + "h1:iV249UVwKvuNaFHcvba4SitTyWpUNGvqdyy6SatjjH4=", + "h1:jTkF6nmuZvbXvl08L6iwe2RfV4PkPdy2Cnn+7aWkgu8=", + "h1:kevkHUKdNWcl+ET894COOV9i18hXeACWfu4AJXct/b0=", + "h1:ovkjqc3GnFlb5mVY51ZFV3VwrSbxQQtMTXYvEmBs2xg=", + "h1:quymfa/OKEfWI5JXFEwGbUY2aAy0vet3rA9JWJam+3k=", + "h1:tm5Tw1sfVURwtKGB34mUqekBxgo4HqANIV+uyrucYjA=", + "zh:1dec8766336ac5b00b3d8f62e3fff6390f5f60699c9299920fc9861a76f00c71", + "zh:43f101b56b58d7fead6a511728b4e09f7c41dc2e3963f59cf1c146c4767c6cb7", + "zh:4c4fbaa44f60e722f25cc05ee11dfaec282893c5c0ffa27bc88c382dbfbaa35c", + "zh:51dd23238b7b677b8a1abbfcc7deec53ffa5ec79e58e3b54d6be334d3d01bc0e", + "zh:5afc2ebc75b9d708730dbabdc8f94dd559d7f2fc5a31c5101358bd8d016916ba", + "zh:6be6e72d4663776390a82a37e34f7359f726d0120df622f4a2b46619338a168e", + "zh:72642d5fcf1e3febb6e5d4ae7b592bb9ff3cb220af041dbda893588e4bf30c0c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a1da03e3239867b35812ee031a1060fed6e8d8e458e2eaca48b5dd51b35f56f7", + "zh:b98b6a6728fe277fcd133bdfa7237bd733eae233f09653523f14460f608f8ba2", + "zh:bb8b071d0437f4767695c6158a3cb70df9f52e377c67019971d888b99147511f", + "zh:dc89ce4b63bfef708ec29c17e85ad0232a1794336dc54dd88c3ba0b77e764f71", + "zh:dd7dd18f1f8218c6cd19592288fde32dccc743cde05b9feeb2883f37c2ff4b4e", + "zh:ec4bd5ab3872dedb39fe528319b4bba609306e12ee90971495f109e142d66310", + "zh:f610ead42f724c82f5463e0e71fa735a11ffb6101880665d93f48b4a67b9ad82", + ] +} + +provider "registry.opentofu.org/hashicorp/helm" { + version = "3.2.0" + constraints = "~> 3.2.0" + hashes = [ + "h1:/TdLShnQEtyQRij46dZJobeA8clDnuncSdc2KRkc0B0=", + "h1:8j4dgSY+8Y9tukMTo0WuKPyJx0JKUeZsYx9ltTUmEuo=", + "h1:AOxR+hQy92GqWcp4rnD3ZBrDlip7M7fOB4zL4aw8AU4=", + "h1:IytRFhZXOMPVB36GainacnNoBtiibAdVMdezG2DPVag=", + "h1:XpXkXaOaDyICkDLyrRNBzxqUc8zrKp6Y7sw17FGD+Xg=", + "h1:c1NQPYp6/iIg0PG/HFodq0oyDVI/FAYZF32eEOliWaw=", + "h1:dBAYwgWCv9/ulFx7uxbkNzPYL9TI1aqUF8E7EBJbIRk=", + "h1:h05ap1OKMOtgqspVfdc1GmkX9vJJHquxn5g26Ik7+t4=", + "h1:h21YEXSmcV7Xe64kptnt0WQYEh6m99jVR8sec2o9stk=", + "h1:ikX+6kgc+kYNDAiNghhOhhY15T1Jt6omoWoHlh8CEsY=", + "h1:jvnisz9lHFn+5sjbuZwGCqqCvbddTeqWvrYtK4Wcbxw=", + "h1:nK2+CnIIaNh3zl4nRRhpxrA5Z7sAMrB3Jmzjcm9hw/4=", + "h1:r8rm1wyokhuaFvRDptDm2G1BsvrV6ukGiPoZRGaVmbc=", + "h1:thNSoWm4pdgTEO1XBi1n5V2nwsdSA6EVEXP3qPZiFcA=", + "h1:zVNhod3npW/4gn5QTcQzmlTSdAP7Ttjs59KW6VVyaoo=", + "zh:1a214581dee54ec4e9afa4050e54f6c187aed4b51b2d2ac929c58706b65e1159", + "zh:2f8ba94af93011768ed1fffd4d25b980cd764f2d49c9f13475512ec48464da0b", + "zh:36373bca4f374e95f654def79e0b12df0c8f2e01c634db80ff2737ded29062e3", + "zh:3a26b5c3e47b2bbc01faa0fa9fe816ea4f74f1f8f4555dfad7f62ee38266aaff", + "zh:51bc637700f13cfc1f7c6a8c03a4b5b5755338419912d945dbfe5ccb0ddbf614", + "zh:53f32f91afcb682209de124d8a994e591687023ae7c0dfed6184c5511778b16b", + "zh:6f4434327ed466b2b5be0d5f4aa537bca71ebd7f7fe05468065aeda52dfc8896", + "zh:7394e8c6f5027fa21699e46c9a45bee1ce87fd8dd98f2e89e8d414ec70c8e4e1", + "zh:d90b855a0990e3aa6445afe66de730c2f4651f39699c6b6345ca02f9af2a1a08", + "zh:d9b7246e6af0f75155ed532855014f888e9e5613242c89e321b4e0f9b54f5726", + "zh:dabd399ca36172c15d176a24cf199ac886ef191f7131e806d76a8dc209e5beb8", + "zh:e57987397be46dc123365f16c3bec4dd0615453c8bfdcfc1b7c9f3202a09c516", + "zh:ec88430b833b943b38d02f70b33250c72bb6ffa3b3d22360990a41390161a2b2", + "zh:f8ea01b57982e9ed9ad3a750b1caba261a478f5b0ddcac78a4502d886fd2fc74", + "zh:fb63819037158205ebf42b649c3a8ec308234ffe987a5dbd4444e1e0683f1170", + ] +} + +provider "registry.opentofu.org/hashicorp/kubernetes" { + version = "3.2.1" + constraints = "~> 3.2.1" + hashes = [ + "h1:+VM77HEd3CgzxwQ0PtfOc5tQrRQ2FZ9YJj1ShiMTsoI=", + "h1:32YYVAK/LUIheXfaxxnwL4qzf6QgWaMVkByneQH8EpA=", + "h1:8WnHwjdwYmtpTJg5reAs9VGtKq67E3dQdIrcjDW1Xao=", + "h1:8pNFJAX4IXSaPr+f02FsD6bUlZrMT5rkPPnA5sm2szI=", + "h1:Ad9kERqM2KFLkW/beJ6VsV3QxD05o98ZjC/epjpDcnI=", + "h1:EtS/ztrc03Vy+u9Q6eflu+HYVMMhQIufMaHS+vlbprY=", + "h1:GROcvqLewzybvZa6LjWjU7EWE7aOttp9ovBkT1b8lhc=", + "h1:OR1GGSnvGgFNKA8ilnttn1cy5wT8N0qzGpbqWOZ+yG8=", + "h1:PJBBJz/9kOjaGjGXCQpVslstEbeTxQBx8zu/ZAvRHak=", + "h1:QzzyadpGU0iHlOKsXX/ge6/apcwRXmzVlJxSzVclM/I=", + "h1:Un9WHqrP13fUt64c7VcO60VfvJty0tnrX88b8wy/4SU=", + "h1:VDyHxveT5+lt75fY3oOpa1D/08i1+AC6Af5XoQXEK7E=", + "h1:glcBsCYHGLC1os31lZ22X0se9EyTjQO/1kVMeHj5uJ8=", + "h1:qGon41gIBlkXIbSpsovc5UJr4okWcjaw7KkgPFxhNlU=", + "h1:tsq7mekr30obQVOK83ZNAiRb7rzRSZnWxQLdjD1Rvo0=", + "zh:018e382285ab0ca5a68e4e4116e1cfc3a20c57194f4e678bd87654f5aecf5b14", + "zh:0e8d233199b6896a6b9379b2f41bfdcdbb42b80f5f1c4ed7d87825ce53bae639", + "zh:112221d98fae795d89e6a0adc526fbe2a115316bd052fe10433d93061183b5e2", + "zh:1487c229ad673ddbd6c46ac511985d9a7ce86120ccf1c929acacb3761f3f1f9c", + "zh:2b73e4087cfbf20049240914a02f1344e43eb96a3c7d734454e4c49875aba1df", + "zh:3de5de4e50e58228f612fa158577f623c3a679b18918f62e6b0a925b23e4ec04", + "zh:6501c980ecb830728929798848105b27ce1cba1b604745fc609ecc54a80e0bd4", + "zh:67db18b88e93520eb052c5fae1f0dbedefe8433692b98ebdd3e29c3824ef08eb", + "zh:6aa9bada1a35dd1eb1a37e0233a031d8ecc1ae76ead3e851419bc49ce0dd67ba", + "zh:80e499f1971e2f5dfa7bbb4c40fa2bb303d6c0752b3d04f9a6869b7051071913", + "zh:93d63aee4fbacd28eef5330d517f37da2ead51dbd9c1d89e3e2a19a158c15a5d", + "zh:97c9d6971915400300f78300b807470c00793068b2b90506463da96cf5067092", + "zh:9a626f4c13f32317e8b891950bfd34eb69970599b9e8f62a5faa565e8c60f962", + "zh:d233fc2f7173db3ce5de21dc17e37b7757956b48783be3a81b8d92a6893a4fbc", + "zh:dfc6282c33ab4747d899915027de314687c6f8b49b7c95cafa8544bc45a0d15a", + ] +} + +provider "registry.opentofu.org/tehcyx/kind" { + version = "0.11.0" + constraints = "~> 0.11" + hashes = [ + "h1:+Q1amcdec50yH5urYvojIkQZdCXJwV/6wG7rFpIgfaY=", + "h1:5YkI2sD2PSilFvCvUL2dtyRkrTvxO7PLOgbIZXMbelA=", + "h1:6ahtXT8czMx6a3cfQRhrCkKGotFkSHtfvnGOHw6mH0E=", + "h1:B7hSI3H98Qr6Hkp8CtJm6nVrnWqxbGUgj7jWW/QAIzo=", + "h1:BOGuvXDo/Qes3RrTdYR/LY9tqQucpsIkvE2tfRaXTzE=", + "h1:w1vPgnfTycZq2MXQoS4sJ+URsXMwIv8u2drC9BE8DPA=", + "zh:10cf5f11ed1b24bcc2a64ddfe529dbe240ac72c075100039eb8a182abd5a25d8", + "zh:1c652afcea840545f9e21cf42369560966eafe52986d578c31a35247624442bf", + "zh:8ed94e1387970e7b885c7a68579b17a662d769d04dd3a0917d6c795741d0b97c", + "zh:97e3591b821b8a7cd1d0bc6322c1cbeed882ca26ff357cdcfab8dc0b17279090", + "zh:d5789b07c0a76d086d19acf948246875cb20bea0826a164f4f2c36b8fe527385", + "zh:e5a1117080f6b51e836bf41576e62ee1ea738a5d5fb2f5b0fccb25c942dfb557", + ] +} diff --git a/kind-cluster/cluster.sh b/tier-4/kind-cluster/cluster.sh similarity index 75% rename from kind-cluster/cluster.sh rename to tier-4/kind-cluster/cluster.sh index 7a9fc8f..019cc7c 100755 --- a/kind-cluster/cluster.sh +++ b/tier-4/kind-cluster/cluster.sh @@ -32,6 +32,18 @@ check() { echo "Waiting for flux-system pods..." mise exec -- kubectl wait --for=condition=Ready pods --all -n flux-system --timeout=180s + # The SpiceDB Operator's CRDs (notably SpiceDBCluster) are applied directly via + # OpenTofu (spicedb-operator.tf), independently of the git-synced flux-system + # Kustomization, precisely so the operator can install its CRDs without waiting + # on that Kustomization to succeed first — clusters/kind/spicedb-cluster.yaml + # instantiates a SpiceDBCluster, which can't be applied until those CRDs exist. + # On a cold cluster the git-synced Kustomization's first attempt will still fail + # for that reason; wait for the operator here, then force one reconciliation + # instead of waiting out kustomize-controller's own retry backoff. + echo "Waiting for the SpiceDB Operator bootstrap to become Ready..." + mise exec -- kubectl wait --for=condition=Ready kustomization/spicedb-operator -n flux-system --timeout=180s + mise exec -- flux reconcile kustomization flux-system --with-source --timeout=180s + echo "Waiting for FluxInstance to become Ready..." mise exec -- kubectl wait --for=condition=Ready fluxinstance/flux -n flux-system --timeout=180s diff --git a/tier-4/kind-cluster/flux.tf b/tier-4/kind-cluster/flux.tf new file mode 100644 index 0000000..7becf0d --- /dev/null +++ b/tier-4/kind-cluster/flux.tf @@ -0,0 +1,57 @@ +# Flux is installed via the flux-operator Helm chart, pulled directly from its +# OCI registry (oci://ghcr.io/controlplane/charts/flux-operator) rather than a +# traditional Helm repo, since we standardize on OCI-hosted charts. +resource "helm_release" "flux_operator" { + name = "flux-operator" + namespace = "flux-system" + repository = "oci://ghcr.io/controlplaneio-fluxcd/charts" + chart = "flux-operator" + version = var.flux_operator_version + + create_namespace = true + + depends_on = [kind_cluster.this] +} + +# The FluxInstance CR tells flux-operator which Flux controllers to run and, +# optionally, which git repository to sync. Source controllers for both +# OCIRepository and GitRepository are enabled so charts can be pulled from +# OCI registries by default, with git sync available when configured. +resource "kubectl_manifest" "flux_instance" { + yaml_body = yamlencode({ + apiVersion = "fluxcd.controlplane.io/v1" + kind = "FluxInstance" + metadata = { + name = "flux" + namespace = "flux-system" + } + spec = { + distribution = { + version = var.flux_version + registry = "ghcr.io/fluxcd" + } + components = [ + "source-controller", + "kustomize-controller", + "helm-controller", + "notification-controller", + "source-watcher", + ] + cluster = { + multitenant = false + networkPolicy = true + } + # The repository is public, so no pullSecret is needed to clone it. + # GHCR chart pulls (OCIRepository sources) are a separate concern — + # see clusters/kind/ocirepository-archetype-backend.yaml. + sync = var.flux_git_repository == "" ? null : { + kind = "GitRepository" + url = var.flux_git_repository + ref = "refs/heads/${var.flux_git_branch}" + path = var.flux_git_path + } + } + }) + + depends_on = [helm_release.flux_operator] +} diff --git a/tier-4/kind-cluster/kind.tf b/tier-4/kind-cluster/kind.tf new file mode 100644 index 0000000..08c4433 --- /dev/null +++ b/tier-4/kind-cluster/kind.tf @@ -0,0 +1,21 @@ +provider "kind" {} + +resource "kind_cluster" "this" { + name = var.cluster_name + wait_for_ready = true + + kind_config { + kind = "Cluster" + api_version = "kind.x-k8s.io/v1alpha4" + + node { + role = "control-plane" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + + node { + role = "worker" + image = var.kubernetes_version == null ? null : "kindest/node:${var.kubernetes_version}" + } + } +} diff --git a/tier-4/kind-cluster/kyverno.tf b/tier-4/kind-cluster/kyverno.tf new file mode 100644 index 0000000..ae21a75 --- /dev/null +++ b/tier-4/kind-cluster/kyverno.tf @@ -0,0 +1,44 @@ +# Kyverno is reconciled by Flux, sourced from its OCI chart. +resource "kubectl_manifest" "kyverno_oci_repository" { + yaml_body = yamlencode({ + apiVersion = "source.toolkit.fluxcd.io/v1" + kind = "OCIRepository" + metadata = { + name = "kyverno" + namespace = "flux-system" + } + spec = { + interval = "1h" + url = "oci://ghcr.io/kyverno/charts/kyverno" + ref = { + tag = var.kyverno_version + } + } + }) + + depends_on = [kubectl_manifest.flux_instance] +} + +resource "kubectl_manifest" "kyverno_helm_release" { + yaml_body = yamlencode({ + apiVersion = "helm.toolkit.fluxcd.io/v2" + kind = "HelmRelease" + metadata = { + name = "kyverno" + namespace = "flux-system" + } + spec = { + targetNamespace = "kyverno" + install = { + createNamespace = true + } + interval = "1h" + chartRef = { + kind = "OCIRepository" + name = "kyverno" + } + } + }) + + depends_on = [kubectl_manifest.kyverno_oci_repository] +} diff --git a/tier-4/kind-cluster/mise.toml b/tier-4/kind-cluster/mise.toml new file mode 100644 index 0000000..bfed49c --- /dev/null +++ b/tier-4/kind-cluster/mise.toml @@ -0,0 +1,6 @@ +[tools] +helm = "latest" +kind = "latest" +kubectl = "latest" +opentofu = "latest" +yq = "latest" diff --git a/tier-4/kind-cluster/outputs.tf b/tier-4/kind-cluster/outputs.tf new file mode 100644 index 0000000..534130d --- /dev/null +++ b/tier-4/kind-cluster/outputs.tf @@ -0,0 +1,10 @@ +output "kubeconfig" { + description = "Kubeconfig for the kind cluster." + value = kind_cluster.this.kubeconfig + sensitive = true +} + +output "cluster_endpoint" { + description = "API server endpoint of the kind cluster." + value = kind_cluster.this.endpoint +} diff --git a/kind-cluster/policy-reporter.tf b/tier-4/kind-cluster/policy-reporter.tf similarity index 96% rename from kind-cluster/policy-reporter.tf rename to tier-4/kind-cluster/policy-reporter.tf index 22a06a7..20052a6 100644 --- a/kind-cluster/policy-reporter.tf +++ b/tier-4/kind-cluster/policy-reporter.tf @@ -84,8 +84,10 @@ resource "kubectl_manifest" "policy_reporter_helm_release" { ui = { enabled = true } - kyvernoPlugin = { - enabled = true + plugin = { + kyverno = { + enabled = true + } } } } diff --git a/tier-4/kind-cluster/providers.tf b/tier-4/kind-cluster/providers.tf new file mode 100644 index 0000000..3f41ef0 --- /dev/null +++ b/tier-4/kind-cluster/providers.tf @@ -0,0 +1,23 @@ +provider "helm" { + kubernetes = { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + } +} + +provider "kubernetes" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate +} + +provider "kubectl" { + host = kind_cluster.this.endpoint + client_certificate = kind_cluster.this.client_certificate + client_key = kind_cluster.this.client_key + cluster_ca_certificate = kind_cluster.this.cluster_ca_certificate + load_config_file = false +} diff --git a/tier-4/kind-cluster/spicedb-operator.tf b/tier-4/kind-cluster/spicedb-operator.tf new file mode 100644 index 0000000..3fa41df --- /dev/null +++ b/tier-4/kind-cluster/spicedb-operator.tf @@ -0,0 +1,106 @@ +# The SpiceDB Operator's CRDs (notably SpiceDBCluster) must exist before Flux's +# git-synced Kustomization can apply clusters/kind/spicedb-cluster.yaml, which +# instantiates a SpiceDBCluster. If the operator's GitRepository/Kustomization +# were themselves resources inside that same git-synced Kustomization, the +# whole apply would deadlock on a cold cluster: kustomize-controller validates +# the entire manifest set together, so the SpiceDBCluster CR's failed dry-run +# (no CRD yet) blocks the operator's own GitRepository/Kustomization from ever +# being created, and the CRD never gets installed. Applying the operator +# bootstrap directly via OpenTofu — the same pattern kyverno.tf uses — breaks +# that cycle: it's guaranteed to exist and start reconciling independently of +# whether the git-synced Kustomization has succeeded yet. + +resource "kubectl_manifest" "spicedb_operator_git_repository" { + yaml_body = yamlencode({ + apiVersion = "source.toolkit.fluxcd.io/v1" + kind = "GitRepository" + metadata = { + name = "spicedb-operator" + namespace = "flux-system" + } + spec = { + interval = "2h" + url = "https://github.com/authzed/spicedb-operator" + ref = { + tag = "v1.26.0" + } + } + }) + + depends_on = [kubectl_manifest.flux_instance] +} + +resource "kubectl_manifest" "spicedb_operator_kustomization" { + yaml_body = yamlencode({ + apiVersion = "kustomize.toolkit.fluxcd.io/v1" + kind = "Kustomization" + metadata = { + name = "spicedb-operator" + namespace = "flux-system" + } + spec = { + interval = "2h" + path = "./config" + prune = true + wait = true + sourceRef = { + kind = "GitRepository" + name = "spicedb-operator" + } + } + }) + + depends_on = [kubectl_manifest.spicedb_operator_git_repository] +} + +# RBAC extension granting spicedb-operator permissions to manage +# PodDisruptionBudgets and EndpointSlices. Kept alongside the operator's own +# bootstrap for the same reason: it must exist before the operator reconciles +# SpiceDBCluster resources, independent of the git-synced Kustomization. +resource "kubectl_manifest" "spicedb_operator_pdb_extension_role" { + yaml_body = yamlencode({ + apiVersion = "rbac.authorization.k8s.io/v1" + kind = "ClusterRole" + metadata = { + name = "spicedb-operator-pdb-extension" + } + rules = [ + { + apiGroups = ["policy"] + resources = ["poddisruptionbudgets"] + verbs = ["create", "delete", "get", "list", "patch", "update", "watch"] + }, + { + apiGroups = ["discovery.k8s.io"] + resources = ["endpointslices"] + verbs = ["create", "delete", "get", "list", "patch", "update", "watch"] + }, + ] + }) + + depends_on = [kubectl_manifest.flux_instance] +} + +resource "kubectl_manifest" "spicedb_operator_pdb_extension_role_binding" { + yaml_body = yamlencode({ + apiVersion = "rbac.authorization.k8s.io/v1" + kind = "ClusterRoleBinding" + metadata = { + name = "spicedb-operator-pdb-extension" + } + roleRef = { + apiGroup = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = "spicedb-operator-pdb-extension" + } + subjects = [ + { + kind = "ServiceAccount" + name = "spicedb-operator" + namespace = "spicedb-operator" + }, + ] + }) + + depends_on = [kubectl_manifest.spicedb_operator_pdb_extension_role] +} diff --git a/kind-cluster/variables.tf b/tier-4/kind-cluster/variables.tf similarity index 95% rename from kind-cluster/variables.tf rename to tier-4/kind-cluster/variables.tf index f916aba..d984c7f 100644 --- a/kind-cluster/variables.tf +++ b/tier-4/kind-cluster/variables.tf @@ -1,7 +1,7 @@ variable "cluster_name" { description = "Name of the kind cluster." type = string - default = "kind" + default = "tier-4" } variable "kubernetes_version" { @@ -37,7 +37,7 @@ variable "flux_git_branch" { variable "flux_git_path" { description = "Path within the git repository containing the cluster's Flux manifests." type = string - default = "clusters/kind" + default = "tier-4/clusters/kind" } variable "kyverno_version" { diff --git a/tier-4/kind-cluster/versions.tf b/tier-4/kind-cluster/versions.tf new file mode 100644 index 0000000..6fecaec --- /dev/null +++ b/tier-4/kind-cluster/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + kind = { + source = "tehcyx/kind" + version = "~> 0.11" + } + helm = { + source = "hashicorp/helm" + version = "~> 3.2.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 3.2.1" + } + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.19" + } + } +} diff --git a/tier-4/mise.toml b/tier-4/mise.toml new file mode 100644 index 0000000..8c4a255 --- /dev/null +++ b/tier-4/mise.toml @@ -0,0 +1,5 @@ +[tools] +helm = "latest" +yq = "latest" +flux2 = "latest" +jq = "latest" diff --git a/tier-4/scripts/spicedb-fixture.sh b/tier-4/scripts/spicedb-fixture.sh new file mode 100755 index 0000000..9806c6c --- /dev/null +++ b/tier-4/scripts/spicedb-fixture.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Loads schema.zed and relationships.txt into SpiceDB. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURES_DIR="${SCRIPT_DIR}/../fixtures/spicedb" + +SPICEDB_ENDPOINT="${SPICEDB_ENDPOINT:-http://127.0.0.1:8443}" +AUTH_HEADER="Authorization: Bearer showcase-authz-key" + +write_schema() { + local schema_file="${1:-${FIXTURES_DIR}/schema.zed}" + if [ ! -f "$schema_file" ]; then + echo "Error: schema file '${schema_file}' not found." >&2 + exit 1 + fi + + echo "==> Writing schema from ${schema_file}..." + local payload + payload=$(jq -n --rawfile schema "$schema_file" '{schema: $schema}') + + curl -s -f -X POST "${SPICEDB_ENDPOINT}/v1/schema/write" \ + -H "Content-Type: application/json" \ + -H "${AUTH_HEADER}" \ + -d "$payload" | jq . + echo "✔ Schema applied successfully." +} + +write_relationships() { + local rel_file="${1:-${FIXTURES_DIR}/relationships.txt}" + if [ ! -f "$rel_file" ]; then + echo "Error: relationships file '${rel_file}' not found." >&2 + exit 1 + fi + + echo "==> Writing relationships from ${rel_file}..." + local updates="[]" + + while IFS= read -r line || [ -n "$line" ]; do + # Trim whitespace + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + case "$line" in + \#*|"") continue ;; + esac + + # Parse tuple: resource_type:resource_id#relation@subject_type:subject_id[#sub_relation] + if [[ "$line" =~ ^([^:]+):([^#@]+)#([^#@]+)@([^:#@]+):([^#@]+)(#([^#@]+))?$ ]]; then + local res_type="${BASH_REMATCH[1]}" + local res_id="${BASH_REMATCH[2]}" + local relation="${BASH_REMATCH[3]}" + local subj_type="${BASH_REMATCH[4]}" + local subj_id="${BASH_REMATCH[5]}" + local subj_rel="${BASH_REMATCH[7]:-}" + + local update_json + if [ -n "$subj_rel" ]; then + update_json=$(jq -n \ + --arg rt "$res_type" --arg ri "$res_id" --arg rel "$relation" \ + --arg st "$subj_type" --arg si "$subj_id" --arg sr "$subj_rel" \ + '{ + operation: "OPERATION_TOUCH", + relationship: { + resource: { objectType: $rt, objectId: $ri }, + relation: $rel, + subject: { object: { objectType: $st, objectId: $si }, optionalRelation: $sr } + } + }') + else + update_json=$(jq -n \ + --arg rt "$res_type" --arg ri "$res_id" --arg rel "$relation" \ + --arg st "$subj_type" --arg si "$subj_id" \ + '{ + operation: "OPERATION_TOUCH", + relationship: { + resource: { objectType: $rt, objectId: $ri }, + relation: $rel, + subject: { object: { objectType: $st, objectId: $si } } + } + }') + fi + + updates=$(echo "$updates" | jq --argjson u "$update_json" '. += [$u]') + else + echo "Warning: skipping invalid tuple line: $line" >&2 + fi + done < "$rel_file" + + curl -s -f -X POST "${SPICEDB_ENDPOINT}/v1/relationships/write" \ + -H "Content-Type: application/json" \ + -H "${AUTH_HEADER}" \ + -d "{\"updates\": ${updates}}" | jq . + echo "✔ Relationships applied successfully." +} + +apply_fixtures() { + write_schema "${1:-${FIXTURES_DIR}/schema.zed}" + write_relationships "${2:-${FIXTURES_DIR}/relationships.txt}" +} + +revoke_relationship() { + # write_relationships only ever issues OPERATION_TOUCH (upsert) for lines + # present in relationships.txt — deleting a line and re-applying does NOT + # revoke the tuple, since TOUCH never removes anything not mentioned. To + # actually revoke a relationship, issue an explicit OPERATION_DELETE. + local subject_id="$1" + local relation="${2:-member}" + local resource_type="${3:-team}" + local resource_id="${4:-backend_core}" + local subject_type="${5:-user}" + echo "==> Revoking ${resource_type}:${resource_id}#${relation}@${subject_type}:${subject_id}..." + curl -s -f -X POST "${SPICEDB_ENDPOINT}/v1/relationships/write" \ + -H "Content-Type: application/json" \ + -H "${AUTH_HEADER}" \ + -d "{ + \"updates\": [{ + \"operation\": \"OPERATION_DELETE\", + \"relationship\": { + \"resource\": { \"objectType\": \"${resource_type}\", \"objectId\": \"${resource_id}\" }, + \"relation\": \"${relation}\", + \"subject\": { \"object\": { \"objectType\": \"${subject_type}\", \"objectId\": \"${subject_id}\" } } + } + }] + }" | jq . + echo "✔ Relationship revoked." +} + +check_permission() { + local user="$1" + local service="${2:-apps-archetype-backend-demo}" + echo "==> Checking 'deploy' permission for user '${user}' on service '${service}'..." + curl -s -X POST "${SPICEDB_ENDPOINT}/v1/permissions/check" \ + -H "Content-Type: application/json" \ + -H "${AUTH_HEADER}" \ + -d "{ + \"consistency\": { \"fullyConsistent\": true }, + \"resource\": { \"objectType\": \"service\", \"objectId\": \"${service}\" }, + \"permission\": \"deploy\", + \"subject\": { \"object\": { \"objectType\": \"user\", \"objectId\": \"${user}\" } } + }" | jq . +} + +usage() { + echo "Usage: $0 {apply [schema_path] [relationships_path]|schema [schema_path]|relationships [relationships_path]|check [service]|revoke [relation] [resource_type] [resource_id] [subject_type]}" + echo "" + echo "Examples:" + echo " $0 apply # Apply default schema.zed and relationships.txt" + echo " $0 check magnusp # Check if user magnusp can deploy apps-archetype-backend-demo" + echo " $0 check unauthorized-dev # Check unauthorized user" + echo " $0 revoke magnusp # Revoke magnusp's team:backend_core#member relationship" + echo "" + echo "Note: 'apply' only ever upserts (OPERATION_TOUCH) — editing a tuple out" + echo "of relationships.txt and re-applying does NOT revoke it. Use 'revoke'." + exit 1 +} + +case "${1:-}" in + apply) + apply_fixtures "${2:-}" "${3:-}" + ;; + schema) + write_schema "${2:-}" + ;; + relationships) + write_relationships "${2:-}" + ;; + check) + [ $# -ge 2 ] || usage + check_permission "$2" "${3:-apps-archetype-backend-demo}" + ;; + revoke) + [ $# -ge 2 ] || usage + revoke_relationship "$2" "${3:-}" "${4:-}" "${5:-}" "${6:-}" + ;; + *) + usage + ;; +esac