diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 368d4a174..203af86c7 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -106,6 +106,12 @@
"HOST_PROJECT_PATH": "${localWorkspaceFolder}",
"PROJECT_PATH": "/workspaces/${localWorkspaceFolderBasename}",
"GIT_USER_NAME": "${localEnv:GIT_USER_NAME}",
- "GIT_USER_EMAIL": "${localEnv:GIT_USER_EMAIL}"
+ "GIT_USER_EMAIL": "${localEnv:GIT_USER_EMAIL}",
+ // Same path as go.testEnvVars above, so a plain `go test ./internal/controller/...`
+ // in the terminal works too and not only a test launched from the Go extension.
+ // post-create.sh creates the symlink; `task link-envtest-assets` repairs it. Note
+ // `task test` does NOT read this: it exports the path from setup-envtest itself, so
+ // the gate is unaffected by a stale link.
+ "KUBEBUILDER_ASSETS": "${containerWorkspaceFolder}/bin/k8s/current"
}
}
diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh
index 75e863e76..cf7230376 100644
--- a/.devcontainer/post-create.sh
+++ b/.devcontainer/post-create.sh
@@ -56,6 +56,22 @@ else
sudo chown -R vscode:vscode /home/vscode || true
fi
+# envtest binaries, and the bin/k8s/current symlink that KUBEBUILDER_ASSETS points at.
+# Doing it here means a controller test run from the Test Explorer works on a fresh
+# container, before anyone has run `task test` to create the link as a side effect.
+#
+# Non-fatal on purpose: it needs the network to fetch the binaries, and an environment
+# that is otherwise usable should not fail to come up because that fetch did not. The
+# task is idempotent, so `task test` repairs it later either way.
+if command -v task >/dev/null 2>&1; then
+ log "Linking envtest assets for KUBEBUILDER_ASSETS"
+ if ! (cd "${workspace_dir}" && task link-envtest-assets); then
+ log "WARNING: could not prepare envtest assets. Run 'task link-envtest-assets' once you"
+ log "WARNING: have network; until then IDE-run controller tests will fail on a missing"
+ log "WARNING: KUBEBUILDER_ASSETS path."
+ fi
+fi
+
# Persist the ~/.claude.json file by making it a symlink (this trick can be used for other potenial config file in the home folder as well)
touch /home/vscode/persisted-home/.claude.json
rm -f /home/vscode/.claude.json && ln -s /home/vscode/persisted-home/.claude.json /home/vscode/.claude.json
diff --git a/.docs-lint-scope b/.docs-lint-scope
index 6d9780121..2ced2c7e6 100644
--- a/.docs-lint-scope
+++ b/.docs-lint-scope
@@ -27,3 +27,5 @@ docs/architecture.md
docs/configuration.md
docs/spec/attribution.md
docs/components.md
+docs/quickstart.md
+docs/demo/commit-window.md
diff --git a/.golangci.yml b/.golangci.yml
index 16ab64849..d826bb6bd 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -384,6 +384,12 @@ linters:
# opens nothing it was handed by a reference, and writes nothing at all.
- path: 'hack/doccheck/.*\.go'
linters: [forbidigo, gosec]
+ # The settings-index generator is a standalone dev CLI too: it reports what it
+ # rewrote to the console (forbidigo), and it reads the CRD glob and writes the
+ # one document named by the targetFile constant (gosec G304/G703). The only
+ # caller that supplies a different path is its own test, into t.TempDir().
+ - path: 'hack/crdfields/.*\.go'
+ linters: [forbidigo, gosec]
# Allow fmt.Println in metrics exporter (initialization logs)
- path: 'internal/telemetry/.*\.go'
linters: [forbidigo]
diff --git a/.vale/styles/HouseStyle/Filler-Judgment.yml b/.vale/styles/HouseStyle/Filler-Judgment.yml
index 930d4803e..cf9129312 100644
--- a/.vale/styles/HouseStyle/Filler-Judgment.yml
+++ b/.vale/styles/HouseStyle/Filler-Judgment.yml
@@ -1,7 +1,19 @@
# docs/style-guide.md -> "Words to cut", the rows that need the deletion test.
#
-# Split out of WordsToCut.yml. These four are sometimes correct, and no linter can
-# tell which time it is looking at, so this rule reminds rather than rules.
+# Split out of WordsToCut.yml. These are sometimes correct, and no linter can tell
+# which time it is looking at, so this rule reminds rather than rules.
+#
+# `real` is here rather than in WordsToCut.yml because it fails the same test in
+# both directions. It is filler wherever nothing unreal is in play -- "the real
+# configuration objects that drive gitops-reverser" promised a distinction the
+# guide never draws, since there is no second, fake set of objects to tell them
+# apart from. It earns its place wherever there IS a counterpart: "it can never
+# clash with a real one" contrasts with a synthetic `_unlabeled` bucket, and "a
+# real apiserver" contrasts with a fake. Measured before adding it: 431 uses
+# across docs/ and the README, more than three times `actually`, and nearly all
+# of them the second kind. That ratio is why this can only ever be a suggestion
+# -- a rule that fired on all 431 would be read as noise and skimmed, which is
+# exactly the failure WordsToCut.yml's header records.
#
# The guide's own example is the proof: `actually` earns its place in "the source
# document, where the value actually lives", because it marks a contrast with the
@@ -22,3 +34,4 @@ tokens:
- actually
- genuinely
- really
+ - real
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 759e32a4c..89efe2043 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -149,7 +149,7 @@ The design note for the playground flow lives in
## Documentation checks
-`task lint-docs` runs all three, and `task lint` includes it. Each owns one thing and none of them
+`task lint-docs` runs them all, and `task lint` includes it. Each owns one thing and none of them
overlaps with the others:
| Task | Tool | Checks |
@@ -157,13 +157,21 @@ overlaps with the others:
| `task lint-doc-links` | `hack/doccheck` | Every reference resolves, including repo-relative doc paths cited inside Go comments, YAML, and shell. No off-the-shelf tool reads those. |
| `task lint-markdown` | markdownlint-cli2 | Structure: fences, headings, lists, blank lines, bullet style. |
| `task lint-prose` | Vale | English against [`docs/style-guide.md`](docs/style-guide.md): em dashes, American spelling, product names, words to cut. |
+| `task lint-metric-names` | `hack/metricnames.sh` | Every `gitopsreverser_` metric named anywhere in the tree is actually registered. |
+| `task lint-settings-index` | `hack/crdfields` | The settings index in [`docs/configuration.md`](docs/configuration.md) lists every CRD field, with the schema's own required flags and defaults. |
```bash
task lint-docs # links and structure everywhere; prose on the gated files
task lint-markdown-fix # apply the safe mechanical fixes
task lint-prose DOCS_SCOPE=all # the whole tree, to see the prose backlog
+task settings-index # rewrite the settings index after an API change
```
+**The settings index is generated.** `docs/configuration.md` carries it between
+`` markers; edit
+[`hack/crdfields/fields.yaml`](hack/crdfields/fields.yaml) and run `task settings-index` instead of
+editing the table. Adding an API field without describing it there fails the gate, naming the field.
+
**Links and structure are checked on every tracked file; only prose is gated on the files
[`.docs-lint-scope`](.docs-lint-scope) lists.** Structure was staged the same way until the residue
was cleared, so markdownlint now gates the whole tree and a new document cannot regress it. Vale
diff --git a/README.md b/README.md
index 6e1736f4c..06759692f 100644
--- a/README.md
+++ b/README.md
@@ -4,327 +4,203 @@
[](https://scorecard.dev/viewer/?uri=github.com/ConfigButler/gitops-reverser)
[](https://www.bestpractices.dev/projects/13468)
[](https://github.com/ConfigButler/gitops-reverser/releases)
+[](https://codecov.io/gh/ConfigButler/gitops-reverser)
[](https://www.apache.org/licenses/LICENSE-2.0)
-[](go.mod)

-[](https://codecov.io/gh/ConfigButler/gitops-reverser)
[](https://github.com/ConfigButler/gitops-reverser/pkgs/container/gitops-reverser)
[](https://artifacthub.io/packages/search?repo=gitops-reverser)
[](https://github.com/ConfigButler/gitops-reverser/issues)
# GitOps Reverser
-GitOps Reverser is a Kubernetes operator that turns Kubernetes API resources into clean YAML in Git.
-It is configurable, and can be used as:
+GitOps Reverser watches selected Kubernetes resources and commits a clean YAML representation to Git.
+It strips `status`, `managedFields`, and runtime metadata. For supported layouts, it updates existing
+manifests in place, preserving comments and document structure.
-- a live audit trail, or
-- a "reverse" GitOps-reconcilable repo that keeps API-first workflows intact.
+Use it to capture live changes, bring an existing cluster into Git, or experiment with API-first
+workflows alongside Flux or Argo CD.
-The broader pattern is described at [reversegitops.dev](https://reversegitops.dev).
+[Quick start](docs/quickstart.md): install the operator and capture your first ConfigMap in Git.
+
+It is pre-1.0: the CRDs are `v1alpha3` and the configuration surface can still change between
+releases. [Before you adopt it](#before-you-adopt-it) covers what to weigh.
-Want proof? See this
-[example commit](https://github.com/ConfigButler/example-audit/commit/800a51e5a8edcccbc85c94d5fef7ef7cc8381b7b)
-in [ConfigButler/example-audit](https://github.com/ConfigButler/example-audit).
-
-Live commit messages use `GitTarget.spec.commit.message.liveTemplate` for every window size.
-An attached `CommitRequest.spec.message` overrides it literally; snapshots use `reconcileTemplate`.
-See [configuration](docs/configuration.md#commit-message-templates) and the
-[breaking template migration](docs/UPGRADING.md#one-live-commit-message-template).
-
-## What it does
-
-- Reconciles existing Kubernetes API state into Git: the repo reflects the exact current state.
-- Captures live changes through watches.
-- Includes real actors for every change if you configure
- [kube-apiserver audit webhooks](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/#webhook-backend).
-
-
+Inspect an [example commit](https://github.com/ConfigButler/example-audit/commit/800a51e5a8edcccbc85c94d5fef7ef7cc8381b7b).
+Named Kubernetes actors in Git history require optional
+[audit attribution](docs/attribution-setup-guide.md); the default uses a configured Git identity.
-## What it can't
-
-It edits the **intent layer**: the documents a human authored. The **expansion layer** a controller
-derives from them stays out of scope. So it cannot reverse Helm-rendered resources into a clean
-`values.yaml`, and it will not invent structure for templating it does not model. Simple Kustomize
-layouts *are* supported ([see below](#simple-kustomize-support)); the full verdict table is in
-[`support-contract.md`](docs/design/support-boundary/support-contract.md).
-
-## When it fits
-
-Good fit if you want API-first and Git at the same time.
+## How it works
-| Good fit | Poor fit |
+Changes reach the Kubernetes API however your users make them: `kubectl`, a GUI, a CI job, or an
+agent over MCP.
+
+
+
+1. **Watch** the Kubernetes API for the types each `GitTarget` claims, the single source of object
+ state. A new target also reconciles all existing resources of those types into Git.
+2. **Sanitize** the change and compare it against what Git already holds.
+3. **Write** stable YAML to the target folder and push, grouping a burst of changes into one commit.
+
+Custom resources configure it: `GitProvider` for repository and credentials, `GitTarget` for branch
+and folder, `WatchRule` or `ClusterWatchRule` for what to capture, and `ClusterProvider` for the
+source cluster, created as `default` on install. See [Configuration](docs/configuration.md).
+
+## Principles
+
+- **We never commit secret content unencrypted. Ever.** A resource classified as sensitive is
+ encrypted before it can reach a commit. If encryption fails, or no encryptor is configured, the
+ write is rejected. There is no plaintext fallback.
+- **We only write one way: Kubernetes to Git.** Deploying Git back into a cluster is Flux's and
+ Argo CD's job. Run one of them alongside if you want the round trip. See
+ [bi-directional usage](docs/bi-directional.md).
+- **We never put the wrong name on a change.** When the facts are weak, late, conflicting or
+ missing, the author is `unknown (attribution unresolved)` instead of a guess. A person's name in
+ the author field means we are sure. See [audit attribution](docs/attribution-setup-guide.md).
+- **We refuse a folder we cannot support.** An unsupported layout is refused before anything is
+ written, with `Stalled=True` and a reason naming the problem. We will not write the half we
+ understood and leave you the rest. See [status conditions](docs/spec/status-conditions-guide.md).
+- **We assume changes arrive through the API.** Publication is driven by API writes, and a branch
+ that moved underneath is an expected exception rather than a failure. CI tests with Flux and Argo
+ CD verify that the round trip settles without a commit loop. See
+ [API-first publication](docs/api-first-publication.md).
+
+## Features
+
+- **Capture existing resources and future changes.** A new target captures the selected resources
+ already in the cluster, then follows changes. With deletion mirroring enabled, removal follows
+ the deletion request, even while finalizers keep the object around. Choose which deletions reach
+ Git with a [deletion policy](docs/configuration.md#deletion-policy-specprunemode).
+- **In-place edits keep the shape of your file.** Updates preserve key order, comments, and
+ untouched documents in multi-document files.
+- **Duplicate resource identities are rejected.** A folder holding the same resource in two
+ documents is refused rather than adopted, so a capture cannot update one copy and leave the other
+ stale.
+- **Inspect a repository before you point at it.** `manifest-analyzer --mode scan-repo` classifies
+ every candidate folder under a repository root, read-only and with no cluster, so you can see what
+ a target could adopt before creating one. See [`cmd/manifest-analyzer/`](cmd/manifest-analyzer/).
+- **SOPS and age, set up on the fly.** The operator creates the encryption configuration and can
+ generate a missing age key in your chosen Kubernetes `Secret`. Private keys stay in the cluster,
+ never in Git; back up a generated key before relying on it. See [SOPS and age](docs/sops-age-guide.md).
+- **Signed commits.** SSH signing through `GitProvider.spec.commit.signing`, including what it takes
+ to earn a verified badge on your Git host. See [commit signing](docs/commit-signing.md).
+- **Commit messages you control.** Separate templates for live windows, reconciles, and save
+ requests. A bad template holds the target at `Validated=False` instead of surfacing at commit
+ time. See [message templates](docs/commit-messages.md).
+- **Status you can wait on.** Conditions follow the kstatus convention, so `Ready`, `Reconciling`,
+ and `Stalled` mean what `kubectl wait` and GitOps tooling expect, asserted in tests against the
+ kstatus library rather than against our own reading of it.
+- **Metrics.** A Prometheus surface with copy-pasteable PromQL for the questions operators ask, and
+ a named list of what is deliberately not instrumented. See
+ [interpreting metrics](docs/interpreting-metrics.md).
+
+## What it can write
+
+Choose the resources to watch and the repository, branch, and folder to write into.
+
+| Your source in Git | What you can do |
|---|---|
-| Clusters where you can grant watch/RBAC and write to Git (optionally run Valkey/Redis for the full feature set) | Production HA requirements today |
-| Teams that want API-to-Git capture first, then named author attribution later | Shared paths with two always-on writers fighting over the same resources |
-| API-first or hybrid teams that still want Git history; brownfield discovery, hotfix capture, migration toward GitOps | Teams who want Git to stay the write path, with humans editing manifests first |
+| Plain Kubernetes manifests | Capture resources and update their existing YAML documents |
+| Supported Kustomize layouts | Update source manifests or image/replica declarations; add and remove resources |
+| A Flux `HelmRelease` or Argo CD `Application` | Capture the declaration, including chart versions and inline values |
+| Helm templates or standalone `values.yaml` | No writeback from rendered workloads |
-## How it works
+Kustomize support includes local bases and overlays, with the base kept read-only when targeting an
+overlay. Generators, components, remote bases, and several other transforms are unsupported. See the
+[supported subset](docs/configuration.md#kustomize-support-in-the-target-path).
-1. **Watches** the Kubernetes API for the resource types each `GitTarget` claims. Watch is the
- single source of object state.
-2. **Sanitizes** each change (status, `managedFields`, and runtime noise removed) and diffs it
- against the current Git content.
-3. **Writes** stable YAML to Git with useful commit metadata, authored according to the mode below.
-
-It can also:
-
-- Encrypt `Secret` values before commit with **SOPS + age** (Secret-shaped custom resources can opt in).
-- SSH-sign every commit via `GitProvider.spec.commit.signing`.
-- Group changes within a time window into a single commit.
-- Take your own commit message and "why" from a `CommitRequest`.
-
-### Who authors the commits
-
-Every commit carries a Git *author* and a Git *committer*. By default both are one configured
-identity (`configured-author`). Turn on attribution and the **author** becomes the real Kubernetes
-actor (user, service account, or CI identity), while the committer never moves. That needs
-kube-apiserver audit delivery (managed control planes like EKS/GKE/AKS generally do not expose it)
-and an attribution transport: see the [attribution setup guide](docs/attribution-setup-guide.md).
-
-If attribution is enabled but a live change has no usable audit fact, the commit says so: its author
-is `unknown (attribution unresolved)`, and the configured committer identity is never substituted in
-its place. For a change that should have a named actor, treat that identity as a signal to verify the
-audit policy, webhook route, source identity, and Redis connectivity.
-
-**Valkey/Redis is optional but advised.** Without it the default mode works fine; adding it unlocks
-warm-restart cursors, `CommitRequest` author capture, and attribution.
-
-### Delivery guarantees
-
-While the watch is connected the operator sees each individual update and commits it, so Git tracks
-changes as they happen. Across a *gap* (pod restart, disconnect, `410 Gone`) it reconciles to current
-state instead of replaying versions it never saw, so edits made during the gap collapse into one
-commit. Nothing is lost or left stale; deletes are reconciled on reconnect. See
-[`docs/architecture.md`](docs/architecture.md) for replay and `410 Gone` details.
-
-## Simple Kustomize support
-
-The write path runs **kustomize itself** (`sigs.k8s.io/kustomize/api`) in memory, with no plugins, no
-exec, no network, and no remote bases. That render decides where a change belongs and checks the
-result before committing. What it can do:
-
-- Edit `resources:` (and `bases:`), `namespace:`, `images:`, and `replicas:` as real declarations.
-- Write a change where the value lives in source (the document itself, or the governing
- `images:`/`replicas:` entry) instead of mirroring rendered output over your source files.
-- Add a new file to the right `resources:` list in the same commit, and remove the entry when that
- file's last document is deleted, so the repo never stops building.
-- Support `base/` + `overlays/{env}/`: the base is read-only context, never written through an overlay.
-- Create a missing `images:`/`replicas:` entry in an overlay, so one environment changes without
- touching a shared base, and author a `$patch: delete` for an object an overlay inherits.
-- Read `patches:` (local strategic-merge files), `commonLabels`, `labels`, and `commonAnnotations` as
- read-only build context.
-- Verify every commit by re-rendering before and after, refusing it unless your change lands exactly
- and nothing else moves.
-
-It refuses these by name, before writing anything, and reports `Stalled=True`: generators,
-`components`, `namePrefix`/`nameSuffix`, `replacements`, `vars`, `helmCharts`, plugins, inline and
-JSON6902 patches, remote bases, and any field it does not model. One known gap: a strategic-merge
-patch that *edits a field* of a base-owned object is not authored yet. Reasoning in
-[`kustomize-support-boundary.md`](docs/design/support-boundary/kustomize-support-boundary.md).
-
-## Status
-
-Early-stage software; CRDs and behavior may still change.
-
-- Runs as a single controller pod (`replicas=1`).
-- Shared-resource [bi-directional workflows](docs/bi-directional.md) remain experimental. Git push
- webhooks can trigger Flux or Argo CD; coordinating unpublished live edits still needs care.
-- Source recovery covers Kubernetes manifests and simple Kustomize layouts, not Helm authoring models.
-- Tested against Kubernetes `1.37` at the API level (envtest) and `1.36` end-to-end (k3s, which has
- no stable `1.37` release yet); other versions may work but are not in the matrix.
-- Runtime behavior is deterministic: no AI or heuristic mutation at runtime.
-- **Minimum Go for module consumers: `1.27.1`.** Running the image needs no Go at all, but anything
- importing this repo as a module (`pkg/manifestanalyzer`, for example) is bound by the `go`
- directive in [`go.mod`](go.mod), which is the source of truth. That floor can move in any release,
- including a patch release; when it does, the release notes say so.
-
-Good fit for pilots, lab clusters, brownfield discovery, and design partners who can tolerate change.
-Production use should follow an environment-specific review.
-
-### On the road to 1.0
-
-Roughly in priority order:
-
-- **High availability:** `replicaCount > 1` is rejected today. It needs leader/ownership coordination
- so two replicas never write the same `GitTarget`, and Redis becomes required rather than advised.
-- **A stabilized configuration surface:** all six CRDs are `v1alpha3` with no conversion path yet.
-- **More documentation:** day-2 operations, troubleshooting, and worked examples per layout.
-- **A durable worker queue:** a crash between advancing the watch cursor and landing the write can
- currently skip work on restart.
-- **Write-collision safety** across `GitProvider` objects sharing a repository. Until then, keep one
- `GitProvider` per repository.
-- **Better queue and worker observability:** enough metrics to run it without reading logs.
-- **More control over output layout**, plus filtering cluster-generated noise out of the Git view.
-
-Backlog in [docs/TODO.md](docs/TODO.md); longer-range directions in [docs/future/](docs/future/).
+Select the resources that express your intent. For example, watch a `HelmRelease` to capture chart
+settings. The operator cannot automatically distinguish authored resources from controller-generated
+ones. See [choosing what to capture](docs/installing-apps-as-krm.md#the-design-decision-capture-intent-not-the-rendered-output).
## Quick start
-This brings up the **demo**: a starter `GitProvider`, `GitTarget`, and `WatchRule` in a
-`gitops-reverser-quickstart-demo` namespace, watching ConfigMaps there and writing them to
-`/live-cluster` on `main`. It runs in `configured-author` mode (no Redis) by default. The
-chart also renders the cluster-scoped `default` `ClusterProvider` the starter target resolves against.
-
-
-
-**Prerequisites:** a Kubernetes cluster with `kubectl`, Helm 3, and cert-manager for TLS.
-
-### 1. Install cert-manager
-
-The controller mounts an admission certificate at startup, so cert-manager must be healthy *before*
-you install the chart. (`--set servers.admission.enabled=false` drops the dependency; the
-[chart README](charts/gitops-reverser/README.md) covers bring-your-own certificates.)
-
-```bash
-kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
-kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s
-```
-
-### 2. Create a Git repo and a deploy key
-
-Create an (empty) repository the operator will write to, then generate a key. Add the public half as a
-**deploy key with write access**; the private half becomes the Secret in the next step:
-
-```bash
-ssh-keygen -t ed25519 -C "gitops-reverser@cluster" -f /tmp/gitops-reverser-key -N ""
-# Add /tmp/gitops-reverser-key.pub to your Git provider as a deploy key (write access)
-```
-
-### 3. Create the demo namespace and Git credentials
-
-Do this **before** installing. The starter `GitProvider` is only re-checked about every 5 minutes, so
-if the Secret is missing at install time your first commit can be minutes late; having it ready up
-front lets the starter resources go Ready on the first reconcile:
+Follow the [installation walkthrough](docs/quickstart.md) to capture ConfigMaps from a demo namespace
+into a disposable repository. You need a Kubernetes cluster, `kubectl`, Helm 3, and Git write access.
+The walkthrough includes cert-manager setup; Redis and audit delivery are optional.
-```bash
-kubectl create namespace gitops-reverser-quickstart-demo \
- --dry-run=client -o yaml | kubectl apply -f -
-
-kubectl create secret generic git-creds \
- --namespace gitops-reverser-quickstart-demo \
- --from-file=ssh-privatekey=/tmp/gitops-reverser-key \
- --from-literal=known_hosts="$(ssh-keyscan github.com 2>/dev/null)" \
- --dry-run=client -o yaml | kubectl apply -f -
-```
-
-SSH host-key verification fails closed, so the `known_hosts` line is required. Existing Flux or Argo CD
-credentials Secrets are accepted as-is (they must have **write** access). See
-[`docs/configuration.md`](docs/configuration.md) for accepted Secret shapes and
-[`docs/github-setup-guide.md`](docs/github-setup-guide.md) for the full GitHub guide and HTTPS/PAT
-fallback.
-
-### 4. Install GitOps Reverser with the demo enabled
-
-Point the starter `GitProvider` at your repo and install:
+The demo captures a live change:
```bash
-helm install gitops-reverser \
- oci://ghcr.io/configbutler/charts/gitops-reverser \
- --namespace gitops-reverser \
- --create-namespace \
- --set quickstart.enabled=true \
- --set-string quickstart.gitProvider.url=git@github.com:OWNER/REPO.git
-```
-
-Replace `OWNER/REPO` with your repository (angle brackets would be parsed by the shell).
-
-Three things worth knowing about this install:
-
-- **No Redis**, so warm-restart cursors and author capture stay inactive. Add one with
- `--set queue.redis.addr=HOST:PORT` (plus `queue.redis.auth.existingSecret` if it needs auth).
-- **Cluster-wide read on every watchable type, including Secrets** (`rbac.watchTypes.mode=any`): fine
- for a demo, probably too wide for a real cluster. [`docs/rbac.md`](docs/rbac.md) narrows it.
-- **SOPS is on for the starter target**, so a `sops-age-key` Secret is generated in the demo namespace
- with a backup reminder. Only matters once you mirror Secrets; back it up if you keep the demo.
-
-Wait for the controller. On a fresh install this waits on cert-manager issuing the certificate:
-
-```bash
-kubectl rollout status deployment/gitops-reverser -n gitops-reverser --timeout=300s
+kubectl create configmap test-config --from-literal=key=value -n gitops-reverser-quickstart-demo
```
-Then check the starter resources:
+Inspect the commit under `live-cluster/` in your repository. Then edit the ConfigMap and inspect the
+next diff. The walkthrough includes status checks, troubleshooting, and cleanup.
-```bash
-kubectl get gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo
-```
+## Batch changes into a commit
-The `GitProvider` and `WatchRule` report `Ready=True`; the `GitTarget` reports **`Validated=True`**.
-Its aggregate `Ready` stays `Unknown` until first source discovery, which is the expected state.
+Changes for the same target and author share a commit window: each change restarts the timer, and
+the commit is made after that much silence. Omitted, the window is `5s`; `0s` opts into a commit per
+event. A `CommitRequest` can close an open window early and supply the commit message. This allows the
+person making a change to explain **why** the change was necessary.
-### 5. Test it
+
-```bash
-kubectl create configmap test-config --from-literal=key=value -n gitops-reverser-quickstart-demo
-```
+The picture shows the [commit-window example](docs/demo/commit-window.md) running. It carries the
+manifests: a `GitTarget` with a `window`, a `WatchRule` selecting the two types, and the save request
+that closes the window early. It runs on top of the quickstart above.
-A new commit should land in your repository within seconds. If none appears:
+## Try it with your existing repo
-```bash
-kubectl logs -n gitops-reverser deploy/gitops-reverser
-kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo
-```
+Start with a [scratch branch](docs/configuration.md#seeing-what-a-target-will-do-before-it-does-it)
+containing your existing manifests, with no reconciler deploying that branch. Select a small resource
+scope and one destination folder. Inspect the initial commits before making a live edit, then check
+which source files changed.
-Two `GitTarget` conditions stop the data plane and are worth recognizing: `ClusterProviderNotFound`
-(the `default` `ClusterProvider` is missing) and `NamespaceNotAuthorized` (its `accessFrom`
-selector does not cover the demo namespace).
+The operator pushes directly to the configured branch. If you later write to a branch that Flux or
+Argo CD deploys, read the [bidirectional guide](docs/bi-directional.md) first. Live edits can be
+reverted by the reconciler, and [replaying captured state](docs/api-first-publication.md) can overwrite
+concurrent Git edits to the same object. Decide per folder which side is authoritative.
-To tear the demo down: `helm uninstall gitops-reverser -n gitops-reverser` and
-`kubectl delete namespace gitops-reverser-quickstart-demo`.
+## Before you adopt it
-> **Note:** the `default` `ClusterProvider` is cluster-scoped and chart-owned, so uninstalling takes
-> it with it, holding *every* other `GitTarget` in the cluster unready. Mind that if you run the demo
-> alongside a real deployment.
+The CRDs are `v1alpha3`, so the configuration surface can change between releases;
+[`docs/UPGRADING.md`](docs/UPGRADING.md) carries each migration. Keep one `GitProvider` per
+repository, so that two of them never write the same paths.
-### Want named users on your commits?
+- **Access:** the chart defaults to cluster-wide read access, including Secrets. Review
+ [RBAC](docs/rbac.md) to restrict watched types and understand the remaining credential permissions.
+- **History:** batching can collapse intermediate edits. Unpublished work is held in memory and can
+ be lost on restart; recovery captures current state. Git history is not a complete event log.
+- **Deletes:** the default mirrors observed delete events but retains documents absent from a
+ reconnect snapshot. Choose a [deletion policy](docs/configuration.md#deletion-policy-specprunemode)
+ that fits your repository.
+- **Versions:** tested against Kubernetes `1.37` at the API level (envtest) and `1.36` end-to-end
+ (k3s, which has no stable `1.37` release yet). Other versions may work but are not in the matrix.
+ Running the image needs no Go, but anything importing this repo as a module
+ (`pkg/manifestanalyzer`, for example) is bound by the `go` directive in [`go.mod`](go.mod), which
+ is the source of truth. That floor can move in any release, including a patch release; when it
+ does, the release notes say so.
-Enable audit attribution to use the Kubernetes actor as the Git author while keeping the
-committer unchanged. The [attribution setup guide](docs/attribution-setup-guide.md) covers receiver
-configuration, API server delivery, and verification. It requires control over kube-apiserver settings.
+It runs one replica, with no standby failover: the chart rejects `replicaCount > 1` rather than let
+two instances write the same repository. Ownership coordination and a durable worker queue are on
+the way to 1.0. The backlog is in [docs/TODO.md](docs/TODO.md), and longer-range directions in
+[docs/future/](docs/future/).
-### Rather have it managed?
+## Rather have it managed?
ConfigButler can run a small, secure, public-facing Kubernetes API for you: we operate GitOps
Reverser and authorize your end users, with forward-deployed engineers to get you started. You keep a
clean, self-owned Git repo where your users express their intent.
-## Docs
-
-Start with the stable docs surface:
-
-- [`docs/README.md`](docs/README.md)
-- [`docs/configuration.md`](docs/configuration.md)
-- [`docs/api-first-publication.md`](docs/api-first-publication.md): API-first design, replay, and timing
-- [`docs/attribution-setup-guide.md`](docs/attribution-setup-guide.md)
-- [`docs/security-model.md`](docs/security-model.md)
-- [`docs/rbac.md`](docs/rbac.md): the two ClusterRoles, and how to stop the reverser enumerating Secrets
-- [`docs/commit-signing.md`](docs/commit-signing.md)
-- [`docs/github-setup-guide.md`](docs/github-setup-guide.md)
-- [`docs/sops-age-guide.md`](docs/sops-age-guide.md)
-- [`docs/bi-directional.md`](docs/bi-directional.md)
-- [`docs/alternatives.md`](docs/alternatives.md): nearby tools and when another approach fits better
-
-## Looking for early users
-
-If this workflow matches a real problem, feedback is very welcome. The most useful reports are
-install attempts, first-commit experience, audit delivery issues, Git output shape, CRD ergonomics,
-and security or operational concerns.
-
-## Get in touch
-
-- Read the [Reverse GitOps manifesto](https://reversegitops.dev/) for the broader pattern.
-- Connect on [LinkedIn](https://www.linkedin.com/in/simonkoudijs/). Feedback, questions, and ideas
- are welcome.
+## Documentation and feedback
-## Contributing
+- [Configuration](docs/configuration.md): resource selection, Git destinations, and commit behavior
+- [Helm chart](charts/gitops-reverser/README.md): installation options and upgrades
+- [Documentation index](docs/README.md): setup guides and operational details
+- [Reverse GitOps](https://reversegitops.dev/): the manifesto for the broader pattern
-Issues, docs fixes, and code contributions are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md).
+Trying it with a real repo? [Open an issue](https://github.com/ConfigButler/gitops-reverser/issues)
+with the layout you tried, the diff you expected, and what happened. Install attempts, first-commit
+experience, audit delivery, Git output shape, and CRD ergonomics are the most useful reports at this
+stage. Contributions are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
-## License
+Or connect on [LinkedIn](https://www.linkedin.com/in/simonkoudijs/): feedback, questions, and ideas
+are all welcome.
-Apache 2.0
+Licensed under Apache 2.0.
diff --git a/Taskfile-build.yml b/Taskfile-build.yml
index 377a47402..55e24f0dd 100644
--- a/Taskfile-build.yml
+++ b/Taskfile-build.yml
@@ -137,6 +137,35 @@ tasks:
{{.ENVTEST}} use {{.ENVTEST_K8S_VERSION}} --bin-dir "$(pwd)/bin" -p path
touch '{{.ENVTEST_STAMP}}'
+ link-envtest-assets:
+ desc: Point bin/k8s/current at the envtest binaries, which is what the IDE sets KUBEBUILDER_ASSETS to
+ # .devcontainer/devcontainer.json sets go.testEnvVars.KUBEBUILDER_ASSETS to
+ # ${workspaceFolder}/bin/k8s/current, so a controller test run from the Test
+ # Explorer or the CodeLens resolves through this symlink. Nothing used to create
+ # it, and envtest does not say "assets missing" when the path is wrong: the specs
+ # just fail. That cost an investigation once already.
+ #
+ # `task test` does NOT depend on this symlink — it exports the path from
+ # setup-envtest directly, so the gate keeps working if the link is stale or gone.
+ # This exists for the editor.
+ #
+ # There is deliberately no status check. The obvious one — does bin/k8s/current
+ # resolve? — is true of a link left pointing at a previous version, because
+ # setup-envtest keeps every version it has ever downloaded, so the old directory
+ # is still there after a k8s.io/api bump moves ENVTEST_K8S_VERSION. The link
+ # would then keep resolving to the old apiserver and the editor would silently
+ # test against it. A status that compared the link against the selected path
+ # would have to run setup-envtest to learn that path, which is the whole cost of
+ # just relinking, so relinking every time is both cheaper and correct.
+ deps:
+ - setup-envtest
+ cmds:
+ - |
+ set -eu
+ assets="$({{.ENVTEST}} use {{.ENVTEST_K8S_VERSION}} --bin-dir "$(pwd)/bin" -p path)"
+ ln -sfn "${assets}" "$(pwd)/bin/k8s/current"
+ echo "bin/k8s/current -> ${assets}"
+
test:
desc: Run unit tests
deps:
@@ -144,6 +173,7 @@ tasks:
- fmt
- vet
- setup-envtest
+ - link-envtest-assets
# cmd's chart-args test runs `helm template`, which evaluates every template —
# including rbac.yaml, which reads the gitignored chart/config/role.yaml this
# syncs. Without it a fresh checkout fails the unit suite.
@@ -222,10 +252,11 @@ tasks:
# The four checks do not overlap and each is bad at the others' job, so they stay
# four tasks and this aggregates them. See docs/design/docs-linting.md.
#
- # lint-doc-links references resolve, including doc paths inside Go comments
- # lint-markdown markdown structure: fences, headings, lists, blank lines
- # lint-prose English against docs/style-guide.md
- # lint-metric-names every gitopsreverser_ metric named in the tree is registered
+ # lint-doc-links references resolve, including doc paths inside Go comments
+ # lint-markdown markdown structure: fences, headings, lists, blank lines
+ # lint-prose English against docs/style-guide.md
+ # lint-metric-names every gitopsreverser_ metric named in the tree is registered
+ # lint-settings-index the settings table matches the CRDs it claims to describe
#
# This kept the name `lint-docs` because AGENTS.md, docs/style-guide.md, and the
# CI cache assertion all name it. It is now a superset of what it used to do, so
@@ -235,6 +266,7 @@ tasks:
- lint-markdown
- lint-prose
- lint-metric-names
+ - lint-settings-index
lint-doc-links:
desc: Check that every doc reference resolves — markdown links, and docs/**.md cited in Go comments
@@ -262,6 +294,39 @@ tasks:
cmds:
- go run ./hack/doccheck
+ settings-index:
+ desc: Regenerate the settings index in docs/configuration.md from the CRD schemas
+ # The writer half of lint-settings-index. Run it after `task manifests` changes an
+ # API, then commit docs/configuration.md with the change that caused it.
+ cmds:
+ - go run ./hack/crdfields
+
+ lint-settings-index:
+ desc: Check that the settings index in docs/configuration.md matches the CRDs
+ deps:
+ # Task runs deps in parallel, and this one reaches `lint` through lint-docs
+ # while lint-golang reaches manifests through its own branch. Without this,
+ # the check can read config/crd/bases while controller-gen is rewriting it
+ # and pass against the previous CRDs — in the one run that matters, the one
+ # where an API field was just added.
+ - manifests
+ # Unlike its four neighbours this one CAN be fingerprinted: its inputs are three
+ # named paths, not "every tracked file", so there is no unrooted `**` to walk and
+ # no hand-maintained list to drift. That keeps it out of the CI cache exemption.
+ #
+ # It exists because the table it guards was written by hand once and got three
+ # defaults backwards, each in the direction that costs a reader an outage:
+ # accessFrom omitted DENIES every namespace, serializeNamespace omitted INFERS
+ # per document rather than meaning false, and qps/burst fall back to the
+ # operator-wide flags. The schema knew all three. Nothing was asking it.
+ sources:
+ - config/crd/bases/*.yaml
+ - hack/crdfields/*.go
+ - hack/crdfields/fields.yaml
+ - docs/configuration.md
+ cmds:
+ - go run ./hack/crdfields -check
+
lint-metric-names:
desc: Check that every gitopsreverser_ metric named anywhere in the tree is actually registered
# A renamed metric leaves its callers behind silently: operational queries are written
diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md
index 24546efba..3da8afd32 100644
--- a/charts/gitops-reverser/README.md
+++ b/charts/gitops-reverser/README.md
@@ -4,13 +4,11 @@ GitOps Reverser enables synchronization from Kubernetes to one or more Git repos
## Quick Start
-The official end-to-end quickstart lives in the repository root README:
+Follow the [quickstart](../../docs/quickstart.md) for installation and your first Git commit.
-- [`README.md`](../../README.md)
-
-Use that guide for cert-manager, Valkey, Helm install, Git credentials, starter
-`GitProvider`/`GitTarget`/`WatchRule` resources, and first-commit verification. Kube-apiserver audit
-delivery is optional and can be added later for named commit authors.
+Use that guide for cert-manager, Helm install, Git credentials, starter
+`GitProvider`/`GitTarget`/`WatchRule` resources, and first-commit verification. Audit delivery from
+`kube-apiserver` is optional and can be added later for named commit authors.
This chart README stays focused on chart-specific installation, configuration, and operations.
@@ -131,7 +129,8 @@ helm install gitops-reverser \
#### Hardened Single-Replica
-Hardened settings for a controlled pilot or environment-specific production review:
+Tighter defaults than the minimal install: a disruption budget, and the settings worth reviewing
+against your own environment's policy before you rely on it.
```yaml
# hardened-values.yaml
@@ -162,7 +161,7 @@ nodeSelector:
| Parameter | Description | Default |
|-----------|-------------|---------|
-| `replicaCount` | Number of controller replicas (can't be higher than 1 for now, sorry) | `1` |
+| `replicaCount` | Number of controller replicas. One running replica is supported; a higher value is rejected while Helm renders the chart, rather than silently running two writers | `1` |
| `image.repository` | Container image repository | `ghcr.io/configbutler/gitops-reverser` |
| `env` | Extra container env vars, as Kubernetes `EnvVar` entries | `[]` |
| `volumes` / `volumeMounts` | Extra pod volumes and their mounts, appended as-is | `[]` |
@@ -205,13 +204,13 @@ nodeSelector:
| `attribution.collectionWindow` | How long after a `deletecollection` a removal in its scope may still be credited to it. It only has to cover audit batching plus clock skew, since the removal is attributed at delete-request time | `30s` |
| `attribution.collectionUIDCap` | How many object UIDs a `deletecollection` fact carries before the set is dropped and the join falls back to scope matching, which is already correct | `10000` |
| `attribution.auditRouteAnnotationKey` | Audit-event annotation naming the **audit route** each event belongs to. Empty keeps audit routes named (`/audit-webhook/`). Set it only for a control plane emitting **one shared audit stream** for several logical clusters: it enables the bare `/audit-webhook`, which reads the route per event. A `ClusterProvider` joins a route via `spec.attribution.auditRoute` (default: its own name). An event with no annotation is rejected (counted and logged) and never credited to a fallback | `""` |
-| `clusterProvider.createDefault` | Render and own a `ClusterProvider` named `default` — the source cluster a `GitTarget` mirrors from when it omits `spec.clusterProviderRef`. The **operator never creates one**, so without this you commit the object yourself. Chart-owned: turning it off makes Helm delete the provider it created, and a `GitTarget` referencing a missing provider is held unready (`ClusterProviderNotFound`). The `quickstart` values never create one | `true` |
-| `clusterProvider.default.kubeConfig.secretRef.name` | Secret (release namespace) holding a kubeconfig for the rendered `default` provider. Empty means the operator's **own in-cluster** cluster; a name points `default` at a **remote** cluster instead — the name is a convention, not a claim about which cluster it is | `""` |
+| `clusterProvider.createDefault` | Render and own a `ClusterProvider` named `default`: the source cluster a `GitTarget` mirrors from when it omits `spec.clusterProviderRef`. The **operator never creates one**, so without this you commit the object yourself. Chart-owned: turning it off makes Helm delete the provider it created, and a `GitTarget` referencing a missing provider is held unready (`ClusterProviderNotFound`). The `quickstart` values never create one | `true` |
+| `clusterProvider.default.kubeConfig.secretRef.name` | Secret (release namespace) holding a kubeconfig for the rendered `default` provider. Empty means the operator's **own in-cluster** cluster; a name points `default` at a **remote** cluster instead. The name is a convention | `""` |
| `clusterProvider.default.kubeConfig.secretRef.key` | Key within that Secret. Empty reads `value` then `value.yaml` | `""` |
| `clusterProvider.default.accessFrom` | Deny-by-default policy (`names` and/or `selector`) for which **control-cluster** namespaces may reference this provider from a `GitTarget`. The default empty selector admits every namespace | `{selector: {}}` |
| `servers.admission.enabled` | Install the validate-operator-types admission webhook that captures CommitRequest authors (a form of author attribution). Enabled by default; a no-op until `queue.redis.addr` is set | `true` |
| `rbac.create` | Create the manager ClusterRole and its binding | `true` |
-| `rbac.watchTypes.mode` | Which types a `WatchRule` may read. `any` grants cluster-wide read on everything — convenient, but the reverser can then read every Secret in the cluster. `selected` grants read on `rbac.watchTypes.selected` only, so the reverser cannot list or watch Secrets (it keeps `get` on named Secrets it is pointed at). See [`docs/rbac.md`](../../docs/rbac.md) | `any` |
+| `rbac.watchTypes.mode` | Which types a `WatchRule` may read. `any` grants cluster-wide read on everything, including every Secret in the cluster. `selected` grants read on `rbac.watchTypes.selected` only, so the reverser cannot list or watch Secrets (it keeps `get` on named Secrets it is pointed at). See [`docs/rbac.md`](../../docs/rbac.md) | `any` |
| `rbac.watchTypes.selected` | Types to grant when `mode: selected`, as `{apiGroups, resources}` entries (verbs are always `get,list,watch`). Required and non-empty in that mode; `namespaces`, `customresourcedefinitions` and `apiservices` come from the manager role and must not be restated | `[]` |
| `servers.metrics.bindAddress` | Metrics listener bind address | `:8080` |
| `servers.metrics.tls.enabled` | Serve metrics with TLS | `false` |
@@ -246,7 +245,7 @@ See [`values.yaml`](values.yaml) for complete configuration options.
### Audit Webhook URL Contract
When `attribution.enabled=true`, `https://:9444/audit-webhook/`
-receives audit events from kube-apiserver — the route is
+receives audit events from `kube-apiserver`. The route is
`ClusterProvider.spec.attribution.auditRoute` and defaults to the provider's own name.
Audit routes are **named**, including
`/audit-webhook/default`. The bare `/audit-webhook` is rejected with **400** unless
@@ -304,7 +303,7 @@ kubectl get crd | grep configbutler
```
-For first-run GitOps Reverser usage, follow the root quickstart instead of duplicating those steps here.
+For first-run GitOps Reverser usage, follow the quickstart linked above.
### View Logs
diff --git a/charts/gitops-reverser/templates/validate-replica-count.yaml b/charts/gitops-reverser/templates/validate-replica-count.yaml
index d12b2e34c..835e0eaea 100644
--- a/charts/gitops-reverser/templates/validate-replica-count.yaml
+++ b/charts/gitops-reverser/templates/validate-replica-count.yaml
@@ -1,3 +1,3 @@
{{- if gt (int .Values.replicaCount) 1 -}}
-{{- fail "gitops-reverser does not support HA yet (Sorry I feel your pain: but it can't be perfect from the start). Set .Values.replicaCount to 1." -}}
+{{- fail "gitops-reverser runs a single replica: set .Values.replicaCount to 1. Standby replicas need ownership coordination first, so that two instances never write the same GitTarget." -}}
{{- end -}}
diff --git a/docs/README.md b/docs/README.md
index cb72a1200..9f3be1ec5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -9,8 +9,12 @@ If you only want the supported product docs, start with the files below.
## Start here
-- [`../README.md`](../README.md): product overview and end-to-end quick start
-- [`configuration.md`](configuration.md): core configuration objects and how they fit together
+- [`../README.md`](../README.md): product overview and supported workflows
+- [`quickstart.md`](quickstart.md): installation and your first Git commit
+- [`demo/commit-window.md`](demo/commit-window.md): two resources and a `CommitRequest`
+- [`configuration.md`](configuration.md): core configuration objects, every setting, and how
+ they fit together
+- [`commit-messages.md`](commit-messages.md): the three commit message templates and their context
- [`api-first-publication.md`](api-first-publication.md): why API edits drive publication, how
replay handles remote changes, and how commit windows and push timing fit together
- [`installing-apps-as-krm.md`](installing-apps-as-krm.md): installing an app is adding a KRM
diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md
index 612b5ecf2..2daa4f172 100644
--- a/docs/UPGRADING.md
+++ b/docs/UPGRADING.md
@@ -27,7 +27,7 @@ user-facing place — a Kubernetes `resourceVersion`. A commit message about Git
"revision" should mean something other than a commit.
The rename lands in the same release as the new per-resource
-[`Resources[i].ResourceVersion`](configuration.md#naming-the-state-a-commit-wrote) on
+[`Resources[i].ResourceVersion`](commit-messages.md#naming-the-state-a-commit-wrote) on
`liveTemplate`, so the vocabulary changes once: **`Revision` is a Git commit, `ResourceVersion` is a
Kubernetes version.**
@@ -489,7 +489,7 @@ can prune it. Confirm targets return to `Validated=True` after migration.
The default live subject is `chore: sync N resource(s)` with singular agreement and one retained
resource per body line, including singleton commits. Reconcile defaults use `chore: reconcile`.
Counts describe retained input before comparison with Git. See the
-[configuration reference](configuration.md#commit-message-templates).
+[commit message reference](commit-messages.md).
CommitRequest messages reject whitespace-only text and preserve all accepted Unicode and surrounding
spaces. The 1024-character bound is no longer a byte truncation. A request deadline starts at the
diff --git a/docs/commit-messages.md b/docs/commit-messages.md
new file mode 100644
index 000000000..f4d2c4f54
--- /dev/null
+++ b/docs/commit-messages.md
@@ -0,0 +1,252 @@
+# Commit messages
+
+Every commit GitOps Reverser makes gets its message from one of three templates on
+[`GitTarget.spec.commit.message`](configuration.md#gittargetspeccommit-how-writes-become-commits).
+This page is the reference for all three. Nothing here is required: leave `message` unset and the
+built-in templates apply.
+
+`liveTemplate` formats every live window, including one retained entry and `0s` windows.
+`reconcileTemplate` formats atomic snapshots and resyncs. Invalid templates report
+`Validated=False` with reason `InvalidConfig`; validation exercises singleton and mixed-operation
+windows, empty authors, and scoped and whole-target snapshots through the production renderer.
+Sample execution cannot prove every possible conditional branch valid. That applies to
+`requestTemplate`'s [message check](#framing-a-save-message) too: a template that drops the save
+message only on some branch passes validation and falls back at commit time.
+
+| Input, in precedence order | Message source |
+|---|---|
+| Attached [save request](configuration.md#commitrequest) **and** `requestTemplate` set | `requestTemplate`, with the request's message as `.RequestMessage` |
+| Non-empty literal override, including an attached [save request](configuration.md#commitrequest) | Exact supplied text |
+| Live window of any size | `liveTemplate` |
+| Atomic snapshot or resync | `reconcileTemplate` |
+
+The default live template produces a Conventional Commits subject and one retained entry per body line:
+
+```yaml
+spec:
+ commit:
+ message:
+ liveTemplate: |-
+ chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}
+
+ {{range .Resources -}}
+ - [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}
+ {{end -}}
+ reconcileTemplate: "chore: reconcile {{.Count}} {{if .Resource}}{{.Resource}}{{else}}resources{{end}}{{if .Namespace}} in {{.Namespace}}{{end}}{{if .ResourceVersion}} (last resourceVersion: {{.ResourceVersion}}){{end}}"
+```
+
+| Template | Fields |
+|---|---|
+| `liveTemplate` | `Author`, `GitTarget`, `Count`, `Operations`, `Resources`, and the `LabelValues` / `LabelValue` accessors |
+| `requestTemplate` | the same fields, plus `RequestMessage` |
+| Each `Resources` entry | `Operation`, `Group`, `Version`, `Resource`, `Kind`, `Namespace`, `Name`, `APIVersion`, `ResourceVersion`, `Generation`, `Labels`, and the `Label` accessor |
+| `reconcileTemplate` | `Count`, `GitTarget`, `Group`, `Version`, `Resource`, `APIVersion`, `Namespace`, `ResourceVersion` |
+
+Live `Count` counts retained entries after window coalescing, before the writer compares them with
+Git. Repeated edits to one resource collapse to one entry, with the last operation retained.
+`Operations` counts those retained `CREATE`, `UPDATE`, and `DELETE` entries; `Resources` preserves
+first-seen order. An entry already matching Git still counts, and several entries may share a file.
+The count can exceed the number of changed resources. A no-op creates no commit, even with a literal
+message. Printing a resource entry directly keeps its `group/version/resource[/namespace]/name` form.
+
+`RequestMessage` belongs to `requestTemplate`. It exists on the live context too, but is always
+empty there: a window carrying a save request's message renders `requestTemplate` when one is
+configured and the literal message when one is not, so `liveTemplate` only ever runs for windows
+that have no request message. Do not reach for `{{if .RequestMessage}}` inside `liveTemplate`;
+it never fires.
+
+`Author` is the raw window username and is empty when no actor is named. It does not use an OIDC
+display name or the `attribution-unresolved` Git author sentinel. The sentinel appears only in the
+Git author header when attribution ran without resolving an actor. Messages never change authorship.
+
+## Framing a save message
+
+By default a [save request](configuration.md#commitrequest)'s message **replaces** the template, so supplying one
+costs you the resource body `liveTemplate` would have produced: the commit says why, but no longer
+says what. `requestTemplate` composes the two.
+
+```yaml
+spec:
+ commit:
+ message:
+ requestTemplate: |-
+ {{.RequestMessage}}
+
+ {{range .Resources -}}
+ - [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}
+ {{end -}}
+```
+
+It renders only for a window a save request attached to; every other window is unaffected. Omit it
+and request messages are committed verbatim, exactly as before.
+
+The request is **never** parsed as a template. Its message arrives as `.RequestMessage` and is
+committed unaltered, so a save-button user supplies the content while the operator owns the
+wording around it. Nothing a requester writes is ever executed, and braces inside a request message
+stay literal in every case.
+
+A `requestTemplate` that never renders `.RequestMessage` is **rejected** with `Validated=False`,
+because dropping the requester's stated reason is the one thing this field must not do. Every
+spelling that puts the message in the commit is accepted (`{{.RequestMessage}}`, a pipeline, or a
+variable), because the check renders the template and looks for the message in the output rather
+than scanning the template's text.
+
+`requestTemplate` is validated against the same window shapes as `liveTemplate`, so a template
+reading a label some resources do not carry is caught when the target reconciles rather than at
+commit time. If one
+does fail to render in production, the request's message is committed verbatim instead of the
+window being lost, and the commit is counted under `message_source="commit_request_fallback"`.
+Alert on that rate: the commit itself succeeds and no condition moves, so it is the only signal.
+
+## Kind, scope, and labels
+
+`Kind` is the commit-message spelling of the `{kind}` [placement variable](configuration.md#template-variables),
+and `Namespace` now answers the scope question completely: it renders the resource's namespace, or
+the literal `_cluster` when the resource is cluster-scoped, exactly as `{namespace}` does in a path.
+A body line no longer has to guard it with `{{if .Namespace}}`, because the field is never blank;
+the default template no longer does.
+
+`Labels` is the resource's labels as the writer commits them, and is read with the `Label`
+accessor:
+
+```yaml
+liveTemplate: |-
+ chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}{{with .LabelValue "team"}} for {{.}}{{end}}
+
+ {{range .Resources -}}
+ - [{{.Operation}}] {{.Kind}} {{.Namespace}}/{{.Name}} ({{.Label "app.kubernetes.io/instance"}})
+ {{end -}}
+```
+
+Three things to know:
+
+- **Read a label with `{{.Label "team"}}`, never `{{.Labels.team}}`.** These templates render with
+ `missingkey=error`, so indexing a label a resource does not carry does not render empty: it fails
+ the render, and a failed render fails the whole commit, losing the window until the next resync.
+ `Label` returns the empty string instead. Validation catches the dotted form (its sample events
+ include a resource with no labels), so such a template is refused by the `Validated` gate rather
+ than at 2am, but the accessor is the spelling to write.
+- **A commit spans n resources, so a label is a set here.** `LabelValues "team"` is the sorted,
+ distinct list of values in this commit, skipping resources that do not set it; `LabelValue "team"`
+ is the single value when the whole commit agrees on one, and empty when it does not. A subject
+ line that names a team is only honest under the second. The two differ on a resource that does
+ not carry the label: `LabelValues` skips it, `LabelValue` treats it as a disagreement and renders
+ nothing, so a commit holding one labeled and one unlabeled resource is named after neither.
+- **A `DELETE` carries no object**, because the resource is already gone from the cluster, so
+ `Kind` and `Labels` are empty for one. The identity fields (`Name`, `Namespace`, `Resource`, …)
+ are unaffected, and so are `ResourceVersion` and `Generation`: the watch delivers the final
+ object, so the last state that existed is still knowable even though the object is not. A commit containing a
+ `DELETE` therefore has no agreed `LabelValue`: what the deleted resource was labeled is not
+ something the window still knows.
+
+## Naming the state a commit wrote
+
+Two counters describe the observed state each entry was at. Neither is committed to the file
+(`resourceVersion` and `generation` are stripped from every manifest on purpose, because a counter
+inside a manifest would make every observation a byte change); both travel beside the object, in
+the message only. Both are off by default, and both are guarded with `{{with}}`:
+
+```yaml
+liveTemplate: |-
+ chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}
+
+ {{range .Resources -}}
+ - [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}{{with .ResourceVersion}}@{{.}}{{end}}{{with .Generation}} gen{{.}}{{end}}
+ {{end -}}
+```
+
+| | `{{.ResourceVersion}}` | `{{.Generation}}` |
+|---|---|---|
+| moves when | **anything** is written, `/status` included | only the **desired state** changes |
+| present on | every resource the operator observed | only kinds with a spec: **not** a ConfigMap or a Secret |
+| absent value | `""` | `0` |
+| safe to compare | for **equality** only | yes: per object, starts at `1`, one step per spec write |
+
+**Turn `ResourceVersion` on to make a commit joinable**: to an audit log entry, to a
+`kubectl get -o yaml` taken at the time, to another operator's logs. Equal to the object's current
+version means nothing is pending. Do not subtract two of them: `resourceVersion` is opaque by API
+contract and is the cluster-wide store revision in practice, so two consecutive commits of one
+ConfigMap can read `@1331` then `@8402` with nothing skipped, because every other object's writes
+moved the same counter.
+
+**Turn `Generation` on to see what a commit missed.** It is the counter a gap is meaningful in: a
+commit at `gen3` following one at `gen6` means three spec changes that were not committed
+separately. That is the question `ResourceVersion` cannot answer.
+
+Read them together, because each is blind where the other sees:
+
+- **`ResourceVersion` lags, deliberately.** An update whose committed content would be identical (a
+ `/status`-only write) is never routed, so the version in a commit can be older than the object's
+ current one. A resource re-edited inside one window contributes the last routed version, and an
+ entry that already matched Git still contributes one.
+- **`Generation` misses metadata.** A label- or annotation-only edit changes what gets committed
+ without moving it, so an unchanged `Generation` across two commits does not mean an unchanged
+ commit. And it is `0` for every spec-less kind, which is much of what a typical target mirrors.
+- **Neither counts skips.** To count what the operator deliberately did not route, read
+ `watch_events_total{outcome="unchanged"}`, which is exactly that census.
+
+A producer that observed no object renders nothing for either: reconcile, resync, and bootstrap
+writes all leave both empty.
+
+`reconcileTemplate` gets none of these. It describes a *type* being reconciled rather than a list
+of resources, so there are no labels to read, and its own `{{.ResourceVersion}}` is the snapshot
+`LIST`'s version: one value for the whole run, not any single object's. Live names n resources, so
+it names each one's version; reconcile names a type, so it names the snapshot's. Its `Namespace`
+keeps the plain meaning it always had: the namespace a namespace-scoped reconcile covered, empty for a whole-target or all-namespaces
+one. That emptiness means "every namespace", not "cluster-scoped", so the `_cluster` sentinel would
+be a lie there rather than a convenience.
+
+## Two languages, one vocabulary
+
+Placement templates and commit templates are deliberately different renderers: a path has to be
+statically checkable (that is what lets the operator prove a Secret route cannot collide two
+Secrets onto one file), while a commit message has to iterate over n resources, which needs
+`range` and `if`. Neither language can do the other's job.
+
+The nouns are the same in both, and only the spelling follows each host language:
+
+| Concept | Placement | Commit message |
+|---|---|---|
+| namespace, or `_cluster` when cluster-scoped | `{namespace}` | `.Namespace` |
+| kind | `{kind}` | `.Kind` |
+| name | `{name}` | `.Name` |
+| API version | `{apiVersion}` | `.APIVersion` |
+| one label | `{label:team}` | `.Label "team"` |
+| the observed version | (none) | `.ResourceVersion` |
+| the desired-state counter | (none) | `.Generation` |
+
+The two counters are the first nouns that legitimately exist on only one side. A path keyed on a
+counter would write a new file whenever it moved, which is the opposite of what placement is for: a
+path has to be stable and statically checkable. A message describes one moment, so it can name
+one.
+
+The capitals are Go's, not a style choice: `text/template` can only reach exported struct fields,
+which must begin with one. The braces are lower-case because a placement template reads like the
+manifest it is filing (`metadata.namespace`). A label needs a method call rather than a field on
+the commit side because a Go template field name cannot contain a `:` or a `/`.
+
+Reconcile type fields name the synced type; `Namespace` names a namespace-scoped snapshot.
+Whole-target snapshots leave those fields empty. `ResourceVersion` is the snapshot's resourceVersion
+and can be absent, including a pure sweep. Guard optional values as in the example.
+
+`ResourceVersion` was called `Revision` before `v0.48.0`. A `reconcileTemplate` still naming
+`{{.Revision}}` is rejected, and the `GitTarget` says so on its `Validated` condition. See
+[the upgrade note](UPGRADING.md#reconciletemplates-revision-is-now-resourceversion).
+
+A reconcile runs per *cell* (a (type, namespace) pair) rather than per target, so a
+namespace-scoped run covers exactly one namespace and the default subject names it:
+
+```text
+chore: reconcile 4 configmaps in team-a (last resourceVersion: 1331)
+```
+
+Without it, a target watching one type in two namespaces writes two byte-identical subjects, which
+is the same reason the type is in there. `Namespace` stays guarded by `{{if}}` rather than falling
+back to a sentinel the way `Resources[i].Namespace` does, and the difference is not an oversight:
+per resource, empty has exactly one meaning (the kind has no namespaces), so `_cluster` is a true
+name for it. Per run, empty covers two different facts: an all-namespaces sweep of a namespaced
+type, and a cluster-scoped type that has no namespaces. No single word is true of both, so the
+honest rendering is to say nothing.
+
+`eventTemplate` and `groupTemplate` are retired and rejected. Follow the
+[upgrade instructions](UPGRADING.md#one-live-commit-message-template) to migrate existing templates.
diff --git a/docs/configuration.md b/docs/configuration.md
index 26beec6c3..a28353f33 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,7 +1,7 @@
# Configuration model
-This guide explains the real configuration objects that drive gitops-reverser after the install
-steps in the [root README](../README.md).
+This guide explains the configuration objects that drive gitops-reverser after the install steps
+in the [quickstart](quickstart.md).
The short version:
@@ -16,7 +16,7 @@ The short version:
The chart's optional `quickstart` values are a convenience layer that creates starter
instances of those same resources.
-For a first trial, use the root README quick start. It runs configured-author: Git writes work without
+For a first trial, use the [quickstart](quickstart.md). It runs configured-author: Git writes work without
kube-apiserver audit delivery, and every commit uses the configured committer identity. Add audit
attribution later only when you need named Kubernetes users or service accounts in Git history.
@@ -27,15 +27,104 @@ attribution later only when you need named Kubernetes users or service accounts
The usual flow is:
1. Create a `GitProvider` for repository access and commit behavior.
-2. Create a `ClusterProvider` for the source cluster, including the `default` provider when a target
- omits its source reference.
-3. Create a `GitTarget` that points at the Git provider, source cluster, branch, and repository path.
-4. Create one or more `WatchRule` or `ClusterWatchRule` objects that point at that target.
-5. Create a `CommitRequest` only when you want to flush an open window before the normal timer.
+2. Create a `GitTarget` that points at the Git provider, source cluster, branch, and repository path.
+3. Create one or more `WatchRule` or `ClusterWatchRule` objects that point at that target.
+4. Create a `CommitRequest` only when you want to flush an open window before the normal timer.
That means one repository connection can back multiple targets, and one target can be fed by
multiple watch rules.
+> **`ClusterProvider` is not one of those steps.** Installing created the `default` one, and that is
+> the source cluster a `GitTarget` mirrors from when it omits `spec.clusterProviderRef`. Write your
+> own only to mirror a second, remote cluster.
+
+
+
+## Field overview
+
+Ready-made manifests for all six objects live in [`config/samples/`](../config/samples/). They
+cross-reference the same `example-provider` / `example-target` names, so the set can be applied
+together and edited down, rather than assembled from the fragments below.
+
+Every field not marked **required** may be omitted. Sections linked from the tables explain the
+fields that need more than a line.
+
+### `GitProvider` (namespaced): where and how to push
+
+| Field | Default | What it does |
+|---|---|---|
+| `url` | **required** | Repository URL, SSH or HTTPS |
+| `allowedBranches` | **required** | Branches a `GitTarget` may write. A target naming any other is rejected |
+| `secretRef` | anonymous access | Secret holding the write credential. See [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret) |
+| `knownHostsRef` | the Secret's own `known_hosts` | Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification fails closed either way |
+| `commit.committer` | the operator's built-in identity | Name and email written as the commit committer. See [committer identity](#committer-identity) |
+| `commit.signing` | off | SSH commit signing. See [commit signing](#commit-signing) |
+
+### `ClusterProvider` (cluster-scoped): the source cluster
+
+| Field | Default | What it does |
+|---|---|---|
+| `kubeConfig` | the operator's own cluster | Points at a remote source cluster instead of the one the operator runs in |
+| `accessFrom` | **no namespace** | Deny-by-default list of control-cluster namespaces that may reference this provider. Omitted, none may; an empty `selector: {}` admits every namespace |
+| `allowAnySourceNamespace` | `false` | Whether rules may watch namespaces other than their own, `*` included |
+| `attribution.auditRoute` | the provider's own name | Audit route this provider joins. See [audit route](#audit-route-and-auditfactsreceived) |
+| `qps` | the operator-wide flag | Client rate limit for this source cluster. Ignored when `kubeConfig` is omitted |
+| `burst` | the operator-wide flag | Client burst for this source cluster. Ignored when `kubeConfig` is omitted |
+
+### `GitTarget` (namespaced): which branch and folder
+
+The destination fields are immutable: to move a target, delete it and create a new one.
+
+| Field | Default | What it does |
+|---|---|---|
+| `gitProviderRef` | **required** | The `GitProvider` backing this target, in the same namespace |
+| `branch` | **required** | Branch to write. Must be in the provider's `allowedBranches` |
+| `path` | **required** | Folder within the repository. `.` targets the root; empty is rejected |
+| `clusterProviderRef` | `{"name":"default"}` | Source cluster to mirror from. The default names a `ClusterProvider` called `default` |
+| `commit.window` | `5s` | How long to coalesce changes into one commit. `0s` commits per event. See [the commit window](#the-commit-window-speccommitwindow) |
+| `commit.message` | the built-in templates | How commits are phrased. See [commit messages](commit-messages.md) |
+| `placement.byType` | the canonical path | Per-type path templates for new documents. See [where new resources are written](#where-new-resources-are-written-specplacement) |
+| `placement.default` | the canonical path | Catch-all path template for types with no `byType` entry |
+| `placement.useKustomize` | off | Keep the folder a kustomize folder. See [keeping the folder a kustomize folder](#keeping-the-folder-a-kustomize-folder-specplacementusekustomize) |
+| `serializeNamespace` | inferred per document | Whether a committed document carries its own `metadata.namespace`. An explicit `false` admits exactly one source namespace. See [whether documents carry their namespace](#whether-documents-carry-their-namespace-specserializenamespace) |
+| `prune.mode` | `OnEvent` | Which deletions reach Git. See [deletion policy](#deletion-policy-specprunemode) |
+| `onRefusal` | `Ignore` | What happens to an edit with no legal destination. See [reverting a refused edit](#reverting-a-refused-edit-speconrefusal) |
+| `encryption` | off | SOPS + age encryption for sensitive resources |
+| `suspend` | `false` | Stop writing without deleting the target. See [stopping a target from writing](#stopping-a-target-from-writing-specsuspend) |
+
+### `WatchRule` (namespaced): what to capture
+
+| Field | Default | What it does |
+|---|---|---|
+| `gitTargetRef` | **required** | The `GitTarget` these rules feed |
+| `rules[].resources` | **required** | Plural resource names to watch |
+| `rules[].apiGroups` | resolved from the resource name | API groups to match. `[""]` is the core group. Omitted resolves the named resource across the served surface, and selects nothing when more than one group serves that name |
+| `rules[].apiVersions` | the preferred served version | API versions to match. `["*"]` watches every served version |
+| `rules[].operations` | `CREATE`, `UPDATE`, `DELETE` | Which operations produce a write |
+| `rules[].sourceNamespace` | the rule's own namespace | Namespace to watch in the source cluster, or `*`. See [watching a different source namespace](#watching-a-different-source-namespace) |
+
+### `ClusterWatchRule` (cluster-scoped): what to capture, cluster-wide
+
+The same shape as a `WatchRule`, for cluster-scoped types. It selects no namespaces, so it has no `sourceNamespace`.
+
+| Field | Default | What it does |
+|---|---|---|
+| `gitTargetRef` | **required** | The `GitTarget` these rules feed |
+| `rules[].resources` | **required** | Plural cluster-scoped resource names to watch |
+| `rules[].apiGroups` | resolved from the resource name | API groups to match. Omitted resolves the named resource across the served surface, and selects nothing when more than one group serves that name |
+| `rules[].apiVersions` | the preferred served version | API versions to match. `["*"]` watches every served version |
+| `rules[].operations` | `CREATE`, `UPDATE`, `DELETE` | Which operations produce a write |
+
+### `CommitRequest` (namespaced): save now
+
+| Field | Default | What it does |
+|---|---|---|
+| `gitTargetRef` | **required** | The target whose open window to close |
+| `message` | the target's templates | Commit message, committed verbatim unless `requestTemplate` frames it |
+| `closeDelaySeconds` | `2` | How long to wait for pending events. See [sizing `closeDelaySeconds`](#sizing-closedelayseconds) |
+
+
+
## Why the two provider types have different scopes
| Object | Scope | What it represents |
@@ -150,7 +239,7 @@ throwaway/dev clusters only: it permits SSH when **no** source provided any `kno
If `spec.commit` is omitted, gitops-reverser uses its built-in defaults.
`spec.commit.message` is not here. A commit's wording describes the folder being written, so it is
-[`GitTarget.spec.commit.message`](#commit-message-templates); setting it on a `GitProvider` is
+[`GitTarget.spec.commit.message`](commit-messages.md); setting it on a `GitProvider` is
rejected.
#### Author vs committer
@@ -550,7 +639,7 @@ spec:
liveTemplate: "chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}"
```
-Configure [automatic messages](#commit-message-templates), submit a [save message](#commitrequest),
+Configure [automatic messages](commit-messages.md), submit a [save message](#commitrequest),
and interpret its [conditions](#commitrequest).
#### The commit window (`spec.commit.window`)
@@ -571,250 +660,22 @@ write rather than stopping the mirror.
#### Commit message templates
-`liveTemplate` formats every live window, including one retained entry and `0s` windows.
-`reconcileTemplate` formats atomic snapshots and resyncs. Invalid templates report
-`Validated=False` with reason `InvalidConfig`; validation exercises singleton and mixed-operation
-windows, empty authors, and scoped and whole-target snapshots through the production renderer.
-Sample execution cannot prove every possible conditional branch valid. That applies to
-`requestTemplate`'s [message check](#framing-a-save-message) too: a template that drops the save
-message only on some branch passes validation and falls back at commit time.
-
-| Input, in precedence order | Message source |
-|---|---|
-| Attached [save request](#commitrequest) **and** `requestTemplate` set | `requestTemplate`, with the request's message as `.RequestMessage` |
-| Non-empty literal override, including an attached [save request](#commitrequest) | Exact supplied text |
-| Live window of any size | `liveTemplate` |
-| Atomic snapshot or resync | `reconcileTemplate` |
-
-The default live template produces a Conventional Commits subject and one retained entry per body line:
+Three templates decide how a commit is phrased: `liveTemplate` for a live window,
+`reconcileTemplate` for an atomic snapshot or resync, and `requestTemplate` for a window a
+[save request](#commitrequest) attached to. Each is a Go template over a documented context,
+checked when the target reconciles rather than at commit time, and each has a built-in default, so a
+target that sets none still writes useful messages.
```yaml
spec:
commit:
message:
- liveTemplate: |-
- chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}
-
- {{range .Resources -}}
- - [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{if .Namespace}}{{.Namespace}}/{{end}}{{.Name}}
- {{end -}}
- reconcileTemplate: "chore: reconcile {{.Count}} {{if .Resource}}{{.Resource}}{{else}}resources{{end}}{{if .Namespace}} in {{.Namespace}}{{end}}{{if .ResourceVersion}} (last resourceVersion: {{.ResourceVersion}}){{end}}"
-```
-
-| Template | Fields |
-|---|---|
-| `liveTemplate` | `Author`, `GitTarget`, `Count`, `Operations`, `Resources`, and the `LabelValues` / `LabelValue` accessors |
-| `requestTemplate` | the same fields, plus `RequestMessage` |
-| Each `Resources` entry | `Operation`, `Group`, `Version`, `Resource`, `Kind`, `Namespace`, `Name`, `APIVersion`, `ResourceVersion`, `Generation`, `Labels`, and the `Label` accessor |
-| `reconcileTemplate` | `Count`, `GitTarget`, `Group`, `Version`, `Resource`, `APIVersion`, `Namespace`, `ResourceVersion` |
-
-Live `Count` counts retained entries after window coalescing, before the writer compares them with
-Git. Repeated edits to one resource collapse to one entry, with the last operation retained.
-`Operations` counts those retained `CREATE`, `UPDATE`, and `DELETE` entries; `Resources` preserves
-first-seen order. An entry already matching Git still counts, and several entries may share a file.
-The count can exceed the number of changed resources. A no-op creates no commit, even with a literal
-message. Printing a resource entry directly keeps its `group/version/resource[/namespace]/name` form.
-
-`RequestMessage` belongs to `requestTemplate`. It exists on the live context too, but is always
-empty there: a window carrying a save request's message renders `requestTemplate` when one is
-configured and the literal message when one is not, so `liveTemplate` only ever runs for windows
-that have no request message. Do not reach for `{{if .RequestMessage}}` inside `liveTemplate`;
-it never fires.
-
-`Author` is the raw window username and is empty when no actor is named. It does not use an OIDC
-display name or the `attribution-unresolved` Git author sentinel. The sentinel appears only in the
-Git author header when attribution ran without resolving an actor. Messages never change authorship.
-
-##### Framing a save message
-
-By default a [save request](#commitrequest)'s message **replaces** the template, so supplying one
-costs you the resource body `liveTemplate` would have produced: the commit says why, but no longer
-says what. `requestTemplate` composes the two.
-
-```yaml
-spec:
- commit:
- message:
- requestTemplate: |-
- {{.RequestMessage}}
-
- {{range .Resources -}}
- - [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}
- {{end -}}
-```
-
-It renders only for a window a save request attached to; every other window is unaffected. Omit it
-and request messages are committed verbatim, exactly as before.
-
-The request is **never** parsed as a template. Its message arrives as `.RequestMessage` and is
-committed unaltered, so a save-button user supplies the content while the operator owns the
-wording around it. Nothing a requester writes is ever executed, and braces inside a request message
-stay literal in every case.
-
-A `requestTemplate` that never renders `.RequestMessage` is **rejected** with `Validated=False`,
-because dropping the requester's stated reason is the one thing this field must not do. Every
-spelling that puts the message in the commit is accepted (`{{.RequestMessage}}`, a pipeline, or a
-variable), because the check renders the template and looks for the message in the output rather
-than scanning the template's text.
-
-`requestTemplate` is validated against the same window shapes as `liveTemplate`, so a template
-reading a label some resources do not carry fails at admission rather than at commit time. If one
-does fail to render in production, the request's message is committed verbatim instead of the
-window being lost, and the commit is counted under `message_source="commit_request_fallback"`.
-Alert on that rate: the commit itself succeeds and no condition moves, so it is the only signal.
-
-##### Kind, scope, and labels
-
-`Kind` is the commit-message spelling of the `{kind}` [placement variable](#template-variables),
-and `Namespace` now answers the scope question completely: it renders the resource's namespace, or
-the literal `_cluster` when the resource is cluster-scoped, exactly as `{namespace}` does in a path.
-A body line no longer has to guard it with `{{if .Namespace}}`, because the field is never blank;
-the default template no longer does.
-
-`Labels` is the resource's labels as the writer commits them, and is read with the `Label`
-accessor:
-
-```yaml
-liveTemplate: |-
- chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}{{with .LabelValue "team"}} for {{.}}{{end}}
-
- {{range .Resources -}}
- - [{{.Operation}}] {{.Kind}} {{.Namespace}}/{{.Name}} ({{.Label "app.kubernetes.io/instance"}})
- {{end -}}
-```
-
-Three things to know:
-
-- **Read a label with `{{.Label "team"}}`, never `{{.Labels.team}}`.** These templates render with
- `missingkey=error`, so indexing a label a resource does not carry does not render empty: it fails
- the render, and a failed render fails the whole commit, losing the window until the next resync.
- `Label` returns the empty string instead. Validation catches the dotted form (its sample events
- include a resource with no labels), so such a template is rejected at admission rather than at
- 2am, but the accessor is the spelling to write.
-- **A commit spans n resources, so a label is a set here.** `LabelValues "team"` is the sorted,
- distinct list of values in this commit, skipping resources that do not set it; `LabelValue "team"`
- is the single value when the whole commit agrees on one, and empty when it does not. A subject
- line that names a team is only honest under the second. The two differ on a resource that does
- not carry the label: `LabelValues` skips it, `LabelValue` treats it as a disagreement and renders
- nothing, so a commit holding one labeled and one unlabeled resource is named after neither.
-- **A `DELETE` carries no object**, because the resource is already gone from the cluster, so
- `Kind` and `Labels` are empty for one. The identity fields (`Name`, `Namespace`, `Resource`, …)
- are unaffected, and so are `ResourceVersion` and `Generation`: the watch delivers the final
- object, so the last state that existed is still knowable even though the object is not. A commit containing a
- `DELETE` therefore has no agreed `LabelValue`: what the deleted resource was labeled is not
- something the window still knows.
-
-##### Naming the state a commit wrote
-
-Two counters describe the observed state each entry was at. Neither is committed to the file
-(`resourceVersion` and `generation` are stripped from every manifest on purpose, because a counter
-inside a manifest would make every observation a byte change); both travel beside the object, in
-the message only. Both are off by default, and both are guarded with `{{with}}`:
-
-```yaml
-liveTemplate: |-
- chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}
-
- {{range .Resources -}}
- - [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}{{with .ResourceVersion}}@{{.}}{{end}}{{with .Generation}} gen{{.}}{{end}}
- {{end -}}
-```
-
-| | `{{.ResourceVersion}}` | `{{.Generation}}` |
-|---|---|---|
-| moves when | **anything** is written, `/status` included | only the **desired state** changes |
-| present on | every resource the operator observed | only kinds with a spec: **not** a ConfigMap or a Secret |
-| absent value | `""` | `0` |
-| safe to compare | for **equality** only | yes: per object, starts at `1`, one step per spec write |
-
-**Turn `ResourceVersion` on to make a commit joinable**: to an audit log entry, to a
-`kubectl get -o yaml` taken at the time, to another operator's logs. Equal to the object's current
-version means nothing is pending. Do not subtract two of them: `resourceVersion` is opaque by API
-contract and is the cluster-wide store revision in practice, so two consecutive commits of one
-ConfigMap can read `@1331` then `@8402` with nothing skipped, because every other object's writes
-moved the same counter.
-
-**Turn `Generation` on to see what a commit missed.** It is the counter a gap is meaningful in: a
-commit at `gen3` following one at `gen6` means three spec changes that were not committed
-separately. That is the question `ResourceVersion` cannot answer.
-
-Read them together, because each is blind where the other sees:
-
-- **`ResourceVersion` lags, deliberately.** An update whose committed content would be identical (a
- `/status`-only write) is never routed, so the version in a commit can be older than the object's
- current one. A resource re-edited inside one window contributes the last routed version, and an
- entry that already matched Git still contributes one.
-- **`Generation` misses metadata.** A label- or annotation-only edit changes what gets committed
- without moving it, so an unchanged `Generation` across two commits does not mean an unchanged
- commit. And it is `0` for every spec-less kind, which is much of what a typical target mirrors.
-- **Neither counts skips.** To count what the operator deliberately did not route, read
- `watch_events_total{outcome="unchanged"}`, which is exactly that census.
-
-A producer that observed no object renders nothing for either: reconcile, resync, and bootstrap
-writes all leave both empty.
-
-`reconcileTemplate` gets none of these. It describes a *type* being reconciled rather than a list
-of resources, so there are no labels to read, and its own `{{.ResourceVersion}}` is the snapshot
-`LIST`'s version: one value for the whole run, not any single object's. Live names n resources, so
-it names each one's version; reconcile names a type, so it names the snapshot's. Its `Namespace`
-keeps the plain meaning it always had: the namespace a namespace-scoped reconcile covered, empty for a whole-target or all-namespaces
-one. That emptiness means "every namespace", not "cluster-scoped", so the `_cluster` sentinel would
-be a lie there rather than a convenience.
-
-##### Two languages, one vocabulary
-
-Placement templates and commit templates are deliberately different renderers: a path has to be
-statically checkable (that is what lets the operator prove a Secret route cannot collide two
-Secrets onto one file), while a commit message has to iterate over n resources, which needs
-`range` and `if`. Neither language can do the other's job.
-
-The nouns are the same in both, and only the spelling follows each host language:
-
-| Concept | Placement | Commit message |
-|---|---|---|
-| namespace, or `_cluster` when cluster-scoped | `{namespace}` | `.Namespace` |
-| kind | `{kind}` | `.Kind` |
-| name | `{name}` | `.Name` |
-| API version | `{apiVersion}` | `.APIVersion` |
-| one label | `{label:team}` | `.Label "team"` |
-| the observed version | (none) | `.ResourceVersion` |
-| the desired-state counter | (none) | `.Generation` |
-
-The two counters are the first nouns that legitimately exist on only one side. A path keyed on a
-counter would write a new file whenever it moved, which is the opposite of what placement is for: a
-path has to be stable and statically checkable. A message describes one moment, so it can name
-one.
-
-The capitals are Go's, not a style choice: `text/template` can only reach exported struct fields,
-which must begin with one. The braces are lower-case because a placement template reads like the
-manifest it is filing (`metadata.namespace`). A label needs a method call rather than a field on
-the commit side because a Go template field name cannot contain a `:` or a `/`.
-
-Reconcile type fields name the synced type; `Namespace` names a namespace-scoped snapshot.
-Whole-target snapshots leave those fields empty. `ResourceVersion` is the snapshot's resourceVersion
-and can be absent, including a pure sweep. Guard optional values as in the example.
-
-`ResourceVersion` was called `Revision` before `v0.48.0`. A `reconcileTemplate` still naming
-`{{.Revision}}` is rejected, and the `GitTarget` says so on its `Validated` condition. See
-[the upgrade note](UPGRADING.md#reconciletemplates-revision-is-now-resourceversion).
-
-A reconcile runs per *cell* (a (type, namespace) pair) rather than per target, so a
-namespace-scoped run covers exactly one namespace and the default subject names it:
-
-```text
-chore: reconcile 4 configmaps in team-a (last resourceVersion: 1331)
+ liveTemplate: "chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}"
```
-Without it, a target watching one type in two namespaces writes two byte-identical subjects, which
-is the same reason the type is in there. `Namespace` stays guarded by `{{if}}` rather than falling
-back to a sentinel the way `Resources[i].Namespace` does, and the difference is not an oversight:
-per resource, empty has exactly one meaning (the kind has no namespaces), so `_cluster` is a true
-name for it. Per run, empty covers two different facts: an all-namespaces sweep of a namespaced
-type, and a cluster-scoped type that has no namespaces. No single word is true of both, so the
-honest rendering is to say nothing.
-
-`eventTemplate` and `groupTemplate` are retired and rejected. Follow the
-[upgrade instructions](UPGRADING.md#one-live-commit-message-template) to migrate existing templates.
+See [commit messages](commit-messages.md) for the full reference: the precedence rules, every field
+of the template context, how to frame a save request's message with what was saved, and the
+validation a bad template fails.
### Seeing what a target will do, before it does it
@@ -1251,7 +1112,7 @@ placement:
Resources sharing a label value bundle into one file, which is usually the point: every ConfigMap
labeled `app.kubernetes.io/instance: voter` lands in `voter/configmaps.yaml`. The commit that
-writes them can name the same label: see [Kind, scope, and labels](#kind-scope-and-labels).
+writes them can name the same label: see [Kind, scope, and labels](commit-messages.md#kind-scope-and-labels).
##### Every resource is placed, labeled or not
@@ -1689,7 +1550,8 @@ are:
- `operations`: `CREATE`, `UPDATE`, `DELETE`, or `*`; omitted means all operations.
- `apiGroups`: `""` for the core group, `*` for all groups, or omitted to resolve the named resource
- across the served API surface.
+ across the served API surface. A name served by more than one group is ambiguous when the group is
+ omitted, and is watched in none of them: name the group.
- `apiVersions`: a served version such as `v1`; omitted means the preferred served version.
- `resources`: plural resource names such as `configmaps`, `secrets`, or `*`.
@@ -1787,8 +1649,8 @@ The entire spec is immutable. Create a new `CommitRequest` for each save attempt
A present message accepts 1–1024 Unicode characters, including newline. All other ASCII control
characters, including tab, carriage return, and DEL, are rejected, as is whitespace-only text.
Accepted surrounding spaces are preserved. Braces such as `{{.Author}}` remain literal; omission
-uses [the live template](#commit-message-templates). To frame a save message with what was saved,
-configure [`requestTemplate`](#framing-a-save-message) on the GitTarget. The request is still never
+uses [the live template](commit-messages.md). To frame a save message with what was saved,
+configure [`requestTemplate`](commit-messages.md#framing-a-save-message) on the GitTarget. The request is still never
parsed as a template. A rejected request leaves automatic mirroring
available. The submitter chooses any semantic prefix; free-form messages are accepted.
@@ -2017,7 +1879,7 @@ attribution:
## Quickstart vs hand-managed resources
-Keep using the [root README quickstart](../README.md#quick-start) when you want the fastest first commit.
+Keep using the [quickstart](quickstart.md) when you want the fastest first commit.
The chart's `quickstart` values create a starter `GitProvider`, `GitTarget`, and `WatchRule` for you.
The starter `GitTarget` writes under `live-cluster` by default. Override
diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md
new file mode 100644
index 000000000..299f70138
--- /dev/null
+++ b/docs/demo/commit-window.md
@@ -0,0 +1,183 @@
+# Commit-window example
+
+Two Kubernetes resources become one Git commit: one created, one edited. A `CommitRequest` supplies
+the message and asks the operator to close the window before its normal timer expires. The request
+itself is outside the selected resource types, so saving never commits the save button.
+
+**Run the [quickstart](../quickstart.md) first.** It leaves behind everything this example builds
+on: the `gitops-reverser-quickstart-demo` namespace, the `git-creds` Secret, a `GitProvider` named
+`example-provider` whose `allowedBranches` is `"*"`, and the `default` `ClusterProvider`. If you
+configured those yourself instead, substitute your own names below.
+
+The example writes to its own branch, `commit-window-demo`, which no reconciler deploys. The
+quickstart's own starter `GitTarget` keeps watching ConfigMaps in this namespace and writing them to
+`live-cluster` on `main`, so the ConfigMap below lands in both places. That is expected: two targets
+may share one provider as long as they write different folders.
+
+## Select the resources
+
+Save as `watch.yaml`. All resources in this example use the same namespace and target.
+
+```yaml
+apiVersion: configbutler.ai/v1alpha3
+kind: GitTarget
+metadata:
+ name: window-demo
+ namespace: gitops-reverser-quickstart-demo
+spec:
+ gitProviderRef:
+ name: example-provider
+ branch: commit-window-demo
+ path: apps/demo
+ commit:
+ window: "5s"
+ message:
+ requestTemplate: |-
+ {{.RequestMessage}}
+
+ {{range .Resources -}}
+ {{.Kind}}/{{.Name}}
+ {{end -}}
+ placement:
+ byType:
+ v1/configmaps: "configmap-{name}.yaml"
+ v1/serviceaccounts: "serviceaccount-{name}.yaml"
+---
+apiVersion: configbutler.ai/v1alpha3
+kind: WatchRule
+metadata:
+ name: window-demo
+ namespace: gitops-reverser-quickstart-demo
+spec:
+ gitTargetRef:
+ name: window-demo
+ rules:
+ - apiGroups: [""]
+ apiVersions: [v1]
+ resources: [configmaps, serviceaccounts]
+```
+
+Omitting `operations` selects `CREATE`, `UPDATE`, and `DELETE` by default.
+
+Two fields exist for the sake of a legible demo rather than because the window needs them:
+
+- **`placement.byType`** puts both files directly under `apps/demo` as `configmap-hello.yaml` and
+ `serviceaccount-hello.yaml`. Without it they land on the
+ [built-in canonical path](../configuration.md#declaring-a-layout-bytype--default),
+ `apps/demo/gitops-reverser-quickstart-demo/configmaps/hello.yaml`.
+- **`requestTemplate`** keeps the changed resources in the commit body underneath the requested
+ message. A request message otherwise **replaces** the generated message entirely, so the commit
+ would say why but no longer say what. See
+ [framing a save message](../commit-messages.md#framing-a-save-message).
+ `{{.Kind}}` is read off the object, and a `DELETE` event carries none, so a deletion in the window
+ renders as `/hello`. Use `{{.Resource}}` where a window can contain one; the built-in live template
+ does.
+
+Apply this setup and let the initial synchronization finish before going on:
+
+```bash
+kubectl apply -f watch.yaml
+kubectl get gittarget,watchrule -n gitops-reverser-quickstart-demo
+```
+
+The rule selects **two resource types in that namespace**, not two individual names. Other ConfigMaps and
+ServiceAccounts in that namespace are also eligible. Initial synchronization may capture existing
+resources such as the `kube-root-ca.crt` ConfigMap and the `default` ServiceAccount. Keep other
+writes to the target quiet while you run the example.
+
+## Seed the ConfigMap
+
+Create the ConfigMap on its own first, so the recorded batch below contains one `CREATE` and one
+`UPDATE` rather than two creates. Let its commit land before going on:
+
+```bash
+kubectl create configmap hello --from-literal=message="Hello" -n gitops-reverser-quickstart-demo
+```
+
+## Apply the resources
+
+Save as `hello.yaml`. These demonstrate resource capture without deploying a workload. The
+ServiceAccount is new; the ConfigMap changes the value seeded above.
+
+```yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: hello
+ namespace: gitops-reverser-quickstart-demo
+automountServiceAccountToken: false
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: hello
+ namespace: gitops-reverser-quickstart-demo
+data:
+ message: Hello from Reverse GitOps
+```
+
+## Close the window with a message
+
+Save as `save-now.yaml`. Use `kubectl create` rather than `kubectl apply`: the request has a
+`generateName` and no name, so each save is a fresh object and `apply` refuses it.
+
+```yaml
+apiVersion: configbutler.ai/v1alpha3
+kind: CommitRequest
+metadata:
+ generateName: save-hello-
+ namespace: gitops-reverser-quickstart-demo
+spec:
+ gitTargetRef:
+ name: window-demo
+ message: "feat(demo): save hello resources"
+ closeDelaySeconds: 2
+```
+
+Run these commands together, creating the request only after the apply succeeds:
+
+```bash
+kubectl apply -f hello.yaml && kubectl create -f save-now.yaml
+```
+
+The request must reach a matching open window. If you are stepping through this with pauses between
+commands, set the target's `commit.window` to `30s` first. `closeDelaySeconds: 2` gives watch events
+time to reach the worker; it does not extend the normal window or guarantee that every event has
+arrived. Change the ConfigMap's `message` value again on a repeat run, so there is something to
+commit.
+
+The default configured-author setup without request author capture works for this example. If actor
+attribution and request author capture are enabled, submit the resources and the request as the same
+Kubernetes user. See [CommitRequest](../configuration.md#commitrequest) for author matching and
+outcomes.
+
+## Check the result
+
+```bash
+kubectl get commitrequests -n gitops-reverser-quickstart-demo -o wide
+```
+
+Look for `Pushed=True` and a `status.sha`. `Pushed` is a wide column; `Ready` and the commit SHA
+show without `-o wide`. A terminal `Ready=True` on its own does not prove a commit was pushed: it is
+also the outcome when there was nothing to save or no matching open window.
+
+Applying several resources sends separate API writes. The window groups their captured changes into
+one commit:
+
+```text
+feat(demo): save hello resources
+
+ServiceAccount/hello
+ConfigMap/hello
+```
+
+The commit adds `apps/demo/serviceaccount-hello.yaml` and modifies `apps/demo/configmap-hello.yaml`.
+
+| Resource | Captured by this rule? |
+|---|---|
+| ServiceAccount, ConfigMap | Yes |
+| CommitRequest, GitTarget, WatchRule | No |
+| Services, Deployments, Pods, ReplicaSets | No |
+
+Without the request, the window closes on its own five seconds after the last change, and the
+built-in live template writes the message instead.
diff --git a/docs/github-setup-guide.md b/docs/github-setup-guide.md
index 867ad861e..fba693da4 100644
--- a/docs/github-setup-guide.md
+++ b/docs/github-setup-guide.md
@@ -7,15 +7,15 @@ This guide covers the simplest GitHub path for the chart's starter configuration
- a starter `GitTarget`
- a starter `WatchRule`
-The root [`README.md`](../README.md) already covers the full operator install. This guide starts
-after GitOps Reverser is running and kube-apiserver audit delivery is configured.
+The [quickstart](quickstart.md) covers the full operator install. This guide starts after
+GitOps Reverser is running. Audit delivery is optional and only needed for actor attribution.
## Assumptions
- GitOps Reverser is installed
-- the audit webhook is already wired into kube-apiserver
- you will use the chart `quickstart` path
-- the quickstart namespace is `default` unless you override it
+- this guide overrides `quickstart.namespace` to `default`; the chart default is
+ `gitops-reverser-quickstart-demo`
## Recommended path: SSH deploy key
@@ -112,7 +112,7 @@ kubectl -n default create secret generic git-creds \
A `bearerToken` key is also accepted for token auth without a username (handy for fine-grained PATs
and GitLab access tokens). Already have a Flux or Argo CD Git credentials Secret? It is accepted
-as-is — just make sure its key or token has **write** access, since GitOps Reverser pushes commits
+as-is. Make sure its key or token has **write** access, since GitOps Reverser pushes commits
(Flux/Argo only clone, so their credentials are often read-only). See
[`configuration.md`](configuration.md#reusing-a-flux-or-argo-cd-credentials-secret) for the full key
mapping.
diff --git a/docs/images/commit-window.excalidraw.svg b/docs/images/commit-window.excalidraw.svg
new file mode 100644
index 000000000..ae00a3995
--- /dev/null
+++ b/docs/images/commit-window.excalidraw.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 000000000..07384adea
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,164 @@
+# Quick start
+
+Point this at a disposable repository: the demo commits real cluster state, and the fastest way to
+throw it away afterwards is to delete the repo. It creates a starter `GitProvider`,
+`GitTarget`, and `WatchRule` in the `gitops-reverser-quickstart-demo` namespace, watching ConfigMaps
+there and writing them to
+`/live-cluster` on `main`. It runs in `configured-author` mode (no Redis) by default. The
+chart also renders the cluster-scoped `default` `ClusterProvider` the starter target resolves against.
+
+
+
+**Prerequisites:** a Kubernetes cluster with `kubectl`, Helm 3, and cert-manager for TLS.
+
+To write the objects yourself instead of letting the chart generate them, start from
+[`config/samples/`](../config/samples/): one manifest per object, consistent enough to apply
+together. [`configuration.md`](configuration.md) explains every field.
+
+## 1. Install cert-manager
+
+Skip this step if cert-manager is already installed and healthy.
+
+The controller mounts an admission certificate at startup, so cert-manager must be healthy *before*
+you install the chart. (`--set servers.admission.enabled=false` drops the dependency; the
+[chart README](../charts/gitops-reverser/README.md) covers bring-your-own certificates.)
+
+```bash
+kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
+kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s
+```
+
+## 2. Create a Git repo and a deploy key
+
+Create an (empty) repository the operator will write to, then generate a key. Add the public half as a
+**deploy key with write access**; the private half becomes the Secret in the next step:
+
+```bash
+ssh-keygen -t ed25519 -C "gitops-reverser@cluster" -f /tmp/gitops-reverser-key -N ""
+# Add /tmp/gitops-reverser-key.pub to your Git provider as a deploy key (write access)
+```
+
+## 3. Create the demo namespace and Git credentials
+
+Do this **before** installing. The starter `GitProvider` is only re-checked about every 5 minutes, so
+if the Secret is missing at install time your first commit can be minutes late; having it ready up
+front lets the starter resources go Ready on the first reconcile:
+
+Scan the host keys once, check them, then use that same file, so what you verified is what the
+Secret gets:
+
+```bash
+kubectl create namespace gitops-reverser-quickstart-demo \
+ --dry-run=client -o yaml | kubectl apply -f -
+
+ssh-keyscan github.com > /tmp/gitops-reverser-known_hosts 2>/dev/null
+ssh-keygen -lf /tmp/gitops-reverser-known_hosts
+
+kubectl create secret generic git-creds \
+ --namespace gitops-reverser-quickstart-demo \
+ --from-file=ssh-privatekey=/tmp/gitops-reverser-key \
+ --from-file=known_hosts=/tmp/gitops-reverser-known_hosts \
+ --dry-run=client -o yaml | kubectl apply -f -
+```
+
+SSH host-key verification fails closed, so `known_hosts` is required. `ssh-keyscan` pins whichever
+key answers on the network, so compare the fingerprints it printed against
+[GitHub's published list](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints)
+before you rely on the Secret. They are linked rather than copied in here because GitHub has rotated
+a host key before, and a fingerprint pasted into a guide goes stale silently. Existing Flux or Argo CD
+credentials Secrets are accepted as-is (they must have **write** access). See
+[`configuration.md`](configuration.md) for accepted Secret shapes and
+[`github-setup-guide.md`](github-setup-guide.md) for the full GitHub guide and HTTPS/PAT
+fallback.
+
+## 4. Install GitOps Reverser with the demo enabled
+
+Point the starter `GitProvider` at your repo and install:
+
+```bash
+helm install gitops-reverser \
+ oci://ghcr.io/configbutler/charts/gitops-reverser \
+ --namespace gitops-reverser \
+ --create-namespace \
+ --set quickstart.enabled=true \
+ --set-string quickstart.gitProvider.url=git@github.com:OWNER/REPO.git
+```
+
+Replace `OWNER/REPO` with your repository (angle brackets would be parsed by the shell).
+
+This install has these defaults:
+
+- **No Redis**, so warm-restart cursors and author capture stay inactive. Add one with
+ `--set queue.redis.addr=HOST:PORT` (plus `queue.redis.auth.existingSecret` if it needs auth).
+- **Cluster-wide read on every watchable type, including Secrets** (`rbac.watchTypes.mode=any`).
+ [`rbac.md`](rbac.md) explains how to narrow it.
+- **SOPS is on for the starter target**, so a `sops-age-key` Secret is generated in the demo namespace
+ with a backup reminder. Only matters once you mirror Secrets; back it up if you keep the demo.
+
+Wait for the controller. On a fresh install this waits on cert-manager issuing the certificate:
+
+```bash
+kubectl rollout status deployment/gitops-reverser -n gitops-reverser --timeout=300s
+```
+
+Then check the starter resources:
+
+```bash
+kubectl get gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo
+```
+
+The `GitProvider` and `WatchRule` should report `Ready=True`. The `GitTarget`'s aggregate `Ready`
+stays `Unknown` until its first source discovery, which is the expected state and not a fault, so
+the condition to check on it is `Validated`. It is not a printed column, so ask for it directly:
+
+```bash
+kubectl wait --for=condition=Validated gittarget/example-target \
+ -n gitops-reverser-quickstart-demo --timeout=60s
+```
+
+`condition met` means the configuration is accepted and the target is waiting for work. That is the
+point to go and create a ConfigMap.
+
+If it times out instead, the `Reason` column tells you which of the three states you are in:
+
+```bash
+kubectl get gittarget example-target -n gitops-reverser-quickstart-demo
+kubectl describe gittarget example-target -n gitops-reverser-quickstart-demo
+```
+
+A `Reason` that names a missing or unauthorized dependency (`ClusterProviderNotFound`,
+`NamespaceNotAuthorized`, `InvalidConfig`) is a misconfiguration to fix. One that names discovery or
+streams still settling is initialization, and resolves on its own. `describe` prints every condition
+with its message, which is where the specific cause is written.
+
+## 5. Test it
+
+```bash
+kubectl create configmap test-config --from-literal=key=value -n gitops-reverser-quickstart-demo
+```
+
+A new commit should land in your repository within seconds. If none appears:
+
+```bash
+kubectl logs -n gitops-reverser deploy/gitops-reverser
+kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo
+```
+
+Two `GitTarget` conditions stop the data plane and are worth recognizing: `ClusterProviderNotFound`
+(the `default` `ClusterProvider` is missing) and `NamespaceNotAuthorized` (its `accessFrom`
+selector does not cover the demo namespace).
+
+## Clean up
+
+To tear the demo down: `helm uninstall gitops-reverser -n gitops-reverser` and
+`kubectl delete namespace gitops-reverser-quickstart-demo`.
+
+> **Note:** the `default` `ClusterProvider` is cluster-scoped and chart-owned, so uninstalling takes
+> it with it, holding *every* other `GitTarget` in the cluster unready. Mind that if you run the demo
+> alongside a real deployment.
+
+## Want named users on your commits?
+
+Enable audit attribution to use the Kubernetes actor as the Git author while keeping the
+committer unchanged. The [attribution setup guide](attribution-setup-guide.md) covers receiver
+configuration, API server delivery, and verification. It requires control over kube-apiserver settings.
diff --git a/docs/style-guide.md b/docs/style-guide.md
index b7f0442a4..75c667ff3 100644
--- a/docs/style-guide.md
+++ b/docs/style-guide.md
@@ -204,6 +204,7 @@ README:
| Word | Count | Verdict |
|---|---|---|
+| `real` | 431 | keep only when something unreal is in play |
| `just` | 129 | almost always cut |
| `actually` | 113 | keep only when contrasting with a stated expectation |
| `genuinely` | 54 | almost always cut |
@@ -216,6 +217,12 @@ README:
because it is marking a contrast with the rendered output you might have expected. It does not
survive in "the operator actually retries", where it is filler.
+"Real" is the same test with a sharper question: what is the unreal one? It survives in "it can
+never clash with a real one", where the counterpart is a synthetic bucket name, and in "a real
+apiserver", where it is a fake. It does not survive in "the real configuration objects that drive
+gitops-reverser", which promises a distinction the page never draws -- there is no second, unreal
+set of objects, so the word only makes the reader look for one.
+
Also avoid: `it's worth noting`, `keep in mind`, `in summary`, `at the end of the day`,
`delve into`, `seamlessly`. The docs are mostly clean of these already. Keep them that way.
diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml
new file mode 100644
index 000000000..4117b970f
--- /dev/null
+++ b/hack/crdfields/fields.yaml
@@ -0,0 +1,192 @@
+# Prose for the settings index in docs/configuration.md. `task settings-index`
+# joins it with config/crd/bases and rewrites the generated block.
+#
+# This file owns the DESCRIPTION and the link to the section that explains a
+# field. It does not own whether a field is required or what it defaults to:
+# those come from the CRD schema, because a hand-written default is a claim that
+# can be wrong, and three of them were.
+#
+# `default:` here describes what omitting a field DOES when the schema declares
+# no default of its own ("anonymous access"). Setting it for a field the schema
+# already defaults is an error.
+#
+# A path listed here is a leaf: its children are not required. A top-level field
+# with no entry must have every child described, or the run fails naming it. That
+# is how `commit.window` stays in the table while the message templates do not.
+
+preamble: |
+ ## Field overview
+
+ Ready-made manifests for all six objects live in [`config/samples/`](../config/samples/). They
+ cross-reference the same `example-provider` / `example-target` names, so the set can be applied
+ together and edited down, rather than assembled from the fragments below.
+
+ Every field not marked **required** may be omitted. Sections linked from the tables explain the
+ fields that need more than a line.
+
+kinds:
+ - kind: GitProvider
+ title: "`GitProvider` (namespaced): where and how to push"
+ fields:
+ - path: url
+ doc: Repository URL, SSH or HTTPS
+ - path: allowedBranches
+ doc: Branches a `GitTarget` may write. A target naming any other is rejected
+ - path: secretRef
+ default: anonymous access
+ doc: >-
+ Secret holding the write credential. See
+ [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret)
+ - path: knownHostsRef
+ default: the Secret's own `known_hosts`
+ doc: >-
+ Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification
+ fails closed either way
+ - path: commit.committer
+ default: the operator's built-in identity
+ doc: >-
+ Name and email written as the commit committer. See
+ [committer identity](#committer-identity)
+ - path: commit.signing
+ default: "off"
+ doc: SSH commit signing. See [commit signing](#commit-signing)
+
+ - kind: ClusterProvider
+ title: "`ClusterProvider` (cluster-scoped): the source cluster"
+ fields:
+ - path: kubeConfig
+ default: the operator's own cluster
+ doc: Points at a remote source cluster instead of the one the operator runs in
+ - path: accessFrom
+ default: "**no namespace**"
+ doc: >-
+ Deny-by-default list of control-cluster namespaces that may reference this provider.
+ Omitted, none may; an empty `selector: {}` admits every namespace
+ - path: allowAnySourceNamespace
+ doc: Whether rules may watch namespaces other than their own, `*` included
+ - path: attribution.auditRoute
+ default: the provider's own name
+ doc: >-
+ Audit route this provider joins. See
+ [audit route](#audit-route-and-auditfactsreceived)
+ - path: qps
+ default: the operator-wide flag
+ doc: Client rate limit for this source cluster. Ignored when `kubeConfig` is omitted
+ - path: burst
+ default: the operator-wide flag
+ doc: Client burst for this source cluster. Ignored when `kubeConfig` is omitted
+
+ - kind: GitTarget
+ title: "`GitTarget` (namespaced): which branch and folder"
+ intro: "The destination fields are immutable: to move a target, delete it and create a new one."
+ fields:
+ - path: gitProviderRef
+ doc: The `GitProvider` backing this target, in the same namespace
+ - path: branch
+ doc: Branch to write. Must be in the provider's `allowedBranches`
+ - path: path
+ doc: Folder within the repository. `.` targets the root; empty is rejected
+ - path: clusterProviderRef
+ doc: "Source cluster to mirror from. The default names a `ClusterProvider` called `default`"
+ - path: commit.window
+ default: "`5s`"
+ doc: >-
+ How long to coalesce changes into one commit. `0s` commits per event. See
+ [the commit window](#the-commit-window-speccommitwindow)
+ - path: commit.message
+ default: the built-in templates
+ doc: How commits are phrased. See [commit messages](commit-messages.md)
+ - path: placement.byType
+ default: the canonical path
+ doc: >-
+ Per-type path templates for new documents. See
+ [where new resources are written](#where-new-resources-are-written-specplacement)
+ - path: placement.default
+ default: the canonical path
+ doc: Catch-all path template for types with no `byType` entry
+ - path: placement.useKustomize
+ default: "off"
+ doc: >-
+ Keep the folder a kustomize folder. See
+ [keeping the folder a kustomize folder](#keeping-the-folder-a-kustomize-folder-specplacementusekustomize)
+ - path: serializeNamespace
+ default: inferred per document
+ doc: >-
+ Whether a committed document carries its own `metadata.namespace`. An explicit `false`
+ admits exactly one source namespace. See
+ [whether documents carry their namespace](#whether-documents-carry-their-namespace-specserializenamespace)
+ - path: prune.mode
+ doc: >-
+ Which deletions reach Git. See
+ [deletion policy](#deletion-policy-specprunemode)
+ - path: onRefusal
+ doc: >-
+ What happens to an edit with no legal destination. See
+ [reverting a refused edit](#reverting-a-refused-edit-speconrefusal)
+ - path: encryption
+ default: "off"
+ doc: SOPS + age encryption for sensitive resources
+ - path: suspend
+ default: "`false`"
+ doc: >-
+ Stop writing without deleting the target. See
+ [stopping a target from writing](#stopping-a-target-from-writing-specsuspend)
+
+ - kind: WatchRule
+ title: "`WatchRule` (namespaced): what to capture"
+ fields:
+ - path: gitTargetRef
+ doc: The `GitTarget` these rules feed
+ - path: rules.resources
+ doc: Plural resource names to watch
+ - path: rules.apiGroups
+ default: resolved from the resource name
+ doc: >-
+ API groups to match. `[""]` is the core group. Omitted resolves the named resource across
+ the served surface, and selects nothing when more than one group serves that name
+ - path: rules.apiVersions
+ default: the preferred served version
+ doc: API versions to match. `["*"]` watches every served version
+ - path: rules.operations
+ default: "`CREATE`, `UPDATE`, `DELETE`"
+ doc: Which operations produce a write
+ - path: rules.sourceNamespace
+ default: the rule's own namespace
+ doc: >-
+ Namespace to watch in the source cluster, or `*`. See
+ [watching a different source namespace](#watching-a-different-source-namespace)
+
+ - kind: ClusterWatchRule
+ title: "`ClusterWatchRule` (cluster-scoped): what to capture, cluster-wide"
+ intro: >-
+ The same shape as a `WatchRule`, for cluster-scoped types. It selects no namespaces, so it has
+ no `sourceNamespace`.
+ fields:
+ - path: gitTargetRef
+ doc: The `GitTarget` these rules feed
+ - path: rules.resources
+ doc: Plural cluster-scoped resource names to watch
+ - path: rules.apiGroups
+ default: resolved from the resource name
+ doc: >-
+ API groups to match. Omitted resolves the named resource across the served surface, and
+ selects nothing when more than one group serves that name
+ - path: rules.apiVersions
+ default: the preferred served version
+ doc: API versions to match. `["*"]` watches every served version
+ - path: rules.operations
+ default: "`CREATE`, `UPDATE`, `DELETE`"
+ doc: Which operations produce a write
+
+ - kind: CommitRequest
+ title: "`CommitRequest` (namespaced): save now"
+ fields:
+ - path: gitTargetRef
+ doc: The target whose open window to close
+ - path: message
+ default: the target's templates
+ doc: Commit message, committed verbatim unless `requestTemplate` frames it
+ - path: closeDelaySeconds
+ doc: >-
+ How long to wait for pending events. See
+ [sizing `closeDelaySeconds`](#sizing-closedelayseconds)
diff --git a/hack/crdfields/main.go b/hack/crdfields/main.go
new file mode 100644
index 000000000..79ceda973
--- /dev/null
+++ b/hack/crdfields/main.go
@@ -0,0 +1,496 @@
+// SPDX-License-Identifier: Apache-2.0
+
+// Command crdfields renders the settings index in docs/configuration.md from the
+// CRD schemas in config/crd/bases, so the table cannot drift from the API.
+//
+// The index is the one place a reader looks to answer "what can I set, and what
+// happens if I don't". Hand-maintaining it went wrong three times in the commit
+// that introduced it: spec.accessFrom omitted DENIES every namespace rather than
+// admitting them, an omitted spec.serializeNamespace INFERS per document rather
+// than meaning false, and qps/burst fall back to operator-wide flags and are
+// ignored without a kubeConfig. Each was written from memory of the prose and
+// each was wrong in the direction that costs a reader an outage.
+//
+// So the two halves are split by who can be trusted with them:
+//
+// - The SCHEMA owns whether a field is required and what it defaults to. Those
+// come from config/crd/bases, which controller-gen regenerates from the Go
+// types, so they cannot be stale without `task manifests` being stale too.
+//
+// - fields.yaml owns the one-line description and the link to the section that
+// explains the field. Prose is not derivable: a CRD description is written for
+// `kubectl explain` and runs to paragraphs, which is the wrong shape for a
+// table and the wrong voice for a guide.
+//
+// What makes it a gate rather than a convenience is the coverage check. Adding a
+// field to the API and not describing it fails the build, and so does describing
+// one that no longer exists, and so does a whole CRD kind that fields.yaml never
+// mentions. The tool cannot write the sentence for you; it can refuse to let the
+// table pretend the field is not there.
+//
+// Depth is decided by fields.yaml, not by a fixed limit. A path listed there is a
+// leaf: the walk stops and its children are not required. A top-level property
+// with no entry of its own must have every one of its children described, or the
+// run fails naming it. That is what keeps `spec.commit.window` in the table while
+// `spec.commit.message.liveTemplate` stays in its own document.
+//
+// Usage:
+//
+// go run ./hack/crdfields # rewrite the generated block
+// go run ./hack/crdfields -check # fail if the block is out of date
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "sigs.k8s.io/yaml"
+)
+
+const (
+ crdDir = "config/crd/bases"
+ fieldsFile = "hack/crdfields/fields.yaml"
+ targetFile = "docs/configuration.md"
+
+ beginMarker = ""
+ endMarker = ""
+)
+
+// fieldDoc is one row of the index: the prose half, which no schema can supply.
+type fieldDoc struct {
+ // Path is dotted, relative to spec: "commit.window". For an array field it
+ // names the item's property: "rules.resources" renders as `rules[].resources`.
+ Path string `json:"path"`
+ // Doc is the description cell, rendered as markdown so it can carry links.
+ Doc string `json:"doc"`
+ // Default describes the behaviour of omitting a field the SCHEMA gives no
+ // default for ("anonymous access"). Setting it for a field that has a schema
+ // default is an error: the schema would be the better answer and the two
+ // could disagree.
+ Default string `json:"default,omitempty"`
+ // Skip keeps a field out of the table. The reason is required and is there
+ // for the next reader, who will want to know why the field is missing.
+ Skip string `json:"skip,omitempty"`
+}
+
+type kindDoc struct {
+ Kind string `json:"kind"`
+ Title string `json:"title"`
+ Intro string `json:"intro,omitempty"`
+ Outro string `json:"outro,omitempty"`
+ Fields []fieldDoc `json:"fields"`
+}
+
+type indexDoc struct {
+ Preamble string `json:"preamble"`
+ Kinds []kindDoc `json:"kinds"`
+}
+
+// schema is the slice of an OpenAPI v3 schema this tool reads.
+type schema struct {
+ Type string `json:"type"`
+ Default *json.RawMessage `json:"default"`
+ Required []string `json:"required"`
+ Properties map[string]*schema `json:"properties"`
+ Items *schema `json:"items"`
+}
+
+// children returns the properties a path may descend into, treating an array as
+// its item type so that rules[].resources is reachable from rules.
+func (s *schema) children() map[string]*schema {
+ if s == nil {
+ return nil
+ }
+ if s.Type == "array" && s.Items != nil {
+ return s.Items.Properties
+ }
+ return s.Properties
+}
+
+func (s *schema) requires(name string) bool {
+ target := s
+ if s.Type == "array" && s.Items != nil {
+ target = s.Items
+ }
+ for _, r := range target.Required {
+ if r == name {
+ return true
+ }
+ }
+ return false
+}
+
+func main() {
+ check := flag.Bool("check", false, "fail if the generated block is out of date instead of rewriting it")
+ flag.Parse()
+
+ if err := run(*check); err != nil {
+ fmt.Fprintf(os.Stderr, "crdfields: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(check bool) error {
+ index, err := loadIndexDoc(fieldsFile)
+ if err != nil {
+ return err
+ }
+ schemas, err := loadSchemas(crdDir)
+ if err != nil {
+ return err
+ }
+ rendered, err := render(index, schemas)
+ if err != nil {
+ return err
+ }
+ return writeBlock(targetFile, rendered, check)
+}
+
+func loadIndexDoc(path string) (*indexDoc, error) {
+ raw, err := os.ReadFile(path) // #nosec G304 -- a fixed path in this repository
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", path, err)
+ }
+ var doc indexDoc
+ if err := yaml.Unmarshal(raw, &doc); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ if len(doc.Kinds) == 0 {
+ return nil, fmt.Errorf("%s describes no kinds", path)
+ }
+ if err := validateIndexDoc(&doc); err != nil {
+ return nil, fmt.Errorf("%s: %w", path, err)
+ }
+ return &doc, nil
+}
+
+// validateIndexDoc catches the two ways an entry goes wrong before the schema is
+// ever consulted, so the error names the cause rather than a missing table cell.
+func validateIndexDoc(doc *indexDoc) error {
+ var problems []error
+ for _, kd := range doc.Kinds {
+ for _, f := range kd.Fields {
+ switch {
+ case f.Path == "":
+ problems = append(problems, fmt.Errorf("%s: an entry has no `path:`", kd.Kind))
+ case f.Skip == "" && f.Doc == "":
+ problems = append(problems, fmt.Errorf(
+ "%s.%s: needs a `doc:`, or a `skip:` saying why it is left out", kd.Kind, f.Path))
+ case isBareYAMLBool(f.Default):
+ // YAML reads a bare off/on/yes/no as a boolean, so `default: off`
+ // reaches this table as "false". It is silent, and it produced a
+ // wrong cell the first time this file was written.
+ problems = append(problems, fmt.Errorf(
+ "%s.%s: `default: %s` was read as a YAML boolean; quote it",
+ kd.Kind, f.Path, f.Default))
+ }
+ }
+ }
+ return errors.Join(problems...)
+}
+
+// isBareYAMLBool reports a default that YAML coerced out of off/on/yes/no.
+// A genuine boolean default belongs in the schema, so it never needs spelling here.
+func isBareYAMLBool(value string) bool {
+ return value == "true" || value == "false"
+}
+
+// loadSchemas reads every CRD in dir and returns each kind's stored-version spec schema.
+func loadSchemas(dir string) (map[string]*schema, error) {
+ entries, err := filepath.Glob(filepath.Join(dir, "*.yaml"))
+ if err != nil {
+ return nil, fmt.Errorf("glob %s: %w", dir, err)
+ }
+ if len(entries) == 0 {
+ return nil, fmt.Errorf("no CRDs found in %s", dir)
+ }
+
+ out := make(map[string]*schema, len(entries))
+ for _, path := range entries {
+ kind, spec, err := loadCRD(path)
+ if err != nil {
+ return nil, err
+ }
+ out[kind] = spec
+ }
+ return out, nil
+}
+
+func loadCRD(path string) (string, *schema, error) {
+ raw, err := os.ReadFile(path) // #nosec G304 -- paths come from a glob of a fixed directory
+ if err != nil {
+ return "", nil, fmt.Errorf("read %s: %w", path, err)
+ }
+
+ var crd struct {
+ Spec struct {
+ Names struct {
+ Kind string `json:"kind"`
+ } `json:"names"`
+ Versions []struct {
+ Storage bool `json:"storage"`
+ Schema struct {
+ OpenAPIV3Schema struct {
+ Properties struct {
+ Spec *schema `json:"spec"`
+ } `json:"properties"`
+ } `json:"openAPIV3Schema"`
+ } `json:"schema"`
+ } `json:"versions"`
+ } `json:"spec"`
+ }
+ if err := yaml.Unmarshal(raw, &crd); err != nil {
+ return "", nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+
+ for _, v := range crd.Spec.Versions {
+ if v.Storage {
+ if v.Schema.OpenAPIV3Schema.Properties.Spec == nil {
+ return "", nil, fmt.Errorf("%s: stored version has no spec schema", path)
+ }
+ return crd.Spec.Names.Kind, v.Schema.OpenAPIV3Schema.Properties.Spec, nil
+ }
+ }
+ return "", nil, fmt.Errorf("%s: no stored version", path)
+}
+
+func render(index *indexDoc, schemas map[string]*schema) (string, error) {
+ var buf bytes.Buffer
+ buf.WriteString(strings.TrimRight(index.Preamble, "\n") + "\n\n")
+
+ var problems []error
+
+ // The loop below catches a described kind whose CRD is gone. This is the other
+ // direction: a CRD that fields.yaml never mentions. Without it a new kind ships
+ // with no index entry at all and -check still passes, because a walk driven by
+ // index.Kinds never reaches a kind nobody listed.
+ described := make(map[string]bool, len(index.Kinds))
+ for _, kd := range index.Kinds {
+ described[kd.Kind] = true
+ }
+ var undescribed []string
+ for kind := range schemas {
+ if !described[kind] {
+ undescribed = append(undescribed, kind)
+ }
+ }
+ sort.Strings(undescribed)
+ for _, kind := range undescribed {
+ problems = append(problems, fmt.Errorf(
+ "%s: a CRD defines this kind and %s does not describe it", kind, fieldsFile))
+ }
+
+ for _, kd := range index.Kinds {
+ spec, ok := schemas[kd.Kind]
+ if !ok {
+ problems = append(problems, fmt.Errorf("%s: no CRD defines this kind", kd.Kind))
+ continue
+ }
+ if err := checkCoverage(kd, spec); err != nil {
+ problems = append(problems, err)
+ }
+ section, err := renderKind(kd, spec)
+ if err != nil {
+ problems = append(problems, err)
+ continue
+ }
+ buf.WriteString(section)
+ }
+ if len(problems) > 0 {
+ return "", fmt.Errorf("%s is out of step with the CRDs:\n %w",
+ fieldsFile, errors.Join(problems...))
+ }
+ return buf.String(), nil
+}
+
+// checkCoverage is the half that makes this a gate. A top-level property is
+// covered when it is described itself, skipped, or has every one of its children
+// described or skipped. Anything else is a field the guide does not mention.
+func checkCoverage(kd kindDoc, spec *schema) error {
+ described := make(map[string]bool, len(kd.Fields))
+ for _, f := range kd.Fields {
+ described[f.Path] = true
+ }
+
+ var missing []string
+ for name, child := range spec.Properties {
+ if described[name] {
+ continue
+ }
+ kids := child.children()
+ if len(kids) == 0 {
+ missing = append(missing, name)
+ continue
+ }
+ anyDescribed := false
+ var undescribed []string
+ for kid := range kids {
+ if described[name+"."+kid] {
+ anyDescribed = true
+ } else {
+ undescribed = append(undescribed, name+"."+kid)
+ }
+ }
+ if !anyDescribed {
+ missing = append(missing, name)
+ continue
+ }
+ missing = append(missing, undescribed...)
+ }
+ if len(missing) == 0 {
+ return nil
+ }
+ sort.Strings(missing)
+ return fmt.Errorf(
+ "%s: %d field(s) in the CRD that %s does not describe: %s\n"+
+ " add an entry with a `doc:`, or a `skip:` saying why it is left out",
+ kd.Kind, len(missing), fieldsFile, strings.Join(missing, ", "))
+}
+
+func renderKind(kd kindDoc, spec *schema) (string, error) {
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "### %s\n\n", kd.Title)
+ if kd.Intro != "" {
+ fmt.Fprintf(&buf, "%s\n\n", kd.Intro)
+ }
+ buf.WriteString("| Field | Default | What it does |\n|---|---|---|\n")
+
+ var problems []error
+ for _, f := range kd.Fields {
+ if f.Skip != "" {
+ continue
+ }
+ row, err := renderRow(kd.Kind, f, spec)
+ if err != nil {
+ problems = append(problems, err)
+ continue
+ }
+ buf.WriteString(row)
+ }
+ if len(problems) > 0 {
+ return "", errors.Join(problems...)
+ }
+ if kd.Outro != "" {
+ fmt.Fprintf(&buf, "\n%s\n", kd.Outro)
+ }
+ buf.WriteString("\n")
+ return buf.String(), nil
+}
+
+func renderRow(kind string, f fieldDoc, spec *schema) (string, error) {
+ node, required, err := resolve(spec, f.Path)
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: %w", kind, f.Path, err)
+ }
+
+ schemaDefault := ""
+ if node.Default != nil {
+ schemaDefault = formatDefault(*node.Default)
+ }
+ if schemaDefault != "" && f.Default != "" {
+ return "", fmt.Errorf(
+ "%s.%s: the schema already defaults this to %s, so remove the `default:` from %s",
+ kind, f.Path, schemaDefault, fieldsFile)
+ }
+
+ cell := "none"
+ switch {
+ case required:
+ cell = "**required**"
+ case schemaDefault != "":
+ cell = schemaDefault
+ case f.Default != "":
+ cell = f.Default
+ }
+ return fmt.Sprintf("| `%s` | %s | %s |\n", displayPath(f.Path, spec), cell, f.Doc), nil
+}
+
+// resolve walks a dotted path and reports whether the last segment is required
+// by its immediate parent.
+func resolve(spec *schema, path string) (*schema, bool, error) {
+ node, required := spec, false
+ for _, seg := range strings.Split(path, ".") {
+ kids := node.children()
+ next, ok := kids[seg]
+ if !ok {
+ return nil, false, errors.New("no such field in the CRD schema")
+ }
+ required = node.requires(seg)
+ node = next
+ }
+ return node, required, nil
+}
+
+// displayPath spells an array field the way a manifest does, so a reader can
+// match the row to the YAML they are writing: rules[].resources, not rules.resources.
+func displayPath(path string, spec *schema) string {
+ segs := strings.Split(path, ".")
+ node := spec
+ out := make([]string, 0, len(segs))
+ for i, seg := range segs {
+ next := node.children()[seg]
+ name := seg
+ if next != nil && next.Type == "array" && i < len(segs)-1 {
+ name += "[]"
+ }
+ out = append(out, name)
+ node = next
+ }
+ return strings.Join(out, ".")
+}
+
+func formatDefault(raw json.RawMessage) string {
+ trimmed := strings.TrimSpace(string(raw))
+ if trimmed == "" || trimmed == "null" {
+ return ""
+ }
+ var asString string
+ if err := json.Unmarshal(raw, &asString); err == nil {
+ if asString == "" {
+ return ""
+ }
+ return "`" + asString + "`"
+ }
+ var compact bytes.Buffer
+ if err := json.Compact(&compact, raw); err != nil {
+ return "`" + trimmed + "`"
+ }
+ return "`" + compact.String() + "`"
+}
+
+// writeBlock replaces the marked region of path, or reports what a run would change.
+func writeBlock(path, body string, check bool) error {
+ raw, err := os.ReadFile(path) // #nosec G304 -- a fixed path in this repository
+ if err != nil {
+ return fmt.Errorf("read %s: %w", path, err)
+ }
+ content := string(raw)
+
+ start := strings.Index(content, beginMarker)
+ end := strings.Index(content, endMarker)
+ if start < 0 || end < 0 || end < start {
+ return fmt.Errorf("%s: cannot find the generated block; it must contain\n %s\n %s",
+ path, beginMarker, endMarker)
+ }
+
+ updated := content[:start] + beginMarker + "\n\n" + body + content[end:]
+ if updated == content {
+ return nil
+ }
+ if check {
+ return fmt.Errorf(
+ "%s is out of date with the CRDs. Run `task settings-index` and commit the result", path)
+ }
+ if err := os.WriteFile(path, []byte(updated), 0o600); err != nil {
+ return fmt.Errorf("write %s: %w", path, err)
+ }
+ fmt.Printf("crdfields: updated the settings index in %s\n", path)
+ return nil
+}
diff --git a/hack/crdfields/main_test.go b/hack/crdfields/main_test.go
new file mode 100644
index 000000000..47a416e01
--- /dev/null
+++ b/hack/crdfields/main_test.go
@@ -0,0 +1,288 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "sigs.k8s.io/yaml"
+)
+
+// specSchema builds the slice of a CRD this tool reads, from the YAML a CRD
+// actually carries, so the tests exercise the same parse the real files take.
+func specSchema(t *testing.T) *schema {
+ t.Helper()
+ var s schema
+ require.NoError(t, yaml.Unmarshal([]byte(targetLike), &s))
+ return &s
+}
+
+const targetLike = `
+type: object
+required: [branch]
+properties:
+ branch:
+ type: string
+ suspend:
+ type: boolean
+ default: false
+ commit:
+ type: object
+ properties:
+ window:
+ type: string
+ message:
+ type: object
+ properties:
+ liveTemplate: {type: string}
+ rules:
+ type: array
+ items:
+ type: object
+ required: [resources]
+ properties:
+ resources: {type: array}
+ operations: {type: array}
+`
+
+// The gate: a field nobody described must fail the run, because the failure mode
+// it replaces is a table that quietly stops listing everything you can set.
+func TestCheckCoverage_FailsOnAFieldNobodyDescribed(t *testing.T) {
+ spec := specSchema(t)
+ kd := kindDoc{Kind: "GitTarget", Fields: []fieldDoc{
+ {Path: "branch", Doc: "Branch to write"},
+ {Path: "commit.window", Doc: "Coalescing window"},
+ {Path: "commit.message", Doc: "How commits are phrased"},
+ {Path: "rules.resources", Doc: "What to watch"},
+ {Path: "rules.operations", Doc: "Which operations"},
+ }}
+
+ err := checkCoverage(kd, spec)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "suspend", "the undescribed field must be named")
+}
+
+// A described parent is a leaf: its children are the next document's business.
+// Without this, describing spec.commit.message would drag every template field in.
+func TestCheckCoverage_ADescribedParentStopsTheWalk(t *testing.T) {
+ spec := specSchema(t)
+ kd := kindDoc{Kind: "GitTarget", Fields: []fieldDoc{
+ {Path: "branch", Doc: "Branch to write"},
+ {Path: "suspend", Doc: "Stop writing"},
+ {Path: "commit", Doc: "How writes become commits"},
+ {Path: "rules", Doc: "What to watch"},
+ }}
+
+ assert.NoError(t, checkCoverage(kd, spec))
+}
+
+// The half-described parent is the real drift: somebody added a sibling to a
+// group the table already expands, and listing four of five silently loses one.
+func TestCheckCoverage_FailsOnASiblingAddedToAnExpandedParent(t *testing.T) {
+ spec := specSchema(t)
+ kd := kindDoc{Kind: "GitTarget", Fields: []fieldDoc{
+ {Path: "branch", Doc: "Branch to write"},
+ {Path: "suspend", Doc: "Stop writing"},
+ {Path: "commit.window", Doc: "Coalescing window"},
+ {Path: "rules.resources", Doc: "What to watch"},
+ {Path: "rules.operations", Doc: "Which operations"},
+ }}
+
+ err := checkCoverage(kd, spec)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "commit.message")
+}
+
+func TestCheckCoverage_SkipCoversAFieldDeliberatelyLeftOut(t *testing.T) {
+ spec := specSchema(t)
+ kd := kindDoc{Kind: "GitTarget", Fields: []fieldDoc{
+ {Path: "branch", Doc: "Branch to write"},
+ {Path: "suspend", Skip: "internal"},
+ {Path: "commit", Doc: "How writes become commits"},
+ {Path: "rules", Doc: "What to watch"},
+ }}
+
+ assert.NoError(t, checkCoverage(kd, spec))
+}
+
+// Required and default are read off the schema, never off the prose, which is
+// the whole reason this tool exists.
+func TestRenderRow_TakesRequiredAndDefaultFromTheSchema(t *testing.T) {
+ spec := specSchema(t)
+
+ required, err := renderRow("GitTarget", fieldDoc{Path: "branch", Doc: "Branch to write"}, spec)
+ require.NoError(t, err)
+ assert.Contains(t, required, "| **required** |")
+
+ defaulted, err := renderRow("GitTarget", fieldDoc{Path: "suspend", Doc: "Stop writing"}, spec)
+ require.NoError(t, err)
+ assert.Contains(t, defaulted, "| `false` |")
+}
+
+// A prose default is for the fields the schema leaves open, and only those. If
+// the schema also has one the two can disagree, and the prose is the half that
+// nothing checks.
+func TestRenderRow_RejectsAProseDefaultTheSchemaAlreadyGives(t *testing.T) {
+ spec := specSchema(t)
+
+ _, err := renderRow("GitTarget", fieldDoc{Path: "suspend", Default: "off", Doc: "Stop"}, spec)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "remove the `default:`")
+}
+
+func TestRenderRow_ReportsAFieldThatNoLongerExists(t *testing.T) {
+ spec := specSchema(t)
+
+ _, err := renderRow("GitTarget", fieldDoc{Path: "commit.retired", Doc: "Gone"}, spec)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "GitTarget.commit.retired")
+}
+
+// An array field is spelled the way the manifest spells it, so a reader can match
+// the row to the YAML in front of them.
+func TestDisplayPath_SpellsAnArrayItemWithBrackets(t *testing.T) {
+ spec := specSchema(t)
+
+ assert.Equal(t, "rules[].resources", displayPath("rules.resources", spec))
+ assert.Equal(t, "commit.window", displayPath("commit.window", spec))
+}
+
+func TestFormatDefault(t *testing.T) {
+ for name, tc := range map[string]struct{ in, want string }{
+ "string": {`"5s"`, "`5s`"},
+ "bool": {`false`, "`false`"},
+ "number": {`2`, "`2`"},
+ "object": {`{"name": "default"}`, "`{\"name\":\"default\"}`"},
+ "null": {`null`, ""},
+ "empty": {`""`, ""},
+ } {
+ t.Run(name, func(t *testing.T) {
+ assert.Equal(t, tc.want, formatDefault(json.RawMessage(tc.in)))
+ })
+ }
+}
+
+// `default: off` is read by YAML as a boolean and reaches the table as "false".
+// It is silent, and it wrote a wrong cell the first time fields.yaml was filled in.
+func TestValidateIndexDoc_CatchesABareYAMLBoolean(t *testing.T) {
+ var doc indexDoc
+ require.NoError(t, yaml.Unmarshal([]byte(`
+kinds:
+ - kind: GitProvider
+ fields:
+ - path: commit.signing
+ default: off
+ doc: SSH commit signing
+`), &doc))
+
+ err := validateIndexDoc(&doc)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "quote it")
+}
+
+func TestValidateIndexDoc_RequiresADocOrASkip(t *testing.T) {
+ doc := indexDoc{Kinds: []kindDoc{{Kind: "GitTarget", Fields: []fieldDoc{{Path: "branch"}}}}}
+
+ err := validateIndexDoc(&doc)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "needs a `doc:`")
+}
+
+// The committed fields.yaml and the committed CRDs must agree, which is what the
+// lint task asserts. Running it here too means a `task manifests` that adds a
+// field fails the unit suite, not only the docs gate.
+func TestCommittedFieldsFileCoversTheCommittedCRDs(t *testing.T) {
+ index, err := loadIndexDoc("fields.yaml")
+ require.NoError(t, err)
+ schemas, err := loadSchemas("../../" + crdDir)
+ require.NoError(t, err)
+
+ out, err := render(index, schemas)
+
+ require.NoError(t, err)
+ for _, kd := range index.Kinds {
+ assert.Contains(t, out, "### "+kd.Title)
+ }
+ assert.NotContains(t, out, "| none |",
+ "a field with no default and no prose for it reads as though omitting it does nothing")
+}
+
+func TestWriteBlock_ReportsAMissingMarker(t *testing.T) {
+ path := t.TempDir() + "/doc.md"
+ require.NoError(t, writeFile(path, "# Doc\n\nno markers here\n"))
+
+ err := writeBlock(path, "body", false)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "cannot find the generated block")
+}
+
+func TestWriteBlock_CheckFailsWhenTheBlockIsStale(t *testing.T) {
+ path := t.TempDir() + "/doc.md"
+ require.NoError(t, writeFile(path,
+ "# Doc\n\n"+beginMarker+"\n\nold body\n"+endMarker+"\n"))
+
+ err := writeBlock(path, "new body\n", true)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "task settings-index")
+}
+
+func TestWriteBlock_RewritesOnlyTheMarkedRegion(t *testing.T) {
+ path := t.TempDir() + "/doc.md"
+ require.NoError(t, writeFile(path,
+ "# Doc\n\nbefore\n\n"+beginMarker+"\n\nold body\n"+endMarker+"\n\nafter\n"))
+
+ require.NoError(t, writeBlock(path, "new body\n", false))
+
+ got := readFile(t, path)
+ assert.Contains(t, got, "before\n")
+ assert.Contains(t, got, "after\n")
+ assert.Contains(t, got, "new body")
+ assert.NotContains(t, got, "old body")
+ assert.NoError(t, writeBlock(path, "new body\n", true), "a fresh block must satisfy -check")
+}
+
+func TestLoadSchemas_ReportsAnEmptyDirectory(t *testing.T) {
+ _, err := loadSchemas(t.TempDir())
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no CRDs found")
+}
+
+func TestLoadCRD_ReportsAnObjectWithNoStoredVersion(t *testing.T) {
+ path := t.TempDir() + "/crd.yaml"
+ require.NoError(t, writeFile(path, strings.TrimSpace(`
+spec:
+ names: {kind: Ghost}
+ versions:
+ - storage: false
+`)+"\n"))
+
+ _, _, err := loadCRD(path)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no stored version")
+}
+
+func writeFile(path, content string) error {
+ return os.WriteFile(path, []byte(content), 0o600)
+}
+
+func readFile(t *testing.T, path string) string {
+ t.Helper()
+ raw, err := os.ReadFile(path) // #nosec G304 -- a path this test just created
+ require.NoError(t, err)
+ return string(raw)
+}