This guide explains the real configuration objects that drive gitops-reverser after the install steps in the root README.
The short version:
GitProviderdefines where and how to pushClusterProviderdefines the Kubernetes source cluster a target mirrors fromGitTargetdefines which branch and repository path to write intoWatchRuledefines which namespaced resources should produce Git writes, and in which source namespacesClusterWatchRuledoes the same for cluster-scoped resourcesCommitRequestoptionally asks the operator to close the current commit window now
The chart's optional quickstart values are a convenience layer that creates starter
instances of those same resources.
For a first trial, use the root README quick start. It runs configured-author: Git writes work without kube-apiserver audit delivery, and every commit uses the configured committer identity. Add audit attribution later only when you need named Kubernetes users or service accounts in Git history.
The usual flow is:
- Create a
GitProviderfor repository access and commit behavior. - Create a
ClusterProviderfor the source cluster, including thedefaultprovider when a target omits its source reference. - Create a
GitTargetthat points at the Git provider, source cluster, branch, and repository path. - Create one or more
WatchRuleorClusterWatchRuleobjects that point at that target. - Create a
CommitRequestonly when you want to flush an open window before the normal timer.
That means one repository connection can back multiple targets, and one target can be fed by multiple watch rules.
| Object | Scope | What it represents |
|---|---|---|
GitProvider |
Namespace | A Git destination and the credentials allowed to write it. A repository is normally one team's write boundary, so the provider and its Secret sit in that team's namespace. |
ClusterProvider |
Cluster | One Kubernetes source cluster. It can feed targets in several namespaces, and its connection, watch state and attribution identity must mean the same thing to all of them. |
There is no default GitProvider: the operator cannot infer a safe repository, branch, or write
credential, so every GitTarget names one. GitTarget.spec.clusterProviderRef does default, to the
conventional name default. That is a concrete reference to jump to; it does not claim that
default is the local cluster.
ClusterProvider.spec.accessFrom decides which control-cluster namespaces may contain GitTargets
that reference the provider. It does not select namespaces in the source cluster and grants nothing
there.
GitProvider defines the Git remote, credentials, allowed branches, push strategy, and commit
behavior.
The important fields are:
spec.url: repository URLspec.secretRef.name: Secret with Git credentials such as SSH or HTTPS authspec.knownHostsRef: optional ConfigMap/Secret with SSHknown_hostsshared across providersspec.allowedBranches: branches this provider is allowed to writespec.commit: committer identity and signing
How writes are batched and phrased is not here: spec.commit.window and spec.commit.message
belong to the GitTarget that owns the folder.
Example:
apiVersion: configbutler.ai/v1alpha3
kind: GitProvider
metadata:
name: example-provider
namespace: default
spec:
url: git@github.com:example-org/example-repo.git
secretRef:
name: git-creds
allowedBranches:
- mainThe referenced Secret holds the Git credentials. The examples use the Kubernetes-native keys,
which match the built-in Secret types and the tooling around them (kubectl create secret generic --type=…, Sealed Secrets, External Secrets, SOPS):
| Auth | Keys |
|---|---|
| SSH | ssh-privatekey (+ optional ssh-password passphrase, known_hosts) |
| HTTP basic | password (+ optional username) |
| HTTP bearer token | bearerToken (GitHub fine-grained PAT, GitLab access token; no username) |
password is what selects HTTP basic auth, and username is optional: a Secret with only
password authenticates with an empty username, which is what Azure DevOps expects for a Personal
Access Token. ADO ignores the username entirely; measured, any value including none is accepted.
A username with no password is an error, because that one is a real mistake.
Note that an empty value and an absent key are the same thing to the credential reader, so
username: "" behaves exactly like omitting it.
The credential reader's design is inspired by both Flux and Argo CD: it accepts their Secret key names alongside the native ones, so you can reuse a Git credentials Secret you already have instead of re-authoring it. The keys read for each auth method:
| Credential | Native key (recommended) | Flux key (also read) | Argo CD key (also read) |
|---|---|---|---|
| SSH private key | ssh-privatekey |
identity |
sshPrivateKey |
| SSH key passphrase | ssh-password |
password (when an SSH key is present) |
(unsupported by Argo) |
| SSH host keys | known_hosts |
known_hosts |
external ConfigMap → supply via spec.knownHostsRef |
| HTTP basic auth | username + password |
username + password |
username + password |
| HTTP bearer token | bearerToken |
bearerToken |
bearerToken |
Auth precedence is SSH key → HTTP basic → bearer token. Client certificates (mTLS), custom CA certificates, and GitHub App credentials are not supported.
A reused Secret needs write access. Flux and Argo CD only clone, so their Git credentials are often read-only (a read-only deploy key, a read-scoped token). GitOps Reverser pushes commits, so a reused Secret's key or token must have write access on the repository; otherwise the commits will fail to push.
SSH host keys are resolved in priority order: the credentials Secret's own known_hosts, then
spec.knownHostsRef (a namespace-local ConfigMap or Secret keyed known_hosts, or ssh_known_hosts
for data copied out of Argo's argocd-ssh-known-hosts-cm), then an install-level default known-hosts
ConfigMap in the controller's namespace (--default-known-hosts-configmap). If none yields a valid
host key, SSH fails closed. Host-key rotation is an admin-owned declarative update; verify
fingerprints out of band. The controller flag --insecure-allow-missing-known-hosts relaxes this for
throwaway/dev clusters only: it permits SSH when no source provided any known_hosts; a
known_hosts that is present but unparseable is always a hard error.
spec.commit configures the identity this connection commits under:
committer: the operator identity written as the Git committersigning: the SSH signing key configuration
If spec.commit is omitted, gitops-reverser uses its built-in defaults.
spec.commit.message is not here. A commit's wording describes the folder being written, so it is
GitTarget.spec.commit.message; setting it on a GitProvider is
rejected.
These are different on purpose:
- Author: who made the cluster change
- Committer: who wrote the Git commit object
For mirrored-resource commits, the author comes from the configured committer identity unless
attribution.enabled=true and a matching kube-apiserver audit event names the Kubernetes user or
service account. Snapshot/reconcile commits are operator-authored.
When attribution IS enabled and no matching audit fact arrives, the commit is authored
unknown (attribution unresolved) <attribution-unresolved@gitops-reverser.invalid> instead of the
committer. That distinction is the point: a committer-authored commit means attribution was never
attempted, while the sentinel means it was attempted and did not resolve, which is worth investigating.
Such commits also count under author_kind="unresolved" in commits_total.
That distinction is useful in practice:
git log --author=aliceanswers "what did Alice change?"git log --committer="GitOps Reverser"answers "what did the operator write?"
When signing is enabled, Git hosting platforms usually verify the committer identity, not the Kubernetes author.
Use spec.commit.committer to control the bot identity written as the Git committer:
spec:
commit:
committer:
name: GitOps Reverser
email: 12345678+gitops-reverser-bot@users.noreply.github.comDefaults:
name:GitOps Reverseremail:noreply@configbutler.ai
If signing is enabled, spec.commit.committer.email should be an email that the Git hosting
platform recognizes for the account that owns the signing key.
GitOps Reverser signs commits from spec.commit.signing.
The signing Secret uses these data keys:
signing.key: PEM-encoded SSH private keypassphrase: optional passphrase for encrypted private keyssigning.pub: optional convenience copy of the public key
The operator publishes the effective public key in .status.signingPublicKey.
Let the operator generate the signing key:
apiVersion: configbutler.ai/v1alpha3
kind: GitProvider
metadata:
name: example-provider
namespace: default
spec:
url: git@github.com:example-org/example-repo.git
allowedBranches:
- main
secretRef:
name: git-creds
commit:
committer:
name: GitOps Reverser
email: 12345678+gitops-reverser-bot@users.noreply.github.com
signing:
secretRef:
name: gitops-reverser-signing-key
generateWhenMissing: trueBring your own signing key:
ssh-keygen -t ed25519 -f /tmp/gitops-reverser-signing -N ""
kubectl create secret generic gitops-reverser-signing-key \
-n default \
--from-file=signing.key=/tmp/gitops-reverser-signing \
--from-file=signing.pub=/tmp/gitops-reverser-signing.pubspec:
commit:
committer:
name: GitOps Reverser
email: 12345678+gitops-reverser-bot@users.noreply.github.com
signing:
secretRef:
name: gitops-reverser-signing-keyIf you start from the Helm chart quickstart, edit the generated GitProvider directly when you
want custom spec.commit behavior because the starter values do not currently expose those fields.
For the platform-facing behavior behind "valid signature" versus "verified badge", see commit-signing.md.
ClusterProvider names the Kubernetes cluster a GitTarget mirrors from. It is the read-side
peer of GitProvider: a target has one source cluster and one Git destination.
default is the conventionally opinionated provider name, not an operator-generated object and not
a synonym for the local cluster. Its only special behavior is that a GitTarget which omits
spec.clusterProviderRef references a user-created ClusterProvider named default. That provider
may omit spec.kubeConfig to use the operator's in-cluster configuration, or set it to mirror a
remote cluster.
For a remote source cluster, create a provider with a kubeconfig Secret. The Secret is resolved from the operator's namespace; it is connection material for the operator, not a per-target setting.
apiVersion: configbutler.ai/v1alpha3
kind: ClusterProvider
metadata:
name: prod-eu-1
spec:
kubeConfig:
secretRef:
name: default-source-kubeconfig
accessFrom:
names: [team-a]
selector:
matchLabels:
gitops.configbutler.ai/source-access: "true"accessFrom is evaluated against namespaces in the control cluster, where
GitTargets live. In this example, a GitTarget in team-a, or in a control-cluster namespace
with the shown label, may reference prod-eu-1. names and selector are ORed, and an omitted
policy admits no control-cluster namespace.
Which namespaces are read from the source cluster is bounded by the source connection's Kubernetes
RBAC: the credential's own RBAC is the hard maximum, and there is no second allow-list beside it. A
WatchRule may name a source namespace other than its own only when this provider also sets:
# Deny-by-default. While false, a WatchRule mirroring through this provider may
# watch only its OWN namespace.
allowAnySourceNamespace: trueThat flag delegates the choice of source namespace to the GitTargets this provider admits; it
grants nothing on its own, since the credential still has to be able to read what is chosen. It is
required for every cross-source-namespace request, including sourceNamespace: "*". Setting it on an
in-cluster provider is a much sharper decision than on a remote one: there the config plane is
the watched cluster, so it deliberately bypasses live namespace RBAC and lets the owner of an
admitted GitTarget mirror another namespace's objects into a Git destination they control. That is
legitimate to grant on purpose, which is why it is explicit and defaults to false.
spec.kubeConfig and GitTarget.spec.clusterProviderRef are immutable: changing either would silently
make an existing materialization mean a different source cluster. Rotate credential contents in the
referenced Secret instead. qps and burst optionally tune a remote provider's client; the
ClusterProvider conditions validate its configuration, while the consuming GitTarget reports the
live source reachability and stream state.
spec.attribution.auditRoute is the route this cluster's attribution facts arrive on: the <name>
segment the apiserver's audit webhook URL ends in, /audit-webhook/<name>. It defaults to the
provider's own metadata.name, and it is what partitions the facts, so two providers carrying the
same route read one cluster's facts and two carrying different routes can never cross-credit an
author.
An apiserver takes one audit webhook backend and therefore posts under one route. A second
ClusterProvider naming the same cluster must be pointed at that route:
spec:
attribution:
auditRoute: defaultThe AuditFactsReceived condition reports whether that route has ever delivered, with a default
FACTS printer column:
$ kubectl get clusterprovider
NAME READY REASON FACTS AGE
default True Succeeded True 31m
srcns-delegating True Succeeded Unknown 4mTrue/Received: a fact has arrived, and the message carries when the first one did.Unknown: none ever has, so every commit mirrored through this provider is authoredunknown (attribution unresolved). Three silences look identical on the object and need three different fixes, so the reason says which one it is:
| Reason | What was observed | Where to look |
|---|---|---|
TransportUnavailable |
the last append to the fact transport failed | the transport itself, named in the message: with --author-attribution-transport=redis (the default) the Redis/Valkey at --redis-addr, and with memory the operator's logs. No route can be judged while this holds: every route looks silent for the same reason |
NoAuditDelivery |
no audit request has ever reached this operator, on any route | the API server's audit webhook backend and its connectivity to the operator. This provider's route is not at fault |
RouteUnused |
audit is arriving and no fact has ever been published for this route | this provider's spec.attribution.auditRoute first, then the audit policy: a policy whose level or verbs leave nothing attributable produces no fact even when the route is correct |
The status stays Unknown for all three. A failing transport is a fault in the pipeline, not in
this provider, and turning it into a per-object False would flip every not-yet-latched
ClusterProvider at once: one outage, many verdicts. The reason carries the diagnosis instead.
It is a one-way latch for a given route: once True it stays True, across controller restarts
and regardless of how long the route stays quiet afterward. Silence after proof is a quiet
cluster; silence before it is inconclusive rather than a verdict, which is why the status is
Unknown and the reason above says which of the three cases it is. The latch separates the two
without a timer, so there is no False state and no grace window.
The latch is keyed to the route it was earned on, recorded in status.auditRoute. Because
spec.attribution.auditRoute is mutable, repointing a provider at a different route starts the
condition over: proof that one route delivered says nothing about another.
The condition is not part of Ready: a provider reading a route nobody posts to mirrors
perfectly and loses only the commit author. It is absent entirely when the operator runs with
--author-attribution=false.
The operator never creates a ClusterProvider, and never re-creates one you delete. If a
GitTarget references a provider that does not exist (including default), the target is held
unready through the ordinary "provider not found" path. That is deliberate: a source cluster is a
connection with credentials and an authorization policy, so it is yours to declare, review, and roll
back like any other resource under GitOps.
There are two supported ways to get one, and both are fully declarative:
- Commit it yourself. The object above is ordinary YAML. Put it in the repository that manages this install. This is the recommended path once you are past a first trial.
- Let the chart render it. The chart can create and own a
ClusterProvidernameddefault, including itsaccessFrom, from a single value. See charts/gitops-reverser/README.md. Turn that value off to manage the object yourself. Helm then deletes the provider it created on the next upgrade, so ownership never silently splits between Helm and you. Because a missing provider holds its targets unready, plan that switch together with committing your own object.
The chart value is a rendering convenience, not runtime behavior: with it off, nothing in the operator brings the object back.
The chart renders the default provider by default, including when its optional quickstart starter
resources are enabled. It gives the starter GitTarget a declared in-cluster source without adding a
source reference to its manifest. Turn clusterProvider.createDefault off only when you manage that
provider yourself.
Use another provider name when a target needs a different source cluster:
spec:
clusterProviderRef:
name: prod-eu-1The provider name is deliberately stable. It is the source-cluster identity used for watches and,
when audit attribution is enabled, for joining an audit event to the corresponding watch event.
Changing a target's source cluster changes what its folder means, so clusterProviderRef is
immutable.
GitTarget decides where inside the repository resources are written.
The important fields are:
spec.gitProviderRef: whichGitProviderbacks this targetspec.clusterProviderRef: whichClusterProvidersupplies resources; omit it to reference the user-createddefaultproviderspec.branch: which allowed branch to write tospec.path: required relative path inside the repository; use.only when you deliberately want the repository rootspec.encryption: howSecretresources should be encrypted before commitspec.placement: optional policy for where new resources are written (see Where new resources are written); omit it and a new resource takes the folder's one kustomization root, or the built-in canonical pathspec.placement.useKustomize: whether the operator maintains akustomization.yamlfor this folder, creating one when the folder has none (see Keeping the folder a kustomize folder)spec.serializeNamespace: whether written documents carry their ownmetadata.namespace(see Whether documents carry their namespace); omit it and each document's namespace is inferred from the folderspec.prune: which deletion paths may remove documents from this target's folder (see Deletion policy); omit it for the safe default
Example:
apiVersion: configbutler.ai/v1alpha3
kind: GitTarget
metadata:
name: example-target
namespace: default
spec:
gitProviderRef:
name: example-provider
# Omit clusterProviderRef to reference the user-created ClusterProvider named "default".
# clusterProviderRef: {name: prod-eu-1} selects a different source provider.
branch: main
path: live-clusterspec.path is required so a target never writes to the repository root by accident. Use a path
such as live-cluster for the first install. To deliberately target the repository root, set
path: ".". Do not use a leading slash, and do not add a trailing slash.
The target path is authoritative for snapshot reconciliation. A root target can create, update, and
delete managed manifest files at the repository root, so use . only for a repository layout that is
dedicated to this target.
If you enable spec.encryption, that applies to Secret resource writes for this target. For SOPS
and age details, see sops-age-guide.md.
spec.gitProviderRef references a GitProvider in the same namespace as the GitTarget, by name.
The field name says what it points at, so the reference itself carries nothing but the name.
spec.clusterProviderRef references a cluster-scoped ClusterProvider. It defaults to
{name: default} when omitted. That is intentionally different from gitProviderRef: a source cluster
is a shared physical identity, while a Git destination and its credential normally belong to the
target's namespace. The default name can represent either an in-cluster or remote source according
to the ClusterProvider the user created.
The most useful status fields are:
Ready: true when the target is valid, the Git path is accepted, and watched streams are running.Reconciling: true while initial replay, a recheck, or another coarse progress step is in flight.Stalled: true when the target is blocked until a human fixes configuration, RBAC, or Git path content.ValidatedandEncryptionConfigured: control-plane details.StreamsRunning: true when the source watches are past initial replay and routing live events.GitPathAccepted: true when the target Git path is safe to materialize.status.streams: bounded counts for tracked, running, replaying, and blocked streams.status.retention: how many documentsspec.prune.modeis keeping, and under which mode.LayoutResolved: what the last scan resolved about the folder's shape, withstatus.placementcarrying the detail. See below.
Use conditions for automation.
spec.commit says how this target's writes are batched into commits and how those commits are
phrased. Both describe the folder being written rather than the route to the repository, which is
why they sit here and not on the GitProvider: two GitTargets sharing one connection can disagree
about both, so an RBAC folder that wants a commit per change and an app folder that wants a burst
coalesced need not be two connections.
spec:
commit:
window: "5s"
message:
groupTemplate: "{{.Author}} on {{.GitTarget}}: {{.Count}} resource(s)"spec.commit.window controls how arriving events are grouped into commits. The timer resets on
every event; when it has been silent for the configured duration, the buffered events for a given
author are written as one commit. The default is 5s. Setting 0s opts into per-event commits in
the steady-state.
A burst (kubectl apply -k, helm upgrade, an ArgoCD sync wave) becomes one commit per author with
a summary subject; isolated edits still produce one commit each.
An unparseable or negative value is rejected on the object (Validated=False, reason
InvalidConfig). A value already stored before that check falls back to the 5s default at the
write rather than stopping the mirror.
There are three templates, one per commit shape:
spec.commit.message.eventTemplate: per-event commits (only used whenspec.commit.windowis0s).spec.commit.message.groupTemplate: grouped commits produced by the commit window (the common case).spec.commit.message.reconcileTemplate: reconcile commits (the mark-and-sweep reconcile path; one commit per synced type).
spec:
commit:
message:
eventTemplate: "[{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Name}}"
groupTemplate: "{{.Author}} on {{.GitTarget}}: {{.Count}} resource(s)"
reconcileTemplate: "reconciled {{.Count}} {{.Resource}}"eventTemplate can use:
OperationGroupVersionResourceNamespaceNameAPIVersionUsernameGitTarget
Username is empty whenever no actor was named, both in configured-author mode and when
attribution ran and did not resolve. The attribution-unresolved sentinel is scoped to the Git
author header and deliberately does not reach templates or message bodies, so a template
rendering {{.Username}} never has to special-case it. Use git log (or
author_kind="unresolved") to tell the two apart.
groupTemplate can use:
AuthorGitTargetCountOperations(map ofCREATE/UPDATE/DELETEcounts)Resources(slice of{Group, Version, Resource, Namespace, Name})
reconcileTemplate can use:
CountGitTargetGroupVersionResourceAPIVersionRevision
Group/Version/Resource/APIVersion name the synced type for a per-type reconcile and
Revision is the cluster resourceVersion the reconcile was pinned to. The default,
reconciled {{.Count}} {{if .Resource}}{{.Resource}}{{else}}resources{{end}}{{if .Revision}} (last resourceVersion: {{.Revision}}){{end}},
renders e.g. reconciled 6 secrets (last resourceVersion: 1331). The type and revision fields are
empty for a whole-target reconcile or a pure sweep, so guard a template that references them
(the default uses {{if .Resource}} / {{if .Revision}}) to avoid an identity-less subject.
Examples:
spec:
commit:
message:
eventTemplate: "chore: [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Name}}"spec:
commit:
message:
eventTemplate: "[{{.Operation}}] {{.Resource}}/{{.Name}} ({{.Username}})"spec:
commit:
message:
reconcileTemplate: "reconciled {{.Count}} {{.Resource}}@{{.Revision}}"Point one at a scratch branch.
spec:
gitProviderRef: {name: homelab}
branch: gitops-preview # not main
path: apps/checkoutIt commits, and you read the commits: the real files, the real resources: registrations, the real
deletes, in a diff you can review. Nothing is simulated, so nothing can be wrong about it. When you
are happy, delete the preview target and declare the real one. spec.branch is immutable, so the
two are always separate objects, and deleting the preview cannot disturb the real folder.
For inspecting a repository without a cluster at all, use the manifest-analyzer CLI.
spec.suspend is not this. It is the next section.
The panic knob. One field that stops a target writing, without deleting it and without unpicking
the WatchRule objects you would have to rebuild afterwards:
spec:
path: apps/checkout
suspend: trueA suspended target keeps its watches, keeps receiving events, and keeps scanning its folder. A valve
that also stopped looking would leave status.placement frozen at whatever the folder looked like
the moment you turned it, which is exactly when a stale answer costs the most. It plans no new
commit. Ready stays True with reason Suspended, because not writing is the configured outcome
rather than a fault; every other gate still applies, so a suspended target with a broken
GitProvider is still not ready.
status.retention is not published while a target is suspended: no resync sweeps, so nothing is
counted, and a published zero would read as "converged" when it means "not measured".
It takes effect at the next planning boundary, not instantly. Work already committed locally when you set it is still pushed, so a target suspended during a push cooldown can publish one more commit seconds later. That is deliberate: a local commit that is never pushed would sit in the operator's checkout and surface later, out of order, when you resume. Suspend is a valve on new work, not an undo.
Clearing suspend resumes from the cluster's current state on the next resync. The writes
suppressed while it was set are not replayed, so what lands is what the cluster holds then, not a
backlog of the values it passed through.
To re-read the folder now rather than on the periodic cadence, stamp the reconcile-request annotation with any value that changes:
kubectl annotate gittarget checkout \
reconcile.configbutler.ai/requestedAt="$(date -u +%FT%TZ)" --overwriteThe spelling is Flux's reconcile.fluxcd.io/requestedAt with our own group; the value carries no
meaning, only its change does.
status.placement is what the last scan learned about the folder, and it is available before
the target has ever written. It answers one question: why did a write take the shape it did, or why
was it refused? It deliberately restates nothing the spec already carries:
status:
conditions:
- type: LayoutResolved
status: "True"
reason: SingleKustomization # SingleKustomization | Ambiguous | None
message: 'render root "." governs new files; it renders ../../base, which is read-only input'
placement:
mode: KustomizeOverlay # Plain | KustomizeRoot | KustomizeOverlay
renderRoot: .
readOnlyBases: ["../../base"]
resolvedAtRevision: 9f3c1ab
resolvedAt: "2026-07-30T09:14:22Z"-
modeis how this folder is written, and it is the field that predicts the surprises:PlainKustomizeRootKustomizeOverlaya new document written written and registered in resources:same a delete file removed file removed and its resources:entry droppedsame, unless the object is inherited deleting an object the folder inherits from its base n/a n/a a $patch: deleteis authored into the overlay; nothing is removedediting a field the base owns n/a n/a authored into the overlay for images:/replicas:, refused otherwisecan kustomize refuse the write no yes, by re-rendering yes It is absent when the folder covers several render roots, along with
renderRoot: there is no single answer. -
renderRootis the kustomization directory that governs new documents, relative tospec.path;.is the folder itself. Empty forPlain, and when the folder has several roots. -
readOnlyBasesare directories the folder renders but may never write to, spelled the way the overlay's ownresources:spells them. Non-empty exactly whenmodeisKustomizeOverlay, and an edit landing on a document under one of them is what aWriteBoundaryRefusedrefusal is about. -
resolvedAtRevisionandresolvedAtdate the resolution, not the last scan. They advance when the resolution changes, not on every scan of an unchanged folder, so a timestamp well in the past means the folder's shape has been stable, rather than that scanning stopped.
The stanza carries no counters, no examples, and no copy of spec.serializeNamespace or of the
byType map. The rule is that a status field earns its place only if a reader cannot get it from
the spec in the same GET, and it varies with this folder. To preview what a target would write,
use a scratch branch (above) rather than a status field.
LayoutResolved reports the verdict. None (no kustomization governs the folder) is True and
perfectly healthy; it is the ordinary case. Only Ambiguous is False:
A target must cover exactly one kustomize render root. A GitTarget at apps/checkout in a
base-plus-overlays repository covers the base and every overlay, so a new document has no single
root to be placed into, and picking one would hand it to an environment nobody named. Such a target
reports LayoutResolved=False with reason Ambiguous, naming the roots it covers, and refuses to
place new documents (GitPathAccepted=False, reason AmbiguousLayout). Existing documents are
unaffected: they are edited where they already live. The fix is to point the target at one leaf,
apps/checkout/overlays/prod, and declare the other environments as their own GitTarget objects:
one target is one environment is one write partition.
A target removes a document from Git for one of two very different reasons, and spec.prune.mode
controls them separately:
- an explicit source DELETE event: the source cluster told the operator the resource is gone;
- a resync mark-and-sweep: a snapshot taken when a watch stream starts or restarts did not contain a resource that Git still has a document for, so its absence is inferred.
The second is only as trustworthy as the snapshot's scope. A snapshot the operator could not finish is not the risk: a failed list or watch blocks the stream and enqueues no resync at all, and a replay cut short before its initial-events bookmark enqueues nothing, so a source-cluster outage or a revoked RBAC grant currently stops a sweep rather than shrinking one.
The risk is a snapshot that is complete but gathered against the wrong scope: a watch rule narrower
than you intended, version skew, or an older controller that does not understand a newer scope field.
That snapshot is smaller than reality and indistinguishable from a converged one, and a sweep turns
it into deleted manifests. OnEvent is the defense, and it also covers the outage case in depth. Failing
closed there is a property of how the gather works today, and not a guarantee the API makes.
Revoking access to a namespace is neither of those two paths, and removes nothing. When a namespace
leaves a target's watch set (a rule deleted, a sourceNamespace narrowed, a label revoked), the
documents already mirrored from it stay in Git, whatever spec.prune.mode says, and no automatic
process removes them. That is deliberate: deleting a tenant's manifests as a side effect of a policy
edit is destructive and a typo in a selector would be enough to trigger it. Removing them is an
operator action.
| Mode | Explicit source DELETE | Resync mark-and-sweep | Use it for |
|---|---|---|---|
Never |
kept | kept | an archive or tombstone mirror that only ever gains documents |
OnEvent (default) |
mirrored | kept | mirroring observed deletes without ever inferring one |
Always |
mirrored | swept | full desired-state convergence, including cleaning up stale documents |
spec:
prune:
mode: AlwaysOmitting spec.prune means OnEvent. That applies to a GitTarget created before this field
existed as well: an upgrade never changes an existing target to a more destructive policy, and you
do not have to edit anything to be safe.
Choose Always when the folder is meant to be a faithful, converged mirror and you accept that a
bad watch scope can delete manifests. Choose Never when the folder is an audit trail.
A retained document is invisible in Git by design: nothing is written, so a retaining mirror and a
converged one look identical in the folder and in git log. Three signals report it instead, and
none of them is a failure: retention is the configured outcome, so no condition goes False for it.
$ kubectl get gittarget acme -o jsonpath='{.status.retention}'
{"mode":"OnEvent","retainedDocuments":3,"observedTime":"2026-07-21T13:20:00Z"}status.retention.retainedDocumentsis how many managed documents a converged mirror would not hold.0means a resync ran and found nothing to retain; an absentretentionblock means no resync has reported yet, which is not the same thing.modeis the effective mode the count was produced under, and the only place aGitTargetthat predatesspec.pruneshows one at all.- A throttled log line names the target, its path, and the scope (one per target folder per 10
minutes; the full detail is at
-v1). gitopsreverser_prune_retained_documents_total, labeled bygittarget_namespace,gittarget_name, andprune_mode.
status.retention covers the resync sweep only. Under Never a suppressed source DELETE is not
counted, so a Never target can report 0 while still declining to mirror deletes.
The count is refreshed when a resync runs, so it lags a change in the cluster until the next one.
Read observedTime before treating a 0 as live.
spec.prune is mutable (unlike gitProviderRef, branch, and path), so a target can be moved to
Always once its watch scope is confirmed, without recreating it. Widening it to Always re-lists
the target's watched scopes, so the cleanup runs on the edit instead of waiting for the next replay.
Tightening it applies to the next write and leaves the streams alone, which is what makes it usable
as a stop button.
Placement decides the file path for a resource that has no document in Git yet. Once a document exists, updates and deletes always edit it in place at its current location (found by manifest identity, not path), so changing placement never moves an existing file; it only affects resources created after the change.
For each new resource the operator walks this order and stops at the first that produces a path:
spec.placement.byType[<exact type>]: an explicit template for that resource's type, if you declared one.spec.placement.default: your explicit catch-all template, if you declared one.- The folder's one kustomize root: if the whole folder is governed by exactly one supported
kustomization.yaml, the new file goes beside it and is added to itsresources:list in the same commit. This is not a guess about your conventions: a file that kustomization cannot reach would never be rendered, so it would be in Git and applied by nothing. Two or more supported kustomizations is ambiguous, and the operator declines rather than picking one. - Built-in canonical path:
{namespace}/{group}/{resource}/{name}.yaml, namespace first, the group omitted for core resources, no version segment,_cluster/in place of the namespace for cluster-scoped resources (an illegal namespace name, so it can never clash with a real one), and a.sops.yamlsuffix for sensitive resources.
The operator does not read the rest of your repository to place a file. Where you keep the other ConfigMaps does not decide where a new ConfigMap goes: a layout the ladder above cannot derive is one you declare. So editing the repository never changes where the operator writes next.
Every placement is counted, labeled with the GitTarget and the resource type, so "does this folder
need a byType line?" is a query rather than a folder inspection:
# types landing on the built-in path, per target: each is a candidate for a byType entry
sum by (gittarget_namespace, gittarget_name, group, version, resource) (
increase(gitopsreverser_placements_total{source="canonical"}[24h])
)
source="by_type" is the exact rule you wrote for that type, source="default" is your catch-all
answering for a type no byType line names, and source="kustomize_root" is a folder whose own
structure answered. None needs attention, though a default series climbing where you expected
by_type is worth a look: it means a rule you thought you had written is not matching. Two
companions matter as much:
gitopsreverser_placement_refusals_total{reason}counts resources the operator did not write: a template that escapesspec.path(invalid_path), a sensitive resource whose path is already taken (sensitive_append), a plaintext resource routed at an encrypted file (plaintext_onto_encrypted), and two resources of mixed sensitivity landing on one new file (mixed_sensitivity_new_file). Any of these means a policy to fix; the write is retried after you fix it.gitopsreverser_placement_kustomization_entries_total{outcome="failed"}counts new files whoseresources:entry could not be added. The file is committed and kustomize will never build it, so this is the one that looks fine in the folder and is not.
See interpreting-metrics.md for the full label sets.
If the kustomization.yaml governing the destination sets namespace:, and it names the resource's own
namespace, the new file omits metadata.namespace, because the build supplies it and every other document in
that folder omits it too. This applies to a path you declared as much as to the kustomize-root
fallback. When the transformer names a different namespace the namespace is written explicitly:
leaving it out would hand the namespace to kustomize and render a different object than the one being
mirrored.
Set spec.placement when the layout you want is neither of the two things the operator can work out
for itself (a folder's single kustomization root, or the canonical path). For example, a bundle every
ConfigMap joins, or a per-namespace layout:
spec:
placement:
byType:
v1/configmaps: "{namespace}/configmaps.yaml" # bundle every ConfigMap of a namespace into one file
v1/secrets: "{namespace}/secrets/{name}.yaml" # one file per Secret
default: "{namespaceOrCluster}/{group}/{resource}/{name}.yaml"byTypemaps an exact[group/]version/resourcekey (core resources omit the group, e.g.v1/configmaps; grouped resources include it, e.g.apps/v1/deployments) to a path template.defaultis the template for any type with nobyTypeentry. Omit it to fall through to the kustomize-root step and then the built-in path.- Templates are small brace-variable path templates (see the table below), validated statically as
part of the
Validatedgate: an unknown variable, a path that escapesspec.path(a leading/or..), or a non-.yaml/.ymlsuffix fails the target before any write.
Every value is sanitized for use as a single path segment. An empty segment (an omitted variable,
e.g. {group} for a core resource) is dropped from the final path, so {group}/{resource}/{name}.yaml
renders configmaps/app.yaml, not /configmaps/app.yaml. Example values are for an apps/v1 Deployment
named api in namespace team-a:
| Variable | Renders | Example |
|---|---|---|
{name} |
resource name | api |
{namespace} |
the resource's namespace; empty for a cluster-scoped resource | team-a |
{namespaceOrCluster} |
the namespace, or the literal _cluster for a cluster-scoped resource |
team-a (a Node → _cluster) |
{resource} |
plural resource name | deployments |
{group} |
API group; empty for core resources | apps (a ConfigMap → empty) |
{groupPath} |
the API group as a path segment; equivalent to {group} today (the empty core-group segment is dropped either way) |
apps |
{version} |
API version | v1 |
{apiVersion} |
manifest apiVersion: group/version, or version alone for core |
apps/v1 (a ConfigMap → v1) |
{kind} |
manifest kind | Deployment |
{scope} |
namespaced or cluster (a readable label, not a namespace-position value) |
namespaced |
{sensitiveSuffix} |
.sops.yaml for a sensitive resource, .yaml otherwise |
.yaml (a Secret → .sops.yaml) |
{namespace}vs{namespaceOrCluster}, the one to get right. For a cluster-scoped resource{namespace}is empty, so its whole path segment vanishes: a template{namespace}/{resource}/{name}.yamlrendersclusterroles/admin.yamlfor a ClusterRole (no scope folder at all). Use{namespaceOrCluster}when a single template must also place cluster-scoped resources; it keeps a stable_cluster/segment (_cluster/clusterroles/admin.yaml) so namespaced and cluster-scoped resources stay cleanly separated.{scope}is a descriptor (cluster/namespaced), not a substitute, so don't use it as the folder for cluster resources.
Sensitivity is enforced by the operator whatever path is chosen. A Secret (and any operator-configured
sensitive type) is always written encrypted, is never appended to an existing file, and is never
co-mingled with a plaintext document. Two consequences for your templates:
- A
byTyperoute for a sensitive type must be identity-complete: it must contain{name}and a scope such as{namespace}, so two of them can never collide onto one file. - A bundling
defaultthat is not identity-complete (e.g."all.yaml") is rejected unless every sensitive type has its own identity-completebyTypeentry, so a Secret can never fall through into a shared file. If an operator-configured sensitive type still reaches such a path at write time, that resource is skipped fail-safe (logged and counted in the resync summary asplacementSkipped) rather than written unsafely. It is not surfaced as a dedicated status condition today.
spec.serializeNamespace and spec.placement.useKustomize answer two independent questions. Both
are optional; a target that sets neither infers the answer per document. Start here, then read the
section for whichever you set.
Question 1: where does the namespace of a mirrored object live?
| You want | Set | What the folder looks like |
|---|---|---|
Each document to say which namespace it is in. Someone can kubectl apply -f the folder and land everything where it came from |
serializeNamespace: true |
every namespaced document carries metadata.namespace |
The folder to be installable into a namespace chosen at install time, by a Flux targetNamespace, an Argo destination.namespace, or a kustomization.yaml you wrote |
serializeNamespace: false |
no document carries metadata.namespace, and nothing in the folder pins one |
| Neither claim, because the folder is a tree of nested kustomize roots that each supply their own namespace | leave it unset | each document is resolved against the root governing its own path |
Question 2: does the operator maintain this folder's kustomization.yaml?
| You want | Set | What the operator does |
|---|---|---|
| The folder to become a kustomize folder, including from empty | placement.useKustomize: true |
creates kustomization.yaml at spec.path if there is none, adopting the files already there, and registers every new document in it |
Plain files, or a kustomization.yaml only you create |
leave it unset | writes the document; registers it in a root that already governs it, and creates nothing |
Registering a new file with a kustomization that already governs it happens either way. That is
not a setting: a file no resources: list names is a file kustomize never builds.
The three combinations in practice:
| Shape | Spec | Who supplies the namespace |
|---|---|---|
| A mirror you can apply back | serializeNamespace: true, no useKustomize |
the documents |
| A portable artifact | serializeNamespace: false + useKustomize: true |
whatever installs the folder |
| An existing kustomize repository | leave both unset | the kustomization.yaml files already there |
One combination to avoid: serializeNamespace: false on a folder nothing installs. The documents
carry no namespace and nothing supplies one, so kubectl apply -f lands them all in default.
Nothing can detect that for you, because the installer lives outside the repository. See
why nothing checks the supplier.
A path decides where a file sits; it cannot decide what is inside it. spec.serializeNamespace
does: whether the committed document carries its own metadata.namespace.
spec:
path: apps/checkout
serializeNamespace: false| Value | What is written |
|---|---|
| omitted (default) | inferred per document, which is today's behavior |
true |
every namespaced document carries metadata.namespace |
false |
no document carries it; something outside the folder supplies it |
It governs every write the target makes, not only the first one. That is why it sits at the top
level of the spec rather than inside spec.placement, which decides where new documents go and
never moves one already written. It applies to namespaced resources only: a ClusterRole has no
namespace, so the field is ignored for it rather than being an error.
Omitted is not the same as false, and it is the right answer more often than either. Inference
omits metadata.namespace only when the kustomization governing that document's path sets
namespace: to this resource's own namespace, and writes it explicitly otherwise, because
omitting it anywhere else would hand the document to a different namespace than the object it
mirrors. Leave the field unset for a folder that is legitimately non-uniform, such as a tree of
nested kustomize roots each supplying its own namespace: inference resolves every document against
the root governing its own path, which no single folder-wide value can do.
The two explicit values are for the two shapes people declare:
truefor a flat folder applied directly. Nothing downstream supplies a namespace, so a document without one is ambiguous. It also keeps the document portable: it means the same thing pasted anywhere.falsefor a folder whose namespace comes from outside it: a FluxKustomization'sspec.targetNamespace, an Argo CDApplication'sspec.destination.namespace, or a hand-writtenkustomization.yamlin the folder that setsnamespace:. A root the operator creates never sets one.
Nothing checks that the supplier exists, and nothing can. For a raw namespace-free folder the supplier lives in the cluster that consumes the repository, and there may be more than one of them: two deployers may land the same folder in two different namespaces, both correctly. That portability is the point of the shape, so a rule demanding proof inside the folder would report a fault against a folder doing exactly what it was built for.
One thing is checked, and it refuses. An explicit serializeNamespace: false admits exactly
one source namespace. A namespace-free document takes its namespace from a single supplier, so
two source namespaces reaching the folder contradict the setting itself, and the failure is silent:
shop/config and billing/config both resolve to one config.yaml whose bytes name no namespace,
so they are not two documents that collide but one document two live objects take turns
overwriting. A second WatchRule bringing another source namespace to such a target is refused with
GitPathAccepted=False, reason MultipleSourceNamespaces. A rule naming sourceNamespace: "*" is
refused too, without enumerating anything, because a wildcard cannot be shown to be one namespace.
The set counted is the target's own namespace plus the explicit rules[].sourceNamespace of every
WatchRule pointing at it. It is unrelated to the ClusterProvider grants, which answer who may
write here rather than what the folder means.
Inference is never fenced this way. A folder that is truly multi-namespace and namespace-free is what leaving the field unset is for.
What the render check does with the namespace. Every write is compared against the live object
it mirrors, and metadata.namespace is the one field allowed not to match. It is ignored only when
the folder renders the document with no namespace at all, which is serializeNamespace: false with
nothing in the folder supplying one. A kustomization.yaml that declares namespace: makes a
concrete claim, so it is still compared: a root declaring namespace: shop rendering an object that
lives in billing is a relocation, and it is refused whatever serializeNamespace says.
spec:
path: apps/checkout
serializeNamespace: false
placement:
useKustomize: trueIt controls exactly one thing: what happens when no kustomization.yaml governs the path a new
document lands at.
| A kustomization governs the path | Nothing governs the path | |
|---|---|---|
omitted / false (default) |
the new file joins its resources: list |
the file is written and nothing else is touched |
true |
the new file joins its resources: list |
a kustomization.yaml is created at spec.path, and the file joins it in the same commit |
Registering a new file with the kustomization that already governs it is not what this flag controls. It happens in both rows, because a file no kustomization lists is a file kustomize never builds. The flag is only about the empty case, which is what makes an empty repository bootstrappable.
A created root adopts the whole folder. Its resources: lists every managed document
already in the folder alongside the new one, at the paths those files already have. Nothing is
moved, rewritten or re-encoded. A root naming only the new file would leave every other file
sitting in Git and out of every render: the moment a consumer ran kustomize build against the
folder, they would stop being applied, with nothing to show what happened.
A folder that already has a render root never gains a second one, and a document it would not
render is refused. If a byType or default template puts the new document somewhere the
existing root does not govern, the placement is refused with GitPathAccepted=False, reason
UnrenderedPlacement. Two render roots in one target's folder is the ambiguous case, and an
ambiguous folder stops accepting new documents entirely; committing the file unregistered instead
would leave a document sitting in Git looking mirrored while nothing applies it. The fix is a
template that keeps its documents inside the root, or a target pointed at the folder that root
governs. Without useKustomize nothing changes: a target that made no claim about kustomize keeps
its current behavior.
The created root carries no namespace:. It is an apiVersion, a kind and the resources:
list, and nothing else. serializeNamespace: false says the artifact does not encode its deployment
namespace, and creating a root must not re-encode it one file up where an installer cannot override
it, so the namespace still comes from the documents (serializeNamespace unset or true) or from
whatever installs the folder. The reasoning, and the four other answers that were considered, is in
design/created-root-namespace.md. That last line is the point of the pairing with
serializeNamespace: false: on
an empty folder there is nothing to infer from, and the operator owns the file the omission depends
on, so the omission is provable rather than trusted.
The new document is placed beside the root, exactly as it would be beside a root that was
already there, unless a byType or default template says otherwise. A declared template still
decides the path; the created root lists the document wherever the template put it.
The name says less than the field does, so one reading is worth ruling out: useKustomize: false
does not mean "leave kustomize alone". If a folder's kustomization.yaml must never be touched, do
not point a GitTarget at that folder. A kustomization above spec.path is never edited (the
ancestor walk stops at the write jail), so rooting the target lower is the way to say it.
Core Kubernetes Secret resources always use the encrypted Git write path. For a Secret-shaped
custom resource such as CozyStack tenantsecrets, add the resource type to the controller startup
values:
controllerManager:
additionalSensitiveResources:
- core.cozystack.io/tenantsecretsEntries are resource for the core API group or group/resource for grouped APIs. The match ignores
API version, so a served CRD version change does not change the sensitive classification. The custom
resource still needs a GitTarget with spec.encryption configured before Git writes can succeed.
A target path may contain kustomization.yaml files. The operator retains them as build directives
(it never sweeps them) and understands a deliberately small, round-trippable subset:
namespace:+resources:/bases(local files and directory bases): a namespace-less resource file inherits its namespace from the kustomization that references it, andmetadata.namespaceis kept out of the file on write.images:andreplicas:overrides: a live change produced by an override entry (an image tag, name, or digest pinned byimages:, or a replica count pinned byreplicas:(includingkubectl scale) is written back to that entry, preserving comments, and the source manifest keeps its bytes. Only fields the entry already declares are updated; the operator never adds or removes entries. Note that one entry is a shared knob, exactly as in kustomize itself: updating it affects every resource in the build whose image matches.
Two kustomize shapes beyond that subset are supported without being authored:
- A path-based strategic-merge
patches:entry is tolerated as read-only build context: the folder is accepted and what it renders is mirrored, but nothing is ever written into a patch file, and an edit to a field the patch owns is refused per object (not per folder). - An overlay that reads a base outside its own folder (
resources: [../../base]) is rendered by reading that base as read-only context; writes stay insidespec.path, and an image/replica edit lands on the overlay's own entry.
Everything else outside the modeled subset refuses the whole target path before anything is
written: inline or JSON6902 patches and the deprecated patchesStrategicMerge/patchesJson6902
spellings, generators, components, Helm fields, replacements, transformers,
namePrefix/nameSuffix, remote bases, and images:/replicas: values that do not parse (those
would fail kustomize build too). A refusal is loud: the target reports GitPathAccepted=False,
Stalled=True, and Ready=False with reason UnsupportedContent until the path is cleaned up.
Two situations fall back to plain in-place editing of the source manifest instead of refusing:
a resource file reachable from more than one render root with differing override chains
(ambiguous, because the operator will not guess which chain governs), and a live change an entry cannot
express (for example a removed digest, or two containers demanding different values from one
entry). These fallbacks are recorded as store diagnostics, visible in the analyzer CLI and, for
the running operator, in the logs at debug verbosity (manifest store diagnostic).
For design details and the exact boundary, see design/support-boundary/finished/images-and-replicas-edit-through.md.
WatchRule is the namespaced watcher: it selects namespaced resources on its GitTarget's
source cluster and writes them to that GitTarget. Scope is carried by the rule kind: a WatchRule
never selects cluster-scoped types, and a ClusterWatchRule never selects namespaced ones.
Status uses ResourcesResolved for selector resolution, StreamsRunning for source-watch readiness,
GitTargetReady for the referenced target's write readiness, and SourceNamespaceAuthorized for the
source-namespace gate below. A rule can have StreamsRunning=True and still remain Ready=False
when its GitTarget reports GitPathAccepted=False.
The important fields are:
spec.gitTargetRef.name: target to write tospec.rules: one or more resource-match rulesspec.rules[].sourceNamespace: the source-cluster namespace that item watches; omitted means the rule's own namespace
Set spec.rules[].sourceNamespace to mirror a namespace other than the one the WatchRule lives in
which is the case a shared config plane needs, where a tenant's configuration namespace and their source
namespace cannot share a name. It sits on the rule item, beside the resource selector it applies to,
so one WatchRule can follow different resource types in different namespaces.
rules[].sourceNamespace |
Meaning |
|---|---|
| omitted | the WatchRule's own namespace |
| an exact name | one source namespace |
"*" |
every namespace the source credential can read, as one cluster-wide watch |
Naming anything other than the rule's own namespace, including "*", is authorized by two
things, both of which must hold:
- the
GitTarget's namespace is admitted by itsClusterProvider'saccessFrom; and - that
ClusterProvidersetsallowAnySourceNamespace: true.
There is no third condition on the GitTarget. What a target may read from its source cluster is
bounded by that cluster's RBAC for the provider's credential: a request these two conditions permit
still fails with a clean 403 if the credential cannot read the namespace.
apiVersion: configbutler.ai/v1alpha3
kind: WatchRule
metadata:
name: repo-config
namespace: tenant-acme
spec:
gitTargetRef:
name: acme
rules:
- resources: [configmaps] # omitted → tenant-acme, this rule's own namespace
- resources: [secrets]
sourceNamespace: repo-config # one admitted source namespace
- resources: [deployments]
sourceNamespace: "*" # every namespace the credential can read"*" is one cluster-wide list and one cluster-wide watch per matched type, not one of each per
namespace, so its cost does not grow with the cluster. It is bounded by the source credential's RBAC
and by nothing else, which is why it is refused outright while allowAnySourceNamespace is false.
A "*" item and a named-namespace item for the same type are peers, not duplicates: each rule
carries its own operations filter, so a target holding both runs two streams over overlapping
objects. That is correct rather than something to tune away.
The outcome for all items is aggregated into one SourceNamespaceAuthorized condition, also shown by
kubectl get watchrules -o wide. A denied explicit name refuses the whole WatchRule
(Ready=False, Stalled=True, no streams) rather than silently trimming that item and mirroring
part of what you asked for; the message names the failing item by index and by what it selects.
Authorization is re-evaluated on every reconcile, so withdrawing allowAnySourceNamespace revokes a
running rule rather than only affecting new ones.
This changes only which namespace is watched. Git placement always follows each mirrored
object's own namespace, so the rule above writes secrets under repo-config/…, not tenant-acme/….
A namespace allow-list cannot partition cluster-scoped objects, which have no namespace. A
ClusterWatchRule receives every such object its source credential can read. If a tenant must not
see another tenant's cluster-scoped objects, give each tenant its own ClusterProvider and
credential, so that credential's RBAC is the boundary.
Each entry in spec.rules is a logical OR. A resource matching any rule is watched. The rule fields
are:
operations:CREATE,UPDATE,DELETE, or*; omitted means all operations.apiGroups:""for the core group,*for all groups, or omitted to resolve the named resource across the served API surface.apiVersions: a served version such asv1; omitted means the preferred served version.resources: plural resource names such asconfigmaps,secrets, or*.
Subresources such as deployments/scale are not valid rule resources. GitOps Reverser mirrors
top-level resources; selected subresource effects are handled separately by the controller.
Example:
apiVersion: configbutler.ai/v1alpha3
kind: WatchRule
metadata:
name: example-watchrule
namespace: default
spec:
gitTargetRef:
name: example-target
rules:
- operations: [CREATE, UPDATE, DELETE]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["configmaps", "secrets"]Use WatchRule for every namespaced resource, whether or not it lives in the GitTarget's own
namespace.
ClusterWatchRule is the cluster-scoped variant. Use it for cluster-scoped resources such as
nodes, clusterroles, or CRDs. It has no scope choice and no source-namespace selection: to mirror
namespaced resources across namespaces, use a WatchRule with
rules[].sourceNamespace.
Because it is cluster-scoped, its gitTargetRef must include the namespace of the referenced
GitTarget, and that namespace must be admitted by the target's ClusterProvider.
Example:
apiVersion: configbutler.ai/v1alpha3
kind: ClusterWatchRule
metadata:
name: cluster-rbac
spec:
gitTargetRef:
name: example-target
namespace: default
rules:
- operations: [CREATE, UPDATE, DELETE]
apiGroups: ["rbac.authorization.k8s.io"]
apiVersions: ["v1"]
resources: ["clusterroles", "clusterrolebindings"]Cluster-scoped objects have no namespace, so no namespace policy bounds a ClusterWatchRule at all:
it is intentionally cluster-global, limited only by its source credential's Kubernetes RBAC. Use this
sparingly. It grants the widest reach of any rule kind and usually belongs to cluster-admin-managed
setups.
CommitRequest is a one-shot "save now" signal for a same-namespace GitTarget. It does not create
or change watch rules. Instead, it asks the branch worker to finalize a matching open commit window
for the request's author instead of waiting for GitTarget.spec.commit.window.
The important fields are:
spec.gitTargetRef.name: target whose open window should be finalizedspec.message: optional verbatim commit messagespec.closeDelaySeconds: optional 0-300 second delay before the open window is closed, after the request author is known, an extra collect window
Example:
apiVersion: configbutler.ai/v1alpha3
kind: CommitRequest
metadata:
name: save-now
namespace: default
spec:
gitTargetRef:
name: example-target
message: "save default/example-target"
closeDelaySeconds: 2The entire spec is immutable. Create a new CommitRequest for each save attempt.
Progress and outcome are reported through kstatus-compatible conditions (no phase string).
kubectl get commitrequest surfaces Ready, AuthorAttributed, and Pushed; kubectl wait --for=condition=Ready blocks until the request settles:
- Ready (summary):
Trueonce the request reached a non-error terminal outcome. TheReadycondition'sreasonsays which:Committed(a commit was pushed;status.branch/status.shaset), or a benign no-commit:NoWindowInGrace,WindowMismatch, orAlreadyPresent. A failed finalize isReady=Falsewith reasonFinalizeFailed. - Reconciling / Stalled: the kstatus progress/blocked pair.
Reconciling=Truewhile the request is finalizing or waiting throughcloseDelaySeconds;Stalled=Truewhen the finalize failed and needs attention (kstatus reports the object Failed). - AuthorAttributed:
Truewith reasonAttributedFromAdmissionwhen the internal commands admission webhook captured the request submitter.Falsewith reasonCommitterFallbackmeans capture ran but no admission record exists;Falsewith reasonAuthorCaptureDisabledmeans capture is not configured. Neither is a failure. The request then claims no actor and can attach only to an unnamed watch window, whose Git author remains either the configured committer or the explicit unresolved author according to the watch attribution outcome. - Pushed:
Trueonce the commit is in the remote repository.
Object state comes from Kubernetes watch, not from audit. Audit is an optional attribution lookup:
kube-apiserver posts audit events to a named path, /audit-webhook/<audit-route>, where the route is
ClusterProvider.spec.attribution.auditRoute and defaults to the provider's own name. The
operator extracts a minimal attribution fact from each (auditID, user, verb, resourceVersion, GVR, namespace, name, UID,
status, timestamps) and appends it to a per-type fact log: one append per type per request, not one
per event. The watch side follows the log for the types it is watching, holds the facts in a bounded,
TTL'd in-memory index, and attaches the commit author to each watch event by matching a fact within a
bounded grace window. Redis also stores per-watch resume cursors, so short reconnects can resume a
normal watch from the last processed resourceVersion when the apiserver can still serve that history.
Named ingress is currently authenticated to the shared audit CA and gated on the provider name existing; it does not yet bind a particular client certificate to that provider. Do not use one shared audit client credential to attribute several independently administered source clusters. A deployment that needs that boundary should keep sources isolated until provider-bound ingress authentication is shipped.
Most audit streams represent one source cluster and must use a named route, including
/audit-webhook/default. Some control planes emit one shared stream for several logical clusters.
For that shape, the bare /audit-webhook endpoint is available only when the configuration model's
annotation key is set:
attribution:
auditRouteAnnotationKey: example.io/source-clusterWhen this option is set, the receiver reads example.io/source-cluster from each event. Its value is
the audit route the event belongs to, so events in the same batch may route to different
partitions. A ClusterProvider joins a route by setting spec.attribution.auditRoute to the same
value; it defaults to the provider's own name.
The bare endpoint never guesses a route. Rejection happens at two levels:
| Situation | Result |
|---|---|
A request reaches /audit-webhook while auditRouteAnnotationKey is unset |
The whole request is rejected with 400. The bare endpoint is not enabled, so a producer posting to it is misconfigured. |
| An event carries no annotation | That event is rejected: it produces no attribution fact and is never credited to a fallback route. The request still returns 200, so correctly-annotated events in the same batch are kept. |
An annotation naming a route no ClusterProvider has declared is not rejected. The route is a
partition name rather than a claim about an object, so the fact is stored and expires unread if
nothing joins it. That keeps ingestion free of Kubernetes reads, and lets a provider created after
its events started flowing pick them up.
The second row is a per-event rejection rather than a per-request one on purpose. A shared stream is
heterogeneous by definition, so failing the whole batch would discard events that routed correctly and
leave the apiserver retrying a batch that can never succeed. Rejected events are counted and logged, so
a producer that is not stamping the annotation is visible rather than silent. If that count rises,
point the producer at /audit-webhook/<audit-route> instead.
Use an annotation that the producing control plane sets consistently as source metadata. This is routing metadata only: it keeps the audit fact and the watch event in the same source-cluster partition, so a user from one logical cluster can never be credited for a matching object in another.
Valkey/Redis is optional: when --redis-addr is set, watch resume cursors are stored so restarts
pick up where they left off; when left empty, watches cold-replay from scratch on restart instead. The
Helm chart defaults to configured-author (attribution.enabled: false): the audit webhook is
unused and every mirrored-resource commit is authored by the configured committer.
Attribution needs a fact transport, which is not the same as needing Redis:
--author-attribution-transport |
What carries the facts | --redis-addr |
|---|---|---|
redis (default) |
Redis streams, one per (audit route, type) | required |
memory |
an in-process ring buffer | may be empty |
Choose redis for anything you would call production. It is the only transport whose facts survive a
restart, and the only one that can reach a second process, so it is what an eventual HA topology needs.
Choose memory for a single-pod install where running a Valkey StatefulSet to name commit authors
is out of proportion to the benefit. The cost is worth stating plainly: in-memory facts do not survive a
restart, so events in flight across one lose their author. That is already true of any restart today,
which is what keeps the difference small.
memory is refused at startup with more than one replica. The transport only carries facts within
one process, so with two replicas an audit request answered by one pod leaves a watch running on the
other with nothing to join, and every commit through it would be authored attribution-unresolved with
nothing saying why. The chart passes replicaCount in for this check.
queue:
redis:
addr: "valkey:6379"
auth:
existingSecret: "valkey-auth"
existingSecretKey: "password"Every key this operator writes is rooted at queue.redis.keyPrefix (--redis-key-prefix, default
gitops-reverser). Give each reverser its own prefix when several share one Redis/Valkey: --redis-db
separates only 16 logical databases, and one reverser per tenant or per branch environment passes that
long before it reaches any real Redis limit.
When attribution is enabled, these flags tune the join:
-
--author-attribution-ttl(default10m): how long an attribution fact is retained waiting for the matching watch event to join it. -
--author-attribution-grace(default3s): bounded per-event wait for a matching audit fact before a watch event ships authored by theattribution-unresolvedsentinel. Note the delivery floor: the apiserver's own--audit-webhook-batch-max-waitdelays every fact by up to that much, so a grace at or below it will lose actors systematically. The wait ends the moment the fact arrives, so a generous grace costs latency only when a fact never comes.A removal is the exception, and it is worth budgeting for. It waits for evidence about the DELETION rather than settling for the object's last write, so an object edited by one person and deleted by another is credited to the deleter rather than to the editor. Sometimes no delete fact ever arrives, most often because the cluster's audit policy excludes the type (which is true of every type in the recommended policy's runtime-noise list). In that case the removal spends the whole grace before naming the last writer, which is the same answer it would have given immediately. Measured across one e2e run, where the grace is 10s, the cost lands on exactly that case and nowhere else: a removal that finds its delete evidence resolves in about 70ms, while one that never does averages about 3.1s before falling back. Creates and updates do not consult the deletion tiers at all. The watch shard is single-threaded, so the wait also delays the events queued behind it on the same
(GitTarget, type, scope). Lowering this flag bounds both directly.Watching a type your audit policy excludes costs more than attribution. Every removal on such a type spends the full grace before shipping as the committer, because the fact it is waiting for was never recorded. If a watched type's commits are consistently committer-authored, check the audit policy before anything else: a type in the policy's
level: Nonelist can never be attributed, and no operator-side setting changes that. -
--author-attribution-max-facts-per-type(default4096) and--author-attribution-max-facts(default65536): how many facts the in-memory index holds, per type and in total, evicted oldest-first. Per-type is the fair cap: a burst on one noisy type must not evict every other type's facts. Evictions are counted onattribution_fact_index_evictions_total{reason}. -
--author-attribution-collection-window(default30s): how long after adeletecollectiona removal in its scope may still be credited to it. It only has to cover audit batching plus clock skew, because the removal is attributed at delete-request time, so finalizers do not stretch it. Raising it widens the risk of crediting an unrelated delete to the collection's actor. -
--author-attribution-collection-uid-cap(default10000): how many object UIDs adeletecollectionfact carries before the set is dropped and the join falls back to scope matching. The fallback is already correct, so this only decides how often the precise path is taken; drops are counted onattribution_collection_without_uidset_total{reason}.
A matched actor is always named by its own username, humans and service accounts alike (e.g.
system:serviceaccount:flux-system:kustomize-controller); there is no option to collapse service
accounts to the committer.
attribution:
transport: "redis"
ttl: "10m"
grace: "3s"
maxFactsPerType: 4096
maxFacts: 65536
collectionWindow: "30s"
collectionUIDCap: 10000Keep using the root README quickstart when you want the fastest first commit.
The chart's quickstart values create a starter GitProvider, GitTarget, and WatchRule for you.
The starter GitTarget writes under live-cluster by default. Override
quickstart.gitTarget.path=. only when you want the starter target to own the repository root.
Move to hand-managed resources when you want:
- more than one
GitTarget - more than one watch rule
- cluster-scoped watching with
ClusterWatchRule - ad hoc save requests with
CommitRequest - direct control over
GitProvider.spec.commit - direct control over encryption settings
The chart value reference for the starter quickstart block lives in
charts/gitops-reverser/README.md.
- commit-signing.md for signing behavior on Git hosting platforms
- github-setup-guide.md for GitHub auth setup
- azure-devops-getting-started.md for Azure DevOps auth and setup
- sops-age-guide.md for
GitTarget.spec.encryption