From bc41a2edae457b7a7a549f9a74b2b6756344c101 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 19 Jul 2026 18:43:38 +0000 Subject: [PATCH 1/4] docs(readme): correct quick start, document Kustomize support, add road to 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick start pinned cert-manager v1.20.3, a version that appeared nowhere else in the repo and was never exercised. It also omitted the rollout wait (the controller cannot start until cert-manager issues the admission certificate) and told users to check `Ready` on the GitTarget, which is held at Unknown until first source discovery — the e2e asserts `Validated` for exactly this reason. Also: - Document the Kustomize support that already ships. The write path runs kustomize itself as a pre-commit oracle; the README previously claimed it "cannot infer the original structure of arbitrary templates or overlays", which the code contradicts. - Stop over-hedging delivery. While the watch is connected every change is seen and committed; collapsing to current state only happens across a gap. - Reframe HA from a standalone caveat into a road-to-1.0 list alongside the configuration surface (six CRDs still v1alpha3, no conversion path) and docs. - Move the attribution mode tables into the attribution setup guide, which already opened with the same concept. - Warn that `helm uninstall` removes the cluster-scoped default ClusterProvider, and that the default RBAC grants cluster-wide Secret reads. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 197 +++++++++++++++++++------------- docs/attribution-setup-guide.md | 20 ++++ 2 files changed, 140 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 69c5c3eb9..b6f7c45b7 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ # GitOps Reverser -GitOps Reverser is a Kubernetes operator that turns live Kubernetes API activity into clean, -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). +GitOps Reverser is a Kubernetes operator that turns Kubernetes API resources into clean YAML in Git. +It is configurable, and can be used as: + +* a live audit trail, or +* a "reverse" 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
@@ -24,19 +27,29 @@ in [ConfigButler/example-audit](https://github.com/ConfigButler/example-audit). ## What it does -- Keep using the Kubernetes API as the write path. -- Capture those live changes as stable manifests in Git. -- Keep configuration file-backed, reviewable, and reusable. +- Reconciles existing Kubernetes API state into Git: the repo reflects the exact current state. +- Captures live changes through watches. +- Includes real actors for every change if you configure [kube-api server audit webhooks](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/#webhook-backend). ![Overview diagram showing how API events flow through the operator into Git](docs/images/overview.excalidraw.svg) +## What it can't + +It edits the **intent layer** — the documents a human authored — never the **expansion layer** a +controller derived from them. So it cannot reverse Helm-rendered resources into a clean `values.yaml`, +and it will not invent structure for templating it does not model. Simple Kustomize layouts *are* +supported ([see below](#simple-kustomize-support)); the full verdict table is in +[`support-contract.md`](docs/design/support-boundary/support-contract.md). + ## When it fits +Good fit if you want API-first and Git at the same time. + | Good fit | Poor fit | |---|---| | Clusters where you can grant watch/RBAC and write to Git (optionally run Valkey/Redis for the full feature set) | Production HA requirements today | | Teams that want API-to-Git capture first, then named author attribution later | Shared paths with two always-on writers fighting over the same resources | -| API-first or hybrid teams that still want Git history; brownfield discovery, hotfix capture, migration toward GitOps | Workflows that need a guaranteed per-mutation change log rather than a state mirror | +| API-first or hybrid teams that still want Git history; brownfield discovery, hotfix capture, migration toward GitOps | Teams who want Git to stay the write path, with humans editing manifests first | ## How it works @@ -53,74 +66,86 @@ It can also: - Group changes within a time window into a single commit. - Take your own commit message and "why" from a `CommitRequest`. -### Operating modes - -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 | Git author | Git committer | Also needs | -|---|---|---|---| -| **`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 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. +### Who authors the commits + +Every commit carries a Git *author* and a Git *committer*. By default both are one configured +identity (`configured-author`). Turn on attribution and the **author** becomes the real Kubernetes +actor — user, service account, or CI identity — while the committer never moves. That needs +kube-apiserver audit delivery (managed control planes like EKS/GKE/AKS generally do not expose it) +plus Valkey/Redis: see the [attribution setup guide](docs/attribution-setup-guide.md). + +**Valkey/Redis is optional but advised.** Without it the default mode works fine; adding it unlocks +warm-restart cursors, `CommitRequest` author capture, and attribution. + +### Delivery guarantees + +While the watch is connected the operator sees each individual update and commits it, so Git tracks +changes as they happen. Across a *gap* — pod restart, disconnect, `410 Gone` — it reconciles to +current state instead of replaying versions it never saw, so edits made during the gap collapse into +one commit. Nothing is lost or left stale; deletes are reconciled on reconnect. See +[`docs/architecture.md`](docs/architecture.md) for replay and `410 Gone` details. + +## Simple Kustomize support + +The write path runs **kustomize itself** (`sigs.k8s.io/kustomize/api`) in memory — no plugins, no +exec, no network, no remote bases — and uses the render to decide where a change belongs and to check +the result before committing. What it can do: + +- Edit `resources:` (and `bases:`), `namespace:`, `images:`, and `replicas:` as real declarations. +- Write a change where the value actually lives — the source document, or the governing + `images:`/`replicas:` entry — rather than mirroring rendered output over your source files. +- Add a new file to the right `resources:` list in the same commit, and remove the entry when that + file's last document is deleted, so the repo never stops building. +- Support `base/` + `overlays/{env}/`: the base is read-only context, never written through an overlay. +- Create a missing `images:`/`replicas:` entry in an overlay, so one environment changes without + touching a shared base, and author a `$patch: delete` for an object an overlay inherits. +- Read `patches:` (local strategic-merge files), `commonLabels`, `labels`, and `commonAnnotations` as + read-only build context. +- Verify every commit by re-rendering before and after, refusing it unless your change lands exactly + and nothing else moves. + +It refuses by name — before writing anything, reported as `Stalled=True` — generators, `components`, +`namePrefix`/`nameSuffix`, `replacements`, `vars`, `helmCharts`, plugins, inline and JSON6902 patches, +remote bases, and any field it does not model. One known gap: a strategic-merge patch that *edits a +field* of a base-owned object is not authored yet. Reasoning in +[`kustomize-support-boundary.md`](docs/design/support-boundary/kustomize-support-boundary.md). ## Status Early-stage software; CRDs and behavior may still change. -- Single controller pod (`replicas=1`); HA is not supported yet. +- Runs as a single controller pod (`replicas=1`). - Shared-resource bi-directional workflows need explicit coordination. -- Source recovery is limited to Kubernetes manifests, not Helm/Kustomize authoring models. +- Source recovery covers Kubernetes manifests and simple Kustomize layouts, not Helm 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. 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/). +Production use should follow an environment-specific review. + +### On the road to 1.0 + +Roughly in priority order: + +- **High availability** — `replicaCount > 1` is rejected today; needs leader/ownership coordination so + two replicas never write the same `GitTarget`. Redis becomes required rather than advised. +- **A stabilized configuration surface** — all six CRDs are `v1alpha3` with no conversion path yet. +- **More documentation** — day-2 operations, troubleshooting, and worked examples per layout. +- **A durable worker queue** — a crash between advancing the watch cursor and landing the write can + currently skip work on restart. +- **Write-collision safety** across `GitProvider` objects sharing a repository. Until then, keep one + `GitProvider` per repository. +- **Better queue and worker observability** — enough metrics to run it without reading logs. +- **More control over output layout**, plus filtering cluster-generated noise out of the Git view. + +Backlog in [docs/TODO.md](docs/TODO.md); longer-range directions in [docs/future/](docs/future/). ## Quick start This brings up the **demo**: a starter `GitProvider`, `GitTarget`, and `WatchRule` in a -`gitops-reverser-quickstart-demo` namespace. 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. The chart also renders the cluster-scoped `default` -`ClusterProvider`, so the starter target's omitted source reference resolves to the operator's own -cluster. +`gitops-reverser-quickstart-demo` namespace, watching ConfigMaps there and writing them to +`/live-cluster` on `main`. It runs in `configured-author` mode (no Redis) by default. The +chart also renders the cluster-scoped `default` `ClusterProvider` the starter target resolves against. ![Config basics diagram showing the relationship between GitProvider, GitTarget, and WatchRule](docs/images/config-basics.excalidraw.svg) @@ -128,8 +153,12 @@ cluster. **1. Install cert-manager** +The controller mounts an admission certificate at startup, so cert-manager must be healthy *before* +you install the chart. (`--set servers.admission.enabled=false` drops the dependency; the +[chart README](charts/gitops-reverser/README.md) covers bring-your-own certificates.) + ```bash -kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.20.3/cert-manager.yaml +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s ``` @@ -181,23 +210,30 @@ helm install gitops-reverser \ 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)). +Three things worth knowing about this install: -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. +- **No Redis**, so warm-restart cursors and author capture stay inactive. Add one with + `--set queue.redis.addr=HOST:PORT` (plus `queue.redis.auth.existingSecret` if it needs auth). +- **Cluster-wide read on every watchable type, including Secrets** (`rbac.watchTypes.mode=any`) — fine + for a demo, probably not for a real cluster. [`docs/rbac.md`](docs/rbac.md) narrows it. +- **SOPS is on for the starter target**, so a `sops-age-key` Secret is generated in the demo namespace + with a backup reminder. Only matters once you mirror Secrets; back it up if you keep the demo. -Check the starter resources become ready: +Wait for the controller — on a fresh install this waits on cert-manager issuing the certificate: + +```bash +kubectl rollout status deployment/gitops-reverser -n gitops-reverser --timeout=300s +``` + +Then check the starter resources: ```bash kubectl get gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo ``` +The `GitProvider` and `WatchRule` report `Ready=True`; the `GitTarget` reports **`Validated=True`** — +its aggregate `Ready` stays `Unknown` until first source discovery, which is expected, not a failure. + **5. Test it** ```bash @@ -211,15 +247,22 @@ kubectl logs -n gitops-reverser deploy/gitops-reverser kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo ``` +Two `GitTarget` conditions stop the data plane and are worth recognising: `ClusterProviderNotFound` +(the `default` `ClusterProvider` is missing) and `NamespaceNotAuthorized` (its `allowedNamespaces` +selector does not cover the demo namespace). + To tear the demo down: `helm uninstall gitops-reverser -n gitops-reverser` and `kubectl delete namespace gitops-reverser-quickstart-demo`. +> **Note:** the `default` `ClusterProvider` is cluster-scoped and chart-owned, so uninstalling takes +> it with it — holding *every* other `GitTarget` in the cluster unready. Mind that if you run the demo +> alongside a real deployment. + ### Want named users on your commits? -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. +Turn on `attributed-author` mode so the real Kubernetes actor becomes the Git author. Needs audit +delivery and Valkey/Redis — the [attribution setup guide](docs/attribution-setup-guide.md) walks it +through. ### Rather have it managed? diff --git a/docs/attribution-setup-guide.md b/docs/attribution-setup-guide.md index 426ab2ac3..ae3b9f121 100644 --- a/docs/attribution-setup-guide.md +++ b/docs/attribution-setup-guide.md @@ -10,6 +10,26 @@ kube-apiserver audit delivery and a Redis/Valkey backing store. This guide cover 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. +## The two modes + +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 | Git author | Git committer | Also needs | +|---|---|---|---| +| **`configured-author`** *(default)* | the configured identity | the configured identity | nothing | +| **`attributed-author`** | the authenticated Kubernetes actor | the configured identity | audit delivery + Valkey/Redis | + +With `attribution.enabled`, only the **author** column moves: + +| 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. + ## When it fits Attribution works by correlating kube-apiserver **audit events** with the objects the operator sees From 4a0ab92cbbdc500a12e22d1e290a67293e4f45d0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 19 Jul 2026 18:43:38 +0000 Subject: [PATCH 2/4] chore(deps): refresh the version pins Dependabot cannot see, add Renovate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot covers gomod, github-actions, and Dockerfile FROM lines, and it is healthy — every direct Go dependency is current. The pins it cannot see had drifted, including a cert-manager version in the README that existed nowhere else in the repo. Cluster charts (validated by the e2e suite): - cert-manager 1.19.4 -> 1.21.0. Its three breaking changes (tokenrequest RBAC, cert-manager-edit ACME permissions, metrics Helm values) do not apply: we set no cert-manager values beyond crds.enabled, use a self-signed Issuer, and have no serviceAccountRef. - valkey 0.9.3 -> 0.10.0, kcp-operator 0.7.7 -> 0.8.3. - gitea 12.5.1 -> 12.5.3, deliberately NOT the latest 12.7.0. Gitea 1.26 removed the _csrf form input and cookie from /user/login, which the giteaclient WebSession scrapes to verify SSH signing keys, so 12.6.0+ fails the signing e2e. 12.5.3 carries the same Gitea 1.25.5, so this is a chart-only patch. Devcontainer toolchain (needs an image rebuild to take effect): flux2 2.9.2, flux-operator 0.55.0, helm 4.2.3, task 3.52.0, cosign 3.1.2. cosign is pinned twice and both places move together. Renovate is scoped to only what Dependabot cannot see, so the two never open competing PRs: Flux HelmRelease charts, the devcontainer ENV pins, the cert-manager URL in the README, and the cosign-installer input. It holds gitea below 12.6.0 until the web session helper is reworked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .devcontainer/Dockerfile | 10 +- .github/renovate.json | 175 ++++++++++++++++++ .github/workflows/release.yml | 2 +- .../e2e/setup/flux/releases/cert-manager.yaml | 2 +- test/e2e/setup/flux/releases/gitea.yaml | 6 +- test/e2e/setup/flux/releases/valkey.yaml | 2 +- test/e2e/setup/kcp/base/kcp-operator.yaml | 2 +- 7 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 .github/renovate.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 17b2236a7..7f4f7a7d0 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -65,15 +65,15 @@ ENV PATH="/go/bin:/usr/local/go/bin:${PATH}" \ KUSTOMIZE_VERSION=5.8.1 \ KUBEBUILDER_VERSION=4.15.0 \ GOLANGCI_LINT_VERSION=v2.12.2 \ - HELM_VERSION=v4.2.0 \ + HELM_VERSION=v4.2.3 \ K3D_VERSION=v5.9.0 \ - FLUX_VERSION=2.9.0 \ - FLUX_OPERATOR_VERSION=0.53.0 \ - TASK_VERSION=v3.51.1 \ + FLUX_VERSION=2.9.2 \ + FLUX_OPERATOR_VERSION=0.55.0 \ + TASK_VERSION=v3.52.0 \ ACTIONLINT_VERSION=1.7.12 \ HADOLINT_VERSION=2.14.0 \ VALKEY_VERSION=9.1.0 \ - COSIGN_VERSION=v3.1.1 + COSIGN_VERSION=v3.1.2 # Fail early on unsupported architectures instead of producing a partial image. RUN test "$(dpkg --print-architecture)" = "amd64" \ diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..ea0a972da --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,175 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":dependencyDashboard" + ], + "description": [ + "Renovate covers only the pins Dependabot cannot see. Dependabot (.github/dependabot.yml) owns", + "gomod, github-actions, and Dockerfile FROM lines; those managers stay disabled here so the two", + "bots never open competing PRs. What is left is the class that silently rotted: Flux HelmRelease", + "chart versions, the hand-maintained tool pins in the devcontainer ENV block, and version numbers", + "embedded in a URL or a workflow input." + ], + "enabledManagers": [ + "flux", + "custom.regex" + ], + "flux": { + "managerFilePatterns": [ + "/^test/e2e/setup/.+\\.yaml$/" + ] + }, + "packageRules": [ + { + "description": "The e2e cluster charts are test infrastructure — review them as one PR.", + "matchManagers": ["flux"], + "groupName": "e2e cluster charts" + }, + { + "description": "Group the devcontainer toolchain so a refresh is one image rebuild, not ten.", + "matchFileNames": [".devcontainer/Dockerfile"], + "groupName": "devcontainer toolchain" + }, + { + "description": "cosign is pinned twice and must move together: the devcontainer ENV block and the cosign-installer input in release.yml. Keep both in one PR.", + "matchDepNames": ["sigstore/cosign"], + "groupName": "cosign" + }, + { + "description": "Hold Gitea on the 12.5.x chart line (Gitea 1.25.x). Gitea 1.26 removed the _csrf form input and cookie from /user/login, which internal/giteaclient's WebSession scrapes to verify SSH signing keys; the signing e2e spec fails on 12.6.0+. Drop this rule once that helper is reworked.", + "matchDepNames": ["gitea"], + "allowedVersions": "<12.6.0" + } + ], + "customManagers": [ + { + "customType": "regex", + "description": "cert-manager release manifest URL in the README quick start", + "managerFilePatterns": ["/^README\\.md$/"], + "matchStrings": [ + "cert-manager/cert-manager/releases/download/(?v[0-9.]+)/cert-manager\\.yaml" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "cert-manager/cert-manager" + }, + { + "customType": "regex", + "description": "cosign-release input for sigstore/cosign-installer", + "managerFilePatterns": ["/^\\.github/workflows/release\\.yml$/"], + "matchStrings": ["cosign-release:\\s*(?v[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "sigstore/cosign" + }, + { + "customType": "regex", + "description": "devcontainer: kubectl", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["KUBECTL_VERSION=(?v[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "kubernetes/kubernetes" + }, + { + "customType": "regex", + "description": "devcontainer: kustomize (tags are kustomize/vX.Y.Z, the ENV holds a bare X.Y.Z)", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["KUSTOMIZE_VERSION=(?[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "kubernetes-sigs/kustomize", + "extractVersionTemplate": "^kustomize/v(?.+)$" + }, + { + "customType": "regex", + "description": "devcontainer: kubebuilder", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["KUBEBUILDER_VERSION=(?[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "kubernetes-sigs/kubebuilder", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "description": "devcontainer: golangci-lint", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["GOLANGCI_LINT_VERSION=(?v[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "golangci/golangci-lint" + }, + { + "customType": "regex", + "description": "devcontainer: helm", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["HELM_VERSION=(?v[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "helm/helm" + }, + { + "customType": "regex", + "description": "devcontainer: k3d", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["K3D_VERSION=(?v[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "k3d-io/k3d" + }, + { + "customType": "regex", + "description": "devcontainer: flux2 CLI (also rendered into the e2e FluxInstance via FLUX_VERSION)", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["FLUX_VERSION=(?[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "fluxcd/flux2", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "description": "devcontainer: flux-operator", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["FLUX_OPERATOR_VERSION=(?[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "controlplaneio-fluxcd/flux-operator", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "description": "devcontainer: task", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["TASK_VERSION=(?v[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "go-task/task" + }, + { + "customType": "regex", + "description": "devcontainer: actionlint", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["ACTIONLINT_VERSION=(?[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "rhysd/actionlint", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "description": "devcontainer: hadolint", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["HADOLINT_VERSION=(?[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "hadolint/hadolint", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "description": "devcontainer: valkey server (tags carry no leading v)", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["VALKEY_VERSION=(?[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "valkey-io/valkey" + }, + { + "customType": "regex", + "description": "devcontainer: cosign", + "managerFilePatterns": ["/^\\.devcontainer/Dockerfile$/"], + "matchStrings": ["COSIGN_VERSION=(?v[0-9.]+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "sigstore/cosign" + } + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5edef1dc..446b9e3a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,7 +156,7 @@ jobs: # is the only other job that signs release artifacts (publish-helm), # and it gets cosign from that image. Without this input the action # defaults to its own (older) pinned cosign release. - cosign-release: v3.1.1 + cosign-release: v3.1.2 - name: Sign the multi-arch image (cosign keyless) # One signature on the digest covers every tag pointing at it. diff --git a/test/e2e/setup/flux/releases/cert-manager.yaml b/test/e2e/setup/flux/releases/cert-manager.yaml index 5436a4307..369d5b0c7 100644 --- a/test/e2e/setup/flux/releases/cert-manager.yaml +++ b/test/e2e/setup/flux/releases/cert-manager.yaml @@ -13,7 +13,7 @@ spec: kind: HelmRepository name: jetstack namespace: flux-system - version: v1.19.4 + version: v1.21.0 install: createNamespace: false crds: Create diff --git a/test/e2e/setup/flux/releases/gitea.yaml b/test/e2e/setup/flux/releases/gitea.yaml index b210d8a21..ed2a23fad 100644 --- a/test/e2e/setup/flux/releases/gitea.yaml +++ b/test/e2e/setup/flux/releases/gitea.yaml @@ -13,7 +13,11 @@ spec: kind: HelmRepository name: gitea-charts namespace: flux-system - version: 12.5.1 + # Pinned to the 12.5.x line (Gitea 1.25.5) deliberately: Gitea 1.26+ removed the `_csrf` + # form input and cookie from /user/login, which internal/giteaclient's WebSession scrapes to + # log in and verify an SSH signing key. Moving to chart 12.6.0/12.7.0 needs that helper + # reworked first — see the signing e2e spec. + version: 12.5.3 install: createNamespace: false remediation: diff --git a/test/e2e/setup/flux/releases/valkey.yaml b/test/e2e/setup/flux/releases/valkey.yaml index 3efe04eee..46048ef82 100644 --- a/test/e2e/setup/flux/releases/valkey.yaml +++ b/test/e2e/setup/flux/releases/valkey.yaml @@ -13,7 +13,7 @@ spec: kind: HelmRepository name: valkey namespace: flux-system - version: 0.9.3 + version: 0.10.0 install: createNamespace: false remediation: diff --git a/test/e2e/setup/kcp/base/kcp-operator.yaml b/test/e2e/setup/kcp/base/kcp-operator.yaml index b52e1108d..556e63cc7 100644 --- a/test/e2e/setup/kcp/base/kcp-operator.yaml +++ b/test/e2e/setup/kcp/base/kcp-operator.yaml @@ -36,7 +36,7 @@ spec: # 0.7.7 deploys kcp v0.8.x, which serves tenancy.kcp.io/v1alpha1 Workspaces — all this # harness needs. chart: kcp-operator - version: "0.7.7" + version: "0.8.3" sourceRef: kind: HelmRepository name: kcp From aad22b33d3b09a78d7560dcd2c1a5d3daaa04f86 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 19 Jul 2026 21:45:40 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(design):=20decide=20the=20e2e=20Git=20?= =?UTF-8?q?server=20=E2=80=94=20stay=20on=20Gitea,=20adopt=20no=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the investigation behind unpinning Gitea rather than migrating to Forgejo, read from the upstream sources in external-sources/. The premise for migrating was that we were stuck on chart 12.5.x because Gitea 1.26 removed the `_csrf` form input from /user/login, which our WebSession scrapes. That premise does not survive contact with the source: Gitea and Forgejo made the *same* change, both replacing form-token CSRF with Go's stdlib http.CrossOriginProtection, which admits any request carrying neither Sec-Fetch-Site nor Origin. The fix is a deletion, and it works on either server — so migration and unpinning are not the same piece of work. That leaves a cost comparison Gitea wins: it keeps the literal "gitea" signing namespace (Forgejo switched to the ROOT_URL hostname, the one hazardous change), keeps the session cookie name, keeps an HTTP chart repo, and has the better trusted-key lever — TRUSTED_SSH_KEYS reports the commit's own committer email where Forgejo's instance key reports a fixed configured address, which is what our signing assertion checks. Also records why no Go SDK is adopted on either server: there is no official Forgejo SDK, no SDK can cover the web-UI verification flow, the Gitea SDK's version gating is meaningless against Forgejo, and it would add five dependencies to test-only code that is currently stdlib-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/e2e-git-server-choice.md | 223 +++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/design/e2e-git-server-choice.md diff --git a/docs/design/e2e-git-server-choice.md b/docs/design/e2e-git-server-choice.md new file mode 100644 index 000000000..b594fcfac --- /dev/null +++ b/docs/design/e2e-git-server-choice.md @@ -0,0 +1,223 @@ +# The e2e Git server: stay on Gitea, or move to Forgejo? + +> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md) +> +> Scope is test infrastructure only: no shipped code path talks to the Git server. This document +> records what was measured in both upstreams, the decision **not** to adopt a Go SDK either way, and +> — the point it turns on — that the pin forcing this decision is fixable **without** migrating. + +## The forcing function is gone + +The premise for migrating was that we are stuck. [`test/e2e/setup/flux/releases/gitea.yaml`](../../test/e2e/setup/flux/releases/gitea.yaml) +holds chart `12.5.3` (Gitea 1.25.5) deliberately, because Gitea 1.26 removed the `_csrf` form input +and cookie from `/user/login`, which [`internal/giteaclient/webclient.go`](../../internal/giteaclient/webclient.go) +scrapes in order to log in and verify an SSH signing key. + +That premise does not survive contact with the source. **Gitea and Forgejo made the same change**: +both replaced form-token CSRF with Go 1.25's stdlib `http.CrossOriginProtection`. + +| | Gitea (`v1.28.0-dev`) | Forgejo | +|---|---|---| +| `_csrf` in templates | **0 matches** | **0 matches** | +| `services/context/csrf.go` | deleted | deleted | +| Replacement | `http.NewCrossOriginProtection()` `routers/web/web.go:170` | same, `routers/web/web.go:228` | +| Guard | `if !options.SignOutRequired && !options.DisableCrossOriginProtection` `web.go:212` | `if !options.SignOutRequired` `web.go:286` | + +`http.CrossOriginProtection.Check` returns `nil` when a request carries **neither** `Sec-Fetch-Site` +nor `Origin` (`net/http/csrf.go:154-159`, Go 1.26.5). A plain Go `http.Client` sends neither, so it +passes unconditionally on both. `/user/login` is additionally exempt on both, being `SignOutRequired`. + +**Therefore the fix is the same on either server, and it is a deletion:** remove `fetchCSRF`, +`extractCSRFToken`, `csrfInputRE`, and the two `form.Set("_csrf", …)` calls — roughly 45 lines. That +unpins Gitea *in place*. Migration and unpinning are **not** the same piece of work, and an earlier +draft of this document wrongly claimed they were. + +## What that leaves + +With the pin removable either way, the comparison is no longer "migrate or stay broken". It is a +straight cost comparison, and Gitea wins it on cost. + +### Staying on Gitea costs one deletion + +Everything else in our integration is already correct for Gitea and stays correct: + +- **Signing namespace unchanged.** Gitea still verifies with the literal `"gitea"` + (`models/asymkey/ssh_key_verify.go:28`), which is exactly what `verification.go:116` signs with. +- **Session cookie unchanged** — `i_like_gitea` (`modules/setting/session.go:37`). +- **Chart source unchanged** — the existing HTTP `HelmRepository` at `dl.gitea.com/charts`. +- **Service names, values, hostnames, scripts** — all unchanged. + +### Moving to Forgejo costs four changes, one of them risky + +- **Signing namespace becomes `setting.Domain`** (`ssh_key_verify.go:34`). And `setting.Domain` is + not `[server] DOMAIN`: `modules/setting/server.go:150` defaults it to `localhost`, then `:263-267` + overwrites it with the **`ROOT_URL` hostname** when that is non-empty and not a bare IP. So the + namespace becomes `gitea-http.gitea-e2e.svc.cluster.local` — host only, no scheme or port. + **This is the one genuinely risky item in either direction.** A wrong namespace produces a + *valid-looking* signature that the server rejects with a generic + `settings.ssh_invalid_token_signature` flash, so it reads as a credentials or key-material bug. +- **Session cookie becomes `session`** (`modules/setting/session.go:37`, commit `97a3837215`, + v15.0.0, flagged breaking upstream). There are **zero** `i_like_*` literals anywhere in the Forgejo + tree. Our `webclient.go:23` hardcodes the old name and `NewWebSession` hard-fails without it. +- **Chart moves to OCI** — `oci://code.forgejo.org/forgejo-helm/forgejo`, chart `17.1.3`, + appVersion `15.0.5` (verified with `helm show chart`). Our `HelmRepository` must become `type: oci`. + Whether Flux's OCI `HelmRepository` works in our e2e cluster is the one **untested** item. +- **Values churn**, though this direction is a simplification: the five `*.enabled: false` subchart + toggles are deleted (Forgejo's chart dropped every DB/cache subchart and defaults to SQLite), and + `fullnameOverride: gitea` keeps Services at `gitea-http` / `gitea-ssh` so no hostname changes. + The values root is still `gitea:` — the chart is a fork that kept the key — so the entire + `gitea.config.*` block ports verbatim. + +Plus, eventually, ~50 files of `gitea*` → `forgejo*` rename churn to stop the tree lying about what +it talks to. + +### Where Gitea is genuinely ahead + +Two capabilities exist on Gitea with no equal on Forgejo. + +**1. `TRUSTED_SSH_KEYS` is strictly better than Forgejo's equivalent.** Both have a config-level lever +that trusts a known public key without per-user web verification, and both skip identity checks +entirely (`sshsig.Verify(…, "git")` and nothing else): + +| | Gitea | Forgejo | +|---|---|---| +| Lever | `[repository.signing] TRUSTED_SSH_KEYS` (a list) | `FORMAT=ssh` + `SIGNING_KEY=` → `setting.SSHInstanceKey` | +| Where | `services/asymkey/commit.go:396-405` | `models/asymkey/ssh_key_object_verification.go:68-81` | +| Reported `signer.email` | the **commit's own committer email** | the **fixed** configured `SIGNING_EMAIL` | +| Preflight | `GET /api/v1/signing-key.pub` | `GET /api/v1/signing-key.ssh` | + +That last row decides it. `assertGiteaVerified` ([`signing_common_test.go:73-96`](../../test/e2e/signing_common_test.go)) +asserts `v.Signer.Email == expectedSignerEmail`. On Gitea's path that passes unchanged with an +arbitrary per-test committer; on Forgejo's, the spec would have to pin its committer email to +`SIGNING_EMAIL`, and the assertion stops proving user attribution. Gitea also accepts a *list*, where +Forgejo takes a single key. (The Gitea `app.example.ini` comment claiming this path exposes +`SIGNING_EMAIL` is wrong — the code passes `c.Committer.Email`.) + +**2. The SDK option stays open, and only makes sense against Gitea.** `code.gitea.io/sdk/gitea` +(v0.25.1) is actively maintained and tracks Gitea minors. Its version gating — `NewClient` GETs +`/version` and gates features on semver — is *meaningful* against real Gitea and **actively +misleading** against Forgejo, which reports a far higher version so every Gitea feature gate passes +trivially. We are not adopting it (below), but on Gitea it stays a live option; on Forgejo it would +be a liability. + +## Decision: do not adopt a Go SDK, on either server + +**There is no official Forgejo SDK.** Nothing under the `forgejo` org on `code.forgejo.org`. +`earl-warren/forgejosdk`, from a core Forgejo maintainer, is untouched since 2024-01-01. +`mvdkleijn/forgejo-sdk` is a third-party fork of the Gitea SDK with a single maintainer. Forgejo's own +`go.mod:20` depends on `code.gitea.io/sdk/gitea`; it neither uses nor built a Forgejo SDK. + +The Gitea SDK covers all 21 endpoints we call, including the unusual ones +(`POST /users/{login}/tokens`, `GET /user/gpg_key_token`, `POST /admin/users/{u}/keys`, +`POST /admin/users/{owner}/repos`). Coverage is not the problem. Four things are: + +- **No SDK can cover `webclient.go`.** SSH signing-key verification is a web form flow, not REST, on + both servers. Adopting an SDK does not remove hand-rolled HTTP — it *splits* the client into an SDK + half and a hand-rolled half, with two auth models and two error idioms. The web half is where + breakage actually happens, so this is the wrong half to modernise. +- **Closed response types are brittle, and Forgejo proves it.** + `external-sources/forgejo/services/migrations/gitea_sdk_hack.go` uses `go:linkname` to reach the + SDK's **unexported** `(*Client).getParsedResponse`, purely to add one JSON field the SDK does not + model. Forgejo could not extend it through supported means. +- **Supply chain, for test-only code.** Five new direct dependencies we do not share, including two + HTTP-signature libraries and a Windows PuTTY agent shim, for SSH-cert auth we never use — plus an + `x/crypto` version-floor conflict. Our current client is stdlib-only: zero. Hard to justify on a + repo running Scorecard, cosign, and govulncheck, for code absent from the shipped binary. +- **Context is client-scoped, not per-call.** Our client threads `ctx` per method. Under parallel + Ginkgo we would construct a client per context (paying the `/version` round-trip each time) or + mutate shared state and race. + +**Revisit if** we need many more endpoints. The SDK's value rises with breadth of API use, and ours +is narrow and static. + +## SSH key verification works on both + +The signing spec needs the host to report commits as `Verified`, and that is the one thing driven +through the web UI. Forgejo's `verify_ssh` handler (`routers/web/user/setting/keys.go:207-241`) is +logically identical to Gitea's: same `VerificationToken(doer, 1)` / `lastToken(doer, 0)` pair giving a +two-minute window across the minute boundary, same fallback retry, same flash keys. `AddKeyForm` is +byte-identical between the forks. `GET /user/gpg_key_token` serves the **SSH** token too, despite the +GPG-flavoured name. So this is not a differentiator — it works either way. + +**Registering a key is not enough**, on either. `k.Verified` gates the per-user path (Forgejo +`ssh_key_object_verification.go:57-64`, which additionally requires the committer email to be an +*activated* address; Gitea `commit.go:383-390`). The column is written in exactly one place per tree, +reachable only from the web handler. Forgejo's `AddPublicKey` has no `verified` parameter at all, +`POST /admin/users/{u}/keys` creates unverified keys, and no CLI touches it. There is no API +shortcut — it is the web flow, a direct DB write, or a config-level trust lever. + +Neither config lever helps the **generated-key** spec ([`signing_e2e_test.go:71`](../../test/e2e/signing_e2e_test.go)), +which mints a key at runtime: both are read at config load and would need a restart mid-suite. So the +web flow stays regardless of server — which is why the CSRF deletion is the work that actually matters. + +## Recommendation + +**Decouple the two decisions, and do the cheap one first.** + +1. **Unpin Gitea now.** Delete the CSRF scraper and move the chart forward off 12.5.x. This is + required work under *either* outcome, it is a net code deletion, and it removes the only real + pressure. Also drop the `allowedVersions: <12.6.0` guard in + [`.github/renovate.json`](../../.github/renovate.json) and the inline pin comment, which exist + solely to describe a problem that no longer needs a workaround. +2. **Then decide Forgejo on its merits, not under duress.** After step 1 the technical case is close + to neutral-to-negative: Gitea costs nothing further, keeps the literal signing namespace (avoiding + the one hazardous change), and has the better trusted-key lever. Forgejo's advantages are a simpler + chart and project/governance preference — a legitimate reason to move, but not a technical one, and + it should be stated as such rather than dressed up as a fix. + +The honest summary: **the migration was justified by a pin that turns out to be self-inflicted.** +Once the scraper is gone, moving to Forgejo buys a cleaner chart and costs a risky namespace change +plus an untested OCI dependency. If the project preference is strong the migration is entirely +feasible and the plan below holds. If it is not, staying is cheaper and slightly safer. + +## Migration plan, if Forgejo is chosen + +Estimated **1–2 days**, risk concentrated in step 3. + +| # | Step | Size | Risk | +|---|---|---|---| +| 0 | Smoke-test Flux **OCI** `HelmRepository` against `oci://code.forgejo.org/forgejo-helm`. The chart pulls fine with `helm`; Flux OCI support in our cluster is the untested piece. | 30 min | infra unknown — **do first** | +| 1 | `HelmRepository` → `type: oci`; `HelmRelease` → `chart: forgejo`, `version: 17.1.3`. | 30 min | low | +| 2 | Values: delete the five subchart toggles, add `fullnameOverride: gitea`, keep `gitea.config.*` verbatim. | 45 min | low | +| 3 | **SSH signing namespace** → the ROOT_URL hostname. Own commit, own bisect point; land on a scratch cluster first. | half day | **medium — hostile failure mode** | +| 4 | Session cookie constant → `session`. | 10 min | low | +| 5 | Full `task test-e2e`, plus the signing and bi-directional corners. | 1 hour | — | + +The CSRF deletion is deliberately **not** in this table — it belongs to step 1 of the recommendation +and ships either way. + +### Verify immediately after the first install: SSH reachability + +Both Gitea Services today are **headless** (`clusterIP: None`), so DNS returns pod IPs and the +declared Service port is bypassed. Probed from inside the live cluster against `gitea-ssh.gitea-e2e.svc`: + +``` +port 22 closed <- the Service's declared port; nothing listens +port 2222 OPEN <- the rootless image's real listener +port 13000 OPEN <- http, on the *ssh* service name, because headless +``` + +So `repo_setup.go:39`'s `…gitea-ssh…:2222` works by reaching the pod directly, and `SSH_PORT: 22` in +our values is cosmetic — it only sets the advertised clone URL. The apparent contradiction between the +two is not a bug today. But the URL depends on headless behaviour *plus* the rootless listener, not on +a port mapping: if Forgejo's chart Services are not headless, `:2222` breaks. Check this before +debugging anything else. + +### Deliberately out of scope + +**The `gitea*` → `forgejo*` rename.** `internal/giteaclient`, `GiteaTestInstance`, `waitForGiteaAPI`, +`GITEA_*` Taskfile vars, the `gitea-e2e` namespace, `cmd/gitea-signing-debug`, +`hack/checkout-gitea-repo.sh` — ~50 files of churn with real review and merge-conflict cost. Separate +PR once the functional migration is green. `fullnameOverride: gitea` means nothing functionally +depends on it. + +**Crossplane / OpenTofu-managed repositories.** Evaluated and rejected for e2e: each spec creates a +throwaway repo, user, and collaborator inline and uses it milliseconds later (15+ repos per suite +run). A `Workspace` CR reconciled through `tofu plan` is far slower, needs durable remote state that a +recreated k3d cluster destroys, and does not address SSH key *verification* — our actual pain. A +`ForgejoProject` XRD is an interesting **product** direction; it does not belong on the test path. + +## Rollback + +Every change is confined to `test/e2e/setup/flux/**` and `internal/giteaclient/**`, neither of which +ships. Reverting restores the previous server. No CRD, no API, no user-visible surface is touched. From 6459ea0ffbab8d5fd6a5ba6c26271ef31a6a07e3 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 19 Jul 2026 21:45:52 +0000 Subject: [PATCH 4/4] fix(e2e): unpin Gitea to 12.7.0 by deleting the CSRF scraper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea 1.26 replaced form-token CSRF with Go's stdlib http.CrossOriginProtection (routers/web/web.go). That check returns nil when a request carries neither Sec-Fetch-Site nor Origin — every non-browser Go client — and POST /user/login is exempt besides, being SignOutRequired. So the token our WebSession scraped is not merely absent, it is unnecessary. Delete fetchCSRF, extractCSRFToken, csrfInputRE and both form.Set("_csrf", ...) calls, and move the chart from 12.5.3 (Gitea 1.25.5) to 12.7.0 (Gitea 1.27.0) — the version whose signing spec failed earlier today. HTML-scraping a hidden input was the most brittle part of the client, so this is a net reduction in fragility as well as ~45 fewer lines. The Renovate guard holding gitea below 12.6.0 goes with it; it existed only to describe this problem. Validated on Gitea 1.27.0: task lint, task test (coverage 76.6%, baseline unchanged), and task test-e2e — 58 passed, 0 failed, including the signing specs that drive the web session. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/renovate.json | 5 -- internal/giteaclient/webclient.go | 67 ++---------------- internal/giteaclient/webclient_test.go | 90 ++++++++----------------- test/e2e/setup/flux/releases/gitea.yaml | 6 +- 4 files changed, 35 insertions(+), 133 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index ea0a972da..bd682b7d9 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -35,11 +35,6 @@ "description": "cosign is pinned twice and must move together: the devcontainer ENV block and the cosign-installer input in release.yml. Keep both in one PR.", "matchDepNames": ["sigstore/cosign"], "groupName": "cosign" - }, - { - "description": "Hold Gitea on the 12.5.x chart line (Gitea 1.25.x). Gitea 1.26 removed the _csrf form input and cookie from /user/login, which internal/giteaclient's WebSession scrapes to verify SSH signing keys; the signing e2e spec fails on 12.6.0+. Drop this rule once that helper is reworked.", - "matchDepNames": ["gitea"], - "allowedVersions": "<12.6.0" } ], "customManagers": [ diff --git a/internal/giteaclient/webclient.go b/internal/giteaclient/webclient.go index 5dc44402b..022143e80 100644 --- a/internal/giteaclient/webclient.go +++ b/internal/giteaclient/webclient.go @@ -10,7 +10,6 @@ import ( "net/http/cookiejar" "net/url" "os" - "regexp" "strings" ) @@ -19,9 +18,7 @@ const ( debugMatchContextAfter = 120 flashErrorPreviewSize = 400 httpErrorThreshold = 400 - csrfMatchGroupCount = 3 sessionCookieName = "i_like_gitea" - csrfCookieName = "_csrf" ) // WebSession drives Gitea's web UI for flows the REST API does not expose @@ -51,16 +48,14 @@ func NewWebSession(ctx context.Context, baseURL, username, password string, debu Debug: debug, } - // Step 1: GET the login page to receive a _csrf cookie and form token. - csrf, err := s.fetchCSRF(ctx, "/user/login") - if err != nil { - return nil, fmt.Errorf("fetch login CSRF: %w", err) - } - - // Step 2: POST credentials. On success Gitea sets the session cookie + // POST credentials. On success Gitea sets the session cookie // (`i_like_gitea`) and 302-redirects away from /user/login. + // + // No CSRF token is sent. Gitea 1.26 replaced form-token CSRF with Go's + // stdlib http.CrossOriginProtection (routers/web/web.go), which allows any + // request carrying neither Sec-Fetch-Site nor Origin — i.e. every non-browser + // client. /user/login is additionally exempt, being SignOutRequired. form := url.Values{} - form.Set("_csrf", csrf) form.Set("user_name", username) form.Set("password", password) form.Set("remember", "off") @@ -98,14 +93,7 @@ func NewWebSession(ctx context.Context, baseURL, username, password string, debu // block produced by `ssh-keygen -Y sign -n gitea` over the token from // /user/gpg_key_token. func (s *WebSession) VerifySSHKey(ctx context.Context, publicKey, fingerprint, signature string) error { - // Gitea's keys settings page renders a fresh CSRF token we must echo back. - csrf, err := s.fetchCSRF(ctx, "/user/settings/keys") - if err != nil { - return fmt.Errorf("fetch keys-page CSRF: %w", err) - } - form := url.Values{} - form.Set("_csrf", csrf) form.Set("type", "verify_ssh") // The browser-rendered form sets title=none unconditionally and posts the // public key in `content`. Replicate both exactly. @@ -137,49 +125,6 @@ func (s *WebSession) VerifySSHKey(ctx context.Context, publicKey, fingerprint, s return verifySSHFailure(resp.StatusCode, string(body), fingerprint) } -// fetchCSRF GETs the given path and extracts the form CSRF token. Gitea -// renders it as: . -func (s *WebSession) fetchCSRF(ctx context.Context, path string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.BaseURL+path, nil) - if err != nil { - return "", err - } - resp, err := s.HTTPClient.Do(req) - if err != nil { - return "", err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - token := extractCSRFToken(string(body)) - if token != "" { - return token, nil - } - if token = s.cookieValue(csrfCookieName); token != "" { - return token, nil - } - return "", fmt.Errorf("no _csrf input or cookie found at %s (HTTP %d)", path, resp.StatusCode) -} - -func extractCSRFToken(html string) string { - m := csrfInputRE.FindStringSubmatch(html) - if len(m) < csrfMatchGroupCount { - return "" - } - if m[1] != "" { - return m[1] - } - return m[2] -} - -// csrfInputRE matches `` in either order. -var csrfInputRE = regexp.MustCompile( - `]*\bname="_csrf"[^>]*\bvalue="([^"]+)"|` + - `]*\bvalue="([^"]+)"[^>]*\bname="_csrf"`, -) - func (s *WebSession) hasCookie(name string) bool { return s.cookieValue(name) != "" } diff --git a/internal/giteaclient/webclient_test.go b/internal/giteaclient/webclient_test.go index 0c2792fc2..a171613aa 100644 --- a/internal/giteaclient/webclient_test.go +++ b/internal/giteaclient/webclient_test.go @@ -5,7 +5,6 @@ package giteaclient import ( "context" "net/http" - "net/http/cookiejar" "net/http/httptest" "net/url" "os" @@ -17,38 +16,6 @@ import ( reverserGit "github.com/ConfigButler/gitops-reverser/internal/git" ) -func TestExtractCSRFToken_FromFixtures(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - fixture string - want string - }{ - { - name: "login page name before value", - fixture: "testdata/login-page.html", - want: "login-csrf-token", - }, - { - name: "keys page value before name", - fixture: "testdata/keys-page-pre-verify.html", - want: "keys-csrf-token", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - html := readFixture(t, tt.fixture) - if got := extractCSRFToken(html); got != tt.want { - t.Fatalf("extractCSRFToken() = %q, want %q", got, tt.want) - } - }) - } -} - func TestVerifySSHFailure_FromFixtures(t *testing.T) { t.Parallel() @@ -85,34 +52,41 @@ func TestVerifySSHFailure_FromFixtures(t *testing.T) { } } -func TestWebSessionFetchCSRF_FallsBackToCookie(t *testing.T) { +// TestNewWebSession_SendsNoCSRFToken pins the Gitea 1.26+ contract: form-token +// CSRF is gone, replaced by stdlib http.CrossOriginProtection, which admits any +// request carrying neither Sec-Fetch-Site nor Origin. A login that posts a +// `_csrf` field would be sending a field the server no longer models, and — more +// importantly — a client that still tried to *scrape* one would fail outright, +// because the login page no longer renders it. +func TestNewWebSession_SendsNoCSRFToken(t *testing.T) { t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "cookie-csrf-token", Path: "/"}) - _, _ = w.Write([]byte(`no hidden csrf input here`)) + var loginForm url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user/login" && r.Method == http.MethodPost { + _ = r.ParseForm() + loginForm = r.Form + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "session-ok", Path: "/"}) + _, _ = w.Write([]byte(`logged in`)) + return + } + // Serve a login page with no _csrf input, exactly as Gitea 1.26+ does. + _, _ = w.Write([]byte(`
` + + `
`)) })) defer srv.Close() - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatalf("cookiejar.New() error = %v", err) - } - - session := &WebSession{ - BaseURL: srv.URL, - HTTPClient: &http.Client{Jar: jar, Timeout: defaultHTTPTimeout}, - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - token, err := session.fetchCSRF(ctx, "/user/login") - if err != nil { - t.Fatalf("fetchCSRF() error = %v", err) + if _, err := NewWebSession(ctx, srv.URL, "alice", "secret", false); err != nil { + t.Fatalf("NewWebSession() error = %v", err) } - if token != "cookie-csrf-token" { - t.Fatalf("fetchCSRF() = %q, want %q", token, "cookie-csrf-token") + if got := loginForm.Get("_csrf"); got != "" { + t.Fatalf("login form carried _csrf = %q, want it absent", got) + } + if got := loginForm.Get("user_name"); got != "alice" { + t.Fatalf("login form user_name = %q, want %q", got, "alice") } } @@ -125,7 +99,6 @@ func TestNewWebSession_UsesSessionCookieForLoginSuccess(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/user/login": - http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "login-csrf-token", Path: "/"}) _, _ = w.Write([]byte(loginHTML)) case r.Method == http.MethodPost && r.URL.Path == "/user/login": loginPosts++ @@ -165,7 +138,6 @@ func TestNewWebSession_FailsWithoutSessionCookie(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/user/login": - http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "login-csrf-token", Path: "/"}) _, _ = w.Write([]byte(loginHTML)) case r.Method == http.MethodPost && r.URL.Path == "/user/login": _, _ = w.Write([]byte(loginHTML)) @@ -322,22 +294,16 @@ func handleVerificationTokenRequest(w http.ResponseWriter, r *http.Request) { } func handleVerificationLoginPage(w http.ResponseWriter, loginHTML string) { - http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "login-csrf-token", Path: "/"}) _, _ = w.Write([]byte(loginHTML)) } func handleVerificationLoginPost(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() - if r.Form.Get("_csrf") != "login-csrf-token" { - w.WriteHeader(http.StatusBadRequest) - return - } http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "session-ok", Path: "/"}) _, _ = w.Write([]byte(`logged in`)) } func handleVerificationKeysPage(w http.ResponseWriter, keysHTML string) { - http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "keys-csrf-token", Path: "/"}) _, _ = w.Write([]byte(keysHTML)) } @@ -358,8 +324,8 @@ func handleVerificationKeysPost( func assertVerifyForm(t *testing.T, verifyForm *url.Values, publicKey string) { t.Helper() - if got := verifyForm.Get("_csrf"); got != "keys-csrf-token" { - t.Fatalf("verify form _csrf = %q, want %q", got, "keys-csrf-token") + if got := verifyForm.Get("_csrf"); got != "" { + t.Fatalf("verify form carried _csrf = %q, want it absent", got) } if got := verifyForm.Get("type"); got != "verify_ssh" { t.Fatalf("verify form type = %q, want %q", got, "verify_ssh") diff --git a/test/e2e/setup/flux/releases/gitea.yaml b/test/e2e/setup/flux/releases/gitea.yaml index ed2a23fad..82540b753 100644 --- a/test/e2e/setup/flux/releases/gitea.yaml +++ b/test/e2e/setup/flux/releases/gitea.yaml @@ -13,11 +13,7 @@ spec: kind: HelmRepository name: gitea-charts namespace: flux-system - # Pinned to the 12.5.x line (Gitea 1.25.5) deliberately: Gitea 1.26+ removed the `_csrf` - # form input and cookie from /user/login, which internal/giteaclient's WebSession scrapes to - # log in and verify an SSH signing key. Moving to chart 12.6.0/12.7.0 needs that helper - # reworked first — see the signing e2e spec. - version: 12.5.3 + version: 12.7.0 install: createNamespace: false remediation: