diff --git a/README.md b/README.md index 60c8e7b7..df96aa94 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![CI](https://github.com/ConfigButler/gitops-reverser/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/ConfigButler/gitops-reverser/actions/workflows/release.yml) +[![CI](https://github.com/ConfigButler/gitops-reverser/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ConfigButler/gitops-reverser/actions/workflows/ci.yml) [![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) @@ -12,12 +12,8 @@ # GitOps Reverser GitOps Reverser is a Kubernetes operator that turns live Kubernetes API activity into clean, -versioned YAML in Git. - -It is for teams that want API-first workflows without giving up an audit trail, reviewable history, -or a repo that can later be reconciled by GitOps tooling. - -The broader pattern behind this project is described at +versioned YAML in Git: an audit trail, reviewable history, and a GitOps-reconcilable repo, without +giving up API-first workflows. The broader pattern is described at [reversegitops.dev](https://reversegitops.dev).
Demo: kubectl apply triggers a sanitized Git commit within seconds
@@ -34,262 +30,224 @@ in [ConfigButler/example-audit](https://github.com/ConfigButler/example-audit). ![Overview diagram showing how API events flow through the operator into Git](docs/images/overview.excalidraw.svg) -## Audience - -This is advanced operator software to install, even if the downstream workflow is meant to become -simpler for other teams. - ## When it fits | Good fit | Poor fit | |---|---| -| Clusters where you can grant watch/RBAC, run Valkey/Redis in-cluster, and write to Git | Production HA requirements today | -| Teams that want to try API-to-Git capture first, then add named author attribution later | Shared paths with two always-on writers fighting over the same resources | +| 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 | Workflows that need a guaranteed per-mutation change log rather than a state mirror | -> **Author attribution** is the only optional capability: it needs kube-apiserver audit delivery, which -> managed control planes (EKS/GKE/AKS) generally do not expose. Without it the operator still mirrors -> state, with commits authored by the configured committer. Valkey/Redis is optional in committer-only -> mode: when `--redis-addr` is set, watch resume cursors are stored so restarts pick up where they left -> off; when left empty, watches cold-replay from scratch on restart. - ## How it works -1. GitOps Reverser **watches** the Kubernetes API for the resource types each `GitTarget` claims — - watch is the single source of object state. -2. Each change is sanitized (status, `managedFields`, and runtime noise removed) and diffed against the - current Git content. -3. **Valkey/Redis** tracks each GitTarget's watch resume position, so the operator re-picks up exactly - where it left off after a restart or reconnect (and, when configured, holds audit attribution facts). -4. The operator writes stable YAML to Git with useful commit metadata. Commits are authored by the - configured committer, or by the actual user / service account when an audit fact matches - (**attribution** — the one part that is optional). +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. -`Secret` resources can be encrypted before commit with SOPS + age, Secret-shaped custom resource -types can opt into the same path at controller startup, and Git commits can be SSH-signed -through `GitProvider.spec.commit.signing`. +It can also: -Capturing objects served by an **aggregated API server** is supported through the same watch path -(with a LIST fallback for servers that do not implement streaming lists); see -[`docs/architecture.md`](docs/architecture.md). +- 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`. ### Operating modes -Every install needs the same base: Kubernetes watch/RBAC access, -Git credentials, and cert-manager. The only thing that varies is **author attribution**: +Every install shares one base: Kubernetes watch/RBAC access, Git credentials, and cert-manager. The +only thing that varies is **who shows up as the commit author**. -| Mode | Attribution | Additionally needs | Commit author | +| Mode | Git author | Git committer | Also needs | |---|---|---|---| -| **Committer-only** (default) | off | — | configured committer identity | -| **Attributed** | on | kube-apiserver audit delivery | named user / service account on a strong match, committer otherwise | - -Because object state comes from **watch**, GitOps Reverser is a *state mirror with opportunistic -per-mutation history*: it records every change it observes while watching, and collapses intermediate -versions to current state across restarts, reconnects, or `410 Gone` replays. It is not a guaranteed -per-mutation change log. No path silently loses a delete — a delete missed while no watch was running is -reconciled by the replay mark-and-sweep on reconnect. +| **`configured-author`** *(default)* | the configured identity | the configured identity | None | +| **`attributed-author`** | the authenticated Kubernetes actor | the configured identity | audit delivery + Valkey/Redis | + +Every commit carries both a Git *author* and a Git *committer*. In `configured-author` mode they are +the same configured identity (say, `ConfigButler Bot`). Turn on `attribution.enabled` and only the +**author** changes; it becomes whoever actually made the change, while the committer stays put: + +| Who made the change (Kubernetes actor) | Git author | Git committer | +|---|---|---| +| Human user (`simon@example.com`) | Simon | `ConfigButler Bot` | +| Service account (`system:serviceaccount:team-a:deployer`) | the `team-a/deployer` service account | `ConfigButler Bot` | +| CI / GitHub App identity | that CI / App identity | `ConfigButler Bot` | + +The committer column never moves. That is the point. On a strong audit match the actor becomes the +author; with no match, the commit still lands, authored by the committer. `attributed-author` needs +kube-apiserver audit delivery (which managed control planes like EKS/GKE/AKS generally do not expose) +plus Valkey/Redis. See the [attribution setup guide](docs/attribution-setup-guide.md). + +**Valkey/Redis is optional but advised.** The default runs without it in `configured-author` mode. +Add a reachable Valkey/Redis to unlock warm-restart cursors (watches resume instead of cold-replaying), +CommitRequest author capture (the admission webhook is installed by default but only records authors +once Redis is present, a form of author attribution), and attributed-author mode. `configured-author` +needs none of it. + +Because object state comes from **watch**, GitOps Reverser is a *state mirror*: it reflects current +object state, not every intermediate mutation, and no delete is silently lost (one missed while no +watch was running is reconciled on reconnect). See [`docs/architecture.md`](docs/architecture.md) for +replay and `410 Gone` details. ## Boundaries -GitOps Reverser reconstructs clean Kubernetes manifests from live cluster state. It does not -reconstruct higher-level authoring intent that is no longer present in the cluster. - -That means it can write back stable Kubernetes YAML, but it cannot reverse Helm-rendered resources -back into a clean `values.yaml`, and it generally cannot infer the original authoring structure of -arbitrary templates or overlays. +GitOps Reverser reconstructs clean Kubernetes manifests from live cluster state. It does **not** +reconstruct higher-level authoring intent that is no longer in the cluster: it writes back stable +Kubernetes YAML, but it cannot reverse Helm-rendered resources into a clean `values.yaml`, and it +generally cannot infer the original structure of arbitrary templates or overlays. That boundary is intentional. The goal is deployable cluster intent in Git, not magical recovery of every upstream abstraction. ## Status -Early-stage software. CRDs and behavior may still change. - -- Single controller pod only (`replicas=1`); HA is not supported yet. -- Shared-resource bi-directional workflows require explicit coordination. -- Reverse-GitOps source recovery is limited to Kubernetes manifests, not Helm/Kustomize authoring models. -- Tests run against Kubernetes `1.36`. Other versions may work but are not part of the current matrix. -- Runtime behavior is deterministic; there is no AI or heuristic mutation at runtime. +Early-stage software; CRDs and behavior may still change. -GitOps Reverser is a good fit for pilots, lab clusters, brownfield discovery, and design-partner -users who can tolerate API and behavior changes. Production use should happen only after an -environment-specific review. +- Single controller pod (`replicas=1`); HA is not supported yet. +- Shared-resource bi-directional workflows need explicit coordination. +- Source recovery is limited to Kubernetes manifests, not Helm/Kustomize authoring models. +- Tested against Kubernetes `1.36`; other versions may work but are not in the matrix. +- Runtime behavior is deterministic: no AI or heuristic mutation at runtime. -Directions we may revisit later live in [docs/TODO.md](docs/TODO.md) and [docs/future/](docs/future/). +Good fit for pilots, lab clusters, brownfield discovery, and design partners who can tolerate change. +Production use should follow an environment-specific review. Deferred directions live in +[docs/TODO.md](docs/TODO.md) and [docs/future/](docs/future/). ## Quick start -This quick start sets up **committer-only mode**: the operator mirrors watched Kubernetes state into Git, -commits are authored by the configured committer identity, and named authors can be added later. It is the -easiest way to prove the workflow works. - -**Prerequisites** +This brings up the **demo**: a starter `GitProvider`, `GitTarget`, and `WatchRule` in a +`gitops-reverser-quickstart-demo` namespace. It watches ConfigMaps in that namespace and writes them +to `/live-cluster` on the `main` branch. It runs in **`configured-author` mode** (one +committer identity, no Redis) by default. -- Kubernetes cluster with `kubectl` configured -- cert-manager for TLS certificate management +![Config basics diagram showing the relationship between GitProvider, GitTarget, and WatchRule](docs/images/config-basics.excalidraw.svg) -Managed platforms such as EKS, GKE, and AKS generally work for this committer-only flow because it does -not require control-plane audit webhook configuration. +**Prerequisites:** a Kubernetes cluster with `kubectl`, Helm 3, and cert-manager for TLS. **1. Install cert-manager** ```bash -kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.20.3/cert-manager.yaml kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s ``` -**2. Install Valkey with auth** *(required for author attribution; optional in committer-only mode)* - -```bash -kubectl create namespace gitops-reverser -kubectl create secret generic valkey-auth \ - --namespace gitops-reverser \ - --from-literal=password="$(openssl rand -base64 32)" - -helm repo add valkey https://valkey.io/valkey-helm/ && helm repo update -helm install valkey valkey/valkey --version 0.9.3 --namespace gitops-reverser \ - --set auth.enabled=true \ - --set auth.usersExistingSecret=valkey-auth \ - --set auth.aclUsers.default.passwordKey=password \ - --set "auth.aclUsers.default.permissions=~* &* +@all" -``` - -**3. Install GitOps Reverser** - -With Valkey (warm restarts — watches resume from last cursor): - -```bash -helm install gitops-reverser \ - oci://ghcr.io/configbutler/charts/gitops-reverser \ - --namespace gitops-reverser \ - --create-namespace -``` +**2. Create a Git repo and a deploy key** -Without Valkey (committer-only mode — watches cold-replay on restart): +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 -helm install gitops-reverser \ - oci://ghcr.io/configbutler/charts/gitops-reverser \ - --namespace gitops-reverser \ - --create-namespace \ - --set queue.redis.addr="" +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) ``` -**4. Create Git credentials** +**3. Create the demo namespace and Git credentials** -SSH deploy key example: +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 -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 +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 Git credentials Secrets are accepted as-is (they just need **write** access). See +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 GitHub path and HTTPS/PAT fallback. +[`docs/github-setup-guide.md`](docs/github-setup-guide.md) for the full GitHub guide and HTTPS/PAT +fallback. -**5. Enable the starter configuration** +**4. Install GitOps Reverser with the demo** -![Config basics diagram showing the relationship between GitProvider, GitTarget, and WatchRule](docs/images/config-basics.excalidraw.svg) - -Use the chart's `quickstart` values so Helm creates a starter `GitProvider`, `GitTarget`, and -`WatchRule`: +A single install enables the demo and points the starter `GitProvider` at your repo: ```bash -helm upgrade gitops-reverser \ +helm install gitops-reverser \ oci://ghcr.io/configbutler/charts/gitops-reverser \ --namespace gitops-reverser \ - --reuse-values \ + --create-namespace \ --set quickstart.enabled=true \ - --set quickstart.gitProvider.url=git@github.com:/.git + --set-string quickstart.gitProvider.url=git@github.com:OWNER/REPO.git ``` -The default quickstart namespace is `default`, so the `git-creds` Secret above should exist there -unless you explicitly set `quickstart.namespace` to something else. +Replace `OWNER/REPO` with your repository (angle brackets would be parsed by the shell). + +This runs without Redis, so warm-restart cursors and author capture (CommitRequest and +attributed-author) stay inactive; the operator is healthy regardless. To enable them, point it at a +reachable Valkey/Redis with `--set queue.redis.addr=HOST:PORT` (add +`--set queue.redis.auth.existingSecret=SECRET` if it requires auth; see the +[chart README](charts/gitops-reverser/README.md)). -The starter `GitTarget` writes under `live-cluster` by default. That keeps the first run away from -the repository root. To deliberately target the root instead, add -`--set quickstart.gitTarget.path=.` to the Helm command. +The starter `GitTarget` has SOPS encryption enabled, so on first reconcile the controller generates a +`sops-age-key` Secret in the demo namespace and annotates it with a backup reminder. That is expected; +it only matters once you mirror `Secret` resources (the demo watches ConfigMaps). Back up that key if +you keep the demo around. -Check that the starter resources become ready: +Check the starter resources become ready: ```bash -kubectl get gitprovider,gittarget,watchrule -n default +kubectl get gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo ``` -See [`docs/configuration.md`](docs/configuration.md) for how `GitProvider`, `GitTarget`, and -`WatchRule` fit together after the starter install. - -**6. Test it** +**5. Test it** ```bash -kubectl create configmap test-config --from-literal=key=value -n default +kubectl create configmap test-config --from-literal=key=value -n gitops-reverser-quickstart-demo ``` -You should see a new commit land in your Git repository within seconds. - -If no commit appears, start with: +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 default +kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo ``` -### Add named authors later - -The quick start deliberately avoids kube-apiserver audit delivery. To make commits use the actual -Kubernetes user or service account when a strong audit match exists, enable attribution and then follow -the Helm notes: +To tear the demo down: `helm uninstall gitops-reverser -n gitops-reverser` and +`kubectl delete namespace gitops-reverser-quickstart-demo`. -```bash -helm upgrade gitops-reverser \ - oci://ghcr.io/configbutler/charts/gitops-reverser \ - --namespace gitops-reverser \ - --reuse-values \ - --set attribution.enabled=true +### Want named users on your commits? -helm get notes gitops-reverser -n gitops-reverser -``` +Every Git commit has both an author and a committer. Turn on **`attributed-author` mode** and the +real Kubernetes actor (user, service account, or CI identity) becomes the Git author, while the +configured identity stays the committer. It needs kube-apiserver audit delivery and Valkey/Redis; the +[attribution setup guide](docs/attribution-setup-guide.md) walks through the setup. -Those notes include the audit webhook URL, the client certificate Secret names, and the kubeconfig shape -that kube-apiserver needs. For networking and TLS tradeoffs around audit delivery, see -[`docs/design/audit-webhook-api-server-connectivity.md`](docs/design/audit-webhook-api-server-connectivity.md). +### Rather have it managed? -If you need exact edit authorship but do not want to operate this audit path yourself, I welcome -conversations about a managed ConfigButler solution. +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 here for the stable docs surface: +Start with the stable docs surface: - [`docs/README.md`](docs/README.md) - [`docs/configuration.md`](docs/configuration.md) +- [`docs/attribution-setup-guide.md`](docs/attribution-setup-guide.md) - [`docs/security-model.md`](docs/security-model.md) - [`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) - -If you are evaluating alternatives or deciding when another approach is a better fit, start with -[`docs/alternatives.md`](docs/alternatives.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. +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 behind - this work. +- 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. diff --git a/Taskfile-build.yml b/Taskfile-build.yml index 03577dd6..9e777210 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -377,6 +377,7 @@ tasks: --set labels.managedBy=kubectl \ --set createNamespace=true \ --set queue.redis.addr={{.DEFAULT_AUDIT_REDIS_ADDR}} \ + --set queue.redis.auth.existingSecret=valkey-auth \ --include-crds \ > dist/install.yaml diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index 6511d203..a52c8191 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -29,11 +29,13 @@ For the chart's optional starter `quickstart` block, see [`docs/configuration.md - Kubernetes 1.36 (probably works for older versions, but not tested) - cert-manager (for TLS certificates) -- **Valkey or Redis** reachable from the controller, with auth enabled (`queue.redis.auth.existingSecret`). - This is **required**: it holds each GitTarget's watch resume state so work is re-picked up after a - restart or reconnect. -- **Optional — attributed mode only:** kube-apiserver audit delivery, which adds commit-author - attribution. Without it the operator still mirrors state, authored by the configured committer. +- **Valkey or Redis: optional but advised.** The default install runs without it in + `configured-author` mode (watches cold-replay on restart). Add it to unlock warm-restart resume + cursors, CommitRequest author capture (the admission webhook), and attributed-author mode; enabling + any of those requires a non-empty `queue.redis.addr`. +- **Optional, attributed-author mode only:** kube-apiserver audit delivery, which adds + mirrored-resource commit-author attribution. Without it the operator still mirrors state, authored by + the configured committer. ## Installation @@ -173,19 +175,21 @@ nodeSelector: | `auditService.nodePort` | Fixed NodePort for the audit Service when `auditService.type=NodePort` | `30444` | | `auditService.clusterIP` | Optional fixed ClusterIP for the dedicated audit Service | `""` | | `quickstart.enabled` | Create starter `GitProvider`, `GitTarget`, and `WatchRule` resources | `false` | -| `quickstart.namespace` | Namespace for the starter quickstart resources | `default` | +| `quickstart.namespace` | Namespace for the starter quickstart resources (create it, and the git-creds Secret, before install) | `gitops-reverser-quickstart-demo` | +| `quickstart.createNamespace` | Let the chart create `quickstart.namespace` (Helm then owns it) | `false` | | `quickstart.gitProvider.url` | Repository URL used by the starter `GitProvider` | `""` | | `quickstart.gitProvider.secretRef.name` | Existing Secret name used by the starter `GitProvider` | `git-creds` | | `quickstart.gitTarget.path` | Repository path used by the starter `GitTarget`; set `.` only to deliberately target the repo root | `live-cluster` | | `quickstart.watchRule.rules` | Rules used by the starter `WatchRule` | `configmaps create/update/delete` | -| `queue.redis.addr` | Redis/Valkey endpoint (`host:port`). **Required** — holds each GitTarget's watch resume cursors (state continuity) and, when audit is configured, the attribution facts | `valkey:6379` | -| `queue.redis.auth.existingSecret` | Name of a pre-created Secret holding the Redis password | `valkey-auth` | +| `queue.redis.addr` | Redis/Valkey endpoint (`host:port`). Optional but advised: empty runs `configured-author` with cold-replay on restart. Set it for warm-restart cursors and for the admission webhook to actually record CommitRequest authors (admission runs as a no-op without it); **required** only when `attribution.enabled=true` | `""` | +| `queue.redis.auth.existingSecret` | Name of a pre-created Secret holding the Redis password (only used when `queue.redis.addr` is set) | `""` | | `queue.redis.auth.existingSecretKey` | Key within the Secret that holds the password | `password` | | `queue.redis.auth.username` | Optional Redis ACL username | `""` | | `queue.redis.tls.enabled` | Enable TLS for Redis connection | `false` | | `attribution.enabled` | Run audit ingress and name mirrored-resource commit authors from matching kube-apiserver audit facts | `false` | | `attribution.ttl` | How long an attribution fact is retained waiting for the matching watch event to join it | `10m` | | `attribution.grace` | Bounded per-event wait for a matching audit fact before a watch event ships as the committer | `3s` | +| `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` | | `servers.metrics.bindAddress` | Metrics listener bind address | `:8080` | | `servers.metrics.tls.enabled` | Serve metrics with TLS | `false` | | `servers.metrics.tls.certPath` | Metrics TLS certificate mount path | `/tmp/k8s-metrics-server/metrics-server-certs` | @@ -194,7 +198,6 @@ nodeSelector: | `service.ports.audit` | Service port for audit ingress when `attribution.enabled=true` | `9444` | | `service.ports.metrics` | Service port for metrics | `8080` | | `servers.healthProbe.bindAddress` | Liveness/readiness probe bind address (`--health-probe-bind-address`) | `:8081` | -| `servers.admission.enabled` | Run the validating admission server hosting the validate-operator-types webhook (captures the CommitRequest submitter as the commit author) | `true` | | `servers.admission.port` | Admission webhook container/Service port | `9443` | | `servers.admission.timeoutSeconds` | Admission webhook timeout (failurePolicy is Ignore) | `2` | | `servers.admission.tls.certManager` | Mint the admission serving cert via cert-manager (false = BYO via `secretNameOverride`) | `true` | @@ -222,14 +225,15 @@ See [`values.yaml`](values.yaml) for complete configuration options. When `attribution.enabled=true`, `https://:9444/audit-webhook` receives audit events from kube-apiserver. The operator extracts a minimal attribution fact from each (auditID, user, verb, resourceVersion, GVR, namespace, name, UID, status, timestamps) into the Redis attribution index -(populated only when audit attribution is enabled). The same Redis endpoint also stores each GitTarget's -watch resume cursors — that part is always needed — so reconnects resume a normal watch from the last -processed resourceVersion when the apiserver can still serve that history. Object state itself comes -from Kubernetes **watch**, not from audit; audit only names the commit author. +(populated only when audit attribution is enabled). When a Redis endpoint is configured it also stores +each GitTarget's watch resume cursors, so reconnects resume a normal watch from the last processed +resourceVersion when the apiserver can still serve that history. Object state itself comes from +Kubernetes **watch**, not from audit; audit only names the commit author. When `attribution.enabled=false`, the audit webhook is not rendered or served and the product runs -committer-only: mirrored-resource commits are authored by the configured committer. `queue.redis.addr` -is still required because Redis/Valkey stores watch resume cursors. +configured-author: mirrored-resource commits are authored by the configured committer. `queue.redis.addr` +is optional here: set it for warm-restart resume cursors, or leave it empty and watches cold-replay +from scratch on restart. Cluster ID path segments are rejected. diff --git a/charts/gitops-reverser/templates/NOTES.txt b/charts/gitops-reverser/templates/NOTES.txt index b18bfbd4..2eb75f6d 100644 --- a/charts/gitops-reverser/templates/NOTES.txt +++ b/charts/gitops-reverser/templates/NOTES.txt @@ -25,9 +25,12 @@ What the chart did for you: - configured the audit listener without TLS (`servers.audit.tls.enabled=false`) {{- end }} {{- else }} -- started in committer-only mode (`attribution.enabled=false`) +- started in configured-author mode (`attribution.enabled=false`) {{- end }} {{- if .Values.quickstart.enabled }} +{{- if .Values.quickstart.createNamespace }} +- created the demo namespace `{{ .Values.quickstart.namespace }}` +{{- end }} - created quickstart resources in namespace `{{ .Values.quickstart.namespace }}` - GitProvider: `{{ .Values.quickstart.gitProvider.name }}` - GitTarget: `{{ .Values.quickstart.gitTarget.name }}` @@ -35,7 +38,13 @@ What the chart did for you: {{- end }} What the chart did NOT do for you: +{{- if .Values.quickstart.enabled }} +- it did NOT create your Git credentials Secret. If `{{ .Values.quickstart.gitProvider.secretRef.name }}` is not + already in namespace `{{ .Values.quickstart.namespace }}`, create it now; the starter GitProvider is re-checked + only about every 5 minutes, so add it promptly to avoid a delayed first commit. +{{- else }} - it did NOT create your Git credentials Secret +{{- end }} - it did NOT install Valkey/Redis {{- if .Values.attribution.enabled }} - it did NOT write `/etc/kubernetes/...` files on your control-plane node(s) diff --git a/charts/gitops-reverser/templates/deployment.yaml b/charts/gitops-reverser/templates/deployment.yaml index 87d8a258..b54acffc 100644 --- a/charts/gitops-reverser/templates/deployment.yaml +++ b/charts/gitops-reverser/templates/deployment.yaml @@ -122,7 +122,7 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - {{- if .Values.queue.redis.auth.existingSecret }} + {{- if and .Values.queue.redis.addr .Values.queue.redis.auth.existingSecret }} - name: REDIS_PASSWORD valueFrom: secretKeyRef: diff --git a/charts/gitops-reverser/templates/quickstart.yaml b/charts/gitops-reverser/templates/quickstart.yaml index c524f0e6..14d18779 100644 --- a/charts/gitops-reverser/templates/quickstart.yaml +++ b/charts/gitops-reverser/templates/quickstart.yaml @@ -1,4 +1,14 @@ {{- if .Values.quickstart.enabled }} +{{- if .Values.quickstart.createNamespace }} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.quickstart.namespace }} + labels: + {{- include "gitops-reverser.labels" . | nindent 4 }} + app.kubernetes.io/component: quickstart +{{- end }} --- apiVersion: configbutler.ai/v1alpha3 kind: GitProvider diff --git a/charts/gitops-reverser/templates/validate-redis.yaml b/charts/gitops-reverser/templates/validate-redis.yaml new file mode 100644 index 00000000..f5119805 --- /dev/null +++ b/charts/gitops-reverser/templates/validate-redis.yaml @@ -0,0 +1,9 @@ +{{- /* +Attribution (attributed-author mode) reads and writes audit facts in Redis, so it cannot run +without an endpoint — the controller would fail at startup. Fail the render early with an +actionable message instead. The admission webhook is deliberately NOT gated here: it stays +enabled without Redis and simply no-ops command-author capture. +*/ -}} +{{- if and .Values.attribution.enabled (eq (trim .Values.queue.redis.addr) "") -}} +{{- fail "attribution.enabled=true requires queue.redis.addr: attributed-author mode stores audit facts in Redis. Set queue.redis.addr, or leave attribution.enabled=false." -}} +{{- end -}} diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 1dfe280a..38ba856f 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -102,6 +102,11 @@ servers: # *command* authorship (from admission). Off → CommitRequests commit as the configured # committer and report AuthorAttributed=False. See # docs/design/commitrequest-admission-authorship.md. + # + # Command-author capture is Redis-backed: the captured author is written to Redis at admission and + # read back at reconcile. Enabled by default so the webhook is always installed, but it is a no-op + # without Redis: the pod runs healthy and CommitRequests commit as the configured committer + # (AuthorAttributed=False) until queue.redis.addr is set. admission: enabled: true bindAddress: ":9443" @@ -161,17 +166,19 @@ servers: secretNameOverride: "" # Redis/Valkey holds each GitTarget's watch resume cursors (warm restart) and, when attribution is -# enabled, the audit attribution facts. Optional in committer-only mode: leave addr empty to run without -# Redis (watches cold-replay on restart). Required when attribution.enabled is true. +# enabled, the audit attribution facts. Empty by default: the chart runs in configured-author mode with +# no Redis (watches cold-replay on restart). Set addr to enable warm restarts; required when +# attribution.enabled is true. queue: redis: - # Redis/Valkey endpoint (host:port). Leave empty to run without Redis in committer-only mode; - # required when attribution.enabled is true. - addr: "valkey:6379" + # Redis/Valkey endpoint (host:port). Empty by default (no Redis, configured-author mode). Set to a + # host:port for warm restarts; required when attribution.enabled is true. + addr: "" auth: - # Pre-existing Secret (same namespace) holding the Redis password. Create it before install; - # leave empty only for dev clusters where Valkey runs without authentication. - existingSecret: "valkey-auth" + # Pre-existing Secret (same namespace) holding the Redis password. Empty by default (no Redis). + # Set it when queue.redis.addr points at an authenticated Valkey; leave empty for a Valkey with + # no auth. + existingSecret: "" # Key within the Secret that holds the password value. existingSecretKey: "password" # Optional Redis username for ACL-based auth (Redis 6+). Not sensitive; stays as a plain value. @@ -182,11 +189,11 @@ queue: # Commit-author attribution from audit facts. Off by default so first-time installs can prove the # Kubernetes-to-Git workflow without kube-apiserver audit webhook configuration. With it off, the -# operator runs committer-only (Redis optional — leave queue.redis.addr empty to run without it), and every mirrored-resource commit uses the +# operator runs configured-author (Redis optional — leave queue.redis.addr empty to run without it), and every mirrored-resource commit uses the # configured committer. attribution: # When true, run the audit webhook ingress and name commit authors from matching audit facts; requires - # a non-empty queue.redis.addr. When false, run committer-only (no audit ingress, commits authored by + # a non-empty queue.redis.addr. When false, run configured-author (no audit ingress, commits authored by # the committer); Redis is optional (leave queue.redis.addr empty to run without it). enabled: false # How long an attribution fact is retained waiting for the matching watch event to join it. @@ -275,9 +282,14 @@ auditKubeconfig: # you explicitly opt in. quickstart: enabled: false - # Namespace for the starter GitProvider, GitTarget, and WatchRule. - # This namespace must already exist. - namespace: default + # Namespace for the starter GitProvider, GitTarget, and WatchRule. Create it (and the git-creds + # Secret) before installing, so the starter GitProvider is Ready on the first reconcile instead of + # waiting up to ~5 minutes (RequeueSteadyInterval) for the Secret to appear. + namespace: gitops-reverser-quickstart-demo + # Let the chart create quickstart.namespace (Helm then owns it). Left false by default because the + # recommended flow pre-creates the namespace and git-creds Secret before install; set true only if + # the credentials Secret will be supplied some other way. + createNamespace: false gitProvider: name: example-provider # Required when quickstart.enabled=true. diff --git a/cmd/main.go b/cmd/main.go index 556eebfb..527e5b17 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -198,7 +198,7 @@ func main() { // Optional author attribution. When enabled, the attribution index is built on the Redis connection, // the audit webhook records minimal facts, and live watch events are author-attributed when a fact - // matches. When disabled (committer-only), no attribution index exists and commits use the configured + // matches. When disabled (configured-author), no attribution index exists and commits use the configured // committer identity; the cursor store above is unaffected. var ( auditRunnable *auditServerRunnable @@ -228,10 +228,11 @@ func main() { setupLog.Info("author attribution enabled: matched audit facts name the commit author", "redisAddr", cfg.redisAddr, "grace", cfg.attributionGrace.String()) case cfg.redisAddr != "": - setupLog.Info("committer-only mode: author attribution disabled; commits use the configured "+ + setupLog.Info("configured-author mode: author attribution disabled; commits use the configured "+ "committer identity", "redisAddr", cfg.redisAddr) default: - setupLog.Info("committer-only mode: no Redis configured; attribution disabled, watches cold-replay on restart") + setupLog.Info("configured-author mode: no Redis configured; attribution disabled, " + + "watches cold-replay on restart") } // Setup watch manager (must be after controllers are set up) @@ -268,10 +269,16 @@ func main() { var commandAuthorStore *queue.CommandAuthorStore var commandAuthorLookup controller.CommandAuthorLookup if cfg.admissionWebhookEnabled { - commandAuthorStore = redisStore.CommandAuthorStore() - commandAuthorLookup = commandAuthorStore - setupLog.Info("validate-operator-types webhook enabled: command submitters are captured at admission " + - "and named as the commit author") + if redisStore != nil { + commandAuthorStore = redisStore.CommandAuthorStore() + commandAuthorLookup = commandAuthorStore + setupLog.Info("validate-operator-types webhook enabled: command submitters are captured at admission " + + "and named as the commit author") + } else { + setupLog.Info("validate-operator-types webhook enabled without Redis: command author capture is a " + + "no-op; CommitRequests commit as the configured committer (AuthorAttributed=False). " + + "Set --redis-addr to capture command authors.") + } } if err := (&controller.CommitRequestReconciler{ Client: mgr.GetClient(), @@ -288,7 +295,7 @@ func main() { } // +kubebuilder:scaffold:builder - // Cert watchers (auditCertWatcher is nil in committer-only mode / --audit-insecure). + // Cert watchers (auditCertWatcher is nil in configured-author mode / --audit-insecure). addCertWatchersToManager(mgr, metricsCertWatcher, auditCertWatcher) // Health checks: readiness reflects the audit ingress preconditions when attribution is on, @@ -411,7 +418,7 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { fs.BoolVar(&cfg.authorAttribution, "author-attribution", true, "Name the real actor (human or service account) who caused each change as the Git commit author, "+ "resolved from matching audit facts; this runs the audit webhook ingress (default true). When "+ - "false, every commit is authored by the configured committer identity (committer-only mode).") + "false, every commit is authored by the configured committer identity (configured-author mode).") fs.StringVar(&cfg.redisUsername, "redis-username", "", "Optional Redis username.") fs.StringVar( &cfg.redisPassword, @@ -519,11 +526,11 @@ func validateAuditConfig(cfg appConfig) error { if cfg.authorAttribution { return errors.New("redis-addr is required when author-attribution is enabled") } - // Committer-only mode with no Redis: watches cold-replay on restart, attribution is off. + // Configured-author mode with no Redis: watches cold-replay on restart, attribution is off. return nil } if !cfg.authorAttribution { - // Committer-only mode with Redis configured: the audit ingress server is not started, so its + // Configured-author mode with Redis configured: the audit ingress server is not started, so its // server/TLS settings are irrelevant. return nil } @@ -552,10 +559,9 @@ func validateAdmissionWebhookConfig(cfg appConfig) error { if !cfg.admissionWebhookEnabled { return nil } - if strings.TrimSpace(cfg.redisAddr) == "" { - return errors.New("redis-addr is required when the admission webhook is enabled: " + - "command authorship requires Redis") - } + // No redis-addr requirement here: the admission webhook stays enabled without Redis and + // simply no-ops command-author capture (commits fall back to the committer). Redis is + // wired only when configured; see the commandAuthorStore setup. if _, _, err := splitBindAddress(cfg.admissionWebhookBindAddress); err != nil { return fmt.Errorf("invalid admission-webhook-bind-address %q: %w", cfg.admissionWebhookBindAddress, err) } @@ -890,9 +896,15 @@ func setupAdmissionWebhooks(mgr ctrl.Manager, commandAuthorStore *queue.CommandA webhookhandler.ValidateAllPath, &ctrladmission.Webhook{Handler: webhookhandler.AdmissionAllowHandler{}}, ) + // Leave Store as a nil interface when there is no Redis-backed store, so the handler + // no-ops rather than dereferencing a typed-nil *CommandAuthorStore. + operatorTypesHandler := &webhookhandler.ValidateOperatorTypesHandler{} + if commandAuthorStore != nil { + operatorTypesHandler.Store = commandAuthorStore + } mgr.GetWebhookServer().Register( webhookhandler.ValidateOperatorTypesPath, - &ctrladmission.Webhook{Handler: &webhookhandler.ValidateOperatorTypesHandler{Store: commandAuthorStore}}, + &ctrladmission.Webhook{Handler: operatorTypesHandler}, ) } diff --git a/cmd/main_audit_server_test.go b/cmd/main_audit_server_test.go index d7836b29..c6e48019 100644 --- a/cmd/main_audit_server_test.go +++ b/cmd/main_audit_server_test.go @@ -132,9 +132,9 @@ func TestParseFlagsWithArgs_RedisAddrRequiredWhenAttributionEnabled(t *testing.T assert.Contains(t, err.Error(), "redis-addr is required when author-attribution is enabled") } -func TestParseFlagsWithArgs_CommitterOnlyNoRedis(t *testing.T) { - fs := flag.NewFlagSet("test-committer-only-no-redis", flag.ContinueOnError) - // Committer-only mode with no Redis: watches cold-replay on restart, no attribution. +func TestParseFlagsWithArgs_ConfiguredAuthorNoRedis(t *testing.T) { + fs := flag.NewFlagSet("test-configured-author-no-redis", flag.ContinueOnError) + // Configured-author mode with no Redis: watches cold-replay on restart, no attribution. cfg, err := parseFlagsWithArgs(fs, []string{ "--author-attribution=false", "--redis-addr=", @@ -144,9 +144,9 @@ func TestParseFlagsWithArgs_CommitterOnlyNoRedis(t *testing.T) { assert.Empty(t, cfg.redisAddr) } -func TestParseFlagsWithArgs_CommitterOnlyDisablesAttribution(t *testing.T) { - fs := flag.NewFlagSet("test-committer-only", flag.ContinueOnError) - // Committer-only = attribution off, Redis still configured (default addr). The audit ingress +func TestParseFlagsWithArgs_ConfiguredAuthorDisablesAttribution(t *testing.T) { + fs := flag.NewFlagSet("test-configured-author", flag.ContinueOnError) + // Configured-author = attribution off, Redis still configured (default addr). The audit ingress // server is not started, so its TLS / client-CA settings need not be configured. cfg, err := parseFlagsWithArgs(fs, []string{ "--author-attribution=false", diff --git a/docs/README.md b/docs/README.md index 69654333..a7d738b2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,8 @@ If you only want the supported product docs, start with the files below. document — Flux `HelmRelease`, Argo CD `Application`, KRO, and core resources all mirror and edit alike - [`commit-signing.md`](commit-signing.md): how valid Git signatures map to platform verification - [`github-setup-guide.md`](github-setup-guide.md): GitHub repository and credential setup +- [`attribution-setup-guide.md`](attribution-setup-guide.md): naming real Kubernetes users as commit + authors via kube-apiserver audit delivery - [`sops-age-guide.md`](sops-age-guide.md): Secret encryption with SOPS + age - [`security-model.md`](security-model.md): controller access, trust boundaries, and the Git credentials Secret shape diff --git a/docs/TODO.md b/docs/TODO.md index c7daf8fb..0030213d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,6 +4,18 @@ This file is meant to track the smaller current backlog, not historical notes. ## Current backlog +- [ ] **High availability (near-term priority).** Support running more than one controller replica; + `replicaCount > 1` is currently hard-rejected by the chart. Redis/Valkey is already the shared store + for watch resume cursors and command-author facts, so the data plane is HA-oriented. The remaining + work is leader/ownership coordination so two replicas never write the same `GitTarget`, plus the + durable worker queue below. Redis becomes required (not just advised) in this mode. + +- [ ] Optional: capture CommitRequest authors without Redis in single-pod mode. + The admission webhook is on by default but no-ops author capture without Redis (it writes the author + to Redis and the controller reads it back). Since both run in one process today (`replicaCount=1`), + an in-memory `CommandAuthorStore` fallback would make capture work with no Redis. Only useful until + HA lands (HA needs the shared Redis store); low priority. + - [ ] Prevent same-repository write collisions across multiple `GitProvider` objects. Decide whether the fix should be validation, a shared queue/lock per repo, or both. Until then, keep recommending one `GitProvider` per repository. diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index f89d0676..781af79a 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,31 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. +## Unreleased — chart defaults now run Redis-free (next minor; behavior change) + +The Helm chart now defaults to the simple, Redis-free `configured-author` path, so a bare +`helm install` comes up healthy without external infrastructure: + +- `queue.redis.addr` now defaults to `""` (was `valkey:6379`) and `queue.redis.auth.existingSecret` + to `""` (was `valkey-auth`). Without a Redis endpoint the operator runs `configured-author` and + watches cold-replay on restart. +- `servers.admission.enabled` stays `true` by default, but the validate-operator-types admission + webhook no longer requires Redis. Without `queue.redis.addr` it runs as a no-op (CommitRequests + commit as the configured committer, `AuthorAttributed=False`); it captures authors once Redis is + configured. Previously enabling admission without Redis failed startup. +- The chart still rejects one invalid combination at render time: `attribution.enabled=true` without + `queue.redis.addr` fails `helm install`/`upgrade` with an actionable message (attributed-author mode + cannot run without Redis) instead of crash-looping the pod. +- `quickstart.namespace` now defaults to `gitops-reverser-quickstart-demo` (was `default`), and a new + `quickstart.createNamespace` (default `false`) controls whether the chart creates it. + +**Migration** + +- To keep the previous behavior, set the values explicitly: `--set queue.redis.addr=valkey:6379 + --set queue.redis.auth.existingSecret=valkey-auth --set servers.admission.enabled=true`. +- `helm upgrade --reuse-values` preserves your existing settings, so reused-value upgrades are + unaffected; only fresh installs (or upgrades that re-specify values) pick up the new defaults. + ## Unreleased — API group version bumped `v1alpha2` → `v1alpha3` (next minor; breaking) The served API version moved from `configbutler.ai/v1alpha2` to `configbutler.ai/v1alpha3` to @@ -31,19 +56,19 @@ CRDs are applied. This branch changes the default install to be easier to try, and it tightens the v1alpha3 status surface around conditions. Existing installs should check the items below before upgrading. -### 1. Helm installs now start committer-only by default +### 1. Helm installs now start configured-author by default The chart default for `attribution.enabled` changed from `true` to `false`. A default install no longer renders the audit receiver Service or audit TLS Secrets, and mirrored-resource commits are authored by the configured committer identity. -Redis/Valkey is optional in committer-only mode. Set `--redis-addr` to store watch resume cursors (warm -restart); leave it empty to cold-replay from scratch on restart. Attribution mode still requires a +Redis/Valkey is optional in configured-author mode. Set `--redis-addr` to store watch resume cursors (warm +restart); leave it empty to cold-replay from scratch on restart. Attributed-author mode still requires a non-empty `--redis-addr`. **Migration** -- If you want the easier committer-only install, no chart value is needed. +- If you want the easier configured-author install, no chart value is needed. - If you currently rely on kube-apiserver audit delivery for named commit authors, set: ```yaml diff --git a/docs/architecture.md b/docs/architecture.md index 0fdba14b..ea7c519d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,10 +36,11 @@ fails; there is no plaintext opt-out. `(GitProvider namespace, GitProvider name, branch)` tuple. Multiple `GitTarget`s may share one branch. Every write to that branch goes through the worker's single event loop and commit window. -**Redis is a hard dependency — treat it as always available.** The operator wires Valkey/Redis -unconditionally and stays not-ready until it is reachable. Redis stores watch resume cursors and the small -coordination records used by attribution, CommitRequest authorship, and HA work; committer-only mode still -requires Redis. +**Redis/Valkey is optional but advised.** The default configured-author mode runs without it: a plain +`helm install` comes up healthy and watches cold-replay on restart. When an endpoint is configured, +Redis stores watch resume cursors (warm restarts) and the small coordination records used by +attribution, CommitRequest author capture, and HA. Attributed-author mode requires Redis, and HA will +require it as the shared store across replicas. **Audit is an optional attribution lookup.** When attribution is enabled, kube-apiserver posts audit events to `/audit-webhook`; the operator extracts a minimal attribution fact (auditID, user, verb, @@ -1032,7 +1033,7 @@ flowchart TD Hi --> Hq{author-attribution?} Hj --> Hq Hq -->|yes| I[Build attribution index + audit fact extractor + audit HTTP server + resolver] - Hq -->|no| J[Committer-only: no attribution index; audit webhook skipped] + Hq -->|no| J[Configured-author: no attribution index; audit webhook skipped] I --> K[Setup + register Watch Manager] J --> K K --> L[Register GitProvider + GitTarget + CommitRequest controllers] @@ -1040,13 +1041,13 @@ flowchart TD M --> N[mgr.Start] ``` -Redis is optional in committer-only mode. When `--redis-addr` is set, the cursor store is wired and a +Redis is optional in configured-author mode. When `--redis-addr` is set, the cursor store is wired and a Redis readiness gate keeps the pod not-ready until Redis is reachable; watches resume from their last stored resourceVersion after a restart. When `--redis-addr` is empty, the cursor store is skipped and watches cold-replay from scratch on restart instead. With `--author-attribution` on (the default), a non-empty `--redis-addr` is required: the attribution index is built on the Redis connection, the audit HTTP handler is wired with the fact extractor, the watch manager gets the author resolver, and the audit -ingress is added to `/readyz`. With `--author-attribution=false` (committer-only) no attribution index is +ingress is added to `/readyz`. With `--author-attribution=false` (configured-author) no attribution index is built and the audit webhook is skipped entirely; every commit is committer-authored. *** diff --git a/docs/attribution-setup-guide.md b/docs/attribution-setup-guide.md new file mode 100644 index 00000000..ecc50d17 --- /dev/null +++ b/docs/attribution-setup-guide.md @@ -0,0 +1,114 @@ +# Attribution Setup Guide + +By default GitOps Reverser runs **configured-author**: every mirrored commit is authored by the single +configured committer identity. **Attribution** turns on a second identity — the actual Kubernetes +user or service account that made the change becomes the commit *author*, while the committer stays +constant. Git carries both. + +Attribution is the only optional capability, and it is a real operational step up: it requires +kube-apiserver audit delivery and a Redis/Valkey backing store. This guide covers what that costs +and how the pieces fit. It assumes GitOps Reverser is already installed and mirroring state — see +the [root README quick start](../README.md#quick-start) for that first run. + +## When it fits + +Attribution works by correlating kube-apiserver **audit events** with the objects the operator sees +on **watch**. That means the apiserver has to deliver audit events to the controller over a webhook, +which only self-managed control planes expose: + +- **Supported:** clusters where you control apiserver flags — k3s, k3d, Talos, Kamaji, kubeadm. +- **Not supported:** managed control planes (EKS, GKE, AKS) that hide apiserver configuration. + +On a managed platform, either front it with a self-managed control plane or stay in configured-author +mode. The [audit webhook connectivity design](design/audit-webhook-api-server-connectivity.md) has +the full reasoning on hosting. + +## Prerequisites + +- GitOps Reverser installed and producing configured-author commits. +- A control plane whose apiserver flags you can set and reload. +- **Redis/Valkey, required.** In configured-author mode it is optional; with attribution on it holds the + audit attribution facts (in addition to watch resume cursors), so `queue.redis.addr` must be + non-empty. See [Redis is required for HA](redis-required-for-ha.md) for sizing notes. + +## The two sides of the setup + +Enabling attribution splits into a **chart side** (in-cluster, managed by Helm) and a +**control-plane side** (node-local files and apiserver flags the chart cannot touch). Do the chart +side first — it generates the exact values the control-plane side needs. + +### 1. Chart side — enable and read the notes + +```bash +helm upgrade gitops-reverser \ + oci://ghcr.io/configbutler/charts/gitops-reverser \ + --namespace gitops-reverser \ + --reuse-values \ + --set attribution.enabled=true + +helm get notes gitops-reverser -n gitops-reverser +``` + +With `attribution.enabled=true` the chart additionally deploys the audit receiver, its Service, and +(via cert-manager) the audit TLS materials — a root CA Secret and a kube-apiserver client-cert +Secret. + +`helm get notes` is the authoritative, install-specific output: it renders the audit webhook URL +reachable from your control-plane node, the exact Secret names, and a copy-paste block that assembles +the `audit-webhook.kubeconfig` kube-apiserver expects. Treat that rendered block as the source of +truth rather than transcribing values by hand — it reflects your Service type, ports, and TLS +choices. + +### 2. Control-plane side — what the chart cannot do + +The chart deliberately does **not** touch your nodes. Working from the rendered notes, you: + +1. Generate `audit-webhook.kubeconfig` from the audit Secrets. +2. Copy it to **every** control-plane node (it must be a static file present before the apiserver + reads it). +3. Provide an audit **policy** file — start from the tuned example at + [`test/e2e/cluster/audit/policy.yaml`](../test/e2e/cluster/audit/policy.yaml), which already drops + runtime noise and heartbeats. +4. Set the apiserver audit flags and point them at those files: + `--audit-policy-file`, `--audit-webhook-config-file`, and `--audit-webhook-mode=batch`. +5. Restart or reload kube-apiserver once, one node at a time. + +**Use `batch` mode. Never start with `blocking` or `blocking-strict`** — those couple normal API +request latency to the health of the audit receiver and can reject valid requests when the receiver +is slow. The [connectivity design doc](design/audit-webhook-api-server-connectivity.md#audit-webhook-backend-best-practices) +covers the recommended flags and what to tune first; the +[TLS design doc](design/audit-webhook-tls-design.md) covers trusting the receiver certificate versus +the local-only `insecure-skip-tls-verify` shortcut. For a k3s-specific file-placement walkthrough, +see [`audit-setup/cluster/readme.md`](audit-setup/cluster/readme.md). + +## How matching works + +Audit facts and watch events arrive on independent paths, so the controller joins them with a bounded +wait rather than blocking: + +- **`attribution.grace`** (default `3s`) — how long a watch event waits for a matching audit fact + before it ships authored by the committer. Larger raises the attribution hit-rate at the cost of + commit latency. +- **`attribution.ttl`** (default `10m`) — how long an unmatched audit fact is retained waiting for + its watch event. + +Attribution is opportunistic: on a strong match the named user or service account is the author; with +no match in the grace window, the commit still lands, authored by the committer. No change is dropped +for lack of a match. + +## Verifying and reverting + +The controller reports `NotReady` on `/readyz` until the audit listener is accepting connections +(with a loaded TLS cert) and it has reached Redis, so a rollout or cert rotation buffers events in the +apiserver instead of dropping them. Once ready, make a change as a distinct user and confirm the +resulting commit's **author** is that identity while the committer is unchanged. + +To return to configured-author, upgrade with `--set attribution.enabled=false`. The audit receiver and +Service are removed; you can then also remove the apiserver audit flags on the nodes. + +## Related docs + +- [`design/audit-webhook-api-server-connectivity.md`](design/audit-webhook-api-server-connectivity.md): networking, DNS, and TLS tradeoffs for audit delivery +- [`design/audit-webhook-tls-design.md`](design/audit-webhook-tls-design.md): trusting the audit receiver certificate +- [`configuration.md`](configuration.md): core configuration objects +- [`../README.md`](../README.md): product overview and configured-author quick start diff --git a/docs/configuration.md b/docs/configuration.md index c10b286d..64f50241 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,7 +14,7 @@ The short version: The chart's optional `quickstart` values are just a convenience layer that creates starter instances of those same resources. -For a first trial, use the root README quick start. It runs committer-only: Git writes work without +For a first trial, use the root README quick start. 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. @@ -679,11 +679,11 @@ author to each watch event by matching a fact (by resourceVersion/UID) within a The same Redis connection also stores per-watch resume cursors, so short reconnects can resume a normal watch from the last processed resourceVersion when the apiserver can still serve that history. -Valkey/Redis is **optional in committer-only mode**: when `--redis-addr` is set, watch resume cursors are +Valkey/Redis is **optional in configured-author mode**: when `--redis-addr` is set, watch resume cursors are stored so restarts pick up where they left off; when left empty, watches cold-replay from scratch on restart instead. When author attribution is enabled (`--author-attribution=true`), a non-empty `--redis-addr` is required — attribution facts and resume cursors both use the same connection. The Helm -chart defaults to **committer-only** (`attribution.enabled: false`): the audit webhook is unused and every +chart defaults to **configured-author** (`attribution.enabled: false`): the audit webhook is unused and every mirrored-resource commit is authored by the configured committer. ```yaml diff --git a/docs/design/commitrequest-admission-authorship.md b/docs/design/commitrequest-admission-authorship.md index ae1035c8..f6672356 100644 --- a/docs/design/commitrequest-admission-authorship.md +++ b/docs/design/commitrequest-admission-authorship.md @@ -356,7 +356,7 @@ Concretely, `attributeAuthor` collapses to: func (r *CommitRequestReconciler) attributeAuthor( ctx context.Context, cr *configbutleraiv1alpha3.CommitRequest, ) (queue.CommandAuthor, commitRequestAttribution) { - if r.AuthorLookup == nil { // validate-operator-types webhook disabled → committer-only + if r.AuthorLookup == nil { // validate-operator-types webhook disabled → configured-author return queue.CommandAuthor{}, attributionCommitter } if a, ok := r.AuthorLookup.LookupCommandAuthor(ctx, cr.UID); ok { @@ -424,12 +424,12 @@ if cfg.internalCommandsWebhookEnabled { // ... if err := (&controller.CommitRequestReconciler{ // ... - AuthorLookup: commandAuthorStore, // nil when the webhook is disabled → committer-only, immediate + AuthorLookup: commandAuthorStore, // nil when the webhook is disabled → configured-author, immediate }).SetupWithManager(mgr); err != nil { /* ... */ } ``` -The `AuthorLookup == nil` contract is preserved and *strengthened*: nil → committer-only; -non-nil + miss → committer-only **for that request** — both immediate, neither waits, +The `AuthorLookup == nil` contract is preserved and *strengthened*: nil → configured-author; +non-nil + miss → configured-author **for that request** — both immediate, neither waits, both surface `AuthorAttributed=False`. **HA.** Admission can hit any replica (it is a `Service`); the controller leader reads @@ -586,7 +586,7 @@ status-contract and behavior change are acceptable. `--admission-webhook` server: when that flag is on, `cmd/main.go` builds the `CommandAuthorStore` and registers both the always-allow observer and the validate-operator-types handler (`setupAdmissionWebhooks`). The controller's `AuthorLookup` - is that store (nil when the flag is off → committer-only, immediate). + is that store (nil when the flag is off → configured-author, immediate). - **Chart value surface.** A new `internalCommands` block in `values.yaml` (`enabled`, `bindAddress`/`port`, `tls.*`, `certManager.enabled`, `timeoutSeconds`), defaulting `enabled: true` to mirror `attribution.enabled`. New templates @@ -604,8 +604,8 @@ status-contract and behavior change are acceptable. - **Window-match coupling.** Command authorship is admission-sourced, but the attach still matches the open window *by author string* (`openWindow.Author`, set from the edit's audit attribution). So a named admission author only finalizes a window that - was *also* attributed to the same user — i.e. with audit on. In committer-only mode + was *also* attributed to the same user — i.e. with audit on. In configured-author mode the window author is empty and a named command author would not match; the CommitRequest e2e specs that assert a named author therefore still skip - committer-only mode. Worth a follow-up if command authorship should stand fully + configured-author mode. Worth a follow-up if command authorship should stand fully alone. diff --git a/docs/design/deletecollection-attribution-expander.md b/docs/design/deletecollection-attribution-expander.md index 18c39f8d..096ee510 100644 --- a/docs/design/deletecollection-attribution-expander.md +++ b/docs/design/deletecollection-attribution-expander.md @@ -215,7 +215,7 @@ objects**. The owner's trap: *in a few seconds you could see more than one `dele - **Option C — commit as committer, document the limit.** v1. **v1: Option C.** The hard case is a narrow intersection (aggregated/hollow body ∧ supports `deletecollection` ∧ -watched ∧ attributed mode), and the failure is *degraded attribution*, not wrong state or wrong author — which is +watched ∧ attributed-author mode), and the failure is *degraded attribution*, not wrong state or wrong author — which is the correct conservative outcome. **Ship §2 + §5 now; Option B is the named fast-follow; Option A is rejected.** ## 7. Recommendation at a glance @@ -265,7 +265,7 @@ the correct conservative outcome. **Ship §2 + §5 now; Option B is the named fa ### 9.2 E2E — implemented Implemented as Ginkgo specs in `test/e2e/deletecollection_intent_e2e_test.go` -(`Describe("DeleteCollection intent & attribution")`). Attributed mode on (skipped in committer-only mode); a +(`Describe("DeleteCollection intent & attribution")`). Attributed-author mode on (skipped in configured-author mode); a GitTarget claims `configmaps` with a 0s commit window; deletes are issued by an **impersonated actor** carrying OIDC name/email claims, and finalizers are cleared by a **separate** impersonated identity. Each spec scopes its collection delete with a per-spec label selector and asserts **convergence** (these files gone/authored thus, diff --git a/docs/design/status-conditions-guide.md b/docs/design/status-conditions-guide.md index 94a96d46..207527f4 100644 --- a/docs/design/status-conditions-guide.md +++ b/docs/design/status-conditions-guide.md @@ -89,7 +89,7 @@ by the `Reconciling`/`Ready` reason and the `Attributed` condition. Canonical reads: -* waiting for the create audit event (attributed mode only): `Reconciling=True` reason +* waiting for the create audit event (attributed-author mode only): `Reconciling=True` reason `WaitingForAuditEvent`, `Attributed=Unknown` → kstatus InProgress * waiting for the close delay (author settled, attached — the `closeDelaySeconds` collect window, then commit and push): `Reconciling=True` reason `WaitingForCloseDelay`, `Attributed` settled, `Pushed=False` @@ -98,6 +98,6 @@ Canonical reads: * benign no-commit (nothing to save / already present / foreign open window): `Ready=True`, `Pushed=False`, `Stalled=False`, with the specific reason on `Ready` → kstatus Current (a correct, non-error outcome) * failed finalize: `Ready=False`, `Stalled=True`, reason `FinalizeFailed` → kstatus Failed -* committer-only mode sets `Attributed=True` (`AttributionNotRequired`) immediately; attributed mode leaves +* configured-author mode sets `Attributed=True` (`AttributionNotRequired`) immediately; attributed-author mode leaves it `Unknown` until the create audit event names the author, or `False` (`AuditEventNotObserved`) if it never arrives and the commit is authored by the configured committer diff --git a/docs/design/watch-first-ingestion-architecture.md b/docs/design/watch-first-ingestion-architecture.md index bc6b6841..9d47d040 100644 --- a/docs/design/watch-first-ingestion-architecture.md +++ b/docs/design/watch-first-ingestion-architecture.md @@ -33,7 +33,7 @@ Three decisions frame this rewrite: GitTarget's watch **resume cursors** (state continuity / work re-pickup) and is the substrate for HA and the planned durable branch-worker queue, so it is a hard dependency in every mode. What is optional is **audit attribution**, toggled by `--author-attribution` (chart - `attribution.enabled`). With attribution off the product runs **committer-only**: it mirrors cluster + `attribution.enabled`). With attribution off the product runs **configured-author**: it mirrors cluster state to Git, authored by the configured committer identity, and Redis still backs the resume cursors. With attribution on (and the audit webhook configured to post to it), the product additionally resolves the **author** from audit facts when the evidence is strong. Attribution off → committer. @@ -363,7 +363,7 @@ sometimes *fewer* when bursts/downtime collapse to current state. Both are accep `CommitRequest` currently **fails closed** if it cannot attribute the requester ([commitrequest_controller.go](../../internal/controller/commitrequest_controller.go), -`attributionFailedMessage`). That does not fit a committer-only install. +`attributionFailedMessage`). That does not fit a configured-author install. New behavior: @@ -380,10 +380,10 @@ signed request — Kubernetes object state alone does not identify the human who | Mode | State source | Redis / audit | Author fidelity | Use | |---|---|---|---|---| -| **Committer-only** | watch | Redis only (no audit webhook) | committer identity | simplest install, state mirror | -| **Attributed** | watch | Redis + audit webhook | named user/SA on strong match; committer otherwise | recommended target | +| **Configured-author** | watch | Redis optional (no audit webhook); cold-replay without it | committer identity | simplest install, state mirror | +| **attributed-author** | watch | Redis + audit webhook | named user/SA on strong match; committer otherwise | recommended target | -The product must not imply committer-only equals attributed mode, nor that attributed mode recovers +The product must not imply configured-author equals attributed-author mode, nor that attributed-author mode recovers every author (the audit policy still bounds which writes carry a fact, and the CommitRequest submitter is not recoverable from state alone). Both modes deliver a continuously updated Git mirror of desired cluster state. @@ -438,10 +438,10 @@ moved attribution bits), `internal/gate`, `webhook/audit_joiner.go`, the materia the late-lane/divert/nudge code. Shrink `audit_handler.go` to fact extraction. **Stage 4 — Config, wiring, docs.** -Keep Redis **required** in `cmd/main.go` (a missing `--redis-addr` is a startup error, and the -readiness gate holds the pod not-ready until Redis is reachable); make only the audit webhook / -attribution optional via `--author-attribution=false` (committer-only — the audit ingress is not -started, but Redis still backs the resume cursors). Remove the dead audit-stream flags +Note: the shipped behavior later relaxed this. Redis is now **optional** in `cmd/main.go` (an empty +`--redis-addr` is valid and runs configured-author with cold-replay; the readiness gate only applies +when Redis is configured). The audit webhook / attribution remains optional via +`--author-attribution=false`; when Redis *is* configured it still backs the resume cursors. Remove the dead audit-stream flags (`--audit-redis-max-len`, `--audit-bytype-*`, `--watch-state-stream`, body TTL/wait, etc.). Update the Helm chart and `config/` so the audit webhook is opt-out while Redis stays a hard dependency. Rewrite `docs/architecture.md` to the watch-first model and document the two operating modes and the diff --git a/docs/design/watch-first-merge-readiness.md b/docs/design/watch-first-merge-readiness.md index d90e39aa..dffe99a8 100644 --- a/docs/design/watch-first-merge-readiness.md +++ b/docs/design/watch-first-merge-readiness.md @@ -120,8 +120,8 @@ optional.** Everything below states it that way; never call Redis optional. index and wires `watchMgr.WatchCursorStore` **unconditionally**; `validateAuditConfig` **requires** a non-empty `--redis-addr`; a new `--author-attribution` flag (default `true`, chart `attribution.enabled`) gates **only** the audit webhook ingress + resolver + CommitRequest lookup. So - "committer-only" now means *attribution off, Redis still on*. `cmd/main_audit_server_test.go` updated - (empty `--redis-addr` is now an error; committer-only is the attribution toggle). + "configured-author" now means *attribution off, Redis still on*. `cmd/main_audit_server_test.go` updated + (empty `--redis-addr` is now an error; configured-author is the attribution toggle). - **Dead-code removal.** Deleted the last materialization-phase-machine residue in `internal/watch` (`NudgeTypeResyncForLateEvent` → `claimedGVRForGroupResource` → `materializerInstance` + the backing `lateNudge*`/`materializer*` fields and `lateNudgeMinInterval` const), zero-external-caller verified; diff --git a/docs/facts/subresources.md b/docs/facts/subresources.md index 2a10f00b..197e0303 100644 --- a/docs/facts/subresources.md +++ b/docs/facts/subresources.md @@ -436,7 +436,7 @@ status: - type: Ready # summary: True once it reached a non-error terminal outcome status: "True" reason: Committed # Committed | NoWindowInGrace | WindowMismatch | AlreadyPresent | FinalizeFailed - - type: Attributed # True immediately when attribution is not required (committer-only) + - type: Attributed # True immediately when attribution is not required (configured-author) status: "True" reason: AttributedFromAuditEvent - type: Pushed # True once the commit is in the remote repository diff --git a/hack/e2e/inject-webhook-tls.sh b/hack/e2e/inject-webhook-tls.sh index ac420e4a..7fee488a 100755 --- a/hack/e2e/inject-webhook-tls.sh +++ b/hack/e2e/inject-webhook-tls.sh @@ -75,8 +75,8 @@ wait_for_manager_rollout() { # terminating pod from before the restart cannot produce a false-positive match. warmup_audit_path() { if kubectl --context "${CTX}" -n "${NAMESPACE}" logs "${MANAGER_DEPLOY}" \ - --since=10m 2>/dev/null | grep -q "watch-first committer-only mode enabled"; then - echo "✅ Watch-first committer-only mode detected — audit warmup not required" + --since=10m 2>/dev/null | grep -q "watch-first configured-author mode enabled"; then + echo "✅ Watch-first configured-author mode detected — audit warmup not required" return 0 fi @@ -101,11 +101,11 @@ warmup_audit_path() { return 1 } -# Committer-only installs (attribution disabled — the chart default since the +# Configured-author installs (attribution disabled — the chart default since the # watch-first work) render no audit TLS material and run no audit ingress, so # there is nothing to inject. The kube-apiserver still boots with the bootstrap # audit webhook config written by start-cluster.sh; with no receiver its batched -# events are simply dropped, which is exactly committer-only behaviour. Detect +# events are simply dropped, which is exactly configured-author behaviour. Detect # this by the absence of the audit root CA Certificate (rendered only when # attribution is enabled) and skip the whole audit webhook TLS dance — otherwise # generate-audit-webhook-kubeconfig.sh below would block for ~2 minutes waiting on @@ -113,7 +113,7 @@ warmup_audit_path() { if ! kubectl --context "${CTX}" -n "${NAMESPACE}" get certificate \ gitops-reverser-audit-root-ca >/dev/null 2>&1; then echo "ℹ️ No audit root CA Certificate in namespace '${NAMESPACE}' —" \ - "committer-only install; skipping audit webhook TLS injection" + "configured-author install; skipping audit webhook TLS injection" exit 0 fi diff --git a/internal/controller/commitrequest_controller_test.go b/internal/controller/commitrequest_controller_test.go index bdbb57ab..17d97e42 100644 --- a/internal/controller/commitrequest_controller_test.go +++ b/internal/controller/commitrequest_controller_test.go @@ -17,10 +17,10 @@ import ( var _ = Describe("CommitRequest controller", func() { const namespace = "default" - // The suite registers the reconciler in attributed mode (a non-nil AuthorLookup + // The suite registers the reconciler in attributed-author mode (a non-nil AuthorLookup // that never resolves) with a Finalizer that never resolves, so these specs cover // the in-progress stamp and the terminal short-circuit only; the full - // attribute → attach → terminal flow and the committer-only path are covered by + // attribute → attach → terminal flow and the configured-author path are covered by // the unit tests in commitrequest_controller_unit_test.go. It("stamps a freshly created CommitRequest with in-progress conditions", func() { commitRequest := &configbutleraiv1alpha3.CommitRequest{ diff --git a/internal/controller/commitrequest_controller_unit_test.go b/internal/controller/commitrequest_controller_unit_test.go index 32679837..490868ec 100644 --- a/internal/controller/commitrequest_controller_unit_test.go +++ b/internal/controller/commitrequest_controller_unit_test.go @@ -382,8 +382,8 @@ func TestCommitRequestReconcile_StaleCacheEchoDoesNotRefinalize(t *testing.T) { // Webhook-disabled mode (no AuthorLookup) never waits: a freshly created CommitRequest // attaches immediately with a blank author and commits as the configured committer, // recording AuthorAttributed=False (CommitterFallback). -func TestCommitRequestReconcile_CommitterOnlyCommitsWithoutWaiting(t *testing.T) { - cr := newCommitRequest("save-committer-only") +func TestCommitRequestReconcile_ConfiguredAuthorCommitsWithoutWaiting(t *testing.T) { + cr := newCommitRequest("save-configured-author") cr.CreationTimestamp = metav1.Now() // fresh: a waiting path would requeue instead of commit c := newCommitRequestClient(t, nil, cr) f := &fakeFinalizer{ @@ -392,10 +392,10 @@ func TestCommitRequestReconcile_CommitterOnlyCommitsWithoutWaiting(t *testing.T) } r := &CommitRequestReconciler{Client: c, APIReader: c, Finalizer: f} // AuthorLookup nil - res := reconcileCommitRequest(t, r, "save-committer-only") + res := reconcileCommitRequest(t, r, "save-configured-author") assert.Zero(t, res.RequeueAfter, "webhook-disabled mode must not requeue waiting for an author") - got := fetchCommitRequest(t, c, "save-committer-only") + got := fetchCommitRequest(t, c, "save-configured-author") requireCondition(t, got, ConditionTypeReady, metav1.ConditionTrue, crReasonCommitted) requireCondition(t, got, ConditionTypePushed, metav1.ConditionTrue, crReasonPushed) requireCondition(t, got, ConditionTypeAuthorAttributed, metav1.ConditionFalse, crReasonCommitterFallback) @@ -407,7 +407,7 @@ func TestCommitRequestReconcile_CommitterOnlyCommitsWithoutWaiting(t *testing.T) // Webhook-disabled mode settles AuthorAttributed=False (CommitterFallback) immediately // and never parks the request in a "waiting for the author" state: even while the // attach is still being polled, the request is in the WaitingForCloseDelay wait. -func TestCommitRequestReconcile_CommitterOnlyAttributedImmediately(t *testing.T) { +func TestCommitRequestReconcile_ConfiguredAuthorAttributedImmediately(t *testing.T) { cr := newCommitRequest("save-committer-poll") cr.CreationTimestamp = metav1.Now() c := newCommitRequestClient(t, nil, cr) diff --git a/internal/watch/author_resolver.go b/internal/watch/author_resolver.go index 7c7a4755..26100c31 100644 --- a/internal/watch/author_resolver.go +++ b/internal/watch/author_resolver.go @@ -28,7 +28,7 @@ const DefaultAttributionGraceWindow = 3 * time.Second const attributionPollInterval = 150 * time.Millisecond // AttributionLookup is the read side of the optional audit attribution index. The -// Redis-backed queue.AttributionIndex satisfies it; nil means committer-only. +// Redis-backed queue.AttributionIndex satisfies it; nil means configured-author. type AttributionLookup interface { // LookupAuthorResolution resolves the strongest author fact for a watch event. // exactCapable is true for ADDED/MODIFIED events (try only the immutable exact key diff --git a/internal/watch/manager.go b/internal/watch/manager.go index 5f528bde..352d4fd0 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -49,7 +49,7 @@ type Manager struct { EventRouter *EventRouter // AuthorResolver optionally names the commit author for a live watch event by // joining the audit attribution index (RV/UID match, bounded grace window). Nil - // is committer-only mode (no audit/Redis): every event commits as the committer. + // is configured-author mode (no audit/Redis): every event commits as the committer. AuthorResolver AuthorResolver // WatchCursorStore optionally persists per-watch resourceVersion cursors so // reconnects can resume without replaying the full type snapshot. diff --git a/internal/watch/target_watch.go b/internal/watch/target_watch.go index 6ed641fd..919c5ead 100644 --- a/internal/watch/target_watch.go +++ b/internal/watch/target_watch.go @@ -689,7 +689,7 @@ func (m *Manager) routeLiveTargetWatchEvent( // attachAuthor names the commit author for a live watch event from the optional // attribution index. The live object still carries its UID and resourceVersion // here (sanitize strips them inside targetWatchGitEvent), so the resolver joins on -// the strongest available key. Committer-only mode (nil resolver) leaves UserInfo +// the strongest available key. Configured-author mode (nil resolver) leaves UserInfo // zero, so the writer authors the commit as the configured committer. func (m *Manager) attachAuthor( ctx context.Context, diff --git a/internal/webhook/audit_handler.go b/internal/webhook/audit_handler.go index 53837cf9..cb8f8b65 100644 --- a/internal/webhook/audit_handler.go +++ b/internal/webhook/audit_handler.go @@ -45,7 +45,7 @@ type auditHandlerFirsts struct { // AuditFactRecorder stores the minimal author-attribution fact for one accepted, // mutating audit event. It is the only thing the audit webhook does now: watch // carries the object body, so audit is a pure attribution lookup table. A nil -// recorder means committer-only mode — the handler is not wired at all. +// recorder means configured-author mode — the handler is not wired at all. type AuditFactRecorder interface { RecordFact(ctx context.Context, event auditv1.Event) error } diff --git a/internal/webhook/audit_handler_test.go b/internal/webhook/audit_handler_test.go index bb8509c2..b79ddd45 100644 --- a/internal/webhook/audit_handler_test.go +++ b/internal/webhook/audit_handler_test.go @@ -231,7 +231,7 @@ func TestAuditHandler_AcceptedEventRecordsFact(t *testing.T) { assert.Equal(t, []string{"create-1"}, recorder.auditIDs()) } -// TestAuditHandler_NilRecorderAcceptsWithoutRecording confirms committer-only +// TestAuditHandler_NilRecorderAcceptsWithoutRecording confirms configured-author // mode: a nil FactRecorder records nothing yet still returns 200. func TestAuditHandler_NilRecorderAcceptsWithoutRecording(t *testing.T) { handler, err := NewAuditHandler(AuditHandlerConfig{}) // FactRecorder nil diff --git a/internal/webhook/validate_operator_types_handler.go b/internal/webhook/validate_operator_types_handler.go index 52c56c27..e8a13a11 100644 --- a/internal/webhook/validate_operator_types_handler.go +++ b/internal/webhook/validate_operator_types_handler.go @@ -85,6 +85,13 @@ func (h *ValidateOperatorTypesHandler) Handle(ctx context.Context, req admission return admission.Allowed("dry-run: not recorded") } + // No store means the webhook is running without a Redis backend (admission is on by + // default, but command-author capture is Redis-backed). Allow without recording; the + // controller then finalizes as the committer (AuthorAttributed=False). + if h.Store == nil { + return admission.Allowed("no author store: not recorded") + } + // metadata.uid identifies the object (not req.UID, which identifies the review). The // API server assigns it before validating admission runs, so it is present even for // a generateName CREATE — no response-body name recovery is ever needed. diff --git a/internal/webhook/validate_operator_types_handler_test.go b/internal/webhook/validate_operator_types_handler_test.go index a3426477..7d184517 100644 --- a/internal/webhook/validate_operator_types_handler_test.go +++ b/internal/webhook/validate_operator_types_handler_test.go @@ -84,6 +84,18 @@ func TestValidateOperatorTypesHandler_RecordsCommandAuthor(t *testing.T) { assert.NotEmpty(t, got.RequestedAt, "RequestedAt is stamped for lag/debug") } +func TestValidateOperatorTypesHandler_NilStoreNoOp(t *testing.T) { + // Admission stays enabled without Redis: no Store, so the handler must allow the + // command without recording (and without a nil dereference). + h := &ValidateOperatorTypesHandler{} + + resp := h.Handle(context.Background(), + commandReview(commitRequestResource(), `{"metadata":{"uid":"cr-uid"}}`, + authnv1.UserInfo{Username: "alice"}, nil)) + + assert.True(t, resp.Allowed, "a missing author store never blocks a command") +} + func TestValidateOperatorTypesHandler_ServiceAccountSubmitter(t *testing.T) { rec := &fakeCommandAuthorRecorder{} h := &ValidateOperatorTypesHandler{Store: rec} diff --git a/test/e2e/commit_author_attribution_e2e_test.go b/test/e2e/commit_author_attribution_e2e_test.go index ac55e471..09cb140f 100644 --- a/test/e2e/commit_author_attribution_e2e_test.go +++ b/test/e2e/commit_author_attribution_e2e_test.go @@ -41,8 +41,8 @@ var _ = Describe("Commit Author Attribution", Label("manager"), Ordered, func() const basePath = "e2e/commit-author-test" BeforeAll(func() { - if committerOnlyModeEnabled() { - Skip("watch-first committer-only mode has no audit facts for author attribution") + if configuredAuthorModeEnabled() { + Skip("watch-first configured-author mode has no audit facts for author attribution") } By("creating commit-author test namespace") diff --git a/test/e2e/commit_request_e2e_test.go b/test/e2e/commit_request_e2e_test.go index b9e4ba12..755ad438 100644 --- a/test/e2e/commit_request_e2e_test.go +++ b/test/e2e/commit_request_e2e_test.go @@ -216,7 +216,7 @@ var _ = Describe("Commit Request", Label("commit-request", "audit-consumer"), Or // generateName headache is gone, see // docs/design/commitrequest-admission-authorship.md §8). This spec proves a // generateName CommitRequest still finalizes and becomes Ready. It is skipped in - // committer-only mode, where the edit's window is committer-authored and the + // configured-author mode, where the edit's window is committer-authored and the // named admission author would not match it end to end. It("finalizes a CommitRequest created with metadata.generateName", func() { basePath := "e2e/commit-request-test" diff --git a/test/e2e/controller_basics_e2e_test.go b/test/e2e/controller_basics_e2e_test.go index 5315a2b3..cb42dc7d 100644 --- a/test/e2e/controller_basics_e2e_test.go +++ b/test/e2e/controller_basics_e2e_test.go @@ -119,8 +119,8 @@ var _ = Describe("Manager Controller Basics", Label("manager"), Ordered, func() }) It("should receive audit webhook events from kube-apiserver", func() { - if committerOnlyModeEnabled() { - Skip("watch-first committer-only mode does not require audit webhook ingestion") + if configuredAuthorModeEnabled() { + Skip("watch-first configured-author mode does not require audit webhook ingestion") } By("recording baseline audit event count") diff --git a/test/e2e/deletecollection_intent_e2e_test.go b/test/e2e/deletecollection_intent_e2e_test.go index 2b249ee6..4dca7bd4 100644 --- a/test/e2e/deletecollection_intent_e2e_test.go +++ b/test/e2e/deletecollection_intent_e2e_test.go @@ -55,8 +55,8 @@ var _ = Describe("DeleteCollection intent & attribution", Label("manager"), Orde ) BeforeAll(func() { - if committerOnlyModeEnabled() { - Skip("watch-first committer-only mode has no audit facts for delete attribution") + if configuredAuthorModeEnabled() { + Skip("watch-first configured-author mode has no audit facts for delete attribution") } By("creating deletecollection-intent test namespace") diff --git a/test/e2e/deployment_scale_author_attribution_e2e_test.go b/test/e2e/deployment_scale_author_attribution_e2e_test.go index f3ce1173..b6947395 100644 --- a/test/e2e/deployment_scale_author_attribution_e2e_test.go +++ b/test/e2e/deployment_scale_author_attribution_e2e_test.go @@ -32,8 +32,8 @@ import ( // separately by commit_author_attribution_e2e_test.go. var _ = Describe("Deployment scale author attribution", Label("manager", "subresource"), func() { It("attributes a kubectl scale commit to the human who ran it", func() { - if committerOnlyModeEnabled() { - Skip("watch-first committer-only mode has no audit facts for author attribution") + if configuredAuthorModeEnabled() { + Skip("watch-first configured-author mode has no audit facts for author attribution") } testNs := testNamespaceFor("scale-author") diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 8b522c74..2649d352 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -94,7 +94,7 @@ func assertNoAnomalousAuditOutcomes() { Expect(err).NotTo(HaveOccurred(), "failed to query the audit-events counter") if total == 0 { _, _ = fmt.Fprintf(GinkgoWriter, - "✅ audit outcome invariant skipped: watch-first committer-only mode produced no audit events\n") + "✅ audit outcome invariant skipped: watch-first configured-author mode produced no audit events\n") return } @@ -121,12 +121,12 @@ func assertNoAnomalousAuditOutcomes() { _, _ = fmt.Fprintf(GinkgoWriter, "✅ no anomalous audit outcomes (0 error-category events across the run)\n") } -func committerOnlyModeEnabled() bool { +func configuredAuthorModeEnabled() bool { out, err := kubectlRunInNamespace(namespace, "logs", "deployment/gitops-reverser", "--since=30m") if err != nil { return false } - return strings.Contains(out, "committer-only mode:") + return strings.Contains(out, "configured-author mode:") } var _ = AfterEach(func() { diff --git a/test/e2e/quickstart_framework_e2e_test.go b/test/e2e/quickstart_framework_e2e_test.go index 465291f8..6076d27d 100644 --- a/test/e2e/quickstart_framework_e2e_test.go +++ b/test/e2e/quickstart_framework_e2e_test.go @@ -22,10 +22,16 @@ const ( quickstartFrameworkEnabledEnv = "E2E_ENABLE_QUICKSTART_FRAMEWORK" quickstartFrameworkModeEnv = "E2E_QUICKSTART_MODE" quickstartTimeoutSecondsEnv = "QUICKSTART_TIMEOUT_SECONDS" + + // readmeDemoNamespace is the exact namespace the README quick start tells a + // first-time user to create. The helm-mode run installs the chart quickstart + // into it so the test exercises the documented path verbatim. + readmeDemoNamespace = "gitops-reverser-quickstart-demo" ) type quickstartFrameworkRun struct { mode string + namespace string testID string repoName string checkoutDir string @@ -51,51 +57,208 @@ var _ = Describe("Quickstart Framework", Label("quickstart-framework"), Ordered, )) } - qsNs := testNamespaceFor("quickstart-framework") - _, _ = kubectlRun("create", "namespace", qsNs) + run = newQuickstartFrameworkRun() + + _, _ = kubectlRun("create", "namespace", run.namespace) By("setting up Gitea repo and credentials for quickstart-framework tests") quickstartRepo = SetupRepo( resolveE2EContext(), - qsNs, + run.namespace, fmt.Sprintf("e2e-quickstart-framework-%d", GinkgoRandomSeed()), ) + run.repoName = quickstartRepo.RepoName + run.checkoutDir = quickstartRepo.CheckoutDir + run.repoURL = quickstartRepo.RepoURLHTTP - _, err := kubectlRunInNamespace(qsNs, "apply", "-f", quickstartRepo.SecretsYAML) + _, err := kubectlRunInNamespace(run.namespace, "apply", "-f", quickstartRepo.SecretsYAML) Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to quickstart namespace") - applySOPSAgeKeyToNamespace(qsNs) - run = newQuickstartFrameworkRun() + // Helm mode drives the README path, where the chart auto-generates the age + // key (generateWhenMissing). The other install modes reuse the pre-seeded key. + if run.mode != "helm" { + applySOPSAgeKeyToNamespace(run.namespace) + } }) AfterAll(func() { - cleanupNamespace(testNamespaceFor("quickstart-framework")) + if !quickstartFrameworkEnabled() { + return + } + cleanupNamespace(run.namespace) }) It("sets up quickstart flow via Go framework", func() { - By("creating dedicated Gitea repository and bootstrap credentials") - run.setupGiteaRepository() + if run.mode == "helm" { + run.runReadmeQuickstartFlow() + return + } + run.runConfigObjectFlow() + }) - By("applying quickstart resources from Go") - run.applyQuickstartResources() +}) - By("verifying quickstart resources become Ready") - run.verifyQuickstartResourcesReady() +// runReadmeQuickstartFlow drives the exact path README.md documents for a +// first-time user: a no-Redis Helm install with quickstart.enabled=true into the +// gitops-reverser-quickstart-demo namespace, then verifies the chart's own +// starter resources become Ready, the controller runs healthy in configured-author +// mode without Redis, and a ConfigMap in the demo namespace lands a commit. +func (r *quickstartFrameworkRun) runReadmeQuickstartFlow() { + By("helm-installing the chart the README way (no Redis, quickstart.enabled, demo namespace)") + r.helmInstallReadmeQuickstart() + + By("verifying the controller is healthy in configured-author mode with no Redis") + r.verifyNoRedisConfiguredAuthor() + + By("verifying the chart's starter resources become Ready") + verifyResourceStatus("gitprovider", "example-provider", r.namespace, "True", "Ready", "") + verifyResourceCondition("gittarget", "example-target", r.namespace, "Validated", "True", "OK", "") + verifyResourceStatus("watchrule", "example-watchrule", r.namespace, "True", "Ready", "") + waitForStreamsRunning("example-target", r.namespace) + + By("verifying the starter GitTarget generated its SOPS age key") + r.verifyGeneratedEncryptionSecret("sops-age-key") + + By("verifying a ConfigMap in the demo namespace lands a commit") + r.verifyStarterConfigMapCommit() +} - By("verifying generated encryption secret and commit flow") - generatedAgeKey := run.verifyGeneratedEncryptionSecret() +// helmInstallReadmeQuickstart upgrades the already-installed release to the exact +// values the README's `helm install` command uses. The quickstart-framework spec +// only runs in the isolated quickstart leg (its own cluster, this the only spec), +// so reconfiguring the single controller to no-Redis here is safe. +func (r *quickstartFrameworkRun) helmInstallReadmeQuickstart() { + ctx := resolveE2EContext() + releaseNs := resolveE2ENamespace() + release := resolveE2EInstallName(releaseNs) + + chart := strings.TrimSpace(os.Getenv("HELM_CHART_SOURCE")) + if chart == "" { + chart = "charts/gitops-reverser" + } - By("verifying quickstart commits for create/update/delete") - run.verifyQuickstartConfigMapCommits() + args := []string{ + "--kube-context", ctx, + "upgrade", "--install", release, chart, + "--namespace", releaseNs, + "--reuse-values", + // The README's no-Redis default: empty addr runs configured-author. + "--set", "queue.redis.addr=", + "--set", "quickstart.enabled=true", + "--set", fmt.Sprintf("quickstart.namespace=%s", r.namespace), + // We created the namespace + git-creds above (README step 3, before install). + "--set", "quickstart.createNamespace=false", + "--set-string", fmt.Sprintf("quickstart.gitProvider.url=%s", r.repoURL), + "--set", fmt.Sprintf("quickstart.gitProvider.secretRef.name=%s", quickstartRepo.GitSecretHTTP), + } - By("verifying quickstart encrypted Secret commit is decryptable") - run.verifyQuickstartSecretEncryption(generatedAgeKey) + cmd := exec.Command("helm", args...) + output, err := cmd.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), + fmt.Sprintf("helm upgrade to README quickstart config failed: %s", strings.TrimSpace(string(output)))) - By("verifying invalid credentials provider shows actionable message") - run.verifyInvalidProviderActionableMessage() - }) + By("waiting for the reconfigured controller to roll out") + Eventually(func() error { + _, rolloutErr := kubectlRunInNamespace( + releaseNs, "rollout", "status", fmt.Sprintf("deployment/%s", release), "--timeout=30s", + ) + return rolloutErr + }, quickstartTimeout(), 3*time.Second).Should(Succeed(), "controller did not roll out after README quickstart install") +} -}) +// verifyNoRedisConfiguredAuthor asserts the controller logged the no-Redis +// configured-author sentinel (main.go emits "configured-author mode: no Redis +// configured" only when queue.redis.addr is empty) and that the pod is not +// crash-looping — proving admission-on-without-Redis is a healthy no-op. +func (r *quickstartFrameworkRun) verifyNoRedisConfiguredAuthor() { + releaseNs := resolveE2ENamespace() + release := resolveE2EInstallName(releaseNs) + + Eventually(func(g Gomega) { + logs, err := kubectlRunInNamespace( + releaseNs, "logs", fmt.Sprintf("deployment/%s", release), "--since=10m", + ) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(logs).To(ContainSubstring("no Redis configured"), + "controller should log the no-Redis configured-author sentinel") + }, quickstartTimeout(), 3*time.Second).Should(Succeed()) + + restarts, err := kubectlRunInNamespace( + releaseNs, "get", "pods", "-l", fmt.Sprintf("app.kubernetes.io/instance=%s", release), + "-o", "jsonpath={.items[*].status.containerStatuses[*].restartCount}", + ) + Expect(err).NotTo(HaveOccurred()) + for _, field := range strings.Fields(restarts) { + count, convErr := strconv.Atoi(field) + Expect(convErr).NotTo(HaveOccurred()) + Expect(count).To(Equal(0), "controller must not crash-loop with admission on and no Redis") + } +} + +// verifyStarterConfigMapCommit creates, updates, and deletes a ConfigMap in the +// demo namespace and asserts the chart's starter GitTarget mirrors each change to +// live-cluster//configmaps/.yaml (the chart default path). +func (r *quickstartFrameworkRun) verifyStarterConfigMapCommit() { + ns := r.namespace + configMapName := fmt.Sprintf("quickstart-config-%s", r.testID) + expectedFile := filepath.Join( + r.checkoutDir, "live-cluster", ns, "configmaps", fmt.Sprintf("%s.yaml", configMapName), + ) + + _, _ = kubectlRunInNamespace(ns, "delete", "configmap", configMapName, "--ignore-not-found=true") + + commitsBefore, err := r.gitCommitCount() + Expect(err).NotTo(HaveOccurred()) + + _, err = kubectlRunInNamespace(ns, "create", "configmap", configMapName, "--from-literal=value=one") + Expect(err).NotTo(HaveOccurred(), "failed to create quickstart ConfigMap") + + Eventually(func(g Gomega) { + g.Expect(r.gitPull()).To(Succeed()) + content, readErr := os.ReadFile(expectedFile) + g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("ConfigMap file must exist at %s", expectedFile)) + g.Expect(string(content)).To(ContainSubstring("value: one")) + commitsAfter, countErr := r.gitCommitCount() + g.Expect(countErr).NotTo(HaveOccurred()) + g.Expect(commitsAfter).To(BeNumerically(">", commitsBefore)) + }, quickstartTimeout(), 2*time.Second).Should(Succeed()) + + _, err = kubectlRunInNamespace(ns, "delete", "configmap", configMapName) + Expect(err).NotTo(HaveOccurred(), "failed to delete quickstart ConfigMap") + + Eventually(func(g Gomega) { + g.Expect(r.gitPull()).To(Succeed()) + _, statErr := os.Stat(expectedFile) + g.Expect(os.IsNotExist(statErr)).To(BeTrue(), fmt.Sprintf("ConfigMap file should be deleted: %s", expectedFile)) + }, quickstartTimeout(), 2*time.Second).Should(Succeed()) +} + +// runConfigObjectFlow is the pre-existing coverage for the non-Helm install modes +// (config-dir, plain-manifests-file), which are Redis-backed and cannot exercise +// the no-Redis README path. It validates the install by creating config objects +// directly and checking the mirror + encryption + actionable errors. +func (r *quickstartFrameworkRun) runConfigObjectFlow() { + By("creating dedicated Gitea repository and bootstrap credentials") + r.setupGiteaRepository() + + By("applying quickstart resources from Go") + r.applyQuickstartResources() + + By("verifying quickstart resources become Ready") + r.verifyQuickstartResourcesReady() + + By("verifying generated encryption secret and commit flow") + generatedAgeKey := r.verifyGeneratedEncryptionSecret(r.encryptionName) + + By("verifying quickstart commits for create/update/delete") + r.verifyQuickstartConfigMapCommits() + + By("verifying quickstart encrypted Secret commit is decryptable") + r.verifyQuickstartSecretEncryption(generatedAgeKey) + + By("verifying invalid credentials provider shows actionable message") + r.verifyInvalidProviderActionableMessage() +} func quickstartFrameworkEnabled() bool { value := strings.ToLower(strings.TrimSpace(os.Getenv(quickstartFrameworkEnabledEnv))) @@ -121,12 +284,19 @@ func quickstartFrameworkMode() string { func newQuickstartFrameworkRun() quickstartFrameworkRun { testID := strconv.FormatInt(time.Now().UnixNano(), 10) + mode := quickstartFrameworkMode() + + // Helm mode uses the exact README demo namespace; other modes stay on a + // throwaway per-run namespace. + ns := readmeDemoNamespace + if mode != "helm" { + ns = testNamespaceFor("quickstart-framework") + } + return quickstartFrameworkRun{ - mode: quickstartFrameworkMode(), + mode: mode, + namespace: ns, testID: testID, - repoName: quickstartRepo.RepoName, - checkoutDir: quickstartRepo.CheckoutDir, - repoURL: quickstartRepo.RepoURLHTTP, providerName: fmt.Sprintf("quickstart-provider-%s", testID), targetName: fmt.Sprintf("quickstart-target-%s", testID), watchRuleName: fmt.Sprintf("quickstart-watchrule-%s", testID), @@ -143,7 +313,7 @@ func (r *quickstartFrameworkRun) setupGiteaRepository() { } func (r *quickstartFrameworkRun) applyQuickstartResources() { - qsNamespace := testNamespaceFor("quickstart-framework") + qsNamespace := r.namespace createGitProviderWithURLInNamespace(r.providerName, qsNamespace, quickstartRepo.GitSecretHTTP, r.repoURL) createGitTargetWithEncryptionOptions( @@ -173,19 +343,19 @@ func (r *quickstartFrameworkRun) applyQuickstartResources() { } func (r *quickstartFrameworkRun) verifyQuickstartResourcesReady() { - ns := testNamespaceFor("quickstart-framework") + ns := r.namespace verifyResourceStatus("gitprovider", r.providerName, ns, "True", "Ready", "") verifyResourceCondition("gittarget", r.targetName, ns, "Validated", "True", "OK", "") verifyResourceStatus("watchrule", r.watchRuleName, ns, "True", "Ready", "") waitForStreamsRunning(r.targetName, ns) } -func (r *quickstartFrameworkRun) verifyGeneratedEncryptionSecret() string { - ns := testNamespaceFor("quickstart-framework") +func (r *quickstartFrameworkRun) verifyGeneratedEncryptionSecret(secretName string) string { + ns := r.namespace var generatedAgeKey string Eventually(func(g Gomega) { - output, err := kubectlRunInNamespace(ns, "get", "secret", r.encryptionName, "-o", "json") + output, err := kubectlRunInNamespace(ns, "get", "secret", secretName, "-o", "json") g.Expect(err).NotTo(HaveOccurred()) var secretObj map[string]interface{} @@ -220,7 +390,7 @@ func (r *quickstartFrameworkRun) verifyGeneratedEncryptionSecret() string { } func (r *quickstartFrameworkRun) verifyQuickstartConfigMapCommits() { - ns := testNamespaceFor("quickstart-framework") + ns := r.namespace configMapName := fmt.Sprintf("quickstart-config-%s", r.testID) expectedFile := filepath.Join( r.checkoutDir, @@ -305,7 +475,7 @@ func (r *quickstartFrameworkRun) verifyQuickstartConfigMapCommits() { } func (r *quickstartFrameworkRun) verifyQuickstartSecretEncryption(generatedAgeKey string) { - ns := testNamespaceFor("quickstart-framework") + ns := r.namespace secretName := fmt.Sprintf("quickstart-secret-%s", r.testID) secretValueOne := fmt.Sprintf("quickstart-plaintext-one-%s", r.testID) secretValueTwo := fmt.Sprintf("quickstart-plaintext-two-%s", r.testID) @@ -379,7 +549,7 @@ func (r *quickstartFrameworkRun) verifyQuickstartSecretEncryption(generatedAgeKe } func (r *quickstartFrameworkRun) verifyInvalidProviderActionableMessage() { - ns := testNamespaceFor("quickstart-framework") + ns := r.namespace Eventually(func(g Gomega) { output, err := kubectlRunInNamespace(ns, "get", "gitprovider", r.invalidProvName, "-o", "json") g.Expect(err).NotTo(HaveOccurred()) diff --git a/test/e2e/watchrule_configmap_secret_e2e_test.go b/test/e2e/watchrule_configmap_secret_e2e_test.go index 8f5224c6..8f746991 100644 --- a/test/e2e/watchrule_configmap_secret_e2e_test.go +++ b/test/e2e/watchrule_configmap_secret_e2e_test.go @@ -572,7 +572,7 @@ spec: } author := string(authorMsg) - if committerOnlyModeEnabled() { + if configuredAuthorModeEnabled() { g.Expect(author).To(ContainSubstring("GitOps Reverser")) } else { g.Expect(author).To(ContainSubstring("jane@acme.com"))