Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b487bed
chore: add optional validatingAdmissionWebhook, and prepare for a sep…
sunib Jun 24, 2026
ec49609
chore: creating plan and code to capture all the mechanisms in a more…
sunib Jun 24, 2026
876ff60
chore: here is M2!
sunib Jun 24, 2026
e0b6369
chore: Interesting findings on the shallow body problem
sunib Jun 25, 2026
758a629
chore: small fixes
sunib Jun 25, 2026
2ccf07a
chore: more improvements in the tests
sunib Jun 25, 2026
7c41668
chore: first draft on architecture update
sunib Jun 25, 2026
8dc3aaa
chore: finishing for now
sunib Jun 25, 2026
e39ca06
test(mutationlab): capture rows 10 (owner-ref cascade) and 13 (conflict)
sunib Jun 25, 2026
77e349f
feat(watch): parallel watch-state stream behind --watch-state-stream
sunib Jun 25, 2026
6d87865
feat(watch): diff watch-derived vs audit-derived desired sets (Phase …
sunib Jun 25, 2026
2535109
test(mutationlab): capture rows 16+17 (watch resync + bookmark)
sunib Jun 25, 2026
d41cfab
chore: finishing the design
sunib Jun 25, 2026
38f59f8
feat: let's get all testing to Kubernetes 1.36
sunib Jun 25, 2026
6dec610
chore: reran all mutatons on k8s v1.36.1
sunib Jun 25, 2026
e612fc6
chore: improving watch-ingestion document.
sunib Jun 25, 2026
d0e25a2
chore: getting the design docs better
sunib Jun 25, 2026
b2d5bc7
feat: watch-first ingestion
sunib Jun 25, 2026
5122032
chore: next steps
sunib Jun 26, 2026
79a9fe6
docs: moving architecture along with the rewrite
sunib Jun 26, 2026
b524d83
chore: relisten to a watch when possible
sunib Jun 26, 2026
7e011db
chore: details on how Redis is needed
sunib Jun 26, 2026
d7bdb16
docs: created new plan, and hopefully found why the tests are so flaky
sunib Jun 26, 2026
acf73d5
chore: easier status and streamsready
sunib Jun 26, 2026
acaea33
chore: e2e flake preventions
sunib Jun 26, 2026
04aa391
chore: overall improvements, fixing things and cleaning docs
sunib Jun 26, 2026
915b524
feat: reworking metrics to new architecture
sunib Jun 26, 2026
261c440
feat(manifestanalyzer,git): refuse unsupported GitTarget folder conte…
sunib Jun 26, 2026
cb8d4b0
feat(watch): surface a refused GitTarget folder as a Blocked stream
sunib Jun 26, 2026
d09ab73
test(e2e): prove unsupported-folder refusal end to end (Test D) + docs
sunib Jun 26, 2026
893e17f
test(e2e): apply sops-age-key in unsupported-folder test so Ready can…
sunib Jun 26, 2026
f2773a8
docs: adding skills and working on status design
sunib Jun 27, 2026
92fa490
chore: improve status, support kstatus
sunib Jun 27, 2026
12f3aa2
chore: refining names and more explicit e2e test for status behaviour
sunib Jun 27, 2026
419ab33
docs: designing gittargetignore
sunib Jun 27, 2026
1c61666
feat: refuse weird files in GitTarget path, but do allow .gittargetig…
sunib Jun 27, 2026
b895ef3
chore: removing settings and preparing merge
sunib Jun 28, 2026
a555d9d
chore: Support CommitRequest with clearer status and non-attribution …
sunib Jun 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .coverage-baseline
Original file line number Diff line number Diff line change
@@ -1 +1 @@
75.0
73.9
14 changes: 14 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,20 @@ RUN printf '%s\n' \
'fi' \
>> /etc/bash.bashrc

# Install Node.js (provides npm + npx, e.g. for installing Claude Code skills).
# Dev stage only; CI does not need a JS runtime.
# NODE_MAJOR -> https://github.com/nodejs/node/releases (track a current LTS line)
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& chmod a+r /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get -y install --no-install-recommends nodejs \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*

# Install Tilt CLI (local dev loop orchestration; dev stage only)
RUN arch="$(dpkg --print-architecture)" && \
case "${arch}" in \
Expand Down
62 changes: 45 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,50 @@ simpler for other teams.

| Good fit | Poor fit |
|---|---|
| Self-managed clusters where you can configure kube-apiserver audit delivery | Managed control planes that do not expose audit webhook configuration |
| API-first or hybrid teams that still want Git history | Shared paths with two always-on writers fighting over the same resources |
| Brownfield discovery, hotfix capture, migration toward GitOps | Production HA requirements today |
| Clusters where you can grant watch/RBAC, run Valkey/Redis in-cluster, and write to Git | Production HA requirements today |
| Self-managed clusters that can also configure kube-apiserver audit delivery (adds commit-author attribution) | Shared paths with two always-on writers fighting over the same resources |
| API-first or hybrid teams that still want Git history; brownfield discovery, hotfix capture, migration toward GitOps | Workflows that need a guaranteed per-mutation change log rather than a state mirror |

> **Author attribution** is the only optional capability: it needs kube-apiserver audit delivery, which
> managed control planes (EKS/GKE/AKS) generally do not expose. Without it the operator still mirrors
> state, with commits authored by the configured committer. **Valkey/Redis is required either way** — it
> holds each GitTarget's watch resume state so work is re-picked up after a restart or reconnect.

## How it works

1. kube-apiserver sends audit events to the operator's audit webhook.
2. GitOps Reverser loads the live object and removes runtime-only noise.
3. Events are queued safely through Valkey/Redis.
4. The operator writes stable YAML to Git with useful commit metadata.
1. GitOps Reverser **watches** the Kubernetes API for the resource types each `GitTarget` claims —
watch is the single source of object state.
2. Each change is sanitized (status, `managedFields`, and runtime noise removed) and diffed against the
current Git content.
3. **Valkey/Redis** tracks each GitTarget's watch resume position, so the operator re-picks up exactly
where it left off after a restart or reconnect (and, when configured, holds audit attribution facts).
4. The operator writes stable YAML to Git with useful commit metadata. Commits are authored by the
configured committer, or by the actual user / service account when an audit fact matches
(**attribution** — the one part that is optional).

`Secret` resources can be encrypted before commit with SOPS + age, Secret-shaped custom resource
types can opt into the same path at controller startup, and Git commits can be SSH-signed
through `GitProvider.spec.commit.signing`.

Capturing objects served by an **aggregated API server** needs extra setup — see the
[aggregated API guide](docs/aggregated-api-guide.md).
Capturing objects served by an **aggregated API server** is supported through the same watch path
(with a LIST fallback for servers that do not implement streaming lists); see
[`docs/architecture.md`](docs/architecture.md).

### Operating modes

Every install needs the same base: Kubernetes watch/RBAC access, **Valkey/Redis** (watch resume state),
Git credentials, and cert-manager. The only thing that varies is **author attribution**:

| Mode | Attribution | Additionally needs | Commit author |
|---|---|---|---|
| **Committer-only** (default) | off | — | configured committer identity |
| **Attributed** | on | kube-apiserver audit delivery | named user / service account on a strong match, committer otherwise |

Because object state comes from **watch**, GitOps Reverser is a *state mirror with opportunistic
per-mutation history*: it records every change it observes while watching, and collapses intermediate
versions to current state across restarts, reconnects, or `410 Gone` replays. It is not a guaranteed
per-mutation change log. No path silently loses a delete — a delete missed while no watch was running is
reconciled by the replay mark-and-sweep on reconnect.

## Boundaries

Expand All @@ -79,7 +106,7 @@ Early-stage software. CRDs and behavior may still change.
- Single controller pod only (`replicas=1`); HA is not supported yet.
- Shared-resource bi-directional workflows require explicit coordination.
- Reverse-GitOps source recovery is limited to Kubernetes manifests, not Helm/Kustomize authoring models.
- Tests run against Kubernetes `1.35`. Other versions may work but are not part of the current matrix.
- Tests run against Kubernetes `1.36`. Other versions may work but are not part of the current matrix.
- Runtime behavior is deterministic; there is no AI or heuristic mutation at runtime.

GitOps Reverser is a good fit for pilots, lab clusters, brownfield discovery, and design-partner
Expand All @@ -90,18 +117,20 @@ Directions we may revisit later live in [docs/TODO.md](docs/TODO.md) and [docs/f

## Quick start

This quick start assumes you already know how to operate a Kubernetes control plane and are willing
to change kube-apiserver audit settings.
This quick start sets up **attributed mode** (named commit authors). **Author attribution is the only
optional part** — to run **committer-only**, skip step 4 (audit delivery) and commits will be authored by
the configured committer. Valkey/Redis (step 2) is **required in both modes**: it holds each GitTarget's
watch resume state so the operator re-picks up where it left off.

**Prerequisites**

- Kubernetes cluster with `kubectl` configured
- cert-manager for TLS certificate management
- Admin access to kube-apiserver configuration so you can enable the
- *(attributed mode only)* Admin access to kube-apiserver configuration so you can enable the
[audit webhook backend](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/#webhook-backend)

Managed platforms such as EKS, GKE, and AKS generally do not expose that control-plane
configuration.
configuration, so they can run committer-only but not attributed mode.

For networking and TLS tradeoffs around audit delivery, see
[`docs/design/audit-webhook-api-server-connectivity.md`](docs/design/audit-webhook-api-server-connectivity.md).
Expand All @@ -113,7 +142,7 @@ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/
kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s
```

**2. Install Valkey with auth**
**2. Install Valkey with auth** *(required — both modes)*

```bash
kubectl create namespace gitops-reverser
Expand All @@ -138,7 +167,7 @@ helm install gitops-reverser \
--create-namespace
```

**4. Configure kube-apiserver audit delivery**
**4. Configure kube-apiserver audit delivery** *(attributed mode only — skip for committer-only)*

Read the Helm post-install notes:

Expand Down Expand Up @@ -225,7 +254,6 @@ Start here for the stable docs surface:
- [`docs/commit-signing.md`](docs/commit-signing.md)
- [`docs/github-setup-guide.md`](docs/github-setup-guide.md)
- [`docs/sops-age-guide.md`](docs/sops-age-guide.md)
- [`docs/aggregated-api-guide.md`](docs/aggregated-api-guide.md)
- [`docs/bi-directional.md`](docs/bi-directional.md)
- [`docs/alternatives.md`](docs/alternatives.md)

Expand Down
15 changes: 14 additions & 1 deletion api/v1alpha2/clusterwatchrule_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,32 @@ type ClusterResourceRule struct {

// ClusterWatchRuleStatus defines the observed state of ClusterWatchRule.
type ClusterWatchRuleStatus struct {
// ObservedGeneration is the latest generation observed by the controller.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// Conditions represent the latest available observations of the ClusterWatchRule's state.
// +optional
// +patchMergeKey=type
// +patchStrategy=merge
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`

// Streams is the bounded stream-readiness roll-up for the types this rule resolves.
// +optional
Streams *WatchRuleStreamsStatus `json:"streams,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef.name`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`
// +kubebuilder:printcolumn:name="Reconciling",type=string,JSONPath=`.status.conditions[?(@.type=="Reconciling")].status`
// +kubebuilder:printcolumn:name="Stalled",type=string,JSONPath=`.status.conditions[?(@.type=="Stalled")].status`
// +kubebuilder:printcolumn:name="GitTargetReady",type=string,JSONPath=`.status.conditions[?(@.type=="GitTargetReady")].status`
// +kubebuilder:printcolumn:name="StreamsRunning",type=string,JSONPath=`.status.conditions[?(@.type=="StreamsRunning")].status`
// +kubebuilder:printcolumn:name="Streams",type=string,JSONPath=`.status.streams.summary`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

// ClusterWatchRule watches resources across the entire cluster.
Expand Down
114 changes: 40 additions & 74 deletions api/v1alpha2/commitrequest_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,48 +22,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// CommitRequestPhase enumerates the lifecycle states of a CommitRequest.
type CommitRequestPhase string

const (
// CommitRequestPhaseWaitingForAuditEvent is the initial phase: the
// CommitRequest's own create audit event — the source its author is
// attributed from, and the anchor that orders the finalize after the
// author's earlier changes — has not been observed yet, or the finalize
// it gates (optional delay + audit-pipeline drain + commit) has not
// completed.
CommitRequestPhaseWaitingForAuditEvent CommitRequestPhase = "WaitingForAuditEvent"
// CommitRequestPhaseCommitted is terminal: the open commit window was
// finalized, pushed to the remote, and status.branch / status.sha are set.
CommitRequestPhaseCommitted CommitRequestPhase = "Committed"
// CommitRequestPhaseRejected is terminal: the request was handled correctly
// but produced no commit. status.reason distinguishes why (NoWindowInGrace,
// WindowMismatch, AlreadyPresent). This is not an error.
CommitRequestPhaseRejected CommitRequestPhase = "Rejected"
// CommitRequestPhaseFailed is terminal: the finalize could not be completed
// (for example a failed local commit, a push that can never land, or
// attribution that never arrived). status.message carries the failure detail.
CommitRequestPhaseFailed CommitRequestPhase = "Failed"
)

// CommitRequestRejectReason explains a Rejected CommitRequest: the request was
// handled correctly but produced no commit. It is set only when phase is Rejected.
// +kubebuilder:validation:Enum=NoWindowInGrace;WindowMismatch;AlreadyPresent
type CommitRequestRejectReason string

const (
// RejectNoWindowInGrace means the grace period elapsed with no matching
// same-author window — nothing was pending to save.
RejectNoWindowInGrace CommitRequestRejectReason = "NoWindowInGrace"
// RejectWindowMismatch means an open window existed but belonged to a different
// author or GitTarget, so it was deliberately left untouched.
RejectWindowMismatch CommitRequestRejectReason = "WindowMismatch"
// RejectAlreadyPresent means a matching window was finalized but produced no
// diff — the change already matches the remote, so the commit was dropped (loop
// prevention).
RejectAlreadyPresent CommitRequestRejectReason = "AlreadyPresent"
)

// CommitRequestSpec defines the desired state of CommitRequest. The spec is
// immutable after creation: a CEL validation rule rejects any update that
// changes it, so a delayed audit event always acts on the spec the object was
Expand All @@ -89,58 +47,66 @@ type CommitRequestSpec struct {
// +kubebuilder:validation:Pattern=`^[^\x00-\x09\x0B-\x1F\x7F]*$`
Message string `json:"message,omitempty"`

// DelaySeconds optionally holds the finalize for this many seconds after
// the CommitRequest's creation, acting as an extra collect window:
// changes the author makes in the meantime still join the open commit
// window and are included in the finalized commit. Omitted or 0 finalizes
// as soon as the CommitRequest is attributed to its author. The window
// can still be closed earlier by another author's change or by the
// provider's commit window timer, exactly as without a CommitRequest.
// CloseDelaySeconds optionally delays closing the open commit window for this
// many seconds after the CommitRequest is attributed, acting as an extra collect
// window: changes the author makes in the meantime still join the open commit
// window and are included in the resulting commit. Omitted or 0 closes the window
// as soon as the CommitRequest is attributed to its author. The window can still
// be closed earlier by another author's change or by the provider's commit window
// timer, exactly as without a CommitRequest.
// +optional
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=300
DelaySeconds int32 `json:"delaySeconds,omitempty"`
CloseDelaySeconds int32 `json:"closeDelaySeconds,omitempty"`
}

// CommitRequestStatus defines the observed state of CommitRequest.
// CommitRequestStatus defines the observed state of CommitRequest. Progress and
// outcome are reported entirely through conditions (kstatus-compatible), so the
// object carries no lifecycle phase string:
//
// - Ready (summary): True once the request reached a terminal outcome that is not
// an error — a pushed commit, or a benign no-commit (nothing to save, already
// present, or a foreign open window). False while in progress or when it failed.
// - Reconciling / Stalled: the kstatus progress / blocked pair. Reconciling=True
// while finalizing; Stalled=True when the finalize failed and needs attention.
// - Attributed (domain): True once the author is settled — immediately True when
// attribution is not required (committer-only), True when resolved from the
// create audit event, and False if the audit event never arrived and the commit
// was authored by the configured committer.
// - Pushed (domain): True once the commit is in the remote repository.
type CommitRequestStatus struct {
// Phase is the lifecycle state of this CommitRequest.
// ObservedGeneration is the most recent generation observed by the controller.
// +optional
// +kubebuilder:validation:Enum=WaitingForAuditEvent;Committed;Rejected;Failed
Phase CommitRequestPhase `json:"phase,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// Reason explains a Rejected phase: the machine-readable discriminator that
// status consumers and tests assert on. Empty for non-Rejected phases.
// Conditions report the request's progress and terminal outcome: the Ready
// summary, the kstatus Reconciling/Stalled pair, and the domain conditions
// Attributed and Pushed.
// +optional
Reason CommitRequestRejectReason `json:"reason,omitempty"`

// Message is a human-readable detail for the terminal phase. When Phase is
// Failed it carries the reason the finalize could not complete; when Phase is
// Rejected it carries the prose for status.reason.
// +optional
Message string `json:"message,omitempty"`

// Branch is the Git branch the commit landed on. Set when Phase is Committed.
// +listType=map
// +listMapKey=type
// +patchStrategy=merge
// +patchMergeKey=type
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`

// Branch is the Git branch the GitTarget commits to. Populated once the
// finalize resolves.
// +optional
Branch string `json:"branch,omitempty"`

// SHA is the resulting commit SHA. Set when Phase is Committed.
// SHA is the resulting commit SHA. Set when the commit was pushed (Pushed=True).
// +optional
SHA string `json:"sha,omitempty"`

// ObservedTime is the timestamp at which the terminal phase was recorded.
// +optional
ObservedTime *metav1.Time `json:"observedTime,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="GitTarget",type=string,JSONPath=`.spec.targetRef.name`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.reason`
// +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.status.branch`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Attributed",type=string,JSONPath=`.status.conditions[?(@.type=="Attributed")].status`
// +kubebuilder:printcolumn:name="Pushed",type=string,JSONPath=`.status.conditions[?(@.type=="Pushed")].status`
// +kubebuilder:printcolumn:name="SHA",type=string,JSONPath=`.status.sha`
// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.spec.message`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

// CommitRequest is a one-shot "save" signal: creating one finalizes the open
Expand Down
Loading