From 6da7a14b47bbf4a0c35e876ac470fe8b37141804 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 09:36:42 +0000 Subject: [PATCH 01/16] docs: split the quickstart out of the README and add a commit-window example The README opened with cert-manager commands and buried what the product is. Installation now lives in docs/quickstart.md, and the README covers the shape of the thing: what it writes, how changes become commits, and what to know before adopting it. - Restore the overview diagram, which no document referenced any more, under a "How it works" section, and add a feature list (signing, message templates, SOPS, metrics, API-first publication, bi-directional support). - Add docs/demo/commit-window.md and its diagram. The example declares the placement that produces the filenames the diagram shows, and the requestTemplate that produces its commit body: without one, a CommitRequest message replaces the generated message and the body would be empty. Its {{.Kind}} caveat for DELETE events is stated, since a delete carries no object. - Restore the Kubernetes test matrix and the Go module floor, which had fallen out of every tracked document. - Add the two new documents to .docs-lint-scope, so moving prose out of the README does not shrink the prose gate. Vale passes on both. - Drop the hardcoded Go version badge: it read 1.26 while go.mod reads 1.27.1. Co-Authored-By: Claude Opus 5 --- .docs-lint-scope | 2 + README.md | 433 ++++++++--------------- charts/gitops-reverser/README.md | 20 +- docs/README.md | 4 +- docs/configuration.md | 4 +- docs/demo/commit-window.md | 177 +++++++++ docs/github-setup-guide.md | 10 +- docs/images/commit-window.excalidraw.svg | 3 + docs/quickstart.md | 128 +++++++ 9 files changed, 483 insertions(+), 298 deletions(-) create mode 100644 docs/demo/commit-window.md create mode 100644 docs/images/commit-window.excalidraw.svg create mode 100644 docs/quickstart.md 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/README.md b/README.md index 6e1736f4c..59a9dcac8 100644 --- a/README.md +++ b/README.md @@ -4,327 +4,202 @@ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ConfigButler/gitops-reverser/badge)](https://scorecard.dev/viewer/?uri=github.com/ConfigButler/gitops-reverser) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13468/badge)](https://www.bestpractices.dev/projects/13468) [![Release](https://img.shields.io/github/v/release/ConfigButler/gitops-reverser?sort=semver)](https://github.com/ConfigButler/gitops-reverser/releases) +[![codecov](https://codecov.io/gh/ConfigButler/gitops-reverser/graph/badge.svg)](https://codecov.io/gh/ConfigButler/gitops-reverser) [![License](https://img.shields.io/github/license/ConfigButler/gitops-reverser)](https://www.apache.org/licenses/LICENSE-2.0) -[![Go](https://img.shields.io/badge/go-1.26-blue?logo=go)](go.mod) ![Platforms](https://img.shields.io/badge/platforms-amd64%20%7C%20arm64-2ea44f?logo=docker) -[![codecov](https://codecov.io/gh/ConfigButler/gitops-reverser/graph/badge.svg)](https://codecov.io/gh/ConfigButler/gitops-reverser) [![Container](https://img.shields.io/badge/container-ghcr.io%2Fconfigbutler%2Fgitops--reverser-2ea44f?logo=docker)](https://github.com/ConfigButler/gitops-reverser/pkgs/container/gitops-reverser) [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/gitops-reverser)](https://artifacthub.io/packages/search?repo=gitops-reverser) [![Open Issues](https://img.shields.io/github/issues/ConfigButler/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. - -The broader pattern is described at [reversegitops.dev](https://reversegitops.dev). +Use it to capture live changes, bring an existing cluster into Git, or experiment with API-first +workflows alongside Flux or Argo CD.
Demo: kubectl apply triggers a sanitized Git commit within seconds
-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). - -![Overview diagram showing how API events flow through the operator into Git](docs/images/overview.excalidraw.svg) - -## 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. - -| Good fit | Poor fit | -|---|---| -| 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 | +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. ## How it works -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/). - -## 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. - -![Config basics diagram showing the relationship between GitProvider, GitTarget, and WatchRule](docs/images/config-basics.excalidraw.svg) - -**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: - -```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: - -```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 +Changes reach the Kubernetes API however your users make them: `kubectl`, a GUI, a CI job, or an +agent over MCP. The operator watches the resources you claim, sanitizes each change, and writes it +to the branch and folder you configured. + +![Overview diagram: humans, kubectl, and MCP clients change resources through the Kubernetes API, which GitOps Reverser watches and commits to Git](docs/images/overview.excalidraw.svg) + +1. **Watch** the Kubernetes API for the types each `GitTarget` claims. Watch is the single source of + object state. +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 all of it: a `GitProvider` holds the repository and credentials, a +`GitTarget` the branch and folder, a `WatchRule` or `ClusterWatchRule` the resources to claim, and a +`ClusterProvider` the source cluster to mirror from (the chart renders a `default` one). +[Configuration](docs/configuration.md) covers each of them. + +## Features + +- **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, validated at admission rather than at commit time. See + [message templates](docs/configuration.md#commit-message-templates). +- **Encrypted Secrets.** SOPS + age encryption before commit, which Secret-shaped custom resources + can opt into. See [SOPS and age](docs/sops-age-guide.md). +- **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). +- **Built for API-first clusters.** Publication is driven by API writes, not by polling, and a + foreign push to the branch is an expected case the worker replays onto. See + [API-first publication](docs/api-first-publication.md). +- **Bi-directional use, proven by tests.** A dedicated e2e corner runs Flux and Argo CD against the + operator in CI and asserts the round trip settles without a commit loop. Shared-path concurrency + is still experimental. See [bi-directional usage](docs/bi-directional.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 | +|---|---| +| 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 | + +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). + +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). + +## Batch changes into a commit + +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 itself. + +![Kubernetes resource changes flow through GitOps Reverser's commit window into Git](docs/images/commit-window.excalidraw.svg) + +This configuration selects ConfigMaps and ServiceAccounts in `demo`. The referenced `GitProvider` +holds the repository URL and credentials. + +```yaml +apiVersion: configbutler.ai/v1alpha3 +kind: GitTarget +metadata: + name: window-demo + namespace: demo +spec: + gitProviderRef: + name: example-provider + branch: commit-window-demo + path: apps/demo + commit: + window: "5s" +--- +apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: + name: window-demo + namespace: demo +spec: + gitTargetRef: + name: window-demo + rules: + - apiGroups: [""] + apiVersions: [v1] + resources: [configmaps, serviceaccounts] ``` -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 -``` +All three operations (`CREATE`, `UPDATE`, `DELETE`) are selected by default. The `CommitRequest` +itself is outside the selected types, so saving never commits the save button. -Then check the starter resources: +The picture shows that example running. The [full example](docs/demo/commit-window.md) carries the +rest of it: the resources to apply, the save request (created with `kubectl create`, because it uses +`generateName`), a `placement` rule for the flat filenames, and the `requestTemplate` that puts the +changed resources under the requested message. -```bash -kubectl get gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo -``` +## Quick start -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. +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. -### 5. Test it +The demo captures a live change: ```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: +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 logs -n gitops-reverser deploy/gitops-reverser -kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo -``` +## Try it with your existing repo -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). +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. -To tear the demo down: `helm uninstall gitops-reverser -n gitops-reverser` and -`kubectl delete namespace gitops-reverser-quickstart-demo`. +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 a captured object can overwrite concurrent Git edits to +that same object. There is no field-level merge. Shared-resource workflows remain experimental. -> **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. +## Before you adopt it -### Want named users on your commits? +GitOps Reverser is early-stage software for labs and pilots. It runs as one controller pod, and its +`v1alpha3` APIs can change. Keep one `GitProvider` per repository to avoid uncoordinated writers. -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. +- **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. -### Rather have it managed? +High availability is the headline gap: `replicaCount > 1` is rejected today. The backlog is in +[docs/TODO.md](docs/TODO.md), and longer-range directions in [docs/future/](docs/future/). + +## 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/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index 24546efba..a22af8ac0 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. @@ -205,13 +203,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 +244,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 +302,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/docs/README.md b/docs/README.md index cb72a1200..12f97be87 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,9 @@ 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 +- [`../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 and how they fit together - [`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 diff --git a/docs/configuration.md b/docs/configuration.md index 26beec6c3..12e1a7500 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). +steps in the [quickstart](quickstart.md). The short version: @@ -2017,7 +2017,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..d9ce8f8a8 --- /dev/null +++ b/docs/demo/commit-window.md @@ -0,0 +1,177 @@ +# 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. + +The example assumes a namespace named `demo`, a working `GitProvider` named `example-provider` in +that namespace, and an authorized `default` `ClusterProvider`. Use a branch no reconciler deploys. +See the [quickstart](../quickstart.md) for installation and Git credentials. + +## 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: 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: 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/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](../configuration.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 demo +``` + +The rule selects **two resource types in `demo`**, 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 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: demo +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: hello + namespace: 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: 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 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 @@ +eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4XHUwMGRhXHUwMGU1fVx1MDBmOVNiS1x1MDAxMlx1MDBlZVx1MDBiZlx1MDBkMlx1MDBlMVx1MDBmYmUmRlx1MDBjZlx1MDBhZH1cdTAwZTlcdTAwMTc3Xlx1MDBhMFx1MDBiOFx1MDBhMFx1MDA4Mlx1MDBhMlx1MDBkOFx1MDBiNlx1MDBjZUxcdTAwMTgsXHUwMDA3ZFx1MDAxMVx1MDAxNHBcdTAwODFcdTAwODlcdTAwZmJcdTAwYmZcdTAwYmYvXHUwMGViXHUwMGEwXHUwMDgwXHUwMDgwW1x1MDBiN1x1MDBmNnJcdTAwZmJcdTAwYzZtWzhLVVx1MDBkNlx1MDA5N19mVmVlXHUwMGZkb1x1MDBhOT9cdTAwYjhcdTAwOGNcdTAwOTc+L1x1MDBjNXdcdTAwZTViXHUwMGFiXlx1MDBlOVx1MDAxNm9cdTAwOTdcdTAwOTZcdTAwOTduXHUwMGUyblx1MDBhZlx1MDBkZWkvfVx1MDAxNlx1MDBjYktcdTAwYmRcdTAwY2V1XHUwMGI3TFdcdTAwOWNcdTAwZjdcdTAwZmJcdTAwOTdcdTAwYmRcdTAwY2ZcdTAwN2ZcdTAwZmNxUVx1MDBlYzZcdTAwZTNcdTAwZmVlXHUwMGFiWFx1MDA4ZVx1MDBhM1x1MDA5YnpcdTAwZWZcdTAwYmFcdTAwZDhcdTAwZWFcdTAwZjVcdTAwYWYrXHUwMGY1TlRcdTAwZWVcXFx1MDBmY1FcdTAwZWZcdTAwYzdcdTAwMTdcdTAwYmRcdTAwZmZHXHUwMDdmXHUwMGU3XHUwMDhhXHUwMDE3XHUwMGYxXHUwMDlmXHUwMDk3XHUwMDlkXHUwMDhiSlx1MDBiZlx1MDAxYlx1MDA4ZFx1MDAxZlx1MDBiZVx1MDAxMldcdTAwZWFcdTAwZmROXHUwMDE3XHUwMGVmXHUwMDg4W1x1MDBmMUVcdTAwZGNcdTAwZWVcdTAwZjdcdTAwOTY+XHUwMGZmXHUwMGZiXHUwMDdmS1x1MDBmNVxuXlx1MDBiMFx1MDBiOXFcdTAwZTBLXHUwMGI2UFtcdTAwZDkobVx1MDA5ZFx1MDBhZVx1MDBkZFx1MDA4OWFcdTAwZjMgXHUwMGE3cVx1MDBlNVx1MDBhOFx1MDA4NVx1MDBhZHo7XHUwMGM2b3dLXHUwMDlmXHUwMDlkN1x1MDA5MTVcdTAwZDJcbidcdTAwYTRcdTAwZDZcdTAwODZcdTAwYTlcdTAwZTVcdTAwYTVcdTAwYzFcdTAwZDJnXHUwMDg1T1x1MDA5OXdcdTAwODJzXHUwMGVkXHUwMGFkXHUwMGQxXFwuL1x1MDBkZFx1MDBkNitcdTAwZmRcdTAwZjNcdTAwYTVcdTAwY2ZseVx1MDBlOTxcdTAwYWVcdTAwZDdcdTAwY2VcdTAwZmJcdTAwYjhIO1x1MDBkYypcdTAwYjhcdTAwZjRcdTAwZGUoXHUwMGE3XHUwMDljXV4qXHUwMGI2a1x1MDBhZDhcXFx1MDBkNFx1MDBlYnc7XHUwMGNkeFx1MDBhZFx1MDBkM0JcdTAwMDM/L1x1MDBmZFx1MDAxZlx1MDAxZVx1MDBkM1x1MDA3ZnhlXHUwMGE5WG5cdTAwZDZcdTAwYmFcdTAwOWRcdTAwZWJ2XHUwMGU1XHUwMGUxXHUwMGJiXHUwMGQ4XHUwMGM3XHUwMGU1XHUwMGI4XHUwMDhhXHUwMGVmXHUwMGFhXHUwMGY1Vlx1MDBlYlx1MDBiMD9cdTAwYTAnQFRcdTAwZThcdTAwZTRcdTAwZDJcdTAwZmRzXHUwMDhlXHUwMDkzXHUwMDk3XHUwMDhiXHUwMGZiXHUwMGRmXHUwMGVmXHUwMGFmXHUwMGFhdFx1MDBmYVx1MDBmZFx1MDA5OC5cdTAwYzNcdTAwMTNrXHUwMGU3XHUwMGVkXHUwMGI4XHUwMDA3XHUwMDExXHUwMGYwXHUwMGU1XHUwMGE1XHUwMGNlZVx1MDBiMVxcXHUwMGVmXHUwMGEzXHUwMDFmXHUwMDljXHUwMGExKVx1MDBmNFx1MDBiYVx1MDBjYkxcdTAwODVcdTAwYTRcdTAwZjNfXHUwMGJjXHUwMGE0XHUwMDBiYWYgXHUwMGEzXHUwMGY2dVx1MDBhYlx1MDBiNVx1MDBiY1RvV2JcdTAwODhhXHUwMGU5XHUwMGY0LnlKXHUwMGJiXHUwMDkyPFx1MDBlNVx1MDA3ZiNRXHUwMDg5XHUwMGJmXHUwMGYwXHUwMGNlXHUwMDE4XHUwMGVmXHUwMGY4XHUwMGVjXHUwMDk1V1x1MDBjYTlcdTAwZTMmXHUwMGM2VDtcdTAwZjZcdTAwZjBbXHUwMGFlXHUwMGQzXHUwMGE2XHUwMGIxXHUwMGY1XlhvXHUwMDlkXHUwMGMxXHUwMDE3XHUwMGY1Xlx1MDAxYSNDXHJcdTAwZmNcXFx1MDBjNVx1MDBjOFx1MDBjNlx1MDBlOD1cdTAwYmRgfTxaaFx1MDBjZlx1MDBmNWVcdTAwYTVcdTAwMTguXHUwMGUxXHUwMGQ2M2ZcdTAwYWNcdTAwYjNCW1x1MDBmNFx1MDAwMUNcdTAwZDRcdTAwYmNvZFx1MDBhYlNuTjxcdTAwZTVcdTAwYjJTT25cdTAwZmY3W2ZcdTAwZmZdXHUwMGM2XHUwMGRmM0NcdTAwZjFfPFx1MDBiOVVcdTAwZWNcdTAwZjVcdTAwZDc6XHUwMDE3XHUwMDE3dVx1MDA5Mlx1MDBkMT5cdTAwZGRzXHUwMGZmXHUwMGJjXlx1MDBiZlx1MDBkOFx1MDBlZFx1MDBhZlx1MDBhMlx1MDBlYlx1MDBmNXZcdTAwZWRcdTAwZmVcdTAwYjNcdTAwYjhdeVx1MDBmNElcdTAwYjgqXHUwMGQ1XHUwMGVkdm5cdTAwY2ZcdTAwZTNiZVx1MDBlMlx1MDBiYUdcdTAwOWZcdTAwZmRcdTAwYjU8XHUwMDAyXHUwMDlkXHUwMGZjUllcdTAwZWRvdFx1MDAwNlx1MDBhOVx1MDBmNlx1MDBkZFx1MDBhNU5XZVx1MDBhN1x1MDBkOFx1MDBkOFx1MDA5Ylx1MDAwMF03Llx1MDBmN1x1MDAxM1x1MDA4MFx1MDAwNFx1MDBlNClcdTAwY2Ijb1x1MDA5OXRcdTAwOTZcdTAwMGIhXHUwMDljXHUwMDkxOkBcdTAwY2YwXHUwMDgzXHUwMDhmXHUwMGJkXHUwMDk1XHUwMGRjXGJcdTAwYTY4XHUwMDg5e1x1MDAwND1cdTAwZWV1ZFx1MDBhY1x1MDA5N1x1MDBjY3hcdTAwYzNcdTAwYTVcdTAwOTBOXHUwMDhmXHUwMGIxXHUwMGM4XHUwMDg1XHUwMDhjXHUwMGE0clx1MDBkMlx1MDAxYVx1MDBjN1x1MDBiNFx1MDAxNkxcdTAwYmNcdTAwMTVcdTAwOGNcdTAwZDVqXFxcdTAwZjZcdTAwZmUlYFx1MDBlNFx1MDA4Zlx1MDBjMXhcdTAwN2ZcdTAwZDVcdTAwMDRcdTAwMTZcdTAwZDlcdTAwZGJcdTAwYjA4XHUwMDk4XHUwMDhmRVx1MDBmOVx1MDA4MEVcdTAwYWUsRFx1MDBhMCRjXHUwMDEzYDRcdTAwOWU/XHUwMDA2XHUwMGEzXHUwMDAxQFx1MDAxOVRcdTAwZGJcdTAwYmZcdTAwMDRcdTAwOGNcdTAwYTNRLFx1MDBiNlx1MDA4Zlx1MDBjZlx1MDBmOEZcdTAwOTdcdTAwZWJcdTAwYzJcdTAwZjFmXHUwMGNiXHUwMGE0Tlx1MDBiZlx1MDBhY1x1MDBmM1x1MDBiZFx1MDA4Ylx1MDBmMShcdTAwMTZpXHUwMGYwXHUwMDk3XHUwMDFlXHUwMGM2fFx1MDBlNyBcdTAwYmRcdTAwZGFcdTAwYjFsXHUwMGZiQmZ6XHUwMDhkbjdcdTAwY2VcdTAwZDYrXHUwMGNkXHUwMGJkXHUwMDk5XHUwMGFiZ3FcdTAwZWVcdTAwOGNcdTAwMTJcdTAwZjJJXHUwMDljP1x1MDBiY1x1MDBhMntcdTAwYmNcdTAwYzlcdTAwY2VdXHUwMGYzbG9cdTAwZmR2XHUwMGQ1XHUwMGU0XFxmZVx1MDBiNXxZXHUwMDliXHUwMGNiZVx1MDBkY0ZcdTAwOWU5a1x1MDBiY1x1MDBlMFx1MDBjZVx1MDBlMFx1MDBmMVx0XHUwMDk3aVx1MDAxM0lcdTAwYTNcdTAwOThcdTAwZDRMXHUwMDBiXHUwMGUzXHUwMGI4MnNcdTAwYzlcdTAwY2M7XHUwMDAwXGIqXHUwMGVlXHUwMGE1XHUwMDEwXHUwMDEyXHUwMGI3XHUwMGZmXHUwMGQyZDZ8XHUwMDhlXHUwMGNjXHUwMDA0Z1x1MDAwMFx1MDAwZlx1MDBkNG9cdTAwMTJAXHUwMGNhXHUwMGRiXHUwMGM3XHUwMDAwXHUwMGQyXHUwMGEwMyVcdTAwOGRcdTAwOTNvYzNcdTAwZThcdTAwYjZcdTAwODROXHUwMGJlXHUwMDk2XHUwMGNkXHUwMDFlXHUwMDhmXHUwMGM1XHUwMDBmYTNWPVxcUT1dT1x1MDA5ZFx1MDBkY11cclxuXHUwMGFkXHUwMGVhTVx1MDBhYlx1MDBiMlx1MDA5Nlx1MDAxOVx1MDBjMy5uXHUwMGI1XHUwMGVhXHUwMDk3XHUwMGJke1x1MDBlNDFcdTAwMTVcdTAwMDF9XHUwMDk2OVx1MDAxOVx1MDBmZVx1MDA4Y1x1MDBhYyhcdTAwZjNcdTAwMTEjQVx1MDAwMH5cdTAwOWFcdTAwYmVcdTAwYjFcdTAwMGZcdTAwYzhcdTAwOTMsXHUwMDEyTlx1MDAwYlx1MDBjZFx1MDBiNTJcdTAwOTJbbSdcdTAwYjhMXHUwMGNhXHUwMGM4alx1MDBjN1x1MDAxZFx1MDBkNFZcdTAwMWFcdTAwYjA6eytcdTAwMTZLXHUwMGEyKkpcdTAwYTU3YXFcdTAwMGVcdTAwOTdcdTAwYmRcclx1MDA4YUVcdTAwZjZcdTAwMWNcdTAwMTQ5XHUwMDA3SFx1MDA5Y1x1MDAwMntPcVx1MDBkOVx1MDA4Y2FcdTAwODUrXHUwMGMyXHUwMDhkXHUwMGE1XHUwMGFiXHUwMGRmXHUwMDhhRVx1MDBlMVx1MDBiNVx1MDBmNS9hXHUwMDFjfSRkfm9cdTAwZjBcdTAwYjVcdTAwN2ZzXHUwMGIwXHUwMDdmXHUwMGI4W1x1MDBkOFx1MDBiOVx1MDBiOT1cdTAwYmRcdTAwYzhcdTAwOGVcdTAwODdcdTAwYmVcdTAwMWZcdTAwZGZcdTAwZjVcdTAwOTNxXHUwMGY3QkQgYlx1MDBkOFx1MDBhY2RcdTAwZWNcdTAwZjlcdTAwODhxbFx1MDA4NHZcdTAwYzJtYlx1MDA4Mk1cdTAwOGY7XHUwMDkwXHUwMGVkXHUwMGJkXHUwMDA3OVx1MDAxOVxcXHUwMGVmXHUwMDlkUlx1MDBlM2FcdTAwZDdcdTAwZWNtXHUwMGMzXHUwMGRjXHUwMGVmXHUwMDE2XHUwMGRiXHUwMGJkXHUwMGNiYlx1MDAxN1x1MDAxZFx1MDBmZlx1MDBjMUMtXHUwMGE2XHUwMDg3elx1MDBhNHdcdTAwYzkgXHUwMGMzXHUwMDhkNFx1MDBjYXArJlx1MDAwN1lcdTAwY2ZcdTAwMTgsXHUwMDkyXHUwMDk3MF5cdTAwZDlcdTAwZjdcdTAwMWFcdTAwZTNcdTAwZTVkXHUwMGY0Pi9cdTAwMWRcdTAwZWRcdTAwYTdTXHUwMDg1XHUwMGY1XHUwMGZmXHUwMGI0XHUwMGQ3Olx1MDBlZGpcdTAwYmRcdTAwOTYtXlx1MDA5Mlx1MDBmMDpcdTAwZWRcdTAwZmVhfVx1MDAxOFx1MDBkYVx1MDA5MVx1MDBmY1x1MDBiNlFcdTAwYmNcdTAwYThcdTAwYjcgXHUwMDA1XHUwMDk3XHUwMGRjXHUwMDk3alx1MDBkNWttXHUwMGIyOXFcdTAwYjVcdTAwOWZcdTAwZjhcdTAwZjZcdTAwZmQ6XHUwMGRjXHUwMGYxXHUwMGZiXHUwMDhmXHUwMGZiXHUwMDFkekpcdTAwMTlcdTAwZjdcdTAwMTVhXHUwMDk0XHUwMGJhY1x1MDAxMXVcdTAwYmFcdTAwZjVaXHUwMGJkXWxcdTAwMTVcdTAwMTZcdTAwYmZcdTAwYmJ4XHUwMGRkXHUwMGVmXHUwMDFjXHUwMGM0XHUwMGJkXHUwMGYwXHUwMGY2flx1MDBmNzpcdTAwMGV9XHUwMDg4XHUwMGI3XHUwMGVlXHUwMGZkXHUwMDliSFx1MDBlOFx1MDAwN3hcdTAwZGVcdTAwYzVcdTAwZTVcdTAwZGRmRzZcdTAwYWVcdTAwYjJKXHUwMDFkcFx1MDBiN1x1MDBlYlx1MDA4NydcdTAwZGRcdTAwOWR5XHUwMGYwXHUwMGU0XHUwMGRjXHUwMGY4SFx0JzVIXHUwMDk0O25GXHUwMGI0XHUwMDA0XHUwMGYyXHUwMDgxXHUwMGY3XHUwMGMwXHUwMDk0IVx1MDAwN1x1MDBjYlx1MDBlOFx1MDBiMUFcdTAwZTRKXHUwMDA0fDJcdTAwMGV3XHUwMGNjKiF+J3hcdTAwYWEnXHUwMGUwXHRfXG5sXHUwMGFjXHUwMGY4JDxcdTAwYjlcdTAwOWFcdTAwYTBcdTAwYTRcdTAwMDd8WmVcdTAwYTVgXHUwMGU0XHUwMGFjXHUwMGJlXHRcdTAwYTBOXGJcdTAwZDhcdTAwZDRcdTAwOTdcdTAwMDB0XHUwMGVkYH1cdTAwMDRcdTAwMTIyelx1MDAwN1x1MDBmMVx1MDBkNXVcdTAwZGNcdTAwZWJcdTAwN2YgSFx1MDAxN1x1MDBiZFx1MDBmZlx1MDAxNUBtXHUwMGIzXHUwMGUyXHUwMGMxXHUwMGUxXHUwMGQ3XHUwMGFkXHUwMGU2XHUwMGU5cXVcdTAwYWRcdTAwYjBcdTAwYmFcdTAwYjZcdTAwZmJcdTAwYzVcdTAwOWNtXHUwMGRkXHUwMDhkXHUwMDgxWlx1MDBiZihcdTAwZDZGXHUwMDA2VFx1MDBjM1x1MDA3Zlx1MDBkN1x1MDBmMEZcdTAwYmNiXHUwMDlhYlx1MDA5OFx1MDAwMFRcdTAwYTdZXHUwMGM0NFx1MDAxY1x1MDAwMFx1MDBlN1x1MDAxNVx1MDAxY0Y7XHUwMGE2UVx1MDBjNyNcdTAwZjJcdTAwZThcdTAwOWNcdTAwYjJaXCJKXHUwMDk1blx1MDA4Y1x1MDBkMzlfLVx1MDA4Mlx1MDBlZDQ0XHUwMDdmZlx1MDBlOG4+XHUwMDAxXVx1MDBlOCxcdTAwOGMnc2pcdTAwMTJcdTAwYmFcYlx1MDA5ZVx1MDAxZUNcdTAwZDdcblx1MDAwYlx1MDAxZVx1MDBmMFx1MDBlZVx1MDBhZFx1MDBlNlx1MDBmM1lcdTAwODc9XHUwMGY4WFx1MDBmZGtcdTAwZGNcdTAwYmV0XHUwMDE5J1x1MDBiZVdcdTAwOTBaXHUwMGU4XHUwMGQwXHUwMDkyXHUwMGYxXHUwMDE1XHUwMDEzXHUwMGJiXHUwMGIyZ1x1MDBhNWpcdTAwODlcdTAwOTdsXHUwMGI5XHUwMDEyXHUwMGFjPigoXHUwMDk2XHUwMGEyWFx1MDBkNmhcdTAwYmZcdTAwZTZFXHUwMDg0XHUwMGNlJFVcdTAwYTBcdTAwMTVcclx1MDBmZTdfXHUwMGU2aEdcdTAwYjlcdTAwMGJcdTAwYzBOXHUwMGJiZlx1MDBhZFx1MDBmZUpcdTAwMTNcdTAwODFydTtVOj1cdTAwYmFcdTAwYWFnXHUwMGQ1Xl01XHUwMDE2XHUwMGUwXG5cdTAwOGVcdTAwOTlAXGZkXHUwMGFjXHUwMGYyLFx1MDBiOFx1MDBmNSR5S1xmXHUwMDg4XyEndE1cdTAwOGVcdFx1MDAxMCEmR1x1MDA5OC9ccnlxXHUwMDljUVx1MDBlMHRcdTAwMGZcdTAwYWM5X1x1MDBmZFx1MDBmYVx1MDBjMFx1MDBkYV5cZixcdTAwYTNcdTAwYTQnUzxcdTAwZTlcdTAwOTZ5XHUwMGMzXHUwMGU2MFx1MDBhMlx1MDBlNlxmXHUwMDFlXHUwMGNkXHUwMDBmXHUwMGMyVVx1MDAxOS0sailcdTAwY2FcdTAwYTZcdTAwY2NNOS5cdTAwYzVcdTAwYjZcdTAwYWNcdTAwOGIxXHUwMGI0XHUwMGRmXHUwMDhhXCJcdTAwODJfXHUwMGMzbGxcdTAwOGFcdTAwZGNcdTAwZjNncDVcdTAwMWRMXHUwMDhlXHUwMDE1aVx1MDA4ZVx1MDA4Zlx1MDBhMlx1MDA5OVx1MDA5MEYzOVx1MDBkN0dcdFx1MDBmOFx1MDBlNFx1MDBkNUFcdTAwY2VcdTAwZGJcdTAwZTF5KVtcdTAwYThcdTAwZGVcdTAwZTlcdTAwYTZcdTAwZWVcdTAwZWNcdTAwMGZ5XHUwMGU2e1x1MDBjZClcdTAwY2NcdTAwOGNcdTAwZjRcdTAwMThcdTAwODVcdTAwMDR4XHUwMGFiXHUwMGMwXHUwMDdmXG5RXHUwMGFlXHUwMDAzKVx1MDA4MDpHJlx1MDAxZlx1MDAxMVx1MDAxNVx1MDBjZFx1MDAxODhcdTAwZTZcdTAwYjlcdTAwZDJcdTAwY2NcdTAwOWJcdTAwMTlcdTAwMTNcdTAwY2VcdTAwYzJcdTAwOWZcdTAwZjlcdTAwODFcdTAwMDRcXFx1MDBiMnJMMzlcdTAwZjdaXHUwMGMyXHUwMGUwXHUwMGRmXG5cdTAwY2JcdTAwMTBcdTAwMWNyXHUwMGU1XHUwMGFjXHUwMDliXHUwMGQwXHUwMDEyXHUwMGU3XCJcdTAwMDFcdTAwYWZcdTAwOTdZXHUwMGM0XHUwMDhkXHUwMDA2b1x1MDA5NFx1MDAwZlx1MDAxMSdcdTAwMThcdTAwMTlcdTAwY2QhXHUwMDlhXHUwMDlhXHUwMDgwXHUwMGU2Ulx1MDBiZVx1MDBkYV5cdTAwYmY+N1x1MDBmY1x1MDBhOF1cdTAwY2JcXFx1MDAxY1x1MDBjNX57XHUwMGE3VU0tXHUwMGZkXHUwMGY3QVx1MDAwZVx1MDBlN1x1MDBjNVx1MDBlZWVcdTAwZDJcdTAwZWZcdTAwYTVcdTAwMWVcdTAwZmR7aVx1MDAxYVNcdTAwMGZcdTAwMDBcdTAwN2ZZXHUwMDEwXHUwMGY4OFx1MDBkNlxcXHUwMDkxXHUwMGYwXHUwMGQ0XHUwMGJkRnRcdTAwZTZcdTAwZDE0XHUwMGYyO1x1MDBjZDJcdTAwYjdcdTAwOTFcdTAwZjVcdTAwOWNMXHUwMDg3XHUwMDgyXHUwMDk2XHUwMDFiXHUwMDA0XHUwMGM5dFx1MDBhMVx1MDAwMlx1MDA4Zlx1MDBjMFx1MDA4MVx1MDAxNVx1MDBmMC5cdTAwOTlcdTAwZjFOXHUwMGE4ZVx1MDBhZCNcdTAwMDdcdTAwY2ZcdTAwOWZcdTAwYTZcdTAwMTVcdTAwYTBcdTAwMTZcdTAwYjBSXHUwMDhhXHUwMGFlXHUwMGUzPlx1MDA4Mlx1MDA5M1x1MDBlYlx1MDBjMTBcZmFcdTAwMDBcdTAwZjdcdTAwY2MuXHUwMGNmXHUwMDg4XHUwMDA0XHUwMGQ3XHUwMGMxXHUwMGRjeVx1MDBmY1x1MDA5Ylx1MDAxZVx1MDA4OFx1MDBhYlx1MDA5NFx1MDBkM1x1MDBlMTJcdTAwMDZcdTAwYTFKXHUwMGM0XHUwMGJjaFx1MDAxMT1MXHUwMGU5SGBcdTAwYzRcdTAwOWNCIFx1MDAwMSFcdTAwMWJcdTAwYjNcdTAwYWNlJC1CRlx1MDAwNVxiXHUwMGUyIyFwXHUwMDk1NFxiPVx1MDBmMXhmMFx1MDAxNFx1MDBkNjE0XHUwMDhkXHUwMGEzaVx1MDAxZVx1MDBhZlZcdTAwMTJcdTAwZmRcdTAwODJcdTAwMDFlPlx1MDBjNFx1MDBkNVx1MDBlY1x1MDAwNXFcdTAwZjRcdTAwMGJcIlx1MDBlNFx0XHUwMGZhIFx1MDBhZlx1MDBmM1x1MDAxMVx1MDBiNVx1MDBjY1x1MDBiMFx1MDBjOVx1MDBmN1x1MDBhMFx1MDA4MCc0VDErXHUwMDFmayhYXHUwMDlhXHUwMDBiXHUwMGM5XHUwMDk1X1x1MDBhOFx1MDBhMX1cdTAwYjZcdTAwYmFcdTAwYmJcdTAwOTlcdTAwZTJcdTAwY2RcdTAwYTNcdTAwZWR2XHUwMGZlXHUwMGU0XHUwMGE0XHUwMGJjXHUwMGJmPlx1MDBkNFx1MDBkY1x1MDBiY1x1MDBiYlx1MDA4NmJcdTAwODAxXHUwMGM0XHUwMDFhXHUwMGM4XHUwMDgwLlx1MDBkMFx1MDBhY1xuXHUwMGE5JylcdTAwYWRcdTAwYjFcYiMtXHUwMGEyW1hBbVx1MDBjZEhpaVx1MDA5MkBJXHUwMDBiXHUwMDg3XHUwMDExXHUwMDkwXHUwMGIyXlx1MDBiZk5pXHUwMGFiXHUwMGY4M1ZZXHUwMGJjXHUwMGMxMlx1MDBhZFx1MDBhNVx1MDAxMVx1MDAwMFx1MDBhY1x1MDAxMW5cIlx1MDBmMFx1MDBjNFdwIFx1MDA4MERcdTAwODROXHUwMGNjWVx1MDBhZVx1MDAxZlxcXHUwMDBiOF5cdTAwMWUjQyZcdTAwZTBcdTAwOGQ6XHUwMDFiXHUwMDlhXHUwMGY0fXVcdTAwOTZcZjpcdTAwYjlcdTAwZTBcdTAwODhoYFx1MDBkMVx1MDBlMVx1MDBiNi9cdTAwYWZcdTAwZjBcYj1cdTAwZTNcdTAwMWNKXGIwXHUwMDgwXHUwMDk1XGZcdTAwYTksXHUwMDk0XHUwMDA3XG5cdTAwMDBcdTAwZjMkXHUwMDhjXHUwMDA2RzFcdTAwYjlcdTAwYmNcdTAwYTJcIkZcdTAwOTNcdTAwYzRcdTAwZDBEXHUwMDAwRElcdTAwMTlcdTAwOTQsMjRcdTAwMDVcdTAwOGRcdTAwOGVSXHUwMDkwXHUwMDA0XHUwMGRjaDxcdTAwOGZHXGZcdTAwZTJcdTAwMTZcdTAwOTBcdTAwOTNcdTAwMTFcdTAwZjBcdTAwZjdcdTAwYjlcdTAwYzZcdTAwODU2XHUwMGMyXHUwMGUwXHUwMGUwYVx1MDAwMVx1MDBiMFx1MDBkYXFcdTAwYWRcdTAwYzJcdTAwODVcdTAwYTBcdTAwMDBcdTAwODF2MFx1MDBmOFx1MDBmNVpcdTAwOGJwXHUwMDlkRVx1MDBiM2RcdTAwZDBaXHUwMDhmXHUwMDExXHUwMGU2XHUwMGNiK1B2aFx1MDBhY1x1MDBmMFx1MDA4YVsrPVx1MDBlNFx1MDA4Y1x1MDBjYlx1MDAxY1x1MDBhOShcdTAwOThcdTAwMDNcdTAwMWNcdTAwMDJcdTAwYzkrZlx1MDBhOFx1MDAxYlx1MDAwNlx1MDBkY1xmXHUwMDg1XHUwMGU3aFx1MDBhZSNcdTAwYWJcdTAwOGFcdTAwZWJcdTAwYjhcdTAwOGFcdTAwYzhLXHUwMDkxXHUwMGRjXHUwMDAyIGhcdTAwOWVcdTAwYzdrWVx1MDBhNFxyXHUwMGY4XHUwMDAwXHUwMGQxXHUwMDlkXG5zXGZcdTAwODJcdTAwZmErXHUwMGQwXHUwMDE2XHUwMDA3XHUwMDhkXHUwMDA3Q1l4XHUwMGQxIFx1MDAxZVx1MDAwMVx1MDBkZVx1MDBlMVx1MDBhMDFcdTAwODNOXHUwMDFiXHUwMDg4XHUwMGNjUFx1MDA3Zlx1MDA4NVx1MDA4OVx1MDBiNFx1MDAwNFx1MDBkNFx1MDAwNFx1MDAxYyBcdTAwODhcdTAwODVcdTAwY2JlXHUwMDFkWVx1MDBiMFx1MDA5NVx1MDAwNVx1MDAwMFx1MDA5OVx1MDA4NVx1MDA5NyRcZl1cdTAwZTVcIlxyUUBIXHUwMGRlaFx1MDAwNFx1MDA4Y3AndVx1MDAwNFx1MDBiZkdcdTAwZTBcdTAwZDlwXHUwMGNmPURCNCYlXHUwMGY5UVx1MDBjY1hcdTAwZWZcdTAwYWRcdTAwMDdSXHUwMGMxXHUwMDllXHUwMDkxXHUwMGQxUCVcdTAwYjRcdTAwMTNcdTAwZWZcdTAwZDY8YTtcdCFcdTAwYTNcdTAwYTl0KWBcdTAwOGZcdTAwZWJcdTAwOTZlXHUwMGM0XHUwMDE4XHRcdTAwZDNcdTAwMGJcdTAwOTJcdTAwMDFIXHUwMDgwRFwiSUE8ZCYpOlx1MDA4NVtmXHUwMDk3TWhcdTAwMWJuXHUwMGQ2XHUwMGI4XHUwMDExXHUwMGIyXHUwMDBibVx1MDBmM0RcdTAwOGIgYVx1MDBmNFx1MDBkZnlcdTAwYTlcdTAwMDRcdTAwYjhcdTAwMTNcdTAwOTdpR1x1MDBlM1x1MDAwZlx1MDBiM1xmXHUwMGVlXHUwMGE2XHUwMGNiXHUwMGE0XHUwMDAwXHUwMGFjXHUwMDExb1lcdTAwMDc2JVx1MDAxN3lcdTAwMTlcdTAwYTJcdTAwYjRcdTAwZGVZalx1MDA5NVx1MDAxNlx1MDA4OH5cdTAwODVPXHUwMGRhJj1cdTAwODZcdTAwZDBzQTNcdTAwMTRcdTAwODhcdTAwMDJcdTAwOTchSVx1MDA4Yj5pelx1MDBhZlx1MDBhMCVcdTAwOTVRXHUwMDE3XHUwMDg4YSEoNFhBXHUwMDE3IVwiRVx1MDBlM1x1MDBjNlx1MDAxNVx1MDA4M0lcdTAwZDBOXHUwMDk5XHUwMDg0XHUwMDhjXHUwMGYxXHUwMDliXHUwMDk2XHUwMDg0MVx1MDBlOVx1MDBhMVx1MDBlZFx1MDBjYmhcdTAwYWQ1XGJcdTAwYjMhalx1MDA4YyBcdTAwY2NyXHUwMDAwXHUwMDlkXHUwMDg4XHUwMGYwOlx1MDAwNlx1MDBlNlx1MDBiN1ogLEdcdTAwY2ZcdTAwYTFcdTAwZWRAJlx1MDBhY1x1MDA4NiBcdFx1MDBkMTVoXHUwMDg1XHUwMDk3XHUwMGIwL15cdTAwYTNcdTAwYjNcdTAwOWVcdTAwYjllXHUwMGY0XHUwMDg4XHUwMGMyeFx1MDAwNVx1MDBlY0E1ZTJcdTAwMDJcdTAwMTg9XHUwMGQ4fG9cdTAwMDFXR1x1MDBlM1x1MDBiZSxxXHUwMDFmWlxyXHUwMGRjXHUwMDFhXHUwMGQydj5cdTAwMWEnRytcdTAwMDZcdTAwOWVcdTAwMDFcdTAwOTNcdTAwOTZcdTAwMGJcdTAwYTVcdTAwZTlhYFx1MDAxNVx1MDA5YVx1MDA5N1x1MDA4MTQwXHUwMGNjdFx1MDAxNSdcdTAwZjkzXHUwMDg0XHUwMGJkXHUwMDg2XHUwMGE2USBXXHUwMDk4R1x1MDBjNyA6XHUwMDlhXHUwMDg3XHUwMDg0XHUwMGNiXHUwMGUzXHUwMDEzXHUwMGZjXHUwMGEwXHUwMGQ3XHUwMDhlXHUwMGU0Z1x1MDBkMX5cdTAwODh2XHUwMDE5XHUwMGJkXHUwMDgxXHUwMGJjYClcdTAwZDFQQ1x1MDBlMDVcdFx1MDAxY1x1MDBiOVx1MDBhMVx1MDBjYlx1MDAxY1x1MDA5OTBcdTAwY2JcdTAwZTBcdChcdTAwMWJJXHUwMGMxXHUwMGQxYVx1MDA4ZVx1MDBiMVx1MDA5N1x1MDBkNlx1MDAwNSNcdTAwODdcdTAwZWJcdTAwOWNcdTAwODcvIVx1MDBiOVFWUFx1MDBmN1x1MDA5N1x1MDAxNVx1MDAwNlx1MDAxOCZcckpcdTAwYTBpXCI/XHUwMGQ4S1x1MDAxZFx1MDAxOVx1MDBkMCNcdTAwMDNcdTAwZWVcdTAwZTFcdTAwZDJcdTAwOTNcdTAwZDVdXHUwMGQ2XHUwMDA0Klx1MDBiOGFcbkZcdTAwMGZcdTAwMGZVXHRuZVx1MDAwNGtcblx1MDBkZVx1MDBhMlx1MDBkNTBcdTAwOWFhXHUwMDAzXHUwMGJjMSQgXHUwMDAyXHUwMGE5XHUwMDgxXHUwMDAzR1x1MDBhYkpcdTAwZTFcdTAwYWRcdTAwMTA4XHUwMGEyeFx1MDBhOSFPYFx1MDAxMlx1MDAwMVx1MDBlNjJcdTAwMDVcdTAwOWJcdTAwZGVcdTAwZDJULVx1MDBhOTJcdTAwMWVCXHUwMGQ3cVx1MDAxOGVHa1xuXGIhXHUwMGEwXHUwMGI5XHUwMGNjLytcZlx1MDBiYlx1MDA4MFx1MDAwZXBPU3hcXFx1MDAwNFx1MDAxZFx1MDBlNVx1MDAxMWlcdTAwMWN6XHUwMDA1XHRAWVx1MDA5NVx1MDBhMVx1MDBhN1x0XHUwMGY4XHUwMDBmXHUwMGQweFx1MDBiY0BcdGFcdFx1MDBlMFBIXHUwMGJhUUBcdTAwZmVKXHUwMDAzJl5cdTAwODZcdTAwYjdiXHUwMGU0XHUwMDFjcFx1MDBhZjFcdTAwZWUucFx1MDAwM1xiXHUwMDAzLlx1MDAxY1x1MDA4NFx1MDAwZTgwXHUwMDEwXHUwMDlkXCJcdTAwZDFcdTAwOTlcdTAwZDBcdTAwMDdcdTAwZjI9RUJwK1xiSFx1MDAxNFx1MDAwNTBcdTAwYjhcdTAwMTNcdTAwYzEuXHUwMDg2XHUwMDkxXHUwMDFhXHUwMDg3XHUwMDgxXHUwMGE3cVx1MDAwMFx1MDAxMUIlOF1cdTAwZTZcdTAwZTBcdTAwZTJweClcdTAwOWNcdTAwMDBcZmhcdTAwYmEgOiBcdTAwMDFcdTAwY2FcdTAwMGZcdTAwYmFEXHUwMGIwXHUwMDFjXHUwMDEwXHUwMGJlXHUwMGMyaU5cdTAwMWFcdTAwODRcdTAwODlcdTAwYWJcdTAwODNcdTAwOGRcdTAwYjBcdTAwZTRuXHUwMDAwI1x1MDAxMDpcdTAwZjk4IFx1MDAxNlx1MDAxOVx1MDBhZTNcdTAwOTFcdHZcdTAwYzFbWnBkXHUwMGExcWAjXHUwMGYwXHUwMDliXHUwMDAzXHUwMGM4XHUwMGEwXzJcdTAwYjlcblx1MDBmOFx1MDBhN1x1MDAxNVNcdTAwYjhMXHUwMGMwXHUwMDg0Rlx1MDBlM1x1MDBlMFx1MDA5NVx1MDA5MFx1MDBiZFx1MDAxN1BtXHUwMDgwXHUwMDhjXHUwMGYzXHUwMGQxS1x1MDBmMSFcdTAwOWFcdTAwODZPXHUwMDg1XHUwMGEwKFZcdTAwOTFcdTAwZGVcdTAwMWFiXHUwMDE5XHUwMDlhXHUwMGYwXHUwMGQ1XHUwMGM5XHUwMGI4XHUwMGFlQHc9XHUwMGRjLlx1MDBiMCVcdTAwY2RPXHUwMDgxXHUwMDg3XHUwMGZjMnRcclx1MDBmMkZcdTAwMTdcdTAwOGNHe1x1MDA4MVx1MDBmN3BcdTAwMWQ+XHUwMDA0XHUwMGY1YExJW1xyXHUwMGU4Klx1MDBhOFxyXHUwMDA1XHUwMDlhXHUwMDg2XHUwMGJjT0FcdTAwODhcdTAwYjl3XHUwMGQ0XHUwMGEwXHUwMDk3XHUwMDg4XG5cdTAwZTlcdTAwY2RcYiBcdTAwYzJcdTAwODVcdTAwMTBcdTAwOGIgXHUwMGVhXCJ3XHUwMDhhXHUwMGViMFx1MDAxNlx1MDA4MFx1MDA5ZSBNXHUwMGQxXHUwMDA2XHUwMDg0Jlx1MDAxOWlcdTAwYWJcdTAwOGFcdTAwMDReXHUwMDhjXHUwMGFlXHUwMGUzSlx1MDAxMVx1MDAxNFx1MDA4Y1x1MDBhZVx1MDAwMlx1MDBmMVx1MDBjMjNcdTAwMDM9OHJcdTAwYmVccilcIlx1MDBiYSFcdTAwYTBcdTAwZThQM1x1MDAwM1x1MDBmN1x1MDBlZFx1MDBmZVx1MDBiZGhcdTAwMDIoXHUwMDFiZlx0XHUwMGFkJiVLRFx1MDBjMFx1MDAwNFx1MDAwMFx1MDBjYVx1MDBlMVxyclx1MDAxN1x1MDBhZSM3XHUwMDk0XHUwMDAwXHUwMDBlZlx1MDA4MVx1MDBhOFx1MDAxZFx1MDAxMVx0XklcdTAwYTRBXHUwMDg4XHUwMGI0XHUwMGY3XHUwMDgzXHUwMDg2XHUwMGNiXHUwMGYwbFx1MDA4YVx1MDA5ZV1cdTAwODBcdTAwOWEjXHUwMGZhXHUwMDAyKVx1MDA4Mlx1MDA4MSFcdTAwZWJcdTAwODB6OVx1MDAxMlxmXHUwMDA3XHUwMGJhYFVcdTAwYmNcdTAwMDRcdTAwMDNDcVx1MDBjOU0kZVxyXHUwMDk0XHUwMDBlXHUwMGQwWlx1MDAxMVx1MDBhZVx1MDA5M2Q3QVdcdTAwMGVcdTAwOTdcdTAwZTFcdTAwMTFwXHUwMDA3fFx1MDAwNHVcdTAwYzJQKlx1MDBmMjxcclx1MDBhND1cdTAwMWFcdTAwMTBcdTAwODhcdTAwMGZcdTAwMWVcdTAwYTcgXHUwMGRkMFx1MDBhMFx1MDA5ZmVcdTAwMWJcdTAwOTFcdTAwYjFcdTAwMDBcdTAwOGE5XHUwMGI5XHUwMGE2YFx1MDBhNER+UFx1MDBiNUAqMlx1MDBhY1x1MDA4MVx1MDA4MF9hXHUwMGMxXHUwMDg5XHUwMGFkfFx1MDA5OExcdTAwYzN3XHUwMGEzXHUwMDllXHUwMGY4XHUwMDg4YnFcdTAwYWZBR1x1MDBlOFx1MDBhNzUgXHUwMGUyXHUwMDg4XHUwMGNjJihcdTAwMWNcdTAwZDZcdTAwMTBcdTAwMTRcdTAwODdcdTAwYzhlXHUwMGQ0Qlx1MDAwYlx1MDBlYVx1MDBkMVx1MDBkMHxcdTAwYmNcdTAwODRcYlxiJlxyPVx1MDBjN3hcdTAwMDFcdTAwZGRcdTAwODJcdTAwYWNVclx1MDAxZFx1MDBlOFxmXHUwMGQ4XHUwMGUwXHUwMGMxXHUwMGJjS1x1MDBkOFx1MDAxZlx1MDA4ZFx1MDAwYlx1MDAxZFx1MDBkZFx1MDA4MkEmXHUwMGYzXHUwMDFlaFx1MDA5Nlx1MDA4MFxuXHUwMGFjXHUwMGMzfVx1MDBjMFx1MDA4OFx1MDA4Mlx1MDBhZlx1MDAxMFx1MDBkN1x1MDBjMzhcdTAwMDFcdTAwZTFoMS5cdTAwODJcdTAwYWRcYlx1MDBlM1x1MDBmNlx1MDAxZVx1MDBkZVx1MDBmM1x1MDBlZVx1MDA5M1x1MDBkZVx1MDBmM1x1MDBlNEpcdTAwYzk7XHUwMGZiXHUwMGQwXFxcdTAwZDNcdTAwYzRcdTAwYzhcdTAwYjRcdTAwMGZccj1cdTAwYTZcdTAwMTlcdTAwODAnfFx1MDBlOFx1MDBkNFx1MDBmNmZcdTAwODNcdTAwZWZuXHUwMGFmXHUwMDE2XHUwMDlhR1x1MDBjN1x1MDBkNVx1MDA5NVx1MDBmM05cdTAwZmJcdTAwZjJcdTAwZjBwXHUwMGYzXHUwMDgzXHUwMGEyXFxcdTAwZWQpXHUwMDFjXHUwMGEzXHUwMGQ4XHUwMDBiYFx1MDA5M2JcdTAwYjRcdTAwZWVcIj05VTBcdTAwYTlcdTAwMWFcdTAwODRcdTAwY2FpXHUwMDE1XHUwMGVlXHUwMDFiXHUwMDgyXFxcdTAwODJcdTAwMTlxLlx1MDBjY1x1MDAwYlx1MDA4Y1x1MDAwZVx1MDAxNFx1MDA3ZlwiXHUwMGRiXHUwMGUwXHUwMGYxV1x1MDBlMmFcdTAwZDJcdTAwMDRcdTAwMWZcdTAwYzBvXHUwMDEyP1x1MDA5N1x1MDBjM1xmXHUwMGJiXHUwMDA2b0BcdTAwOTBcdTAwOGZEXHUwMDkxLTlcdTAwYTRcdTAwYzRcIlx1MDAwZVx1MDAxY1x1MDAwN1x1MDA5M0NcdTAwYjY7XHUwMGYxSFx1MDBlMVx1MDAxNFx1MDBjM1x1MDBiNlx1MDBjMFx1MDA5NipaXHUwMDljXHUwMDg0R2poXHUwMDk1XHUwMDEyXHUwMGNhJlxyXHUwMDk5XHUwMGQzXHUwMDkxI0xcZkFcdTAwYjE8XHUwMDk4XHRaXG41XHUwMGQ2dCFcdTAwZThcclx1MDBlZVx1MDA5Nlx1MDBkMTQ5XHUwMDA2NyBcdTAwYjlcdTAwZWVcdTAwOTFcdTAwMDBcdTAwMTlRXHUwMDkwJGZcdTAwODZcdTAwZTFFNFx1MDBlMlx1MDA4OXE4XHUwMGQxM1x1MDBhZHRKfFx1MDA4MFx1MDAxMVx1MDA4NVx1MDBhM2BcdTAwZDFiXHUwMGI4dlBdSVx1MDBkZUJcdTAwZjBcdTAwYWNcdTAwZDBcdTAwMTRcdTAwYmNcdTAwMTBcdTAwODNcdTAwMGVfXHUwMDFiTVx1MDA4N1x1MDBjNVx1MDA4M1x1MDA5ZVx1MDA5MjVcdTAwZTFcdTAwOTBcdTAwYjdcdTAwOTVcdTAwOTRcdTAwMTBcdTAwYzFcdTAwZGZJT1x1MDBiMz9cdTAwOGRcdTAwOWVcIlx1MDA4NFx1MDA5Ylx1MDA5OVx1MDA3Zlx1MDBkM1x1MDBiNFBcdTAwY2VcdTAwOTheXHUwMGE4XHUwMGE3XHUwMGFhXHUwMDEzX1x1MDA5OVx1MDBlNn5cdTAwYjZeX1x1MDBiYjpcdTAwZTdfXHUwMGUyXHUwMGNjXHUwMDllXHUwMGVlXHUwMGQ3XHUwMGRlP1x1MDBkNlx1MDA4NTdcdTAwYzBcdTAwZTFcdTAwOTBcdTAwMWJcdTAwZDhRXHUwMDE4LFx1MDA5YVx1MDBkMlx1MDAxZk1PXHUwMGMxXHUwMDg0XHUwMDAwXHUwMDBlXHUwMDFlWDNsXHUwMGI0XCIlXSRAXHUwMGZkXHUwMDhlXHUwMDkxXHUwMGMzXHJcdTAwZmFcdTAwZmVWXHUwMGM1XHUwMDg1XHUwMGQ5XHUwMGI0XHUwMDhhXFxcdTAwMWVcdTAwMThcdTAwMTNvJlZcdTAwMDdgXHUwMGI1XHUwMDA0XHUwMGU5KyM3XHUwMDhjYjtcdTAwN2ZcdTAwYWZcdTAwYjlcdTAwYTRcdTAwMTAwXHUwMDk3XHUwMGU2J1x1MDBkM1xcXHUwMGQ4PkRcdTAwOWRcdTAwZTRcdTAwZWZDWkFzXHUwMGUxYlx1MDBjMFx1MDBlMyNrXHUwMGFmKWZVQXNcdTAwOTM1dFx1MDBlNlx1MDBjOVx1MDBkOVx1MDAxNlJcdTAwOTBCclx1MDBkMltcdTAwYzQ0cFx1MDAxNFx1MDBhNFx0Plx1MDAwNTRccnJcdTAwODEjXHUwMDAzXHUwMGFmNSw/e1x1MDBiYVx1MDAwZU5cdTAwMWJsXHUwMGFidFx1MDBlNFx1MDBhY1x1MDBlYXtcdTAwYzV9LEBcdTAwODRcdTAwOWNcYlx1MDBkNlx1MDBjOFxyXHUwMDgwXHUwMDhiXHUwMDA2XHUwMDBiXHUwMGNiXHUwMDEzXHUwMGI3XGZXQnFcdTAwYTFcdTAwOGM0XHUwMGFiXHUwMDgwXHUwMGY3KnJcdTAwMDJcdTAwZTB8XHUwMGMwXHUwMGI2XG5cdFx1MDAxZlx1MDA4MGZcdTAwMWJcdTAwODNcdTAwZWUmU1x1MDA4NSFcdTAwYzZGTFx0XHUwMGNkXHUwMGE1XHUwMGM1clxuXHUwMDdmXHUwMDEwXHUwMDg2XHUwMGMyXHUwMDg1c1x1MDBmMlx1MDA5ZFQ3XHUwMGY3XHUwMGYzXHUwMGE4Llx1MDA5Zlx1MDA5OTpcdTAwYzdcdTAwYzNcdTAwZTBcdTAwYjdcdTAwYzBcdTAwODdfbGJPXHUwMDhia1x1MDA5Ylx1MDBhYntcdTAwZGRcdTAwY2RcdTAwZmRcdTAwZGRjXHUwMGI5XHUwMDliXHUwMGFlXHUwMGJiXHUwMGRjXHUwMDk2XHUwMGZmXHUwMGZhXHUwMGRlXHUwMGFhXHUwMDhiXHUwMDgwXGZcdTAwMTFcdTAwMWJ8dGhcdTAwYTlcdTAwODQpXHUwMGYwXHUwMDg5XHUwMGU2OnhcdTAwOGJcdTAwZjBcdTAwZWRcdTAwYzhcdTAwOTRcdTAwZDBcdTAwZmNcdTAwZDUoXHUwMGM1RVFcdTAwZTIhJVx1MDBlZlx1MDA4MVx1MDBiN1x1MDBhZFJcdTAwZmNcdTAwOWJVXHUwMDE3XHUwMGEwXGJeJVx1MDA4N1x1MDAxM1x0XHUwMGVjTVx1MDBhOS5cdTAwYzJ5SVFcZlx1MDAxYVx1MDBhNmlcdTAwZDFmNE1cdTAwODVcdTAwZDY4XHUwMGJhXHUwMDhiXHUwMGZkZFx1MDBiYVx1MDA4Ylx1MDBkMEdcdTAwODVcdTAwOGUgIFx1MDBkMzZcdTAwZTguXHUwMDA1XHUwMDk4XHUwMGMxXHUwMDgweU1cdTAwMDFeXHUwMGEyXHUwMGJiMFx1MDA5N1x1MDA4ODhcdTAwOTRcdTAwYTRcdTAwOTBcdTAwZGRcdTAwMDVcdTAwZGRFXHUwMGQ4XHUwMDBle0hcdTAwZjlcdTAwOGNcYlx1MDA5Zlx1MDA5OUo0XHUwMDhkQ2FcdTAwYTExXHUwMGY0RHwpXHUwMDgzXHUwMGQxXHUwMDg1NVx1MDBkNHBxYVx1MDBkNzEyXHUwMGY3Rlx1MDBmN1x1MDA5MVx1MDAwNFx1MDBjOVx1MDBlYVx1MDA5Mlx1MDBkZHRcdTAwZDBAS1x1MDAwMVx1MDA5MEpcdTAwYjhAXHUwMGQyL1x1MDBiOFx1MDBkMFx1MDBkMFx1MDBjNFx1MDA5N3ZcdTAwZDBcXEtTXHUwMDBieFx1MDA5YTBEXHUwMDA1XHUwMGMxXHUwMGRiXHUwMDA2XHUwMDhmXHUwMGMwXG5cdTAwYzNcdTAwYjUmXHUwMGFmXHUwMDlhXHUwMGE2XHUwMGJhXHUwMGUwXHUwMGJkU2AgXHUwMDFkXHUwMDA1XHUwMGIwXHUwMDg4bFx1MDBjZHtcdTAwY2QtXHUwMGVmXHUwMGZkNFx1MDBhYVx1MDBlYmZcdTAwOTNcdTAwOTE0XHUwMDE5MXBcdTAwZjNiXHUwMGQ1XHUwMGFkXHUwMDlkZ1x1MDBkN1x1MDBmYlx1MDBjM1x1MDBjYkN9elx1MDA5YVx1MDBlMVx1MDA5OS8lXHUwMGJidVx1MDA5OF1cdTAwN2ZvXHUwMGQ1JVdcdTAwOGEoWiRcdTAwOTZcdTAwOTRcdTAwOTYzXHUwMDEyXHUwMGRkXHUwMGE1XHUwMGI0XHUwMDBlXHUwMDgwXHUwMDA0fCxcdTAwMTApXHUwMDhlXHUwMDEyI01YTVx1MDBiMFx1MDA4MkIhQVx1MDAxMVx1MDBmNTdcdTAwYWEuR1x1MDA4NCdoQlx1MDAwYlx1MDBjMVx1MDBiNlx1MDA5ZFx1MDBmMlx1MDA5N1x1MDAxMVx1MDBiY1x1MDAxM1x1MDBhMlx1MDAxMTBcdTAwYzNcdTAwZDVcdTAwMTNcdTAwYjRcdTAwMDQ9Ul1cblx1MDAxNlx1MDAwMVx1MDBjMVx1MDA5ZmtcdTAwODZcdTAwMTlmUlx1MDBjMz1AXHUwMGQwSFx1MDBmMy00XHUwMDgzXHUwMGI1XHUwMGJjXCIoRlx1MDBkN1x1MDA5Mlx1MDA5OFFQXHUwMGIwXHUwMGE5XHUwMDkzKWEoMlx1MDAwNFx1MDBlN21cdTAwOThcdTAwZjlcdTAwOTFcdTAwYzFcdTAwOWVyXHUwMDlhXHUwMDA3XHUwMDg1PVx1MDBhNVx1MDBlY2VcdTAwYjhcdTAwZDZcdTAwYzlcdTAwZjRcdTAwMTBMJC1cdTAwYTdyclx1MDBkNmhiXHUwMDhkXHUwMGVjXHUwMGFlXHUwMDg0XHUwMDA3TFx1MDBlZlx1MDBjMVx1MDAwM1x1MDBiZFx1MDBiMyNcdTAwZTV9LENGXHUwMGIzXHUwMGFlXHUwMDA2Y1x1MDBjN1x1MDAwNWU3XHQxclx1MDA5OWlSXHI+OTxmXHUwMGIyXHUwMDlmNFx1MDAwM1x1MDBjYVx1MDAxOVx1MDBlNzRBXHUwMDAxXHUwMDFiblx1MDA5Mlx1MDA5NSFcdTAwMWRRNjhcdTAwOWVkWVx1MDBkMFx1MDAwMlx1MDBiMVx1MDAxY1x1MDBlNlx1MDBjZVx1MDBjMSNcdTAwYzJcdTAwYzNcdTAwMGJcdTAwODJcdTAwYjVcdTAwZDZcdTAwZWZcdTAwYTRcdTAwYmJcdTAwZmI/XHUwMDhiXHUwMGVlXHUwMGQyXHUwMGZjXHUwMGNjTFwiIVx1MDBkY2VcdTAwOTBcdTAwOWVcdTAwMTBcdTAwOGJDW1x1MDBkMVx1MDBhY2VcdTAwZWFcdTAwYmJcdTAwZjlCP1x1MDBmYlx1MDBmNVx1MDBlYVx1MDBmNFx1MDBiOFx1MDA5OXN9L1x1MDBlMlx1MDBmN1ZeXHUwMDlhXHUwMGQ2UHBcdTAwZWJsWFM1XHUwMGQ0XHUwMGU1XHUwMGUwMVx1MDA4M1x1MDBiZlx1MDA4NVx1MDAwMFx1MDBmNVx1MDBlMz93XHUwMDlmXHUwMGQzXHUwMDhjXHUwMDBmPVx1MDBhNTNcdTAwZDN0XHUwMGFiXHUwMDg3d1x1MDBmN1x1MDBhZFx1MDBhYVx1MDBhYlx1MDAxMFx1MDBkMFx1MDBlMXFcdTAwMWFcdTAwYTJcdTAwYzZcdTAwY2ZJXHUwMGFiXHUwMGFiXHUwMGYxXHUwMDFkXFw8Rlx1MDBhOVx1MDAwZjRcdTAwZWQkXHUwMGM3KX1cdTAwZGVcdTAwZDBcdTAwMDJcdTAwYzdPZXVcdTAwYzFoPlx1MDBjYyZcbnhcdTAwZjNcdTAwODhAXHUwMGM4XHUwMGU4Qlx1MDA3ZmhOXHUwMDE0Q1x1MDBlZVx1MDBjOVx1MDBjZlxyelx1MDA4YmBcdTAwMThcdTAwYmZaXHUwMDBmK1x1MDAwNlx1MDBlZFxiNlx1MDAxNzJcdTAwZTVQPFhqXHUwMGZjK0w6Q1x1MDBjZFx1MDBhMG9cdTAwODBcdTAwMWYpOX5cdTAwODJNV3BcdTAwYWJcdTAwZDNcdTAwYjRcdTAwODBcdTAwODJPXHUwMGU5XHUwMGJiUUBcdTAwY2NoXHUwMDFlWUBcdTAwMTlcdTAwMTlcdTAwYjErXHUwMGJjZVx1MDBiOFx1MDBjOFx1MDA4Mlx1MDBjNFx1MDBhNVx1MDBiNFdcdTAwYTBjWnlLXHUwMGFlfCxpXHUwMDlhXHUwMGJmXHUwMDAzKSMqXHUwMDg2XHUwMGM5XHUwMDg1Ulx1MDBiYmB0YVx1MDBhZS1NbVhcdTAwY2JcdTAwMDVcdTAwZDlwXHUwMDE1XHUwMGQxVChcdTAwMDdcdTAwOWEpXHUwMGZkXHUwMDA0LFx1MDBmMk5cdTAwOGFcdTAwOWJcdTAwZmZ5XHUwMDE0d1wiXHUwMGExXHUwMGU5XnFpXHUwMDAxXHUwMDFlXHUwMDAxXHUwMDhkW2x1XHUwMGJmXHUwMGM2XVx1MDBkMVx1MDBiOSpcdTAwZGNcdTAwZjZcdTAwZGFcdTAwOGVcdTAwZDdcdTAwYWZTXHUwMGMzXHUwMGFiXHUwMGQzPVx1MDBiZFx1MDBmZlx1MDBkZVx1MDA4YVx1MDBhYlx1MDBhMlx1MDBiMCZkXHUwMGEwcDRdIVx1MDBlNGhcdTAwOGVcblx1MDBmY0tcdTAwODlcdTAwZGNcdTAwZGVcdTAwMDJhXHUwMGYyPlx1MDAxM1x1MDBjM1x1MDBjMFx1MDA4ZFx1MDBhNlx1MDBmOX5cdTAwZjhcdTAwODFcdTAwODBkSGpcdTAwZmI2XHUwMGQ1XHUwMGE1XHUwMDg5XHUwMDdmXHUwMGFmPFx1MDBjY1x1MDAwMFx1MDA4N3ZcdTAwZWFcdTAwYTlZKlx1MDAwNl9cdTAwODBcdTAwZGNlLn1wLVx1MDBlZmNdXHUwMDlhZVx1MDA4Nlx1MDBkM1x1MDBmY1x1MDA5M1ldXHUwMGQyMFx1MDA4NFx1MDBiYVZDKlx1MDA4OFx1MDAwYlx1MDBjMzRcdTAwMTVcdTAwY2NcdTAwOWI1NEFcdTAwMDVcdTAwZjNrWVx1MDAxMkw6QVx1MDA5M1x1MDBjNlBcdTAwZGYwXHUwMDhkXHUwMGEwXHUwMDkzIFZcdTAwYzJcdTAwYTVcdTAwMGVLIXpcdTAwZTRcdTAwZTBcdTAwYjJcYlx1MDBjMTFcdTAwYTdFXHUwMDFiXHUwMDhhXHUwMDdmIWdcdTAwZTguXHUwMGE3JWFcdGFpXHUwMGE5XHUwMGEwSyNcdTAwZGRcdTAwYTVcdTAwOGZcdTAwMTBcdTAwZTdcdTAwMDBZXHUwMDAwXHUwMGFjXHUwMGE0dVhGKztcdTAwYTBcdTAwYmRcdTAwZTBcIiNcdTAwYjJcdTAwMWRcdTAwODVcdTAwYmFcdTAwOGYkTVx1MDBmNlx1MDAxNVx1MDBiZTBcdTAwZmVcdTAwZTlhXHUwMGZjXHUwMDhkYS9zXHUwMDk4XHUwMGRkO1x1MDBlOVx1MDBlZVx1MDBjMU9cdTAwYTNcdTAwYmJcdTAwODJcdTAwY2ZkXHUwMDFmckZcdTAwMWJcdTAwMWRcdTAwYTAjXHUwMDhiXHUwMGU3XHUwMGE5Vlx1MDBjZVx1MDBlMlx1MDBiM0ZcdTAwYjVcdTAwYjFcdTAwYTZcdTAwZjZ1anA+bFx1MDBlN1x1MDA4YmVcdTAwZDdcdTAwN2ZvXHUwMGRkXHUwMGQ1XHUwMGI0XHUwMGU2XHUwMGNlPS1cdTAwOGFcdTAwYzBGXGI7XG52XHUwMGI1XHUwMDg4aHlcdTAwMWNuXHUwMDlkXHUwMDBmO2BGeVx1MDBmZFx1MDA5Y1x1MDBhY1x1MDA4NFx1MDBhNzlHXHUwMGYyXHUwMGUyXHUwMGJlUXVcdTAwZTFcdTAwMTRcdTAwYzMqWHJHXHUwMDEwXHUwMGZkXHUwMGI5R1x1MDAwZTNcdTAwYzVcdTAwZDhGXHUwMDkweVx1MDA4N1x1MDBmM2ZcdTAwMWVcdTAwODJdXHUwMGI4XHUwMDgyeFx1MDBiNVx1MDA5N1x1MDBlMlx1MDAxN1NcdTAwZGRcdTAwYjBKXHUwMGY2XHUwMDAyXHUwMGQ1XHUwMDFkXHUwMGY5XHUwMGNiXHUwMGI0XHUwMDgwR1x1MDBiMldhUVx1MDBkOVx1MDA5MVx1MDBlZVpHa1x1MDBhNlJzXHUwMGUzXHUwMDE0JFx1MDBhMlx1MDBlZlx1MDBhN1x1MDA5ZjxnXHUwMDk2XHUwMGQ2XHUwMDhhXHUwMDA1XHUwMDA0XGJcdTAwZDVcdTAwODVcdTAwZjNMZlx1MDA5ZGZcdTAwYjBcdTAwYjlIXHUwMGIyXHUwMDE0XHUwMGU2SFx1MDAxYVx1MDAxNlx1MDA5Nlx1MDBjYz0lZFx1MDBkMVx1MDAxNlx1MDBhM1x1MDAxMFx1MDBjNFx1MDBkYVx1MDA4OGZcdTAwYWUpXHUwMGY1XHUwMDgwZlx1MDBiZlx1MDBjOFx1MDA5Ylx1MDBhN1x1MDAxOFx1MDAxZVx1MDBhZFx1MDAwN31cdTAwZDBcdTAwYzJcdTAwYjNcdTAwYjFcdTAwZWZldVx1MDAwZlx1MDA3Zlx1MDA5NFx1MDBlNlx1MDA4ZTcxXHUwMDhkXHUwMGEzXVx1MDA5YVx1MDA4MFx1MDA3Zlx1MDBhY1x1MDBiY1x1MDBkMFxmXHUwMDlhXHUwMGQze2IxXHUwMGE4eFx1MDA5M1x1MDBiZl5cdTAwY2JcdTAwZTdcdTAwZGFcdTAwYmRcdTAwOWRcdTAwZTFFVVVcdTAwZDdcdTAwZWNcdTAwZDFcdTAwY2R1XHUwMGYxXHUwMGEzUlx1MDAxZVx1MDBlMVU5XHUwMDA1XHUwMGVmXHUwMDg4XHUwMGYyXHUwMDlhXHUwMGJjUlx1MDAwZlx1MDBjYSpcdTAwYzhBXHUwMDk2XHUwMGMxXHUwMDgzYlx1MDBmNlx1MDBkYlJcdTAwMWVNXHUwMGM0XHUwMDkyZFx1MDA4OWR6XHUwMDg0bFx1MDBmY0NcdTAwZmVcdTAwZDRcdTAwZWNXIzNcdTAwZWJcdTAwYTVcdTAwODFcdTAwYmJcdTAwYzdcdTAwZGNcdTAwZmJcdTAwYTQ8Plx1MDBjMFx1MDBhN1x1MDBmMFx1MDA4MVl4YVx1MDBiMFx1MDBhYm1+VTVcdTAwYmJ3XHUwMGRiXHUwMDg3XHUwMDFiXHUwMGI2enpcdTAwYjl6XHUwMDk3XHUwMGJlPVx1MDBjOVx1MDBjZFx1MDBkYlx1MDA5MVx0XHUwMGI1XHUwMDg0flx1MDBjM2lcdTAwZTVcdTAwYzlcdTAwMDZcdTAwYjhkaFx1MDAwNHdcdTAwZTQxOUpcdTAwODAzbFx1MDBjMVx1MDA4NkxLXHUwMDk5LVx1MDAxOExcdTAwMDRcdTAwZTFcdTAwOTRtRGFcdTAwZGNcdTAwYWZcdTAwYmIhXHUwMGIzXHUwMGM0Nlx1MDA5Zlx1MDBkZFx1MDAwNlx1MDA4N1x1MDBiOFx1MDA5ZFx1MDA5Y3szXHUwMDk5XHUwMGM3XHUwMGFmZ1x1MDBkN3soXHUwMGNkXHJ0XHUwMGM3XHUwMGRmXHUwMGJjXHUwMDBiXHUwMDhlXHUwMGI2V1x1MDBhOFdcdTAwZWVcdTAwYzhcdTAwOWNcdTAwMTlcdTAwOGNcdTAwMWZcdTAwYjIjXHUwMGYzZnU9e35cdTAwZjNcdTAwZTVcdTAwZTZcdTAwZTgydSxcdTAwYTRXXHUwMGM1XHUwMGNkO1N5XHUwMGJjXHUwMGQ3OFx1MDBlMFx1MDAwZVx1MDBkZFx1MDA4YyxcdTAwYWRXXHUwMGMyXHUwMDhjXGJzXHUwMGJmPlx1MDBlYylcdTAwZWZcdTAwMDVMXHUwMDgxIFx1MDA4Zlx1MDAwMitcdTAwYWZcdTAwYzZcdTAwYjZcdTAwMThcdTAwYzJcdTAwODlcdTAwZTBcdTAwYTBcdTAwMDBcdTAwOTAwWTBXdlxmREErXHUwMDFmXHUwMGU0XHRcdTAwOTI7XHUwMGMwZVx1MDBiOFx1MDBlZFx1MDA5ZFx1MDA5MVx1MDBjOFx1MDBkZi1cdTAwZTFcdTAwYmZcdTAwYzQyXHUwMGNmb1xyRmBLc1x1MDBjY1x1MDAxMzhcdTAwYjRcdTAwYjNcdTAwYzFcdTAwMThcdTAwYmNlRFx1MDBjYlx1MDBkMlx1MDBiY3UjXHUwMDE0ZSdcdFx1MDBmZCpcdTAwMWNcdTAwY2VcdTAwMTlcdTAwYTVcdTAwOTVcdTAwZDlcdTAwZDF5XHI0XHUwMGZmd186I2wlXHUwMGI4XHUwMGJkXHUwMDkwO3dcdTAwMWJcdTAwZTlcdTAwYWJcdTAwZjVcdTAwOGNcdTAwOGNTXHUwMGMzXHUwMGVhaWtLXHUwMDFjXHUwMGIwXHUwMGIwXHUwMGUzXHUwMGFhTFx1MDBiYlx1MDAwZig7XHUwMGNlaElcdTAwYzlWTipcdTAwYjhcdTAwMTZcdTAwOTB5XHUwMGYxXHUwMDkyMlx1MDBkYilcdTAwYWRcdTAwOTA7XHUwMGFmXHUwMGI0XHUwMDg2XG5cdTAwMWJcdTAwYWVcdTAwZmZ6K1x1MDBkY1x1MDAxZjBcdTAwMWNcdTAwYjdKXHUwMDlkXHUwMGRiWVx1MDBlMlx1MDBmZHp7XHUwMGQzXHUwMGViXHUwMGRjNFx1MDBmMlx1MDBmNVx1MDBjM1x1MDA5NVx1MDBkYmFcdTAwYjNaXHUwMGE4blx1MDBlY1x1MDBiNVx1MDBjYVx1MDBmM1x1MDA4OFx1MDAxNyxhXCJKXHUwMGEwXHUwMGUzXHUwMGI0Xi5cdTAwZTA3JVx1MDBjY0tcdTAwYTZcdTAwZDJTNiVcdTAwYTVcdTAwYzQoSlx1MDA4YVx1MDA5ZENcdTAwYmRcdTAwYzZxXHUwMGNhXGJcdTAwZTZcdTAwOTQhXHUwMDg3IEZcdTAwZmNcdTAwZGFcdTAwY2NcdTAwYmJcdTAwZmQsXHUwMGYzSipcdTAwMThcdTAwZTBcdTAwYTcreyB0XHUwMGY0M2lcdTAwZTVcdTAwOTRcXFx1MDA4YjBcdTAwZWVcdTAwMWRcdTAwZjdcdTAwZmVcdTAwY2RSXHUwMGVmXHUwMGUzXHUwMGMxXHUwMGY4IVx1MDBjY1tcdTAwZGEsX181Klx1MDBjZGInXHUwMGQ1V0dfNFx1MDA5N2dcdTAwZmRcdTAwYTNcdTAwODVcdTAwOTU9XHUwMGE4XHUwMDBlXHUwMDAzLTdcdTAwYzFcdTAwOGJcdTAwMWVcdTAwZDVcdTAwZjRcdTAwZTA+Mlx1MDA5NEpFaVx1MDA5ZHpcdTAwYmNcdFx1MDA5ZVx1MDBiMjVcdTAwYWRcdTAwYTNcdTAwMTRcdTAwZWRcdTAwYzlcdTAwZWRcdTAwZWZcdTAwZmFcdTAwZGRcdTAwYjdcdTAwYmJcdTAwYmZfXHUwMGU5XHUwMDhlXHUwMDEyXHUwMGNiPVx1MDBiMVVcdTAwY2ZaSnBcdTAwMDFcdTAwZGFcdTAwYTZcdTAwMTZcdTAwODZcdTAwZGRcdTAwYWNcdTAwYmZccjkhXHUwMDEyUS9cdTAwZGJTXHUwMGY1XHUwMGJmR1x1MDAxYlx1MDA3Zlx1MDAxM1t5XHUwMDkzXHUwMGU5b1x1MDBlNVZcdTAwZDkgfVx1MDBhMnp3RVFcdTAwZGFcdTAwZWZcdTAwOWVcdTAwMWRcdTAwY2MtXHUwMGM0XHUwMGUxXHUwMDEwXHUwMDA1XHUwMGU5XHUwMDE3bVx1MDA4Ylx1MDA5Zlx1MDBmZlx1MDBjY3lcdTAwZGJcdTAwZTI5XHUwMGFkN1lcdTAwODVcdTAwZTdcIkpcdTAwOTYjXHUwMDhmXHUwMGRjXGI1XHUwMDBmXHUwMDA1IFx1MDBlZFx1MDBiMFx1MDBlN1x1MDA5OFNcdTAwY2VcdTAwOWRcdTAwMGVcdFx1MDBmOGNcdTAwZGJcdTAwYWJcdTAwZGZcXFx1MDBjNlx1MDBhNVx1MDBlY1x1MDBjYm8rXHUwMGUzXHUwMGYyPVx1MDBiOWdcdTAwZWYpMGB8KVx1MDBjNDVcdTAwZDNgXHUwMDk4SVx1MDBjNEMwKlxcWFx1MDBmMH0jXHUwMGViPDdcdTAwYmVcdTAwZTNcdTAwMWRcdTAwYzdcdTAwODFSXHUwMDhlXHUwMGQxXHUwMGZjYFx1MDA4M15cdTAwYmNcdTAwZDFcdTAwYjhcdTAwOGNcdTAwOTdcdTAwYzdcdTAwZGQ5W1x1MDA4ZC9cdTAwZWFcdTAwOTVKIFx1MDA4NVx1MDBhOVx1MDBkZFx1MDBjNlx1MDAwYlx1MDBkOVx1MDBlNFx1MDBmMVx1MDAwNlx1MDBlNFx1MDBlOVx1MDBlNlx1MDBiY2JcdTAwZGZxXHUwMGZmcGNlXHUwMGE1XHUwMGJkcXBcdTAwYjhzXHUwMDlhXHUwMGJmbHVcdTAwOGFcdTAwYTJcdTAwYmRcdTAwZGVcdTAwYThcdTAwY2YzXHUwMDkzXHUwMGQwXHUwMGM2XGJcdTAwZTRkPVx1MDBlNVx1MDBkMlBcdTAwZWVoXHUwMDAwKlx1MDBlMlx1MDAxNEpcdTAwYmRcdTAwN2ZAXHUwMGUzXHUwMGMzXHUwMDg0XHUwMDhkXHUwMDkxXHUwMGQxXGJcdTAwYmJcdTAwZjdcdTAwMDB9alN8XHUwMDk1XHUwMGI5XHUwMGYyXHUwMGEyXHUwMGYwXHUwMGYyXHUwMDFiXHUwMGI4XHUwMGVhe1x1MDBlMjNcdTAwZmZcdTAwZDRcdTAwYmViWERcdTAwZDhFOUlWXHUwMGExXCJcdTAwYzAjXHUwMGFlclx1MDBmMFx1MDBhOClcdTAwODFcdTAwOWFcdTAwYmZcdTAwMTlcdTAwOWZcdTAwZjBEXldcdTAwZjBqYiRcdTAwZDhcdTAwMGZcdTAwYjFcdTAwODcnJ1x1MDBiMlx1MDA3ZllcdTAwOGNcdTAwMGZcdTAwZTNcdTAwYTNcdTAwZmNcdTAwZDV1XHUwMGIzXHUwMGFma1x1MDBhZFx1MDBmNlFlXHUwMDFlXHUwMDEzQjQgXHUwMGI3XHUwMGUwXHUwMDgwXHRcdTAwZTlcdTAwYzJcdTAwYjRcdTAwZGRcdTAwYThcdTAwMDJcdTAwMDNtXHUwMDExXHUwMDExXHUwMDFlXHUwMGVlXCJcdTAwZmRcdTAwMTlcdTAwYmJhcCtcdTAwODlcclx1MDBjMVx1MDAwM1x1MDBkMlx1MDAxYU9cdTAwMDBcdTAwZmNcdTAwOGRKMFx1MDA5NFx1MDBkOFx1MDBjMVx1MDAxM1x1MDBmYlx1MDA4ZCVcdTAwZWRcdTAwY2NcdTAwOWFcdTAwZjZcdTAwYzJcZlx1MDA5Zlx1MDA4OVx1MDA3ZmlcbjBsXHUwMDlhfFx1MDBmN1xuIVx1MDBmN1x1MDAwNVx1MDAxMFx1MDAwZVx1MDBlM1x1MDBlZU1cdTAwYmRcdTAwMWNcdTAwYTdcdTAwY2FlPFx1MDBmZEdUYFx1MDA5OGlcdTAwYzArXHUwMGE4cHtcdTAwN2ZcdTAwZWRcdTAwZjRcdTAwYmN6W1x1MDBmMlx1MDBlYlx1MDA4Nlx1MDBkN1x1MDBmMntfXHUwMDBlXHUwMGFhXHUwMDk3W3NcdTAwOTFcdTAwYWFcdTAwMWNcdTAwODVcdTAwZjhcdTAwOTJcdTAwYjZVIWRGXHUwMDk4XHUwMDlmXHUwMGE0ZlwiXlx1MDA4ZVx1MDAxOCVcdTAwZTk5OGpcdTAwOWNWXHUwMGRlXHUwMGVlXHUwMDgxalRcdTAwMDBcdTAwYWFcdTAwZTVcdTAwOWMqXHUwMDhiXHUwMGQwXHUwMDA2XHUwMGI2b1x1MDAwNlx1MDBlYVx1MDBjZkJcdTAwOGJcdTAwODdcdTAwOGJRXHUwMDhhaFx1MDBkM1x1MDBjOEdWXHUwMDFiXHUwMDlkXHUwMDlmRSlcdTAwYTVcdTAwYmJcdTAwODg3b1x1MDA4YVx1MDBiN1x1MDA4NiZcXF9cdTAwODJcdTAwZDJcdTAwY2R6XHUwMDdmXHUwMGVmXHUwMGIyXHUwMGY3XHUwMGU5IFx1MDBhNlx1MDAwNlx1MDBjNFx1MDBkZFx1MDBmZlx1MDBiNFx1MDBmZlx1MDBkMVx1MDBiOVx1MDA4Y1x1MDBiYlx1MDBjNX5cdTAwYTdcdTAwZmJcdTAwY2ZcdTAwZWZiXHUwMGMzX1xuXHUwMGQ2XHUwMGE3XHUwMGRiXHUwMGYxXG5cdTAwYzRcdTAwZmFcdTAwOTZZK1YrXHUwMGY5XHUwMGQzXHUwMGRkXHUwMDk1YidjN1x1MDBjNGFcdTAwYjVcdTAwYjI+XHUwMGQ3eCtcdTAwMTk5JVx1MDA5NFx1MDBiMk5cdTAwOGRcdTAwOGRcdTAwYjc2XCJiXHUwMGQyXHUwMGQzXHUwMGY2OFx1MDBjZFx1MDBhOVx1MDA5OFx1MDBkMFx1MDA4NFx1MDBmZFx1MDBkNlx1MDA5Nlx1MDBmMlx1MDBkOTkuXHUwMGY5fUx4XHUwMGUxXHRcdTAwYWNSdTFcdTAwZTVcdTAwY2NkXHUwMDhkN1x1MDBlYmctOFx1MDBlOS9cdTAwZTVoXHUwMGJmXHUwMGQ5XHUwMGMzXHUwMGM0XHUwMGZkXHUwMGYyXHUwMGI1XHUwMDE2fHIsflx1MDA4Y1x1MDAxMVx1MDBlZmZcdTAwZDY6XHUwMGI5XHUwMDhhXHUwMGVmXHUwMGFkXHUwMGE5Slx1MDBhM1x1MDBiMVVcdTAwZmEyaFx1MDBkNlx1MDBjZlx1MDAwYlx1MDA4YlxuXHUwMGJjWVx1MDBkYVx1MDAxMGtcdTAwYjVkXHUwMDk0XHUwMGEzXHUwMDg5aHxcdTAwYjRcdTAwOThvI0tcdTAwOTlcblx1MDBjYypcdTAwZGFcdTAwY2QrXHUwMGU1XHUwMGRmXHUwMGFmXHUwMGMwW1x1MDA4OVx1MDAxZD03XHUwMGMxQlx1MDBjYjI6XHUwMGVjXHUwMGIwXHUwMDliXHUwMGM0XHUwMGUxbHpcdTAwODdcdTAwODZcdTAwMTZDXCJvXHUwMGFlXHUwMGFkXHUwMGY0XHUwMGYyXHUwMDAyb1x1MDBkZFx1MDBjMlx1MDBhMHZcIlx1MDA4Ylx1MDBiZFx1MDBmZFx1MDBmY1x1MDBkYVx1MDBkNSB1WmtcdTAwYTZcdTAwZjVcdTAwYWVdXHUwMGM0MVx1MDBiNDtcdTAwMTVEXHUwMGEyMVx1MDBjMj5cdTAwOTlIXHUwMGI2XHUwMGIwXHUwMDk1XHUwMDAwXHUwMGFlXHUwMDgwXHUwMGU3K1x1MDA5OFx1MDBkMGYyRlx1MDBkMD5cdTAwYTJcdTAwZGNbXHUwMGE5VVx1MDBmMFx1MDBlZH5cdTAwMWZcdTAwYTJcdTAwZjlcdTAwZmFcdTAwMTRcdTAwZDFcdTAwZDBcdTAwYzRcdTAwYWJgejJWXHUwMGYwXHUwMGIzXHUwMGIxXHUwMDgyXHUwMDAwXHUwMGQ1KFx1MDBhM1x1MDBhZH07XHUwMGQzKFx1MDBjZlx1MDBjNWtcdTAwOTlcdTAwZTZcdTAwZjFcdTAwODhcdTAwZmNcdTAwMThcdTAwYmFxXHUwMGRia1x1MDBjM1x1MDBkNHpcdTAwZDFcdTAwZWVcdTAwZjNcdTAwOTNcdTAwYTNSJVtcdTAwZGRcdTAwZGFfXHUwMGRiXHUwMGQ5fFx1MDBhMjpcdTAwYWVcdTAwOGNcdTAwOGNcdTAwZjRcdTAwOWNyXHUwMGM1KUl7XHUwMGI0U1x1MDA4Nlx1MDAxM21qR1x1MDBhYlx1MDBlYVx1MDA5Y2pzUk5cdTAwY2ZcdTAwMDdcdTAwOTZcdTAwYzd9ZSRcdTAwZjFcdTAwOWVcdTAwZjNsd1x1MDBjZlx1MDBkNVx1MDBjOFVcdTAwZGFcdTAwMThmXHUwMGY1NFx1MDBlYlx1MDBjY1x1MDA4OV8pVVx1MDA4N2pcdTAwMDRcdTAwZjBXXHUwMGQ0XHUwMGM4PVlcdTAwYjNcdTAwYTlcdTAwZGFcdTAwZWRcdTAwZDlGXHUwMGRkXHUwMDFjXHUwMGFjXHUwMDE0XHUwMGQzclx1MDBhM3pcdTAwYzZcdTAwODZcdTAwZjJcdTAwZmJWXHUwMGJkXHUwMGRkXHUwMGQ4bFx1MDBhZlx1MDA5Zlx1MDA5YVx1MDBkNlx1MDA5OXVcdTAwYjdxXHUwMGZhbFx1MDBiYlx1MDBiNCNcdTAwZjVcdTAwZDlcdTAwZjVcdTAwYjNwYVRcdTAwODVcdTAwODFcdTAwZDFcdTAwOTZcdTAwYmMwXHUwMDkxXHUwMGUxRVx1MDA4NFx1MDAxMVx1MDBhN1x1MDBmZFx1MDBkMVx1MDA5ZVx1MDBiMlx1MDAxMVx1MDBmZFx1MDBkZlx1MDAxNSxcdTAwY2ZcdTAwMTZUXHUwMDA2d3FcdTAwMGZ0TFx1MDBmYkpsXHUwMGQ2RlFcdTAwODlcdTAwMTFcdTAwY2VcdTAwMTZkQnxcdTAwMTNaXHUwMDk2X1VcdTAwN2ZcdTAwZjlcdTAwOWJcdTAwYjBcdTAwYjVoXHUwMGExa1x1MDAxMVx1MDBiNlx1MDAxOFx1MDAxNVx1MDAxZlx1MDA4MFx1MDAxMVx1MDAxNFx1MDBkZTpcdTAwOWJcdTAwMTBcdTAwMTFidUpcdTAwOTdcdTAwMTFcdTAwY2U7Qzs0XHUwMGY1XHUwMGRmXHUwMDE0Wlx1MDA5Yz1cdTAwMDctR1x1MDA4OVx1MDBiZTpcdTAwYzhtXHUwMDhjLDlcdTAwOWJcdTAwOTVOXHUwMGM1bFx1MDBmOEtDXHUwMGM2V1VcdTAwZGZ+XHUwMGQ1XHUwMGZhXHUwMGY5X29ZXHUwMGFifXhFXHUwMGFmXHUwMGQzZlx1MDBlNydcdTAwZmJcdTAwOTVcdTAwYzNcdTAwMTJcdTAwZWJcdTAwOWVcdTAwYTVcdTAwZjJcdTAwOWJ7XHUwMGYxaVx1MDBlZlx1MDBlYkJkWUZcdTAwYzlcdTAwYjBcdTAwYzlMXHUwMGI4Ylx1MDBhMTZcdTAwMThJXHUwMGRmXHUwMGQxXHUwMGU2XHUwMDg4XHUwMDg5XHUwMGFjXHUwMDkxXHUwMDg5XHUwMGIyXHUwMGM5VH1DU1xyYDdcdTAwZTVQXHUwMDFiXHUwMGRhXHUwMDBmXHUwMGExXHUwMGE4XHUwMGY2REhcdTAwZTlcdTAwZTFcdTAwZWFXXkJcdTAwZTJcdTAwZmMpT1x1MDBjYlx1MDBiM1x1MDAxMFx1MDBlY05VXHUwMGMwXHUwMDlhXHUwMDAxXHUwMDEzXHUwMGY0XHUwMGQxUVx1MDAwMlx1MDBlYVx1MDBkYlx1MDBkN1x1MDA4ZlxuXHUwMGVkXHUwMDgzYVx1MDBlM1x1MDBhNlx1MDA3Zlx1MDBiM1x1MDBjM1pcdTAwODVcdTAwYTNSJ1x1MDBiZFx1MDBmZDVcdTAwZWZcdTAwZWN8cHhcdTAwMTVCXHUwMGM3XHUwMGU3XHUwMGMxMVx1MDBmZlx1MDA5OXNcdTAwZTZcdTAwYTIsXHUwMDk1XHUwMGM0UFRAXHUwMGQ5UVx1MDBmNVwiXHUwMDlkLF5zWDNNWVx1MDA4MFx1MDBmMDNcdTAwMWZcdTAwOTVVXHUwMDE2XHUwMDllXHUwMGE2WUNcdTAwY2RcdTAwMWFNRTJcdTAwYmVcdTAwYzdcdTAwMWFcdTAwZDJPXHUwMGM0MVx1MDBmY1x1MDBjYlx1MDAxM1x1MDBmM1x1MDBmNFx1MDA4YUNcXFZcdTAwODlJXHUwMGNiJWZcdTAwYTMrR1x1MDBmYndcdTAwYTlcdTAwOWFcdTAwZDNcdTAwOWJcdTAwZjNcdTAwODZcdTAwOWVcdTAwMTlcdTAwZTZcdTAwODdcdTAwMTlcdTAwYTlcdTAwZTZ1XHRqXHUwMGRlXHUwMGZhVFx1MDBiY1x1MDBiY2xccj5cdTAwYWRUP1x1MDA5ZCNcdTAwOTRcdTAwZWVEXHUwMDgzXHUwMGUyRVx1MDBlYj1XXHUwMDk1XHUwMDE2UVx1MDBjZlx1MDBhM3lcdTAwYWFcdTAwYTdbXHUwMGY3XHUwMDhheVx1MDBhYVx1MDBkZFx1MDA5NVx1MDA4M3xcdTAwYmNcdTAwN2ZcdTAwZDVcdTAwYmFcdTAwYWNcdTAwOTc7XHUwMDg1XHUwMDhiXHUwMGUzRldcdTAwY2FcdTAwYWZcdTAwYmJcdTAwMGJcdTAwYTlcdTAwY2VTJVx1MDBlNlBsXHUwMDg4Klx1MDBmM1x1MDBiOVEhUlx1MDBhMTxcdTAwOTUuXHUwMGEzXHUwMGVhOklcdTAwZTFcdTAwZjhcdTAwYjE7XHUwMGFmXHUwMDk4XGJLXHUwMDFjXHUwMGNhXHUwMDhmXHUwMGZlXHUwMGE4WVx1MDBiZVx1MDBmM0lcdTAwMDYjZXFcdTAwZmNcdTAwYzJ0XHUwMGY3VFx1MDBkOXA4XHUwMGYwXHUwMGM2U1lcdTAwOWJcdTAwYTlcdTAwZTJcdTAwYjZ6XHUwMGQ2XHUwMDhiXHUwMGE3eVx1MDAxNFx1MDBhYThcdTAwZTBcdTAwZGZcdTAwY2N4XHUwMGNkXHUwMDkzXHUwMGI4XHUwMGJiXHUwMGQxXHUwMDkxXHUwMGQ1XHUwMDhjXHUwMGFjbFx1MDBmM1x1MDBkNlx1MDBlOVx1MDBhOVx1MDBhY1x1MDBiNlx1MDBmNlx1MDBhZlx1MDBlNlx1MDBhZlx1MDA5OFNNXHUwMDllXHUwMDE3XHUwMDk5XHUwMGMzXHUwMGY5XHUwMGNmXHUwMDljV1x1MDBhOVx1MDA5Ylx1MDBjMVx1MDAxNFx1MDAxYVx1MDBhYVx1MDBhM1x1MDBhM2lcdTAwYWZcdTAwOTVQI3xoXHUwMDFkUVx1MDBiMlx1MDBiN1dcdTAwZjJcdTAwZTFgXHUwMDgxXHUwMDA3U1x1MDBjOFx1MDAwMlx1MDBlNVx0XHUwMGZiW1FcdTAwOWR8XHUwMDAyXGZUSVx1MDA4MlNcdTAwMTFcdTAwOTBcdTAwYTlZXHUwMDg2XHUwMDk5XHUwMDFhXHUwMGRkXHUwMDhhXHUwMGNhRlFlXHUwMGNlXHUwMGI3TjI8N1x1MDBiYzNMV1x1MDBlZVx1MDBjNnhcdTAwMDJcdTAwOTFJXHUwMGFmeFx1MDAxM1x1MDBhZlx1MDBiNDtcdTAwYjdcdTAwZWZcdTAwY2V2XHUwMDhiXHUwMGQ4Z1x1MDAwMVx1MDBkYi1uXHUwMGUxK1x1MDAxOFx1MDBlZkVxXHUwMGNjQ0FLXHUwMGExXHUwMDljXHUwMDE2XHUwMGEwL2lcdTAwYTlcbltAXHUwMGIzXHUwMDAzXHUwMDlhaVx1MDBmZlx1MDBiYlx1MDBhMV1cdTAwYjA6XHUwMDE0XHUwMDBiXHUwMGI4XHUwMGNmPlx1MDA4YmBcdTAwZDNcdTAwYjVXVGNcdTAwY2NbXHUwMGFhXHUwMGIxOlx1MDAxMTRANSR5XHUwMDg3XGJcdTAwZDFcdTAwYThyXHUwMDk1XHUwMGU2XHUwMGJmPFx1MDBjYVx1MDA5ZipFXHUwMGFmVChcdTAwYzgxNW9cdTAwZTFcdTAwZmNcdTAwOWNdXHUwMGFjXHUwMDkyXHUwMGFhXHUwMDFjXHUwMGJkOVx1MDAxOVxyXHUwMDExXHUwMDlke1x1MDBlZFx1MDBjOSxcdTAwYjNcdTAwODM0OzZvT1x1MDBiZlxcNEE2Slx1MDBiZmRcdTAwMTEsXHUwMDllUlx1MDA5NG9cdTAwZTBpXHUwMGNidFx1MDBkOFx1MDBjZlx1MDAxNlx1MDAxMjBVXHUwMDE0XHUwMGVhXHUwMDE1XHUwMGQyXHUwMGFlLFx1MDBhYVx1MDBiOVx1MDBhN1x1MDBjZFx1MDBhM1x1MDBmY1x1MDBjYlx1MDBlOVx1MDBkNyxcdTAwOWFYXHUwMDE5XHUwMGJkXHUwMDg2XHUwMGY2bkpcdTAwOGE7XHUwMDhjXHUwMGE3XHUwMDkyXHUwMDhjXHUwMGI0XHUwMDgzXHUwMGM1XHUwMGRkJ3pcdTAwODZcdTAwZGRcdTAwZDkyXHUwMDk0SFx1MDBhNFxyXHUwMGExXHUwMGY2XHUwMGFmXHUwMDE3ZnVcdTAwZjZcdTAwYmJcdTAwZjV7V2BBYlx1MDBlN1x1MDA4Ylx1MDA4Mlx1MDBmZVx1MDA4N1x1MDBmZFx1MDBjMFx1MDBjY1x1MDBhM1x1MDAxMVx1MDA5ZSpcdTAwZjI5XHUwMGNhJ1x1MDBhNFUvXHUwMGVlYChCXHUwMGMxXHUwMDE2XHUwMDA1XHUwMDlmXHUwMGMxXHUwMDhlXHUwMGZkXGJcdTAwMTlRLVx1MDA4ZnBcdTAwOTRcdTAwMWJcdTAwOTVcdTAwYWJUalx1MDBjMjV2Plx1MDBhMlx1MDA5Mlx1MDA5Zlx1MDBiNFx1MDBmOVx1MDAwNnpcdTAwYzlcdTAwN2ZcdTAwMDNcdTAwZjNcdTAwYTFcdTAwOWZcdTAwOWNcdTAwYTRcdTAwYTZcdTAwMWFcdTAwYTFUXHUwMGY4bkJcdTAwYjFcdTAwYzRcdTAwOGNcdTAwYTNMZVx1MDAxMihcdTAwYmVcdTAwZjgmXHUwMGM1XHUwMGIyXHUwMGFmUlx1MDBhY1x1MDA5NTlcdTAwODM0MzZvV1x1MDBhY1x1MDA5ZFx1MDBkYlx1MDBhZFx1MDA5YVx1MDBkZVx1MDBiZGxcdTAwOTU3XHUwMGQ2Vlx1MDAwYl9cdTAwZjRcdTAwYzVcdTAwZmF6alx1MDBkMFx1MDA5YVBcdTAwYWNcdTAwMTFcdTAwYmNcdTAwYjl6Um9cdTAwMTZcdTAwOWRcdTAwZWU2XHUwMGExN1x1MDBjYVx1MDAwYmssXHUwMDk1XHUwMGYxXHUwMGE0Olx1MDA5N1x1MDBlYz5BXHUwMDlhXHUwMDdmPz15XHUwMGQxXHUwMDg0XHUwMGM3XHUwMGM4XHUwMDA0QSNcdTAwYjRcdTAwZDF0XHUwMDBlRFx1MDBhOENcdTAwOTlcdTAwYWNcdTAwZGNIXHUwMDFlcSohSlVcdTAwYTA0XHUwMDkzblJPXHUwMGE4NipcdTAwYjhcdTAwZDdQXHUwMGY5RmFcdTAwZDRUXHUwMGU5XHUwMDdmXHUwMGNiXHUwMDE0ZVx1MDA4OVx1MDBjMVx1MDBjN1x1MDBhNFxuXHUwMDljXHUwMGJmXHUwMGJjXHUwMDllXHUwMDk4XHUwMGU3Tzt0XHUwMDAyPCgmfS1rZ1x1MDBhY1x1MDA5MDdcZlx1MDA4NFx1MDBjM1x1MDBmNVx1MDAwNytcdTAwY2JcdTAwZTNcdTAwOTFcdTAwOWFcdTAwMTlcdTAwYTBcdTAwYjcrXHUwMGNiXHUwMDBiYFx1MDBjZVx1MDA5NFxiVXNcdTAwMTBcdTAwOTdcdTAwMThcdTAwYjBcdTAwMDVcdTAwOTVCelx1MDBjMFx1MDBmOWJcdTAwZmR5ZndcdTAwYzFKOFx1MDBlYkRcdTAwYjJcdTAwYjBcdTAwMDVcdTAwODhcdTAwZmFwXHUwMGFmPVx1MDA5Mk5cdTAwZjBcdTAwMTFhK1x1MDBiMVx1MDBhM1x1MDBjYVx1MDA5NVx1MDBkZlx1MDBkMVx1MDBlYVx1MDBkY1x1MDBhY1x1MDBlNlx1MDAwNj1cdTAwOTM+XHUwMDhjXHUwMGNmXHUwMGNhZ19lYzdcdTAwYmJaaVx1MDAxZC9cZmFpXHUwMDg3Ulx1MDBhNFx1MDBhOVx1MDBiMFxmXHUwMGVkclx1MDA4N1x1MDBmM2FHXHUwMGM5XHUwMGRkXHUwMGI0XHUwMGZmXHUwMDAxXG5cdTAwZWY4OS5+XHUwMGJjXHUwMGM4YGVcdTAwZTRcdTAwOGNcdTAwYjZcdTAwZjBcdTAwZjNoXHUwMDAxPFx1MDBlNDlcdTAwN2ZzXHUwMDkyXHUwMGY3XHUwMGY3XHUwMGRkRFx1MDBmM1ZcdTAwZjVxT1x1MDBmOG9cdTAwY2UyXHUwMGNhelx1MDA5ZVx1MDA5Y1x1MDBhMUM4XHUwMGU3Zlx1MDBmMyZcdTAwYTlcdTAwMTZcdTAwYjJcdTAwYTU6XHUwMGRkXHUwMGVmXHUwMGI0XHUwMDliYHxcZlx1MDBjZlx1MDBlNSBcdTAwMTc3OFx1MDBlMlx1MDA4OFx1MDBlM1x1MDBkYlx1MDBhZVx1MDBhY1VKN1x1MDBhNnA8N1xuXHUwMGI1XGIqXHUwMGUxXHUwMGIzXHUwMDEzX1JcdTAwMTdMXHUwMDkyXHUwMGFlXHUwMGU4XHUwMGU5XHUwMGI4XHUwMDE0XHUwMGY4Q1x1MDA5NFx1MDBjNihcdTAwZThcdTAwZThcdTAwODFcdTAwZjE5PFx1MDA5ZSpcdTAwYWY6XHUwMGY4XFxhXHUwMGYydTJcdTAwMTJcdTAwOGRcdTAwZjBcdFx1MDA5NVx1MDA4M1x1MDBkNnBlXHUwMGJkXHUwMGUzXHUwMGJmXHUwMGJla1x1MDA5MXpcdTAwY2FnXHUwMDBmNVx1MDBiN1x1MDBhNlx1MDBkN1x1MDAxYVx1MDBmNVx1MDBlYz5aIUF4cENcdTAwZmNcdTAwYmJcdTAwMWZcdTAwMWVcdTAwZDVcdTAwYWZfXHUwMGM0XHUwMDlmXHUwMGZlXHUwMGQxe1x1MDA5Y1x1MDAwMkZcdTAwZjVcdTAwOWVcdTAwMThtXHUwMGZjIW1cdTAwYTRcdTAwODVcdTAwOTlcdTAwZjdLXlx1MDA5Y2hcdTAwYzErQlx1MDBjY1x1MDA4Mlx1MDBkOFx1MDAxNFx1MDA4Nzw1PFx1MDA4ZVx1MDAwYidcdTAwZTVcdTAwZWJ9d1FcdTAwZWJcdTAwMWZcdTAwZGRcdTAwY2NNXHUwMGFjZTxcdTAwODI9QihAXHUwMGM1XHUwMDFjXHUwMDgxXHUwMGJmXHUwMDA0XHUwMGE5XHUwMDk0XHUwMDkwXHUwMGEzXGZcdTAwMTVcdTAwYzNOZlx1MDBjZSZcdTAwZjZcdTAwOWFcdTAwZDBsXHUwMDg5XHUwMGQ3SlB9XHUwMGY1UFx1MDA4Mlx1MDBlYVx1MDA5YjdcdTAwMTlcdTAwZmNcdTAwMWNcdTAwMWJcdTAwOWNcdTAwZjhcdTAwZmFiYFx1MDBhYVx1MDA5MFx1MDAxZlx1MDBmNyhcdTAwODdbXHUwMGNmXHUwMDllakYlMU1cdTAwZjBrXHUwMGRldmYxXHUwMGQ1XHUwMDEycS9cdTAwYzJcdTAwZTVcdTAwOWZcdTAwZWNcdTAwMDNcdTAwMTNoXHUwMDkzXHUwMGI3XHUwMGJkXHUwMDAyflx1MDBlN1x1MDAxN1x1MDBiOVx1MDBjZFx1MDBiZFp+XHUwMGFmeyFqJ1x1MDA5NWpcdTAwYmJcdTAwZDJcdTAwYmNcdTAwMGVcdTAwZmVcdTAwZTQsXHUwMGZjaE1cdTAwZDQpXHUwMGZiXHUwMDE4dXY8XHUwMGE1XHUwMGExQ34sXHUwMDlkXHUwMDlmXHUwMDExXHUwMGYwaVx1MDBmY1x1MDBlZlx1MDA4M1x1MDBiOFx1MDA4ZFx1MDBhN1x1MDBiNlxyIDxcdTAwZDZcdTAwOTZPTVx1MDBkMlx1MDBjZDlEXHUwMGM5SipcdTAwODFcdTAwYTQoXHUwMDFiXHUwMGYxXHUwMGZkIVx1MDAxN1x1MDBmMVx1MDA4Zlx1MDAwNV14XHUwMGRmK2BXPm5cdTAwYmJcdTAwYzNcdTAwYmFcdTAwZGRcdTAwZGRVXHUwMGYxXHUwMGUxXHUwMGU1XHUwMGRhMFx1MDBjYlx1MDBlYlx1MDA5OVx1MDBmZVx1MDBjNVxcXHUwMDAzXHUwMGFkXHUwMGE5XHUwMDA0XHUwMDg5XHUwMGExXHUwMDAzXHUwMDE4XHUwMGU4NFx1MDAwNDNcdTAwZGFcdTAwZDdsXHUwMDEwKzplXHUwMGU4XHUwMDFjd1x1MDBmZFx1MDBjZFx1MDA4Y1dcdTAwOGUrXHUwMGFhUlx1MDBmY1x1MDBhOVx1MDBmMVx1MDBiN1x1MDBmNVx1MDBjNC5cdTAwMDJcdTAwYTdcdTAwZThcdTAwYTBcdTAwYjdcdTAwZTlTXHUwMGNiXHUwMGZjXHUwMGNjXHUwMGE2Kk5HZChcdTAwMDUgXHUwMGJkXHUwMGJmKVx1MDBmZVNcdTAwN2YoXHUwMGZhXHUwMGY0XHUwMGViXHUwMGIwJ2o7Zm0nXHUwMGQ3O1x1MDBmNXdcdTAwOGRCXHUwMGY2XHUwMGVjXHUwMDkwXHUwMGY1b0NcdTAwMDNmXHUwMGIxZ1x1MDBhNFx1MDBhMlx1MDBmMlx1MDA5MnRcdTAwZWNcbmJ8XHUwMGNiXHUwMGZkNFx1MDBmOHRcdTAwZTJcdTAwMDF+I1x1MDBmYlx1MDBmZFx1MDAwMlx1MDBlOFx1MDBjYixGXHUwMDlmXHUwMGU0XHUwMDA2XHUwMGFhZlx1MDAxNVx1MDA5YlpcdTAwYWVcdTAwOWFcdTAwYWRcdTAwODZkXHUwMGU5XHUwMGU0XHIrNFx1MDBmZlxiXHUwMGY0fTBcdTAwZmJcdTAwZTlcdTAwZDdcdTAwYjJfXHUwMGYxXHUwMGNjXHUwMGE0TytcdTAwYzM6XHUwMGRmXHUwMDE5XHUwMDk2XHUwMGYzXHUwMGQ2XHUwMGIwdFVdNlx1MDBlN25qXHUwMGE3XHUwMDAwVEpcdTAwY2RcdTAwMTU8bVx1MDBhYVx1MDBmZFx1MDA3Zlx1MDBiZkVcdTAwYzVcdTAwODdcdTAwZDNzSFx1MDBhN1xyXHUwMDlkXHUwMGI3PW9POzxcdTAwOWMoLFx1MDAxZVFcdTAwOWFcdTAwOGR3T1x1MDA5Nlx1MDAxM1x1MDBmOVx1MDAwNTBcdTAwZjhcdTAwZWNcdTAwYTZ2XHUwMDFmXHUwMGVhXHUwMDE0XHUwMGY4aSRKNVx1MDA5Ylx1MDA5Mlx1MDAxYlx1MDA4ZVx1MDBhOFx1MDBiMlx1MDAxZlx1MDBiYlx1MDBhN31mMH5IQm5cdTAwYmFcdTAwOTZcdTAwOTJcdTAwYzNTdlx1MDBmM1x1MDBmNVx1MDBlMFx1MDBjYW5cdTAwYWZcdTAwZWFcdTAwYWZfXHUwMGRjdT47XHUwMDE3d1xiXHUwMGRhXCJcdTAwYWF1XHUwMGM5XHUwMGU4XHUwMDAwITpcdTAwYTEqXHUwMGMxXHUwMDFkbFx1MDBiMVx1MDBhNnpcdTAwOWRcdTAwOTJcdTAwYWJcdTAwOTBEOlx1MDBiMVx1MDA5YVx1MDBmNXgjM1x1MDBmNThcdTAwOWMyKFx1MDBhZFx1MDBhMkpcdTAwYjhcdTAwZmFcdTAwMTdcdTAwZTdcdTAwYzBcdTAwOWRnXHUwMDhiKjhcdTAwYTFcdTAwZThcdTAwZDhcdTAwOWZcdTAwYTlyNmBcdTAwYmRcdTAwZDljXHUwMGRkXHUwMGMxXHUwMDgzdFx1MDBmYVx1MDAxY1x1MDBlN1x1MDAxZlx1MDA4OFx1MDBjMFx1MDBmYlx1MDBmMVx1MDA5OVx1MDAxZFx1MDA5Nlx1MDAxZlx1MDA4Mlx1MDBjNFx1MDBhM1x1MDAwYlx1MDBiNVx1MDBiNlx1MDBiOTNcdTAwZWM2XHUwMGZiXHUwMGY1XHUwMDlkUlx1MDBiM1x1MDBkMVx1MDBkY1x1MDBkYVx1MDBkYVx1MDBlZV5cdTAwY2QvXHUwMGViXHUwMGExWFx1MDBhNFVcdTAwYThcdTAwZWZcclx1MDBmN1x1MDAwNVx1MDA4NnhUXHUwMDg1XHUwMDlkNlx1MDBkMzhNXHUwMDE1XHUwMDAzKFx1MDBiYVx1MDA5ZkdcdTAwODBcdTAwOWFcdTAwYzlIOiFcdTAwMTFcdTAwMTFyOkBcdTAwY2NcdTAwZGJfO1x1MDAwNlx1MDBkOVx1MDA3Zlx1MDAxNlx1MDA4MVx1MDBjYTlcdTAwMGZcdTAwYmRcdTAwOWNcdTAwZGFfL1x1MDBjNCxcdTAwMDB1KFx0XHUwMGE3XHUwMGE1XHUwMGZmSFxuXHUwMDljXHUwMDFkXHUwMDhlXHUwMDFmXHUwMDgyXHUwMGJjXHUwMGZhLVx1MDBkZlx1MDBkZVx1MDBkYm9cdTAwMTVcdTAwZjZcdTAwZWZ8XHUwMGJhXHUwMGJlVTk4XHUwMGZlXHUwMGJhXjlcdTAwOWNcdTAwN2ZcdTAwOTRcdTAwYmNcYlx1MDAxM1x1MDBkNFx1MDBiMFx1MDAxNzIkMFx1MDBlOVx1MDA4N1x1MDBhOUFlOFx1MDAxZElcdTAwZTZcdTAwZGU5XHUwMGY2XHUwMGFkVkcnWlx1MDBmZVx1MDBkMFlcdTAwYzBcdTAwYTd2XHUwMGNjSypuTjwyXHUwMDE5XHUwMGZhXHUwMGNlXHUwMDAxXHUwMDFjXHUwMGRjXHUwMDE0XHUwMDA0cFx1MDBkZVx1MDBlYlx1MDAwZlx1MDBmMFx1MDBmZURcdTAwYTQ+XHUwMGQ0XHUwMGZiS1x1MDBkZVx1MDBmN1xuXHUwMGVmXHUwMGVmXHUwMGU2cFx1MDA3ZnVcdTAwZjd4Y1x1MDBkZm54XHUwMGJmXHUwMGI3U1x1MDBkYj5cdTAwZDdcdTAwZDlcdTAwZjZcdTAwZTJcdTAwOGNhLixcXFx1MDAwN0FcdTAwMDcoUFx1MDBkNVx1MDAwNjM6KX5cdTAwYmNcdTAwMDZhXHUwMDkzfFx1MDBiOVx1MDA4OVx1MDBjY1BcdTAwMWRRXHUwMGUyXHUwMGEyVFZcdTAwZmY2XHUwMGNiXHUwMDBmT1x1MDBlY09cdTAwYjZcdTAwOGUhNDN8alx1MDBmOVx1MDBjMVx1MDBjYVx1MDBkOVxiXHUwMGM0XHUwMGMyXHUwMDhkXHUwMDgxTlx1MDBjYndffThvXHUwMGRkXHUwMGVlfDlcdTAwYmNcdTAwZGM5XHUwMDg4XHUwMGRkXHUwMGQ1XHUwMGMxcW5cdTAwN2ZtXHUwMGI3XHUwMGVlXHUwMDA2XHUwMDBiXHUwMGI2WVx1MDBkMllOXHUwMDExXHUwMDlkIyVcdTAwYTlcdTAwZjA2XHUwMDFkXHUwMGExOlx1MDBhYVx1MDA5N2AjXHUwMGEzXShcdTAwYzZcdTAwMWRcdTAwYWFpXHUwMDdmXHUwMGVjNlx1MDBjYmpcdTAwYjVcdTAwZTJcdTAwOGFcdTAwZWVcdTAwMDdvXHUwMGIzXHUwMGU0XHUwMDg1Z1xyXHUwMDllXHUwMGE1Q1x1MDA4Mlx1MDA5NFx1MDA5Y1x1MDBiMnhcdTAwMThhP3tkO1x1MDAxZDtNXHUwMDg3XHUwMDkzXHUwMGJjXHUwMGZiXHUwMGUwXHUwMGRiXHUwMGZlXHUwMGExXHUwMGM5XHUwMGVkXHUwMDk5dFx1MDBiYXtcdTAwOWMvblx1MDAxN1x1MDBiM1x1MDA5N2dcdTAwYTlcdTAwYzNcdTAwZDJiXHUwMGVkXHUwMDg2I1x1MDA4ZFx1MDBlMWdcdTAwOTZcdTAwY2VcdTAwOTRcdTAwZDdcdTAwYzJcdTAwZmJHK1x1MDA4Y14mXHUwMGVhPVx1MDBkNm5cdTAwMTglXHUwMGU3XHUwMDA1XHUwMDk1Vlx1MDAxNlRcdTAwODFcdTAwZDlcdTAwYmFcdTAwZWZcdTAwYTBcdTAwZTMvXHUwMDFmXHUwMGVlXHUwMGY3XHUwMGQ0XHUwMGYxXHUwMGUzJ0tcdTAwYjNcdTAwYzB+UFx1MDBiZFx1MDBmOFx1MDBjOVx1MDA4MVx1MDAwZVx1MDA5ZFx1MDA3Zlx1MDA5YyNmXHUwMGU5XHUwMDE4VVx1MDBmMVxydVx1MDBhNCpcdTAwZWNcdTAwYWNcdTAwZGRYXHUwMDE5XHUwMDFlXHUwMDFhWThcdTAwYmZtXHUwMGM5XHUwMGY2XHUwMGNkXHUwMGUwXHUwMGFjUFx1MDA5Ylx1MDA5Zlx1MDAxNUtcdTAwODdcdTAwYTJcdTAwYmVoXHUwMDFmXHUwMGMwXHUwMGZjZ1x1MDBjZXM6QFx1MDBmNFx1MDAxMVx1MDBhM2NcdTAwYjFcdTAwZTl0ZyZGXHUwMGYxXHUwMGJlXHUwMDkwXHUwMDBiXHUwMGUwXHUwMGMwWVx1MDBkOFx1MDAwNVx1MDBlN1x1MDA5OVx1MDBhNVx1MDBmNC0+VVx1MDA5M1x1MDBlMlx1MDA4M1x1MDA5Mlx1MDA5Ylx1MDBkZVx1MDA5M1x1MDAwMVx1MDA4ZTdcdTAwMTdjXHUwMDgyXHUwMGNlXHUwMDEwXHUwMGQyXHUwMGQ2eD9cdTAwZTVcdTAwZWRcdTAwY2VcdTAwOWM4XHUwMGU1Qlx1MDBkZEtcdTAwZjlmXHUwMGM1XHUwMGQ3XCJcdTAwYWNcdTAwMTJcdTAwYmRcdTAwYTB4Slx1MDBhYlx1MDBkM1x1MDA4Ylx1MDBmMVx1MDA4Nlx1MDBlMlx1MDBlMElcdTAwZjdDXHUwMDdmW1x1MDAxNlx1MDBlY1wienlcXEhlXHUwMGIyMVx1MDBhZlx1MDBmME1cdTAwYmFcdTAwOTVGZlx1MDBmNVx1MDBlODRcdTAwZGRIXHUwMGY3XHUwMDhiaXNcdTAwOTFqXHUwMGRlXHUwMDllXHUwMDljXHUwMGRlXHUwMGNkXHUwMDA1XHUwMGFhXHUwMDk0Nlx1MDBhMlx1MDAxZG0uXHUwMDljc2pcdTAwZDhcdTAwZmRcdTAwZGNcdTAwYThcdTAwYTNTXHUwMGEzXHUwMGI5XHUwMGEyKlx1MDAwN1x1MDBiNC9cdTAwZTlvXHUwMGUwID9cdTAwYjVNXHUwMDljXHUwMGQxIVV2OjdcdTAwYzIyP1x1MDBiYlVcdTAwODVcdTAwYjJqPmZ2VH1cdTAwYjBcdTAwN2ZcdTAwYWNeXHUwMGViXHUwMDFmXHUwMGM3XHUwMGQ1XHUwMGYzYSpcdTAwZGVcdTAwZGVcdTAwYmFcdTAwYzhcdTAwYWNcdTAwZWVcdTAwYWRkV3Z3XHUwMGNhXHUwMGY5XHUwMGYyXHUwMGNhXHUwMGUyXHUwMGJkXHUwMDlhXHUwMGNhRFx1MDA5YylNJ0kzXHUwMDAwXHUwMGQyJFx1MDAxNlx1MDAxNDFJXHUwMGE0aXlIMUZzXHUwMGNmXHUwMGUzXHUwMDE5XHUwMDAyXHUwMGExRERcdTAwODdcdTAwY2RcdTAwZDNcdTAwMTlcdTAwZDRcdTAwY2VcdTAwY2FwNlx1MDBkNFx1MDBkOFx1MDA4Mlx1MDBkMlx1MDA4MWVcdTAwOWVcdTAwODdcdTAwZjNTXHUwMGU4XHUwMGM3L1xyXHUwMGNjXHUwMGQzJ1x1MDA5Nlx1MDBjOVx1MDBjM1FcdTAwZWNcdTAwYzJOXHUwMDE3XHUwMGY1XHUwMDk5WVx1MDBiM1x1MDA4NFx1MDAxNFx1MDBiOVx1MDBmYyZrXHUwMGJheSZcdTAwZmJcdTAwYWRcdTAwZjJcdTAwOTdcdTAwY2RcdTAwZTZwXHUwMGFiXHUwMGI1XHUwMGI5XHUwMGZhdWd3XHUwMGU1XHUwMGY4clx1MDBmN1x1MDAxYjdcdTAwZjNcdTAwY2VcdTAwN2ZcdTAwZTZcdTAwYmNcbk9aXHUwMGQwXHUwMGMxXHUwMDE2XHUwMDE4UVx1MDA4ZGFmXHUwMGEzXHUwMGRkXHUwMDk2Ulx1MDBmMFx1MDBjOFx1MDAwYlx1MDBjYTxzXHUwMDljXHUwMDhlXHUwMGIzXHUwMDE4XHUwMGZiXHUwMGQ2Qlx1MDA4Nlx1MDAxZCZcdTAwYzZSZX4lXHUwMGJjXHUwMGZkLlx1MDAxY1x1MDBmNTNcdTAwZjhWXHUwMGM1J1x1MDBlYyhcdTAwZTNSXHUwMGQxaVx1MDAxZVMxXHUwMGZjXHUwMGVjXHUwMGE5XHUwMGFiXGZcdTAwZGVcdTAwMGVcdTAwOTVcdTAwYmR4XHUwMGYzdjpcdTAwYTNcdTAwYzM5Nlx1MDBjZnNUXHUwMGFlXHUwMGQzXHUwMGJkKFx1MDBiNj5cdTAwZWRcXFx1MDA5N1x1MDBlMm5cdTAwMWJcdTAwYWZcdTAwZTl9Slx1MDBlZGdcdTAwZGVza1x1MDBjOVwiXHUwMGM2eVx1MDBjNGVcdTAwOGJcdTAwZGFcdTAwZjVcbm5bXHUwMDk0XHUwMDk4PFx1MDA5Zlx1MDBkYkBDdFx1MDBiYT0sXHUwMGFjUVx1MDBlMy10XHUwMDEwY1x1MDBlNFx1MDA5OVx1MDAwNFx1MDA4Mzk6XHUwMGIzXHUwMGNkXHUwMDhjZ1x1MDBlMVx1MDAxNVx1MDA4Mlx0TSFcdTAwYTFSIVx1MDBmMVtcdTAwZmZcdTAwYWVcdTAwY2NWWVxmZ1x1MDBhZFx1MDAxOFx1MDAxZFx1MDBjOTE1JVx1MDA4NSB7XHUwMGI2XHUwMGFlXHUwMDgxXHUwMGE1XHUwMGUza1x1MDBkOFx1MDAwYlx1MDBhYlx1MDA4Y1x1MDBjZVx1MDBlNVx1MDBiNlx1MDBkNFx1MDA4OT1cdTAwYzhcdTAwYmFcdTAwZjZcdTAwZjF+Y1x1MDBkM1x1MDBlNFx1MDBmNEFcdTAwZTNmd1x1MDBiN1x1MDAxMVx1MDA3ZmhZXHUwMDgzXHUwMGY5LVx1MDA5OFx1MDBjN1x1MDA4NFx1MDBiNFx1MDBiN1x1MDA5Mlx1MDA4ZVx1MDA4ZEZMYVxyXHUwMGQ3XHUwMGEzTFZcdTAwMDBcdTAwMDVcdTAwZThcdTAwMTR0Nlx1MDAxM1x1MDAxZE47XHUwMDkxXHUwMGU3XHUwMDE4XHUwMGEwXHUwMGU3XHUwMGZkXHUwMGVjXHUwMGNlXHUwMGYzX1x1MDA5Y1xiXHUwMGUzXHUwMGE3XHUwMDgyXHUwMGNjXHUwMGIwXHUwMGFkXHUwMGQ4XHUwMGJiXHUwMGE5XHUwMDE1RDZbXHUwMDEyXHUwMDAzXHUwMGE2XHUwMGMxXHUwMGUwUmFcdTAwZGZcdTAwY2NcdTAwODRcdTAwOTZTXHUwMGMxXHUwMGMwXHUwMGU3XHUwMDk5XHUwMGYwXHUwMGI4XHUwMGQ4L1x1MDA5Zlx1MDA3Zlx1MDA4MjBcdTAwZTJcdTAwZTJFXHUwMGVmXHUwMGQzPzpcdTAwOTdcdTAwZmRcdTAwZThTXHUwMGYxXHUwMGJhUlx1MDBlZlx1MDA3Zlx1MDA4YW9cdTAwZThcdTAwZjlcdTAwZmZ8T1ZcXFx1MDBjNFVcdTAwOGZYXHUwMGYxJW18XHUwMGNkXHUwMDk2XHUwMGJiXHUwMGY4blx1MDBmYlx1MDBjY3dkXHUwMGQzbF1cXFx1MDA5Nno3XHUwMDk1XHUwMGYzXHUwMGExXHUwMGI5fVx1MDBiMlJcdTAwODdcdTAwODJcdTAwOTNcdTAwZTI8XHUwMGQ1XHUwMGQ0XHUwMDE2PmFIIDxcdTAwOWNcdTAwMDBcdTAwMGI3XHUwMDhmXHUwMDhlXHUwMDE0XHUwMGQyXHUwMDEzXHUwMDE1XHUwMGYxXHUwMGJjXHUwMDhhLFx1MDBkM1x1MDBlME5cdTAwMTN8PyFcdTAwN2ZXXHUwMDhhXHUwMGFjPlx1MDAxMVx1MDA5NUhcdTAwYTNcdTAwZDE/N11cdTAwZmRcdTAwOWRcdTAwY2ROXHUwMGRiXHUwMGQzXHUwMGY5XHUwMDFjdFx1MDBhMlx1MDA4Yn4zRVx1MDBiYVx1MDBhZkpcdTAwMGVcdTAwYWZ3b0tcdTAwZDlWLX1bXHUwMGJmPlx1MDA5M3xcdTAwOTN9I1x1MDBlOVx1MDBjZFx1MDA3Zlx1MDBlNjxcdTAwZDJcdTAwYjM+XHUwMGIyXHUwMDA2ISc5Llx1MDA4Mlx1MDAwZURLXHUwMGMwXHUwMDgxXHUwMGM4QFJoXHUwMDAwXHUwMDAzXHUwMDgwXHUwMDhmJypcdTAwZDFaXHUwMDFkXG58alx1MDBlMFx1MDBjM1x1MDBjYlx1MDBkZlx1MDA4OFx1MDBmNGpPXHUwMGVkN1x1MDA5N1x1MDBkMFx1MDA5MVx1MDBlOUp1XHUwMGQwXG43XHUwMGJiR1x1MDA4Y1x1MDA4ZVx1MDA4MVNcdTAwZjJcdTAwY2RcdTAwOTRcdTAwZTdcdTAwOGNcdTAwYjAvXHUwMGExXHUwMGJjXHUwMGZkXHUwMGViXHUwMGRlXHUwMGY5XHUwMGE3XHUwMDdmXHUwMDFjXHUwMDFlblx1MDBmZFx1MDBiMVUoXHUwMGVjXHUwMDFmXHUwMGJlK1x1MDBjMy1cdTAwZTJcdTAwOWFHXGY3XHUwMGE3SVx1MDBhZiBcdTAwYjRcdTAwZmNGfVx1MDBkMFx1MDBhYlx1MDA4OVx1MDA5ZGpcdTAwZjYsPjhcdTAwYWNcdTAwZDlKXHUwMGU5andtXHUwMGYxXHUwMDg0XHUwMGIwXHUwMDE0Nlx1MDA4Mn1RXGZ4XHUwMDFkV1x1MDA4OHFcXEV0XGZcdTAwOWFcdTAwYTVcdTAwMTNcdTAwYzF4XHUwMGQ4XHUwMDEwfD9cdTAwYWZCR1x1MDBlOCpcdTAwYWZoXHUwMGVlbzJcdTAwZmZcdTAwMDI+T1x1MDBjNFx1MDBjMlx1MDBiOVx1MDBiYXhcdTAwYTRgXHUwMGRlXHUwMGNhXHUwMDlmXHUwMDllXHUwMGNlXHUwMDk2XHUwMDhlRWlcdTAwYTdcdTAwZWZbOytcdTAwZmFcdTAwOTRcdTAwZjVcdTAwZTJcdTAwY2NBXHUwMGU5XHUwMGVjLlx1MDBlNVx1MDA5Nlx1MDA5ZUJ2XHUwMGU3XHUwMGE5XHUwMGI5XHUwMDE3XHUwMDBmXHUwMGRkXHUwMDA3XHUwMGZjXHUwMGE2aVx1MDA4ZVx1MDBkY1x1MDBlNVx1MDA5OVx1MDBkNUk6XHUwMDFiXHUwMGM0XHUwMDhiXHUwMGY3X1x1MDAxOVx1MDBkMlx1MDAxNVx1MDBiN1x1MDBhNlx1MDBjYlx1MDBiYVx1MDBkYVx1MDBjZm1cclx1MDBlYVx1MDBlN1x1MDBiYmtcdTAwOWJcdTAwZWRcdTAwYTNcdTAwYmNcdTAwOTg/XHUwMGJkXHUwMGU2XFxEXHUwMGViPlx1MDAxOEFcdTAwZjhcdTAwZmJ+VFx1MDBlYVx1MDBkNVFcdTAwMWRcZlx1MDBhOVx1MDAxMVx1MDBhOStcdTAwYTdcdTAwZGREKTxcdFx1MDA4Ylx1MDBlNy1cdTAwMDJ6Klx1MDA4NWZcdTAwOTlcdTAwOWZKfVx1MDA4ZChcdTAwZjRcdTAwOTVkXHUwMGYyZDhcdTAwMTFcdTAwZjZcdTAwZTNcdTAwODFcdTAwZjBcdTAwZGE0XHUwMDg4NyBcdTAwZTFcdTAwZjIpXHUwMDhlXHUwMGMzXHUwMDEwSyVcdTAwYTdmXHUwMGUxaFx1MDBjNGdcdTAwMTMjJFxmXHUwMDgyXHUwMGY5XHUwMDg4dVx1MDBlYWpcXFx1MDBlY1x1MDBmZlx1MDBhM1x1MDAxMl90XHUwMGZlXHUwMGY5OVx1MDAxNChIXHUwMDhhXHUwMGIyfFx1MDBlYVx1MDBjNlx1MDBiZFx1MDBjZXVcdTAwYjdcdTAwMWNcdTAwZjdcdTAwMWVvY1x1MDBjMVx1MDBlOFlcdTAwOGNcdTAwYjlUJlx1MDBkNFx1MDA3ZnhcdTAwY2ZcdTAwYmFcdTAwYzYvaFx1MDBkYStcdTAwZThcdTAwZWZgXHUwMGU1XHUwMGVhXHUwMGU4XHUwMDljXHUwMGFmN0xcdTAwZTNcdTAwZDRcdTAwYWVcdTAwZDdcdTAwYmFcdTAwZDdfXHUwMGFmclx1MDBkOVx1MDBjY3xcdTAwYzh7XHUwMDg0XHUwMDFjVjhcdTAwY2IkXHUwMDlkZDVcblFcdTAwOWNcdTAwYjFcdTAwOTFEXHUwMDFjQlx1MDBjN1xyXHUwMDE5XHUwMDEzXG4rPFx1MDBkNFx1MDA4Ylx1MDBiMXRaMVx1MDBlZFx1MDA4OFx1MDBlNFx1MDA5NFx1MDA4ZjsxXHUwMGEzXHUwMDhjOFx1MDA5OFx1MDBkM1x1MDBmMVx1MDA5Nlx1MDA5NipcdTAwMDE6bX5HXHUwMGVhXHUwMGJielx1MDBhYVx1MDBiMFx1MDBiY2B0bFx1MDBlNlQgM2dLXHUwMDAyXHUwMDlkfEhcdTAwZTdcdTAwYjl+XHUwMDAwXHUwMGRlXHUwMGRiXHUwMGYxXHUwMGVkJ0gxXHUwMGZlTGVcdTAwODh6XHUwMDdmXHUwMDEwXHUwMGJjXHUwMGZlXHUwMGU4JVVcdTAwZGVcdTAwOGJJXHUwMDk1XHUwMGY3XHUwMDk1cVlcdTAwYTJcdTAwZmZcdTAwYjQvOlx1MDA5NXpcdTAwYjVcdTAwMWVXJi9cdTAwMDdyXHUwMGFiXHUwMGY1XHUwMGRhRVx1MDBmMXJlQXklbihcdTAwY2JNejRcdTAwY2JaXHUwMGRha1x1MDBmMVx1MDA5ZVx1MDAxM1x1MDBkYlx1MDBlZlx1MDBkN1x1MDBhMVcoXHUwMGQ3YGNcdTAwZThcdTAwMGVcdTAwZmNcdTAwZjlcdTAwYTFcdTAwZGJbXHUwMGQ5Sm9cdTAwZDVcdTAwYjdcdTAwZTNiXHUwMGIzXHUwMGRhXHUwMDllXHUwMDliRlx1MDA4N3FcdTAwOGVcdTAwYWM3XHUwMGNjXHUwMGQxeVx1MDBjMFx1MDBlM0RiTlx1MDBjNVVcdTAwZTlsUVx1MDAxM2ZcdTAwYzJcdTAwZTcnXHUwMDEyS1x1MDAxNXlcdTAwMGUte0FcIlx1MDBmMS9cdTAwOTBGXHUwMGQ3fVx1MDBmZXQsXHUwMGExVChcdTAwZDQ9XHUwMDk1XHUwMGM4OVx1MDBjZnFcdTAwYTBJYlx1MDAxZFx1MDBjZVx1MDA5YVx1MDBmOVx1MDBjMFRcdTAwZTJcdTAwYzdcdTAwYzNcdTAwZjFDXHUwMGQyXHUwMGU4XHUwMGNjXHUwMGM2dlx1MDBhZVx1MDBmZVx1MDBhNV1cdTAwYjleXVx1MDBiZFx1MDBhZW5cdTAwZjNWUVx1MDBlZFx1MDBlZVx1MDA5Zi9cdTAwY2FxXHUwMDkxSlx1MDAxM1x1MDBjY1x1MDBjOFx1MDA5ZFx1MDBhNVx1MDA4Y1x1MDBlY1E2MWg7Qlx1MDBjNFx1MDA4ZVx1MDBlMFx1MDAwMDxcdTAwODRcdTAwMThcdTAwZGVcdTAwZmVccktcdTAwODlcdTAwZjNcdTAwZGVcdTAwYjNpXHUwMGVkdFx1MDBkMFx1MDA5ZVx1MDA5OVx1MDBhYTNccks4Mz1cdTAwYWZcdTAwZTggJVx1MDBjNFx1MDAwNVx1MDBlZlx1MDBjNlx1MDBlNFx1MDAxNFx1MDBiM1x1MDA4M1x1MDBiYVx1MDAwZVx1MDBmYlx1MDBiOFx1MDA4ZFx1MDBkYVlcdTAwZWJcdTAwZDYrXHUwMGUzXHUwMGEwLFx1MDBmY1x1MDBkNlx1MDA4Zi9cdTAwYzNcdTAwZmE2XHUwMGZkXHUwMDkyXHUwMGVkVFx1MDBlMlx1MDBmNXZcdTAwYjFcdTAwZDRcdTAwOWFoXHUwMGM2TT1cdTAwYmVdXWhlXHUwMGZmXG4jXHUwMDExXHUwMDA3IVx1MDAxOF8xXHUwMGIxK3tWXHUwMGFhXHUwMDk2eFx1MDBjOVx1MDA5NitcdTAwOTZSXHJcdTAwOTVhXHUwMGIxXHUwMDE0XHUwMGM1XHUwMGIyZlR9XHUwMGE3XGJcdTAwZGZcdTAwOWVcdTAwYWVcdTAwYmRcdTAwYThfXHUwMGM0XHUwMDg1XHUwMDA0dVx1MDBmNVx1MDA4YmItXHUwMGZlXHUwMGEzd1NcdTAwZmJcdTAwZDddYFx1MDBkMlx1MDAwNFx1MDBhOS99XHUwMGQ2XHUwMGYyXHUwMDEyRFI8Olx1MDBkOCVcdTAwYzJcdTAwYzFcdTAwYmY+Tz1cdTAwZWVcdTAwZmZcdTAwOTZcdTAwOGFcdTAwYmRcdTAwZDhcdTAwYThcdTAwZTVcdTAwZmRcdTAwYWRcdTAwOWM4XHUwMDFkXHUwMGFjXHUwMGFhXHUwMGQyXHUwMGYxXHUwMGRkdXlcdTAwYzhcdTAwZWFcdTAwYzVcdTAwYWRcdTAwMDNWTndudmVFVlx1MDAwNlpmXHUwMDA3XHUwMGZhXHUwMGE2fFFcdTAwYmVcdTAwYzk2Ulx1MDBiN1x1MDBkOTU/XHUwMGFjXFxcdTAwOTRcdTAwZWJcdTAwOTlcdTAwYWRcdTAwY2FcdTAwZTVcdTAwZTlcdTAwZDZBZ1x1MDBmZjBcdTAwYTNcdTAwYjJrXHUwMDk5WnFcdTAwZjNcdTAwY2JcdTAwZTVcdTAwYTk4Z1x1MDBmN1x1MDBiZlcuWlx1MDBhZFxuXHUwMGRiXHUwMGJlXHUwMDg5XHUwMGQzXHUwMGFjXHUwMDllXUtcdTAwZGRmXHUwMGQyXHUwMGI1XHUwMGU0XHUwMGZmXHUwMGZhXHUwMGVhRVx1MDBmMVx1MDBmOFx1MDBhZVx1MDBiN1x1MDA3Zlx1MDBiOH1dXHUwMDEyXHUwMGJhXHUwMDk1aVx1MDBhOFx1MDA5ZFx1MDBjY1pcdTAwY2FcdTAwOTU3N1hxbVx1MDBiNVx1MDA4OVx1MDBjZnNcdTAwYjlcdTAwYmFiXHUwMGI5Rlx1MDA5NmVcdTAwZDJcdTAwZjlcdTAwZTFuXHUwMGUzaFx1MDA5OGVZXHUwMDg2XHUwMDlmd1x1MDBkOUZcdTAwYTZcdTAwOTZLXHUwMGFmX1x1MDBlN1xuXHUwMGI1XHUwMGJiXHUwMGJkXHUwMGI1XHUwMGQ0XHUwMGMzZ1x1MDBkOWFcdTAwZWRcdTAwMWFcdTAwOWZcdTAwYWJsPSV2XHUwMDFieVx1MDA5MVx1MDAxZFx1MDBlNlx1MDBmMVx1MDBkOVx0PjtcdTAwOTFcdTAwODV0NnxcdTAwOTZcdTAwMWJcdTAwZGNcdTAwN2Z3XHUwMDkwXHUwMDllXHUwMGI4XHUwMGZlXHUwMGM5XHUwMGU3XHUwMGU2XHUwMGQyWXxcdTAwOTZcdTAwMWRmXHUwMGQyXHUwMGUxWVx1MDBjM1x1MDBkY1x1MDBmYS1kXHUwMGExOFx1MDA5ZS0yXHUwMGY3XHUwMGNmZVlcdTAwOTk7VGwvXVZcdTAwZjhMXHUwMGUxXHUwMGIzXHUwMGJiXHUwMGJkNFx1MDBlZVx1MDAxZFx1MDAxZVx1MDBlMVx1MDA5ZVx1MDA5YVx1MDBhNFx1MDBlN2VcdTAwMGZcdTAwMTVcdTAwZGZcdTAwYzNcdTAwZmIyXHUwMGU5clx1MDBlOE9cdTAwYWVxXHUwMGYyXHUwMGYwXHUwMGVjXHUwMGMyfVx1MDAxZlx1MDAwN1x1MDBhOVx1MDA4N2dcdTAwZTdcdTAwODdcdTAwZjk7fFx1MDBhNlx1MDBiMlx1MDA4NVx1MDBkYVx1MDBlNHNcdTAwODZ7a1x1MDA4YVx1MDBlMVx1MDBiM1x1MDAwMVx1MDA5ZVx1MDBjM1x1MDBmMVx1MDAxZVx1MDA5NVx1MDAxYlx1MDBkNlx1MDAxZVx1MDBmYTl+XHUwMGY2aXZGflx1MDBiY1NcdTAwY2ZsXHUwMDllXlx1MDA5NjZvfVx1MDBhNlx1MDA5ZVx1MDBkYjgoXHUwMGEwXHUwMDdmLFNcdTAwZGZcdTAwMWRcdTAwY2VcdTAwY2E+O0xdXHUwMGEzXHUwMGM3XHUwMGI3XHUwMGQ5XHUwMGMzXHUwMGQ0XVx1MDBiNlx1MDBhZUQ2XHUwMGJkfldIZ1x1MDBlNG5cdTAwMDNcdTAwZTNaKFx1MDBkN1x1MDBiMlx1MDAwNVx1MDBiNFx1MDBhN1x1MDA5MVx1MDBkNVx1MDA5MFx1MDBjNVx1MDAxMFx1MDBmZEZfXHUwMGQ3eSZcdTAwOWRcdFx1MDBlZlx1MDBkOStcdTAwOTBsXHUwMGYzXHUwMGQ3e1x1MDBlOVx1MDAxM1x1MDBmNDk1XHUwMGM4XHUwMGFkXHUwMGE5XHUwMDAxMFx1MDA4M1x1MDBlZmlcdTAwZmNOVFx1MDBhZVx1MDBkMVx1MDBhY2VcdTAwMWJcdTAwZjhcdTAwYmVAXHUwMGVkXHUwMGNlXHUwMGUzOTVcdTAwYjFHXHUwMGQ3XHUwMDFkKlx1MDBiOVx1MDA5N1x1MDBkZUhPflx1MDA4Nlx1MDBiZVx1MDBkZVx1MDBlNlxueVx1MDA5ZFx1MDBjOHlcdTAwZmQuVzhcdTAwYzFcdTAwYmRNXHUwMGRjXHUwMGRiXHUwMGU0XHUwMDE4N1x1MDA5Mlx1MDAwNVx1MDBjNihcdTAwOGY+Zm93XHUwMDFiZVx1MDBjOG1cdTAwZTM4XHUwMDk3XHUwMGE2XHUwMGVmXHUwMGYzaFt2XGJcdTAwMTlcdTAwZGNcdTAwYTFcdTAwYWRcdTAwMThcdTAwYTcjXHUwMGZhXHUwMGZlNjdcdTAwY2NOXHUwMGJlXHUwMDkzU1x1MDBkZnJcdTAwZTlcdTAwYjJcdTAwOThsXHUwMDFiXHUwMGRlXHUwMDg3XHUwMGZiM3dcdTAwYjkmXHUwMGVlX1NcdTAwMDJcdTAwZmRcdTAwYzBcdTAwZmJcdTAwOGZcdTAwZDBcdTAwZjdcdTAwMWFcdTAwMGZcdTAwZWZcdTAwMWZcdTAwOTZcdTAwYWZcdTAwYjNcdTAwZTlcdTAwMTNcdTAwOWVbS1x1MDA4OSxcdTAwZjVcclx1MDBlM1x1MDA4NFx1MDBmYlx1MDBmNW5cdTAwYTNcdTAwYTlcdTAwZjFcdTAwZGVaXHUwMGFlXHUwMDAxXHUwMGQ5QVx1MDBlZVx1MDBkOSNcdTAwZmNfVzo7XHUwMGE0MThcdTAwYzFcdTAwZmIzQ1x1MDBlMFx1MDAwYmN+XHUwMDA0XHUwMGQ5XHUwMGFlXHUwMGRmXHUwMGQxXHUwMGZic1x1MDAwM1x1MDBhNXJcdTAwYTF9eVx1MDA4Y2VcdTAwMTZtXHUwMDlkej9cdTAwODNMXHUwMDg2XHUwMGQ5XHUwMGMyOlx1MDBiZFx1MDA3ZkBcdTAwZWZcdTAwZGZcdTAwYTNcdTAwZWZcdTAwZDNcdTAwZjlcdTAwZWJsXHUwMDgxXHUwMGYwXHUwMDg2XHUwMGY2XHUwMDFmQj5cdTAwYzNcZlx1MDBkZFx1MDA4Zlx1MDBlZlx1MDBmM1x1MDAwM3xcdTAwOGVcdTAwZmJcdTAwZjFcdTAwZmVCc1x1MDBmMFx1MDBhNVx1MDAxMWRcIlx1MDBmN1xuXHUwMDFiXHUwMGQ5RCZcdTAwOTlBLlx1MDA5MVx1MDBkOWAvXHUwMDlkXHUwMGUyXHUwMGMwXHUwMDE3XHUwMGRhfDJAX1x1MDA4Mlx1MDBlZVx1MDBhMFx1MDBlZFx1MDAxMlx1MDBmYVx1MDBjMVx1MDBmNlx1MDA4MFx1MDBlM1xcIVx1MDBjM1x1MDA5Mz5cdTAwOWRcdTAwYThcdTAwYmQ0XHUwMGZhXHUwMDg0XHUwMGZiXHUwMGQxblx1MDA4Y1x1MDAxMXBcdTAwYmNGXHUwMGI4XHUwMGFhXHUwMDhkXHUwMGVlXHUwMGNmXHUwMGYzbFx1MDBlMyhcdTAwYzFxXHUwMDlhXHUwMGM2MFx1MDBiNElcdTAwYTFcdTAwN2ZcdTAwYTFcdTAwOWZcdTAwYzAmZHNcdTAwMDRcdTAwZDlcdTAwOTZcdTAwMDVcdTAwZGR7P1x1MDBjNlx1MDBiOUJcdTAwMTljXHUwMGQwXHUwMGQ0SVx1MDBiZlx1MDBjYlxuOCFZXHUwMGEyL2VcdTAwOWVcdTAwZTA1XHUwMDdmXHUwMDFiXHUwMDllS1x1MDBiMkxcdTAwOTdcdTAwZTVcdTAwZjR7SVx1MDA5NilcdFx1MDA5ZFxiY1x1MDAwMVx1MDBmZGFcdTAwMThcdTAwMGJcdTAwOTJcdTAwYTUgXHUwMGY5ZFx1MDBhMStcdTAwYTMxXHUwMGJmXHUwMDdmb1x1MDBjMGhcdTAwYWVwXHUwMDlhJlx1MDBkOUI2XHUwMGU4XHUwMGUzXHUwMGZkZ1x1MDBkMFx1MDBjMVx1MDAwMlx1MDBkYVx1MDA4ZX5cdTAwYTBcdTAwZWZcdTAwZjRcdTAwZDlcdTAwMDBcdTAwZWRcdTAwODZcdTAwOGVcdTAwMWVcdTAwYTFcclx1MDBlYlx1MDBjM2w6eVx1MDAxZVx1MDBjNm1QXGJtXHUwMGM1e0leXHJgYHhcdTAwYzRcdTAwZjdcdTAwZDZcdTAwYzNcdTAwMThcdTAwMDFcdTAwZGY1XHUwMGRjXHUwMDkzXHUwMDExYVx1MDA4Y0hcdTAwZWZcdTAwYzdjXGZcdTAwOTljXGZcdTAwMWJcdTAwY2RcdTAwZTFcdTAwZTRcdTAwMThcdTAwYTNvQTZcdTAwOTBcdTAwZTFcdTAwYzRnXHUwMDE5XHUwMGU4TFx1MDA5ZXA5XHUwMDgxXHUwMDliXHUwMGNjLVx1MDBmMVx1MDBjYl5cdTAwOWFcdTAwZmFcdTAwMWFwXHUwMGFmXHUwMGQxXHUwMGQ3XHUwMGMxWFx1MDAxNlx1MDBkOWRcZlxuXHUwMGViXHUwMGI4P0VcdTAwMWNcdTAwYTBcdTAwYThcdTAwYmZcdTAwMThjXHUwMDhkXHUwMGU3XHJcdTAwOTJcdTAwZmJcdTAwOGZcdTAwZjBcdTAwZmRcdTAwZDFcdTAwODhcdTAwMWYzXGZcdTAwMThcdTAwYTBcdTAwZmJlXHUwMDE2XHUwMGZkLFx1MDAwNFx1MDBiZFx1MDBjOVx1MDAwMlx1MDAwM1x1MDBiOFx1MDBiZlx1MDA5MFx1MDA4MVx1MDAxY1x1MDBhMFx1MDBlZl8meFx1MDBhMl1cdTAwZTlcdTAwMTRcdTAwYmZcdTAwMWVcdTAwYjQydVx1MDBmN1x1MDBhZlx1MDBiNVx1MDBjNlx1MDBlZE1ZXHUwMDllXHUwMGI2XHUwMGY3a1x1MDA3Zlx1MDBmZUlcdTAwODFEKENcdTAwMThcdTAwZWNcdTAwYWNcdTAwYTFcdTAwZDVaP1xuXHUwMGI3XHUwMGM4YTtcdTAwODhcdTAwZmJcdTAwZGR6fDNcdTAwZjMtLGFcdTAwZDkrV1x1MDBkNFJcdTAwOTRNXHUwMDk5XHUwMDliclxcXHUwMDhhbVlcdTAwMTdjXHUwMDA1V0hcdTAwMTRcdTAwMWRcdTAwZWM1XHUwMGIzXHUwMGIxKXJcdTAwY2ZfYlx1MDAwMF9cdTAwZmNcdTAwYWNfXHUwMGQ0XHUwMDAwNlIgXHUwMGZlXHUwMGQ1bDZcdTAwZjlcdTAwOTlcdTAwY2VcdTAwOTJcdTAwZDJcZlx1MDA5Ylx1MDBiN1x1MDAwMFx1MDA4OFx1MDBjNFx1MDBiM1x1MDBlZTBcdTAwOTBcdTAwODJcdTAwOTRcdTAwMTkgXHUwMDg1XHUwMDkyXHUwMGFkXHUwMDAzXHUwMGYwZDRcdTAwZThcdTAwZTdcdTAwMTFAXHUwMDkxXHUwMDg1XHUwMDEyXHUwMDllXHUwMDA0UGRcdTAwMGJQaihcYlxmXHUwMDBiQFx1MDAxM1x1MDA4OFx1MDAxYUBcdTAwY2RcdTAwZGVmXHUwMGQzRFx1MDAxNlx1MDBlYkQmOnd0O1x1MDBjOFx1MDAwNVx1MDBjM1x1MDA5M2JcdFlcdTAwOWVAUVx1MDBiZlx1MDBhNFx1MDAwM1x1MDBmOFx1MDAwYiBOXHUwMDE4MFJcdTAwYjBcdTAwMWNcdTAwOGNcIlx1MDA4MFx1MDBjY1x1MDA4OGxcdTAwZjZIeVx1MDBkMlx1MDBhNFx1MDBjOCdQXHUwMGZhQFx1MDBhNnh+U0xcdTAwZGNcdTAwOWZNXHUwMDk0XHUwMDBiXHUwMGEwXHUwMDFkXHUwMDA0Ylx1MDBiOFx1MDAwM1x1MDA4OFx1MDAwMVpqX01cdTAwODNkXHUwMDEyXHUwMGU1S1x1MDA5Zlx1MDBkY1x1MDAwMVx1MDBiNFx1MDA4Mlx1MDA5NFx1MDAxOFx1MDBlNDNcdTAwZGNcdTAwMDNcdTAwZDlcdTAwYTMvLFx1MDBlOU9cdTAwZjhcdFx1MDA4M1x1MDA5YVx1MDBiOVx1MDBkZFx1MDAwM1x1MDBjOHNQXHUwMDEyXHUwMGY0SWdcdTAwMTNcdTAwMTlcdTAwODA0XHUwMGYwc2NcdTAwY2FcdTAwYjBtZlx1MDA5YmRcdTAwOThcdTAwYjJcdTAwMThcdTAwOWJcdTAwMWMrX1x1MDBmOFx1MDBlZWlcdTAwODFcdTAwZDUzXHUwMGVjZFx1MDA5MFx1MDAxZjZZflx1MDA5MFx1MDBhOVx1MDA5NVx1MDBlNVx1MDBjMVx1MDBhMCRcdTAwZmFcdTAwYWRcdTAwZGRcdTAwYWZcdTAwMGZjXHUwMDA3M1x1MDBiODpcdTAwYWNsbVx1MDBkZlx1MDAxNFx1MDBjNVFcdTAwYmZcdTAwYjRcdTAwZDlcdTAwYmE+PVx1MDBjZVx1MDA5ZFx1MDA5NzFmZVx1MDBiOVx1MDBjMU9cdTAwYmVuXHUwMGI3Zlx1MDBiZlx1MDBiZlx1MDBiYylcdTAwMWUrKFx1MDBjOFx1MDBmNk1cdTAwZTVYN1x1MDA4M1x1MDA4Mlx1MDBkNFNcdTAwYjVcdTAwZmRcdTAwYWRcdTAwZDVcdTAwZjNcdTAwY2FmXHUwMGFkdlx1MDA4YXtcdTAwMGIwXHUwMGVjMFxuXHUwMGI3XHUwMGY3XHUwMGNhXHUwMDA3Q1wiYVx1MDAxMlx1MDA4Nlx1MDA5MFx1MDBhM1x1MDAwNCFcdTAwOTBhXHUwMDg2XHUwMDljakNcdTAwMWEnKCZcdTAwZmFcdTAwMDU5XHUwMGQ3XHUwMDAzXHUwMGQxYVx1MDBlY0hcdTAwMDZ5XHUwMDhjU0onXHUwMGNhXHUwMGZmcD9cdTAwMTE0XHUwMGM2YVx1MDA5ZFx1MDA4Y1x1MDAwZVx1MDA4Y1x1MDAxMkhrSFx1MDBlM0hcdTAwZjdcdTAwYWZcdTAwMGZcdTAwODlcdTAwZTBcdTAwYjNcdTAwZjR8XHUwMDE4XHUwMDg0XHUwMDg0XHUwMGM0IFx1MDBmN1x1MDA5MVx1MDBkMVx1MDAwNFx1MDBlMVx1MDBjOFx1MDBiZFx1MDBmNVtcdTAwYzJcdTAwYzFcdTAwMDBcdTAwZTRJXHUwMGU0XHUwMDAwXHUwMGI5XHUwMDk2WVx1MDBiNlx1MDBmMWVEXHUwMDhjIFx1MDA4MnowXHUwMGQ4Mlx1MDAxYlx1MDA4OFx1MDA5OFx1MDBlZVx1MDA4NyFqXHUwMGQ0XHUwMDEycilcdTAwMWNJPFx1MDA5Zlx1MDAxM3lcdTAwZTRcdTAwODJcdTAwZDFdJ1x1MDA4N1x1MDAwMER8XHUwMDkwJVx1MDBhM1x1MDAwM1x1MDAwM1x1MDA4MGdcdTAwMTMuQVx1MDBmMlx1MDA4NUpcdTAwOWFcdTAwYjBcdTAwYjFXXGJGXHUwMGUzLjg1XHUwMDg1XHUwMDkzYVx1MDA4Mlx1MDA5ZFx1MDA5NFx1MDBjZVx1MDA5MVx1MDBjM1ZcYlx1MDBjZVx1MDAxNktcdTAwYzhcdTAwMWRcdTAwZDdcdTAwMDRcdTAwMDcpT1x1MDBhNFx1MDBjNlx1MDA4M1x1MDBkY1x1MDBjOFx1MDBiMVx1MDAxOV8zXHUwMDA0XHUwMGJlblx1MDBmMUxcdTAwYjRrXHUwMDlkXHUwMDFjXHJcdTAwYjQrXHUwMDliXHUwMGY0XHUwMDBiXHUwMDg0XHUwMDllXHUwMDFiXHUwMGJmN0tcdTAwOWZcdTAwYzFcdTAwMDBzXHUwMGI0XHUwMGY1Llx1MDAxN1x1MDBmYVpmXHUwMGY5IWFcdTAwMWVcdTAwODZmXHUwMDk4XHUwMDk5XHUwMGVjXHUwMDBiOVx1MDAxZDBcdTAwZmFcdTAwZTRcdTAwYjRlXHUwMGM4WFxmXHUwMDgzUWlcdTAwYzBcdTAwOTlcdTAwMWFmSGdcdTAwYzhcdTAwMDFcdTAwMDFcdTAwZTFneCFcdTAwZThcZlxmXHUwMDA3XHUwMDhjXHUwMGUxWFx1MDA5NllcdTAwZDJcdTAwMTlcdTAwYjlcdTAwMTdcdTAwYjBKY1x1MDA4ZFx1MDBmMUtcdTAwOTMjSlx1MDBjZVNWXHUwMDA1IzNQXHUwMGU4R3lcdTAwOTh8XHUwMGZmXGJcdTAwMGJcdTAwZmNcdTAwMTFuSU9cdTAwZDZcdTAwZDdncFx1MDAwYlx1MDBkMmdcdTAwZjlcdTAwYzNXXHUwMGUyXHUwMGY2XHUwMGMyXHUwMGYzXHUwMGQyRX5cdTAwZmNcdTAwYWNDXHUwMGRlK1x1MDAxZVx1MDBlYlZcdTAwZjFcdTAwYzJfXHUwMDk2XHUwMDFhXHUwMGUzXHUwMGVmXHUwMGM5XHUwMGY5XHUwMGRiT1x1MDBiOFx1MDBlY19cdTAwYjNcdTAwYTQuOFNcdTAwOTJcdTAwY2VZXHUwMDk5S1x1MDBlYVx1MDBhM29cdTAwZmZcdTAwZmFcdTAwZWJcdTAwYWZcdTAwZmZcdTAwMGZcdTAwYmFcdTAwY2F0XHUwMDhlIn0=UPDATEConfigMapCREATECommitRequestCommitWindowCREATEServiceAccountGitOps Reverser(operator)kubectl apply -f hello.yamlkubectl create -f save-now.yamltime (s)t=0t=0.1t=5t=5.1t=2.4CloseDelayt=4.4Normal Kubernetes APIWatch streams (opt. audit events)Push (SSH/HTTPS)feat(demo): save hello resourcesnew file: apps/demo/serviceaccount-hello.yamlmodified: apps/demo/configmap-hello.yaml \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 000000000..c231343a3 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,128 @@ +# Quick start + +Use a lab cluster and a disposable repository for this demo. 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. + +![Config basics diagram showing the relationship between GitProvider, GitTarget, and WatchRule](images/config-basics.excalidraw.svg) + +**Prerequisites:** a Kubernetes cluster with `kubectl`, Helm 3, and cert-manager for TLS. + +## 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: + +```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 +[`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` report `Ready=True`; the `GitTarget` reports **`Validated=True`**. +Its aggregate `Ready` stays `Unknown` until first source discovery, which is the expected state. + +## 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. From d25270cba4cc5019b2c70d1a4b08067c79214528 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 09:58:25 +0000 Subject: [PATCH 02/16] docs: give the configuration guide a settings index and split out commit messages The guide was 2,043 lines with no way to tell which of it applied to you, and config/samples/ held a complete, internally consistent manifest set that no user-facing document linked. - Add "Every setting at a glance": one table per object listing every field, its default, and where the detail is. Defaults are taken from the CRD schemas and the API doc comments, not from prose: accessFrom omitted denies every namespace rather than admitting them, serializeNamespace omitted infers per document rather than meaning false, and qps/burst fall back to the operator-wide flags and are ignored without a kubeConfig. - Link config/samples/ from the index and from the quickstart. - Move the 246-line commit message template reference to docs/commit-messages.md and leave a pointer. It is optional, self-contained, and sat in the middle of the path a first-time reader takes to their first commit. GitTarget drops from 58% of the guide to 50%. - Point the quickstart reference on line 19 at quickstart.md; it still named the root README. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/README.md | 4 +- docs/UPGRADING.md | 4 +- docs/commit-messages.md | 251 ++++++++++++++++++++++++++++ docs/configuration.md | 330 ++++++++++--------------------------- docs/demo/commit-window.md | 2 +- docs/quickstart.md | 4 + 7 files changed, 349 insertions(+), 248 deletions(-) create mode 100644 docs/commit-messages.md diff --git a/README.md b/README.md index 59a9dcac8..80504af4e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Custom resources configure all of it: a `GitProvider` holds the repository and c 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, validated at admission rather than at commit time. See - [message templates](docs/configuration.md#commit-message-templates). + [message templates](docs/commit-messages.md). - **Encrypted Secrets.** SOPS + age encryption before commit, which Secret-shaped custom resources can opt into. See [SOPS and age](docs/sops-age-guide.md). - **Metrics.** A Prometheus surface with copy-pasteable PromQL for the questions operators ask, and diff --git a/docs/README.md b/docs/README.md index 12f97be87..9f3be1ec5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,9 @@ If you only want the supported product docs, start with the files below. - [`../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 and how they fit together +- [`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..63cf9d505 --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,251 @@ +# 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}}/{{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](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 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](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 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) +``` + +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 12e1a7500..dabc35ad7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. @@ -36,6 +36,78 @@ The usual flow is: That means one repository connection can back multiple targets, and one target can be fed by multiple watch rules. +## Every setting at a glance + +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.name` | 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` | in-cluster | Points at a remote source cluster instead of the operator's own | +| `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 name | Audit route this provider joins. See [audit route](#audit-route-and-auditfactsreceived) | +| `qps` / `burst` | the operator-wide flags | Rate limits 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` | `default` | Source cluster to mirror from | +| `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` | built-in templates | How commits are phrased. See [commit messages](commit-messages.md) | +| `placement.byType` / `.default` | canonical path | Where new documents are filed. See [where new resources are written](#where-new-resources-are-written-specplacement) | +| `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) and `ClusterWatchRule` (cluster-scoped): 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` | every group | API groups to match. `[""]` is the core group | +| `rules[].apiVersions` | every version | API versions to match | +| `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 `*`. `WatchRule` only. See [watching a different source namespace](#watching-a-different-source-namespace) | + +A `ClusterWatchRule` carries the same fields minus `sourceNamespace`, and claims cluster-scoped +types. + +### `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 +222,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 +622,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 +643,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, +validated at admission 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 +1095,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 @@ -1787,8 +1631,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. diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index d9ce8f8a8..a4a9395be 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -62,7 +62,7 @@ Two fields exist for the sake of a legible demo rather than because the window n - **`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](../configuration.md#framing-a-save-message). + [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. diff --git a/docs/quickstart.md b/docs/quickstart.md index c231343a3..2ee63553c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -10,6 +10,10 @@ chart also renders the cluster-scoped `default` `ClusterProvider` the starter ta **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. From ac91e228d9d8f49da654fa7746f68f12d31ec4f2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 10:41:16 +0000 Subject: [PATCH 03/16] feat(docs): generate the settings index from the CRD schemas The settings index was written by hand and got three defaults backwards, each in the direction that costs a reader an outage: 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 the operator-wide flags and are ignored without a kubeConfig. The schema knew all three. Nothing was asking it. `task settings-index` now renders the table between markers in docs/configuration.md, and `task lint-settings-index` fails when it is stale. The two halves are split by who can be trusted with them: config/crd/bases owns required and default, because controller-gen regenerates it from the Go types; hack/crdfields/fields.yaml owns the description and the link to the section that explains the field, because a CRD description is written for `kubectl explain` and is the wrong shape for a table. The coverage check is what makes it a gate. Depth is decided by fields.yaml rather than a fixed limit: a listed path is a leaf, so commit.window stays in the table while the message templates do not. A top-level field with no entry of its own must have every child described, which catches a sibling added to a group the table already expands. Verified by adding a field to GitTargetSpec, running `task manifests`, and watching both the lint task and the unit test name it. fields.yaml also rejects a `default:` that YAML read as a boolean. Writing `default: off` for three fields rendered them as "false" in the Default column, silently, which is the one column that must not lie. lint-settings-index carries `sources:`, unlike the four docs tasks beside it: its inputs are three named paths rather than every tracked file, so it caches normally and needs no entry in the CI cache exemption. Co-Authored-By: Claude Opus 5 --- .golangci.yml | 6 + CONTRIBUTING.md | 10 +- Taskfile-build.yml | 36 ++- docs/configuration.md | 37 ++- hack/crdfields/fields.yaml | 188 ++++++++++++++ hack/crdfields/main.go | 474 ++++++++++++++++++++++++++++++++++++ hack/crdfields/main_test.go | 288 ++++++++++++++++++++++ 7 files changed, 1023 insertions(+), 16 deletions(-) create mode 100644 hack/crdfields/fields.yaml create mode 100644 hack/crdfields/main.go create mode 100644 hack/crdfields/main_test.go 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/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/Taskfile-build.yml b/Taskfile-build.yml index 377a47402..44870760c 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -222,10 +222,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 +236,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 +264,32 @@ 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 + # 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/docs/configuration.md b/docs/configuration.md index dabc35ad7..084b9f2ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,6 +36,8 @@ The usual flow is: That means one repository connection can back multiple targets, and one target can be fed by multiple watch rules. + + ## Every setting at a glance Ready-made manifests for all six objects live in [`config/samples/`](../config/samples/). They @@ -51,7 +53,7 @@ fields that need more than a line. |---|---|---| | `url` | **required** | Repository URL, SSH or HTTPS | | `allowedBranches` | **required** | Branches a `GitTarget` may write. A target naming any other is rejected | -| `secretRef.name` | anonymous access | Secret holding the write credential. See [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret) | +| `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) | @@ -60,11 +62,12 @@ fields that need more than a line. | Field | Default | What it does | |---|---|---| -| `kubeConfig` | in-cluster | Points at a remote source cluster instead of the operator's own | +| `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 name | Audit route this provider joins. See [audit route](#audit-route-and-auditfactsreceived) | -| `qps` / `burst` | the operator-wide flags | Rate limits for this source cluster. Ignored when `kubeConfig` is omitted | +| `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 @@ -75,10 +78,11 @@ The destination fields are immutable: to move a target, delete it and create a n | `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` | `default` | Source cluster to mirror from | +| `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` | built-in templates | How commits are phrased. See [commit messages](commit-messages.md) | -| `placement.byType` / `.default` | canonical path | Where new documents are filed. See [where new resources are written](#where-new-resources-are-written-specplacement) | +| `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) | @@ -86,7 +90,7 @@ The destination fields are immutable: to move a target, delete it and create a n | `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) and `ClusterWatchRule` (cluster-scoped): what to capture +### `WatchRule` (namespaced): what to capture | Field | Default | What it does | |---|---|---| @@ -95,10 +99,19 @@ The destination fields are immutable: to move a target, delete it and create a n | `rules[].apiGroups` | every group | API groups to match. `[""]` is the core group | | `rules[].apiVersions` | every version | API versions to match | | `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 `*`. `WatchRule` only. See [watching a different source namespace](#watching-a-different-source-namespace) | +| `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 -A `ClusterWatchRule` carries the same fields minus `sourceNamespace`, and claims cluster-scoped -types. +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` | every group | API groups to match | +| `rules[].apiVersions` | every version | API versions to match | +| `rules[].operations` | `CREATE`, `UPDATE`, `DELETE` | Which operations produce a write | ### `CommitRequest` (namespaced): save now @@ -108,6 +121,8 @@ types. | `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 | diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml new file mode 100644 index 000000000..73babf951 --- /dev/null +++ b/hack/crdfields/fields.yaml @@ -0,0 +1,188 @@ +# 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: | + ## Every setting at a glance + + 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: every group + doc: API groups to match. `[""]` is the core group + - path: rules.apiVersions + default: every version + doc: API versions to match + - 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: every group + doc: API groups to match + - path: rules.apiVersions + default: every version + doc: API versions to match + - 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..e08307669 --- /dev/null +++ b/hack/crdfields/main.go @@ -0,0 +1,474 @@ +// 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. 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 + 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) +} From ca8a1cc83ce9f30a5555ed2a5af1ea8f714ad4c4 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 11:38:42 +0000 Subject: [PATCH 04/16] fix(docs): correct the default live template, and stop the index check racing manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review, all confirmed against the source. The documented default live commit template carried a {{if .Namespace}} guard that DefaultLiveCommitMessageTemplate in internal/git/types.go does not have. The same file already said "the default template no longer does" two sections below, and showed the unguarded form in another example, so it contradicted itself twice. Stale from before the _cluster sentinel landed. lint-settings-index reached `task lint` through lint-docs while lint-golang reached manifests through its own branch, and Task runs deps in parallel. The check could read config/crd/bases while controller-gen rewrote it and pass against the previous CRDs — in the one run that matters, the one where an API field was just added. lint-golang already carries deps: [manifests] for the same reason; this now does too. ssh-keyscan pins whatever answers on the network, so the quickstart now prints the fingerprint for the reader to check against GitHub's published list. The fingerprints are deliberately NOT copied into the guide: GitHub has rotated a host key before, and a value copied into a doc goes stale silently while a link does not. Co-Authored-By: Claude Opus 5 --- Taskfile-build.yml | 7 +++++++ docs/commit-messages.md | 2 +- docs/quickstart.md | 14 +++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Taskfile-build.yml b/Taskfile-build.yml index 44870760c..19151fba0 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -273,6 +273,13 @@ tasks: 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. diff --git a/docs/commit-messages.md b/docs/commit-messages.md index 63cf9d505..680d76d2f 100644 --- a/docs/commit-messages.md +++ b/docs/commit-messages.md @@ -30,7 +30,7 @@ spec: chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}} {{range .Resources -}} - - [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{if .Namespace}}{{.Namespace}}/{{end}}{{.Name}} + - [{{.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}}" ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 2ee63553c..dd83490f0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -54,7 +54,19 @@ kubectl create secret generic git-creds \ --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 +SSH host-key verification fails closed, so the `known_hosts` line is required. + +`ssh-keyscan` trusts whatever answers on the network, so it pins whichever key it is handed. Check +what you got against the fingerprints GitHub publishes at +[GitHub's SSH key fingerprints](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints) +before you rely on the Secret: + +```bash +ssh-keyscan github.com 2>/dev/null | ssh-keygen -lf - +``` + +They are printed rather than pinned here on purpose: GitHub has rotated a host key before, and a +fingerprint copied into a guide goes stale silently while a link does not. 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 From 310bc9d848660cbdde953b2a8fd2b822bdb3e878 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 11:41:26 +0000 Subject: [PATCH 05/16] docs: make the first visit work end to end, and stop claiming admission validation Four findings from a first-time-reader review. The commit-window example assumed a `demo` namespace with a provider and credentials already in it, while the quickstart creates all of that in `gitops-reverser-quickstart-demo`. Following one guide into the other left the reader with missing setup at the point they were told to apply. The example now builds on the quickstart directly: same namespace, the same `example-provider` it already creates, and its `allowedBranches: ["*"]` is what makes the example's own branch legal. `spec.path` stays `apps/demo`, so the diagram is still true. It also says that the starter target keeps capturing these ConfigMaps to `live-cluster` on main, since a reader will otherwise see that commit and wonder. "Validated at admission" was wrong. The only admission webhook covers commitrequests and watchrules; nothing validates a GitTarget at admission, and `validateCommitConfig` runs in the reconciler, reporting Validated=False with reason InvalidConfig. An operator reads "at admission" as `kubectl apply` failing, which it does not. Corrected in the README, the configuration guide and commit-messages.md. The two remaining admission claims, about CommitRequest author capture, are accurate and stay. The quickstart told readers to expect `Validated=True` from a `kubectl get` that cannot print it: Validated is not a printer column on GitTarget at all, not even at priority=1, so `-o wide` does not help either. It now uses `kubectl wait --for=condition=Validated`, which answers yes or no, and says how to tell initialization from misconfiguration when it times out. The README put a diagram and thirty lines of YAML before installation, for a reader still deciding whether to try it. Quick start now comes first, the batching section keeps the diagram and the explanation and hands the manifests to the example, and the maturity statement moved up next to the introduction where the decision is actually made. The detailed adoption caveats stay where they were. Co-Authored-By: Claude Opus 5 --- README.md | 66 ++++++++++---------------------------- docs/commit-messages.md | 7 ++-- docs/configuration.md | 4 +-- docs/demo/commit-window.md | 30 ++++++++++------- docs/quickstart.md | 25 +++++++++++++-- 5 files changed, 64 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 80504af4e..3016e420d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ manifests in place, preserving comments and document structure. Use it to capture live changes, bring an existing cluster into Git, or experiment with API-first workflows alongside Flux or Argo CD. +It is early-stage software: one controller pod, `v1alpha3` APIs that can still change, and a fit for +labs and pilots rather than production. [Before you adopt it](#before-you-adopt-it) has the detail. +
Demo: kubectl apply triggers a sanitized Git commit within seconds @@ -52,8 +55,8 @@ Custom resources configure all of it: a `GitProvider` holds the repository and c - **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, validated at admission rather than at commit time. See - [message templates](docs/commit-messages.md). + requests. A bad template holds the target at `Validated=False` instead of surfacing at commit + time. See [message templates](docs/commit-messages.md). - **Encrypted Secrets.** SOPS + age encryption before commit, which Secret-shaped custom resources can opt into. See [SOPS and age](docs/sops-age-guide.md). - **Metrics.** A Prometheus surface with copy-pasteable PromQL for the questions operators ask, and @@ -85,53 +88,6 @@ Select the resources that express your intent. For example, watch a `HelmRelease 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). -## Batch changes into a commit - -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 itself. - -![Kubernetes resource changes flow through GitOps Reverser's commit window into Git](docs/images/commit-window.excalidraw.svg) - -This configuration selects ConfigMaps and ServiceAccounts in `demo`. The referenced `GitProvider` -holds the repository URL and credentials. - -```yaml -apiVersion: configbutler.ai/v1alpha3 -kind: GitTarget -metadata: - name: window-demo - namespace: demo -spec: - gitProviderRef: - name: example-provider - branch: commit-window-demo - path: apps/demo - commit: - window: "5s" ---- -apiVersion: configbutler.ai/v1alpha3 -kind: WatchRule -metadata: - name: window-demo - namespace: demo -spec: - gitTargetRef: - name: window-demo - rules: - - apiGroups: [""] - apiVersions: [v1] - resources: [configmaps, serviceaccounts] -``` - -All three operations (`CREATE`, `UPDATE`, `DELETE`) are selected by default. The `CommitRequest` -itself is outside the selected types, so saving never commits the save button. - -The picture shows that example running. The [full example](docs/demo/commit-window.md) carries the -rest of it: the resources to apply, the save request (created with `kubectl create`, because it uses -`generateName`), a `placement` rule for the flat filenames, and the `requestTemplate` that puts the -changed resources under the requested message. - ## Quick start Follow the [installation walkthrough](docs/quickstart.md) to capture ConfigMaps from a demo namespace @@ -147,6 +103,18 @@ kubectl create configmap test-config --from-literal=key=value -n gitops-reverser 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. +## Batch changes into a commit + +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 itself. + +![Kubernetes resource changes flow through GitOps Reverser's commit window into Git](docs/images/commit-window.excalidraw.svg) + +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. + ## Try it with your existing repo Start with a [scratch branch](docs/configuration.md#seeing-what-a-target-will-do-before-it-does-it) diff --git a/docs/commit-messages.md b/docs/commit-messages.md index 680d76d2f..f4d2c4f54 100644 --- a/docs/commit-messages.md +++ b/docs/commit-messages.md @@ -92,7 +92,8 @@ variable), because the check renders the template and looks for the message in t 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 +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. @@ -123,8 +124,8 @@ Three things to know: `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. + 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 diff --git a/docs/configuration.md b/docs/configuration.md index 084b9f2ac..ff866486d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -661,8 +661,8 @@ write rather than stopping the mirror. 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, -validated at admission rather than at commit time, and each has a built-in default, so a target that -sets none still writes useful messages. +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: diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index a4a9395be..48e31b82a 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -4,9 +4,15 @@ Two Kubernetes resources become one Git commit: one created, one edited. A `Comm 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. -The example assumes a namespace named `demo`, a working `GitProvider` named `example-provider` in -that namespace, and an authorized `default` `ClusterProvider`. Use a branch no reconciler deploys. -See the [quickstart](../quickstart.md) for installation and Git credentials. +**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 @@ -17,7 +23,7 @@ apiVersion: configbutler.ai/v1alpha3 kind: GitTarget metadata: name: window-demo - namespace: demo + namespace: gitops-reverser-quickstart-demo spec: gitProviderRef: name: example-provider @@ -41,7 +47,7 @@ apiVersion: configbutler.ai/v1alpha3 kind: WatchRule metadata: name: window-demo - namespace: demo + namespace: gitops-reverser-quickstart-demo spec: gitTargetRef: name: window-demo @@ -71,10 +77,10 @@ Apply this setup and let the initial synchronization finish before going on: ```bash kubectl apply -f watch.yaml -kubectl get gittarget,watchrule -n demo +kubectl get gittarget,watchrule -n gitops-reverser-quickstart-demo ``` -The rule selects **two resource types in `demo`**, not two individual names. Other ConfigMaps and +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. @@ -85,7 +91,7 @@ Create the ConfigMap on its own first, so the recorded batch below contains one `UPDATE` rather than two creates. Let its commit land before going on: ```bash -kubectl create configmap hello --from-literal=message="Hello" -n demo +kubectl create configmap hello --from-literal=message="Hello" -n gitops-reverser-quickstart-demo ``` ## Apply the resources @@ -98,14 +104,14 @@ apiVersion: v1 kind: ServiceAccount metadata: name: hello - namespace: demo + namespace: gitops-reverser-quickstart-demo automountServiceAccountToken: false --- apiVersion: v1 kind: ConfigMap metadata: name: hello - namespace: demo + namespace: gitops-reverser-quickstart-demo data: message: Hello from Reverse GitOps ``` @@ -120,7 +126,7 @@ apiVersion: configbutler.ai/v1alpha3 kind: CommitRequest metadata: generateName: save-hello- - namespace: demo + namespace: gitops-reverser-quickstart-demo spec: gitTargetRef: name: window-demo @@ -148,7 +154,7 @@ outcomes. ## Check the result ```bash -kubectl get commitrequests -n demo -o wide +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 diff --git a/docs/quickstart.md b/docs/quickstart.md index dd83490f0..a20e5bf25 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -108,8 +108,29 @@ Then check the starter resources: kubectl get gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo ``` -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. +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 From 3b6887b6d392c92880169d86f1ba5bb48fbbc08f Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 11:55:40 +0000 Subject: [PATCH 06/16] docs: state the pre-1.0 position without the apology The maturity prose said the same thing four times in different words, and what it said was wider than the facts. "Early-stage software for labs and pilots rather than production" is a recommendation against the product; what is actually true is that the CRDs are v1alpha3 and the configuration surface can move between releases, which UPGRADING.md already tracks release by release. Eight sites, all of them in the three documents a first-time reader meets: - The README intro and the adoption section now say pre-1.0 and point at the migrations, and nothing else. - Single-replica is stated plainly rather than filed under "headline gap". One active writer per target is where a controller ends up regardless; a second replica would buy faster failover, not parallel writing, and the ownership work that makes standby replicas safe is named as on the way to 1.0. The chart refuses replicaCount > 1 at install time, which is a property worth stating, not an embarrassment. - "Experimental" is replaced by the constraint it was standing in for: for a resource edited from both sides there is no merge and no lock, the Kubernetes API wins, so decide per folder which side is authoritative. That is what a reader needed from the word. - The quickstart asked for "a lab cluster"; it now says why a disposable repository is the part that matters. - The chart's replicaCount row apologised ("for now, sorry") and the Helm failure message apologised at greater length. Both now say what is enforced and why, which is what somebody hitting that error needs. Verified the claims rather than reworded around them: the chart fails the whole render on replicaCount > 1 (validate-replica-count.yaml), and leader election is NOT enabled in the manager, so the draft that leaned on it was corrected before it shipped. Co-Authored-By: Claude Opus 5 --- README.md | 24 ++++++++++++------- charts/gitops-reverser/README.md | 5 ++-- .../templates/validate-replica-count.yaml | 2 +- docs/quickstart.md | 3 ++- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3016e420d..a6a5ed41e 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ manifests in place, preserving comments and document structure. Use it to capture live changes, bring an existing cluster into Git, or experiment with API-first workflows alongside Flux or Argo CD. -It is early-stage software: one controller pod, `v1alpha3` APIs that can still change, and a fit for -labs and pilots rather than production. [Before you adopt it](#before-you-adopt-it) has the detail. +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.
1` is rejected today. The backlog is in -[docs/TODO.md](docs/TODO.md), and longer-range directions in [docs/future/](docs/future/). +It runs as a single replica, and the chart refuses `replicaCount > 1` at install time rather than +letting two instances write the same repository. One active writer per target is where a controller +ends up regardless, so what a second replica would buy here is faster failover, not parallel +writing; the ownership coordination that makes standby replicas safe is on the way to 1.0, along +with a durable worker queue. The backlog is in [docs/TODO.md](docs/TODO.md), and longer-range +directions in [docs/future/](docs/future/). ## Rather have it managed? diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index a22af8ac0..7ac1795ef 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -129,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 @@ -160,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. `1` is the only accepted value: a higher one is rejected at startup 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 | `[]` | 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/quickstart.md b/docs/quickstart.md index a20e5bf25..f1a59d608 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,6 +1,7 @@ # Quick start -Use a lab cluster and a disposable repository for this demo. It creates a starter `GitProvider`, +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 From c49a2e43899df11e95e1673ba76172d42a4cb846 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 12:29:05 +0000 Subject: [PATCH 07/16] fix(dev): create the bin/k8s/current symlink the IDE resolves envtest through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .devcontainer/devcontainer.json sets go.testEnvVars.KUBEBUILDER_ASSETS to ${workspaceFolder}/bin/k8s/current, but nothing in the repository created that symlink — the only mention of the path was the setting itself. Running a controller test from the Test Explorer or the CodeLens therefore pointed envtest at a directory that did not exist. What made it expensive is that envtest does not report a missing assets path as such. The specs simply fail, which reads as a broken test rather than a broken environment, and a bare `go test ./...` reproduces it for the same reason. link-envtest-assets now maintains the symlink from the same setup-envtest resolution that `task test` uses, so it follows ENVTEST_K8S_VERSION instead of drifting. Its status check follows the link, so a dangling one — the version moved, bin/ was cleaned — is false and the task re-runs; setup-envtest is a no-op once the binaries are present, so that costs nothing. `task test` deliberately does not read the symlink. It keeps exporting the path from setup-envtest directly, so the gate is unaffected if the link is stale or missing. This is for the editor. Verified by removing the symlink and rebuilding it, by pointing it at a nonexistent version and watching the status check re-run, and by running TestRenamedRequiredField_StoredObjectCanAdoptIt with KUBEBUILDER_ASSETS set to bin/k8s/current — the same test that fails when the variable is unset. Co-Authored-By: Claude Opus 5 --- Taskfile-build.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Taskfile-build.yml b/Taskfile-build.yml index 19151fba0..f804918f2 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -137,6 +137,32 @@ 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. + # + # The status check follows the symlink, so a dangling one (the version moved, + # bin/ was cleaned) is false and the task re-runs. setup-envtest is a no-op once + # the binaries are there, so re-running costs nothing. + deps: + - setup-envtest + status: + - test -d "$(pwd)/bin/k8s/current" + 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 +170,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. From 1f690f6457747e6c5bfb79674f6974f0f1c51ace Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 12:35:44 +0000 Subject: [PATCH 08/16] fix(dev): prepare envtest assets in the devcontainer itself The symlink task only ran as a side effect of `task test`, so a fresh container still had a KUBEBUILDER_ASSETS path pointing at nothing until somebody happened to run the suite. post-create.sh now builds it during container setup, which is where "the environment is ready to use" belongs. It is non-fatal by the same reasoning the Git identity step already uses: it needs the network to fetch the binaries, and an otherwise usable environment should not fail to come up because that fetch did not. The guard is an `if !` around the subshell rather than a bare call, because the script runs under `set -euo pipefail` and a bare failure would abort the whole hook. Verified by pointing it at a missing workspace and watching it warn and still exit 0. containerEnv now carries KUBEBUILDER_ASSETS as well, so a plain `go test ./internal/controller/...` in the terminal works and not only a test launched from the Go extension, which is the only thing go.testEnvVars covers. Co-Authored-By: Claude Opus 5 --- .devcontainer/devcontainer.json | 8 +++++++- .devcontainer/post-create.sh | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 From f72ee4f77dc3f9d71b3324d9f33d7cbd809bf17f Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 12:35:54 +0000 Subject: [PATCH 09/16] docs: verify the host keys that actually reach the Secret, and trim the HA paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four corrections from review. The quickstart scanned the host keys into the Secret and then scanned AGAIN to print fingerprints, so what the reader checked was a second network response rather than the bytes they had just stored. It now scans once into a file, prints the fingerprints of that file, and mounts the same file. That is also one command shorter than checking twice. "The Kubernetes API wins" read as a conflict-arbitration guarantee, which is more than the system offers. Replaced with the behaviour: on replay, captured Kubernetes state can overwrite concurrent Git edits to the same resource. The chart reference said `1` was the only accepted value and that a higher one failed at startup. Both halves were wrong: rendering at 0, 1 and 2 shows that 0 and 1 render and only 2 is refused, and the refusal is a Helm template `fail`, so it happens while the chart renders rather than when the process starts. The commit-window example still gave the canonical fallback path under the old `demo` namespace; it now matches the namespace the example actually uses. The HA paragraph also lost its defence of the limitation. One replica, no standby failover, ownership coordination and durable buffering planned — the operational facts carry it without arguing that other controllers are the same. Co-Authored-By: Claude Opus 5 --- README.md | 16 +++++++--------- charts/gitops-reverser/README.md | 2 +- docs/demo/commit-window.md | 2 +- docs/quickstart.md | 26 ++++++++++++-------------- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index a6a5ed41e..9c3749577 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ Custom resources configure all of it: a `GitProvider` holds the repository and c foreign push to the branch is an expected case the worker replays onto. See [API-first publication](docs/api-first-publication.md). - **Bi-directional use, proven by tests.** A dedicated e2e corner runs Flux and Argo CD against the - operator in CI and asserts the round trip settles without a commit loop. For a resource edited - from both sides there is no merge and no lock: the Kubernetes API wins. See - [bi-directional usage](docs/bi-directional.md). + operator in CI and asserts the round trip settles without a commit loop. There is no merge and no + lock: on replay, captured Kubernetes state can overwrite concurrent Git edits to the same + resource. See [bi-directional usage](docs/bi-directional.md). ## What it can write @@ -148,12 +148,10 @@ repository, so that two of them never write the same paths. is the source of truth. That floor can move in any release, including a patch release; when it does, the release notes say so. -It runs as a single replica, and the chart refuses `replicaCount > 1` at install time rather than -letting two instances write the same repository. One active writer per target is where a controller -ends up regardless, so what a second replica would buy here is faster failover, not parallel -writing; the ownership coordination that makes standby replicas safe is on the way to 1.0, along -with a durable worker queue. The backlog is in [docs/TODO.md](docs/TODO.md), and longer-range -directions in [docs/future/](docs/future/). +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? diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index 7ac1795ef..3da8afd32 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -161,7 +161,7 @@ nodeSelector: | Parameter | Description | Default | |-----------|-------------|---------| -| `replicaCount` | Number of controller replicas. `1` is the only accepted value: a higher one is rejected at startup rather than silently running two writers | `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 | `[]` | diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index 48e31b82a..299f70138 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -64,7 +64,7 @@ Two fields exist for the sake of a legible demo rather than because the window n - **`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/demo/configmaps/hello.yaml`. + `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 diff --git a/docs/quickstart.md b/docs/quickstart.md index f1a59d608..07384adea 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -44,30 +44,28 @@ Do this **before** installing. The starter `GitProvider` is only re-checked abou 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-literal=known_hosts="$(ssh-keyscan github.com 2>/dev/null)" \ + --from-file=known_hosts=/tmp/gitops-reverser-known_hosts \ --dry-run=client -o yaml | kubectl apply -f - ``` -SSH host-key verification fails closed, so the `known_hosts` line is required. - -`ssh-keyscan` trusts whatever answers on the network, so it pins whichever key it is handed. Check -what you got against the fingerprints GitHub publishes at -[GitHub's SSH key fingerprints](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints) -before you rely on the Secret: - -```bash -ssh-keyscan github.com 2>/dev/null | ssh-keygen -lf - -``` - -They are printed rather than pinned here on purpose: GitHub has rotated a host key before, and a -fingerprint copied into a guide goes stale silently while a link does not. Existing Flux or Argo CD +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 From f4caf4969f1a4e3d4a9200a7192503f796c10054 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 14:30:57 +0000 Subject: [PATCH 10/16] fix(dev): relink the envtest assets symlink on every run The status check asked only whether bin/k8s/current resolved to a directory, which is true of a link left pointing at a previous Kubernetes version: 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 task reported itself up to date and the editor kept testing against the old apiserver, while `task test` exported the path from setup-envtest directly and used the new one. Reproduced by pointing the link at 1.36.2 while go.mod selects 1.37: the task said nothing and left it alone. 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 the check is removed rather than repaired. The comment claiming the case was already handled goes with it. Co-Authored-By: Claude Opus 5 --- Taskfile-build.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Taskfile-build.yml b/Taskfile-build.yml index f804918f2..55e24f0dd 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -149,13 +149,16 @@ tasks: # setup-envtest directly, so the gate keeps working if the link is stale or gone. # This exists for the editor. # - # The status check follows the symlink, so a dangling one (the version moved, - # bin/ was cleaned) is false and the task re-runs. setup-envtest is a no-op once - # the binaries are there, so re-running costs nothing. + # 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 - status: - - test -d "$(pwd)/bin/k8s/current" cmds: - | set -eu From 17750f694ae97198ae56184e08898e34c2870d11 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 14:31:03 +0000 Subject: [PATCH 11/16] feat(crdfields): fail the settings index on an undocumented CRD kind The coverage check walked index.Kinds, so it caught a described kind whose CRD had gone but never the other direction: a CRD that fields.yaml does not mention at all. A new kind could ship with no index entry and `crdfields -check` would still pass, because a walk driven by index.Kinds never reaches a kind nobody listed. Verified by adding a CRD with no entry: the check now exits 1 and names the kind. All six kinds on disk are described today, so this needs no exclusions. Co-Authored-By: Claude Opus 5 --- hack/crdfields/main.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/hack/crdfields/main.go b/hack/crdfields/main.go index e08307669..79ceda973 100644 --- a/hack/crdfields/main.go +++ b/hack/crdfields/main.go @@ -24,8 +24,9 @@ // // 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. The tool cannot write the sentence for you; it can -// refuse to let the table pretend the field is not there. +// 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 @@ -265,6 +266,27 @@ func render(index *indexDoc, schemas map[string]*schema) (string, error) { 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 { From 053aacfa04351299450ba2011d54a18a986d9f89 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 14:31:12 +0000 Subject: [PATCH 12/16] docs: correct the watch-selection defaults and cut filler from the config guide The generated settings index said an omitted apiGroups and apiVersions mean "every group" and "every version". Neither holds. choosePreferredRecordVersions collapses a version-less match to one record per group/resource, the preferred served version, so omitting apiVersions watches one version rather than all of them. And a named resource matching records in more than one group with no apiGroups filter is ambiguous, so it is watched in none of them. The same document already said both correctly in prose 1,450 lines further down, which left the index contradicting the page it indexes -- in the column whose only job is "what happens if I do not set this". The prose half gains the consequence it was missing: omitting the group does not just resolve across the served surface, it selects nothing when more than one group serves the name. Also in the configuration guide: - ClusterProvider moves out of the numbered flow into a note. Installing already creates the default one, so writing your own is the second-cluster case rather than a step everyone takes. - "Every setting at a glance" becomes "Field overview". - "the real configuration objects" loses "real", which promised a distinction the page never draws. `real` joins Filler-Judgment.yml at suggestion level rather than WordsToCut.yml, because it was measured first: 431 uses across docs/ and the README, more than three times `actually`, and nearly all of them a genuine contrast with something fake or synthetic. A rule that fired on all of them would be read as noise, which is the failure WordsToCut.yml's own header records. Co-Authored-By: Claude Opus 5 --- .vale/styles/HouseStyle/Filler-Judgment.yml | 17 ++++++++++-- docs/configuration.md | 29 ++++++++++++--------- docs/style-guide.md | 7 +++++ hack/crdfields/fields.yaml | 22 +++++++++------- 4 files changed, 51 insertions(+), 24 deletions(-) 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/docs/configuration.md b/docs/configuration.md index ff866486d..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 [quickstart](quickstart.md). +This guide explains the configuration objects that drive gitops-reverser after the install steps +in the [quickstart](quickstart.md). The short version: @@ -27,18 +27,20 @@ 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. + -## Every setting at a glance +## 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 @@ -96,8 +98,8 @@ The destination fields are immutable: to move a target, delete it and create a n |---|---|---| | `gitTargetRef` | **required** | The `GitTarget` these rules feed | | `rules[].resources` | **required** | Plural resource names to watch | -| `rules[].apiGroups` | every group | API groups to match. `[""]` is the core group | -| `rules[].apiVersions` | every version | API versions to match | +| `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) | @@ -109,8 +111,8 @@ The same shape as a `WatchRule`, for cluster-scoped types. It selects no namespa |---|---|---| | `gitTargetRef` | **required** | The `GitTarget` these rules feed | | `rules[].resources` | **required** | Plural cluster-scoped resource names to watch | -| `rules[].apiGroups` | every group | API groups to match | -| `rules[].apiVersions` | every version | API versions to match | +| `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 @@ -1548,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 `*`. 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 index 73babf951..4117b970f 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -15,7 +15,7 @@ # is how `commit.window` stays in the table while the message templates do not. preamble: | - ## Every setting at a glance + ## 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 @@ -140,11 +140,13 @@ kinds: - path: rules.resources doc: Plural resource names to watch - path: rules.apiGroups - default: every group - doc: API groups to match. `[""]` is the core group + 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: every version - doc: API versions to match + 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 @@ -165,11 +167,13 @@ kinds: - path: rules.resources doc: Plural cluster-scoped resource names to watch - path: rules.apiGroups - default: every group - doc: API groups to match + 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: every version - doc: API versions to match + 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 From a46a3b1eea1a23afeca0a534274d462eedd130f9 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 15:05:41 +0000 Subject: [PATCH 13/16] chore: editz --- README.md | 51 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9c3749577..ca5931c4e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ manifests in place, preserving comments and document structure. Use it to capture live changes, bring an existing cluster into Git, or experiment with API-first workflows alongside Flux or Argo CD. +[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. @@ -47,28 +49,53 @@ to the branch and folder you configured. Custom resources configure all of it: a `GitProvider` holds the repository and credentials, a `GitTarget` the branch and folder, a `WatchRule` or `ClusterWatchRule` the resources to claim, and a -`ClusterProvider` the source cluster to mirror from (the chart renders a `default` one). +`ClusterProvider` the source cluster to mirror from (installing renders a `default` one). [Configuration](docs/configuration.md) covers each of them. +## Principles + +- **We never commit a Kubernetes `Secret` 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. CI tests with + Flux and Argo CD verify that the round trip settles without a commit loop. + ## 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`. Back up generated keys before + relying on them. 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). -- **Encrypted Secrets.** SOPS + age encryption before commit, which Secret-shaped custom resources - can opt into. See [SOPS and age](docs/sops-age-guide.md). +- **Status you can wait on.** Conditions follow the kstatus convention, so `Ready`, `Reconciling`, + and `Stalled` mean what `kubectl wait` and GitOps tooling expect. - **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). -- **Built for API-first clusters.** Publication is driven by API writes, not by polling, and a - foreign push to the branch is an expected case the worker replays onto. See - [API-first publication](docs/api-first-publication.md). -- **Bi-directional use, proven by tests.** A dedicated e2e corner runs Flux and Argo CD against the - operator in CI and asserts the round trip settles without a commit loop. There is no merge and no - lock: on replay, captured Kubernetes state can overwrite concurrent Git edits to the same - resource. See [bi-directional usage](docs/bi-directional.md). ## What it can write @@ -125,8 +152,8 @@ which source files changed. 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 a captured object can overwrite concurrent Git edits to -that same object. There is no field-level merge, so decide per folder which side is authoritative. +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. ## Before you adopt it From 64cb9d67bae014b48f5e6ea3e7440667abf001ba Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 15:05:51 +0000 Subject: [PATCH 14/16] chore: more edits --- README.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ca5931c4e..40ed40013 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Custom resources configure all of it: a `GitProvider` holds the repository and c ## Principles -- **We never commit a Kubernetes `Secret` unencrypted. Ever.** A resource classified as sensitive is +- **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 @@ -66,8 +66,12 @@ Custom resources configure all of it: a `GitProvider` holds the repository and c - **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. CI tests with - Flux and Argo CD verify that the round trip settles without a commit loop. +- **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. +- **We never merge. We replay.** When the branch has moved, we fetch the new base and replay the + writes we have not published onto it. There is no three-way field merge, so a replay can overwrite + a concurrent Git edit to the same object. See [API-first publication](docs/api-first-publication.md). ## Features @@ -84,15 +88,16 @@ Custom resources configure all of it: a `GitProvider` holds the repository and c 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`. Back up generated keys before - relying on them. See [SOPS and age](docs/sops-age-guide.md). + 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. + 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). From f0d088d67358ace7cb45f2dfbbbb6386c5c79c8d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 15:17:55 +0000 Subject: [PATCH 15/16] chore: go for it --- README.md | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 40ed40013..9fee8b344 100644 --- a/README.md +++ b/README.md @@ -37,20 +37,18 @@ Named Kubernetes actors in Git history require optional ## How it works Changes reach the Kubernetes API however your users make them: `kubectl`, a GUI, a CI job, or an -agent over MCP. The operator watches the resources you claim, sanitizes each change, and writes it -to the branch and folder you configured. +agent over MCP. ![Overview diagram: humans, kubectl, and MCP clients change resources through the Kubernetes API, which GitOps Reverser watches and commits to Git](docs/images/overview.excalidraw.svg) -1. **Watch** the Kubernetes API for the types each `GitTarget` claims. Watch is the single source of - object state. +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 all of it: a `GitProvider` holds the repository and credentials, a -`GitTarget` the branch and folder, a `WatchRule` or `ClusterWatchRule` the resources to claim, and a -`ClusterProvider` the source cluster to mirror from (installing renders a `default` one). -[Configuration](docs/configuration.md) covers each of them. +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 @@ -68,10 +66,8 @@ Custom resources configure all of it: a `GitProvider` holds the repository and c 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. -- **We never merge. We replay.** When the branch has moved, we fetch the new base and replay the - writes we have not published onto it. There is no three-way field merge, so a replay can overwrite - a concurrent Git edit to the same object. See [API-first publication](docs/api-first-publication.md). + CD verify that the round trip settles without a commit loop. See + [API-first publication](docs/api-first-publication.md). ## Features @@ -140,7 +136,7 @@ next diff. The walkthrough includes status checks, troubleshooting, and cleanup. 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 itself. +event. A `CommitRequest` can close an open window early and supplies the commit message, allowing the user to indicate **why** he made a certain change. ![Kubernetes resource changes flow through GitOps Reverser's commit window into Git](docs/images/commit-window.excalidraw.svg) From 80bdfab6121d7d2360c6d40b29bceaff30fbe48d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 22 Sep 2026 15:21:47 +0000 Subject: [PATCH 16/16] docs: say why a CommitRequest carries its own commit message The commit-window paragraph described the mechanism and stopped there. A captured change already shows what changed in the diff, so the one thing the cluster cannot supply is intent: the paragraph now says the message is where the person making a change explains why it was necessary. Also fixes verb agreement after the modal ("can close and supply"), replaces a gendered pronoun for an arbitrary user, and wraps the line, which was 152 characters and failed markdownlint MD013. Co-Authored-By: Claude Opus 5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9fee8b344..06759692f 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ next diff. The walkthrough includes status checks, troubleshooting, and cleanup. 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 supplies the commit message, allowing the user to indicate **why** he made a certain change. +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. ![Kubernetes resource changes flow through GitOps Reverser's commit window into Git](docs/images/commit-window.excalidraw.svg)