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..bd682b7d9 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,170 @@ +{ + "$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" + } + ], + "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/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 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. 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/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..82540b753 100644 --- a/test/e2e/setup/flux/releases/gitea.yaml +++ b/test/e2e/setup/flux/releases/gitea.yaml @@ -13,7 +13,7 @@ spec: kind: HelmRepository name: gitea-charts namespace: flux-system - version: 12.5.1 + version: 12.7.0 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