This guide explains the configuration objects that drive gitops-reverser after the install steps in the quickstart.
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 saves: it closes its author's commit window on its own timers, with its own message if it has oneFluxTriggerandArgoCDTriggerhold the FluxKustomizationor Argo CDApplicationthat applies a folder back, so GitOps Reverser decides when it applies
The chart's optional quickstart values are a convenience layer that creates starter
instances of those same resources.
For a first trial, use the quickstart. 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
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 a save: your writes in one commit, on the request's timers rather than the target's, and optionally with your message. - Only when Flux or Argo CD also applies that same folder back to the cluster: hold its
KustomizationorApplicationwith an apply trigger, name the trigger on theGitTarget, and give theGitProvidera push receiver, so GitOps Reverser decides when Git is applied. See who applies this folder and bi-directional GitOps. Flux and Argo CD applying other folders need nothing.
That means one repository connection can back multiple targets, and one target can be fed by multiple watch rules.
ClusterProvideris not one of those steps. Installing created thedefaultone, and that is the source cluster aGitTargetmirrors from when it omitsspec.clusterProviderRef. Write your own only to mirror a second, remote cluster.
Ready-made manifests for all nine objects live in config/samples/. They
cross-reference the same example-provider / example-target names, so the set can be applied
together and edited down, rather than assembled from the fragments below.
Every field not marked required may be omitted. Sections linked from the tables explain the fields that need more than a line.
| Field | Default | What it does |
|---|---|---|
url |
required | Repository URL, SSH or HTTPS |
allowedBranches |
required | Branches a GitTarget may write. A target naming any other is rejected |
accessFrom |
no other namespace | Other namespaces whose GitTargets may write through this provider, by name or label selector. Its own namespace is always admitted |
secretRef |
anonymous access | Secret holding the write credential. See the credentials Secret |
knownHostsRef |
the Secret's own known_hosts |
Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification fails closed either way |
commit.committer |
the operator's built-in identity | Name and email written as the commit committer. See committer identity |
commit.signing |
off | SSH commit signing. See commit signing |
receiver |
off | Push webhook endpoint, so the Git host can say somebody pushed. See receiving push notifications |
Every field is immutable: to change one, delete the GitBranch and create a new one.
| Field | Default | What it does |
|---|---|---|
gitProviderRef |
required | The GitProvider, in this namespace, whose repository and credential write this branch |
branch |
required | Branch to write. Must be in the provider's allowedBranches. Only one GitBranch holds a repository branch; see who writes a repository branch |
parentBranch |
the remote's default branch | Branch a branch the remote does not have yet is created from. Only read, so it need not be allowed. See starting the write branch from another branch |
| Field | Default | What it does |
|---|---|---|
kubeConfig |
the operator's own cluster | Points at a remote source cluster instead of the one the operator runs in |
accessFrom |
no namespace | Deny-by-default list of control-cluster namespaces that may reference this provider. Omitted, none may; an empty selector: {} admits every namespace |
allowCrossNamespace.sources |
false |
Whether rules may watch namespaces other than their own, * included. See cross-namespace grants |
allowCrossNamespace.appliers |
false |
Whether triggers may name a Kustomization or Application in another namespace than their own |
attribution.auditRoute |
the provider's own name | Audit route this provider joins. See audit route |
client.qps |
20 |
Client rate limit for this source cluster. Ignored when kubeConfig is omitted |
client.burst |
30 |
Client burst for this source cluster. Ignored when kubeConfig is omitted |
The destination fields are immutable: to move a target, delete it and create a new one.
| Field | Default | What it does |
|---|---|---|
gitBranchRef |
required | The GitBranch this target writes: its own namespace, or namespace names one whose GitProvider's accessFrom admits it |
path |
required | Folder within the repository. . targets the root; empty is rejected |
clusterProviderRef |
{"name":"default"} |
Source cluster to mirror from. The default names a ClusterProvider called default |
commit.window.idleTimeout |
5s |
Close a commit window after this much silence. 0s commits every write on its own. See the commit window |
commit.window.maxDuration |
1m |
Close a commit window this long after it opened, however much keeps arriving |
commit.message |
the built-in templates | How commits are phrased. See commit messages |
placement.byType |
the built-in path | Per-type path templates for new documents. See where new resources are written |
placement.default |
the built-in path | Catch-all path template for types with no byType entry |
placement.useKustomize |
off | Keep the folder a kustomize folder. See keeping the folder a kustomize folder |
serializeNamespace |
inferred per document | Whether a committed document carries its own metadata.namespace. An explicit false admits exactly one source namespace. See whether documents carry their namespace |
prune.mode |
OnEvent |
Which deletions reach Git. See deletion policy |
applyTriggerRef |
the applier applies on its own schedule | The FluxTrigger or ArgoCDTrigger, in this namespace, holding the applier of this folder, which GitOps Reverser then asks to apply. See who applies this folder |
onRefusal |
Ignore |
What happens to an edit with no legal destination. See reverting a refused edit |
encryption |
off | SOPS + age encryption for sensitive resources |
suspend |
false |
Stop writing without deleting the target. See stopping a target from writing |
| Field | Default | What it does |
|---|---|---|
gitTargetRef |
required | The GitTarget these rules feed |
rules[].resources |
required | Plural resource names to watch |
rules[].apiGroups |
resolved from the resource name | API groups to match. [""] is the core group. Omitted resolves the named resource across the served surface, and selects nothing when more than one group serves that name |
rules[].apiVersions |
the preferred served version | API versions to match. ["*"] watches every served version |
rules[].sourceNamespace |
the rule's own namespace | Namespace to watch in the source cluster, or *. See watching a different source namespace |
rules[].objectSelector |
every object | Label selector the API server applies to the item's LIST and WATCH. See selecting objects by label |
The same shape as a WatchRule, for cluster-scoped types. It selects no namespaces, so it has no sourceNamespace.
| Field | Default | What it does |
|---|---|---|
gitTargetRef |
required | The GitTarget these rules feed |
rules[].resources |
required | Plural cluster-scoped resource names to watch |
rules[].apiGroups |
resolved from the resource name | API groups to match. Omitted resolves the named resource across the served surface, and selects nothing when more than one group serves that name |
rules[].apiVersions |
the preferred served version | API versions to match. ["*"] watches every served version |
rules[].objectSelector |
every object | Label selector the API server applies to the item's LIST and WATCH. See selecting objects by label |
The references are immutable: to hold another Kustomization, delete the trigger and create a new one.
| Field | Default | What it does |
|---|---|---|
clusterProviderRef |
{"name":"default"} |
The ClusterProvider whose cluster holds the Kustomization. Its accessFrom must admit the trigger's namespace |
kustomizationRef |
required | The Kustomization that applies the folders of the GitTargets naming this trigger. See who applies this folder |
The references are immutable: to hold another Application, delete the trigger and create a new one.
| Field | Default | What it does |
|---|---|---|
clusterProviderRef |
{"name":"default"} |
The ClusterProvider whose cluster holds the Application. Its accessFrom must admit the trigger's namespace |
applicationRef |
required | The Application, with automated sync off, that syncs the folders of the GitTargets naming this trigger. See who applies this folder |
| Field | Default | What it does |
|---|---|---|
gitTargetRef |
required | The target whose commit window the request attaches to |
message |
the target's templates | Commit message, committed verbatim unless requestTemplate frames it |
window.attach |
CurrentOrNext |
Which window to attach to: the author's current or next one, or only the next. See the request window |
window.attachTimeout |
2s |
How long to wait for a window to attach to |
window.idleTimeout |
no idle close | Close the attached window after this much silence |
window.maxDuration |
2s |
Close the attached window this long after the attach |
whenNothingToCommit |
Resolve |
CommitEmpty records the message in an empty commit when nothing changed |
| 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 receiver, 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.
Credentials are refused with http:// GitProvider URLs by default. Use HTTPS or SSH for real
repositories. For trusted in-cluster development Git servers that intentionally serve plain HTTP,
start the manager with --insecure-allow-git-http or set
controllerManager.insecureAllowGitHTTP=true in the Helm chart.
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.
spec.receiver lets the Git host tell GitOps Reverser that somebody else pushed. The names follow
Flux's Receiver, so a Flux user already knows them:
spec:
url: https://github.com/acme/platform.git
receiver:
type: github # github | gitlab | gitea | generic-hmac
secretRef:
name: platform-receiver-token # key: token
status:
webhookPath: /git-push/3f9a…c41e # 64 hex charactersPoint the host's push webhook at the receiver listener plus status.webhookPath, with the same
token as the webhook secret. The path is an HMAC of the provider's namespace and name, keyed by the
token, so it names neither and only whoever holds the token can find it. Rotating the token moves
the path: the old one stops serving at once, and the new one is published at the next reconcile
(within 5 minutes, since Secrets are not watched). status.webhookPath stays empty while the Secret
or its token key is missing. Each type verifies the request the way its host signs it:
type |
Verifies | Body |
|---|---|---|
github |
X-Hub-Signature-256, an HMAC of the body |
GitHub's push event |
gitlab |
X-Gitlab-Token, compared with the token |
GitLab's push event |
gitea |
X-Gitea-Signature, an HMAC of the body |
Gitea's push event |
generic-hmac |
X-Signature: sha256=<hex>, an HMAC of the body |
{"repository": "...", "branch": "main", "after": "<commit>"} |
generic-hmac is for a relay, such as one GitHub App webhook fanned out to many repositories. Its
header is the one Flux's generic-hmac Receiver checks.
The signature is checked before anything in the body is read, so an unsigned request cannot probe
which repositories or branches are tracked. Every request that reaches no verified receiver gets the
same 404, whether the path is unknown, from before a token rotation, or its signature is wrong, so
the endpoint reveals nothing about which namespaces or providers exist; the operator's log says
which it was. A verified delivery answers 202 Accepted even when it names a branch no GitTarget
writes, because that is not the host's fault and an error teaches a host to disable the hook. Pushes
to other branches and tag pushes are accepted and ignored.
The listener handles at most 8 deliveries at once and answers 429 above that, and a body over
2 MiB is refused with 413 before its signature is checked. A push event is a few kilobytes, and a
delivery refused for either reason is still found by the periodic refresh.
A delivery is a hint, never a fact. It makes the branch worker read the branch's ref advertisement (one request), and what the remote says decides whether the branch gained a foreign commit. A late delivery for one of GitOps Reverser's own pushes, a duplicate, or a rollback onto one of its older commits are all judged by the remote's answer, not by the commit the delivery names. Nothing fetches or plans until there is something to apply, and a delivery never triggers a cluster snapshot.
The receiver needs its own listener, because the Git host cannot present the client certificate the audit listener requires. See push receiver settings.
Without a receiver, GitOps Reverser still learns of a foreign push, from its next rejected push or from the periodic refresh. The receiver makes it seconds instead of minutes.
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.
allowCrossNamespace: # what an admitted namespace may reach outside itself
sources: false # a WatchRule may watch other namespaces, "*" included
appliers: false # a trigger may name a Kustomization or Application in another namespaceBoth default to false, and each is a power of its own:
sourceslets a namespace read other namespaces' objects into its own Git. AWatchRulemay name a source namespace other than its own only while it istrue; while it isfalse, it watches only its own namespace, andsourceNamespace: "*"is refused outright. It delegates the choice of source namespace and grants nothing on its own, since the credential still has to be able to read what is chosen.applierslets a namespace suspend and apply another namespace's deployments: aFluxTriggerorArgoCDTriggermay name aKustomizationorApplicationoutside its own namespace, such asflux-system. While it isfalse, a trigger naming one elsewhere reportsConfigured=False(ApplierNamespaceNotAllowed). The trigger's own namespace is always allowed; in a remote cluster that is the namespace of the same name.
It is a delegation, not owner consent. You trust every namespace accessFrom admits with
everything in reach of the provider's identity. Neither flag proves that a namespace owns the
objects it reaches, or the repository its GitTarget writes. Setting either 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 object
act on another namespace through the operator's own credential. That is legitimate to grant on
purpose, which is why each is explicit and defaults to false.
allowAnySourceNamespace, which sources replaces, is no longer read. Setting it to true is
refused; see UPGRADING.
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. client.qps and client.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
FACTSRECEIVED printer column:
$ kubectl get clusterprovider
NAME READY REASON FACTSRECEIVED 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.
GitBranch is one branch of the repository a GitProvider names. It holds that branch for every
GitTarget that references it: one branch worker, one clone, one queue, and the GitProvider's
credential, commit identity and signing key.
apiVersion: configbutler.ai/v1alpha3
kind: GitBranch
metadata:
name: example-branch
namespace: default
spec:
gitProviderRef:
name: example-provider # a GitProvider in this namespace
branch: main # must match the provider's allowedBranches
parentBranch: develop # optional; only used to create a missing branchspec.gitProviderRefnames aGitProviderin theGitBranch's own namespace, never another one. Creating aGitBranchtherefore takes the right to create objects where the credential lives.spec.branchmust match one of the provider'sallowedBranches, or theGitBranchreportsStalled=Truewith reasonBranchNotAllowed.spec.parentBranchis what the branch is created from while the remote does not have it. See starting the write branch from another branch.- Every field is immutable. To write another branch, or through another
GitProvider, create a newGitBranchand delete the old one.
Only one GitBranch writes a repository branch. The key is the provider's repository URL,
normalized (so https://github.com/acme/platform.git and git@github.com:acme/platform are one
repository), plus the branch.
- A
GitBranchbecomes a candidate once itsGitProvider's credential opens a push (receive-pack) session on the remote. Nothing is pushed: the session is opened and closed. AGitBranchwhose credential cannot open one reportsReady=Falsewith reasonPushSessionFailedand claims nothing, so creating it first blocks nobody. - Among the candidates for one repository branch, the oldest (by
creationTimestamp) holds it and gets the branch worker. Every other one reportsStalled=Truewith reasonBranchConflictand a message naming the holder. Reference the holder from yourGitTargets, or delete it first. - The holder keeps the branch through a later credential or network failure. It reports
Ready=False(PushSessionFailed) and keeps its pending writes, and the branch does not pass to anotherGitBranchwhile it is down. - The claim lives in the operator's memory. After a restart it is decided again by 1 and 2.
What the push session proves depends on the Git host:
| Host | What opening a push session takes |
|---|---|
| GitHub, GitLab | Push permission on the repository |
| Gitea, Forgejo | A valid credential that can read the repository (Gitea's AGit flow accepts pushes to refs/for/* from readers; branch writes are checked when they arrive) |
| A host that offers push sessions anonymously | Nothing: the proof is only that the repository is reachable |
Nothing refuses a second GitBranch at admission: references are checked in status, so a folder of
manifests can be applied in any order.
status:
repository: github.com/acme/platform # normalized: half of the key only one GitBranch holds
remote:
commit: 4f1c...
lastVerifiedAt: "2026-10-21T09:12:00Z"
verifiedBy: Push
gitTargetCount: 2status.remote is where the branch is on the remote; see
where the branch is.
status.gitTargetCount is how many GitTargets reference this GitBranch, from any namespace. More
than one means they share its branch worker; each still reports its own folder's health on its own
conditions.
kubectl get gitbranch shows the provider, the branch, the commit the branch is at, and Ready;
-o wide adds the reason, the repository, the target count and when the remote was last verified.
| Reason | What it means |
|---|---|
GitProviderNotFound |
No GitProvider of that name in this namespace (Stalled=True) |
GitProviderNotReady |
The GitProvider exists and is not Ready |
BranchNotAllowed |
spec.branch is outside the provider's allowedBranches (Stalled=True) |
PushSessionFailed |
The credential cannot open a push session; a holder keeps the branch meanwhile |
BranchConflict |
Another GitBranch holds this repository branch (Stalled=True) |
ParentBranchNotFound |
Neither the branch nor its parent is on the remote (Stalled=True) |
RecoveringParentBranch |
The parent is back and the worker is publishing what it held |
A GitBranch carries a finalizer. Deleting it stops new intake for every GitTarget on the
branch, pushes or refuses each pending write, gives each attached CommitRequest its outcome, and
only then removes the finalizer and lets another GitBranch hold the branch. Nothing deletes the
branch on the remote.
Deleting a GitBranch that GitTargets still reference is allowed; they report Validated=False
with reason GitBranchNotFound afterwards. A new GitBranch with the same name is a new object and
does not pick up the old one's pending work.
GitTarget decides where inside the repository resources are written.
The important fields are:
spec.gitBranchRef: whichGitBranchthis target writes, and so which repository branch and credentialspec.clusterProviderRef: whichClusterProvidersupplies resources; omit it to reference the user-createddefaultproviderspec.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 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 defaultspec.applyTriggerRef: theFluxTriggerorArgoCDTriggerholding the FluxKustomizationor Argo CDApplicationthat applies this folder, so GitOps Reverser decides when it applies (see Who applies this folder); omit it when nothing applies the folder backspec.onRefusal: whether a refused edit asks the applier to reapply Git (see Reverting a refused edit)
Example:
apiVersion: configbutler.ai/v1alpha3
kind: GitBranch
metadata:
name: example-branch
namespace: default
spec:
gitProviderRef:
name: example-provider
branch: main
---
apiVersion: configbutler.ai/v1alpha3
kind: GitTarget
metadata:
name: example-target
namespace: default
spec:
gitBranchRef:
name: example-branch
# Omit clusterProviderRef to reference the user-created ClusterProvider named "default".
# clusterProviderRef: {name: prod-eu-1} selects a different source provider.
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.gitBranchRef references a GitBranch by name, in the GitTarget's own namespace, and is
required and immutable. Set namespace to write through a GitBranch in another namespace; the
GitProvider that GitBranch names must admit this GitTarget's namespace in its
spec.accessFrom, or the GitTarget is refused with Validated=False (NamespaceNotAuthorized).
One policy governs both hops. A GitBranch that does not exist is Validated=False
(GitBranchNotFound), and one that does not hold its branch, or is not Ready, keeps the target at
Ready=False with reason GitBranchNotReady until it does. Either clears on its own once the
GitBranch appears or proves itself, so manifests can be applied in any order.
spec.gitProviderRef, spec.branch and spec.parentBranch are no longer GitTarget fields:
setting one is refused at admission with a message naming spec.gitBranchRef.
Two GitTargets on one GitBranch must not write the same or nested paths: the later-created one
reports Validated=False with reason TargetConflict.
spec.clusterProviderRef references a cluster-scoped ClusterProvider. It defaults to
{name: default} when omitted. That is intentionally different from gitBranchRef: 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.- While the branch cannot publish to Git,
Ready=Falsewith reasonProgressingsays since when, what failed, and whether intake of new changes is paused;Reconciling=TrueandStalled=False, because the retry needs nobody. Every target on the branch says the same until a push lands, and its streams reportBranchIntakePausedwhile they wait. 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.ApplyTriggerReady: true when the named trigger isReadyand its applier applies this folder's repository and branch. Present only whenspec.applyTriggerRefis set. The trigger's ownstatus.operationsays what was last asked of the applier, and why.
Use conditions for automation.
Start with the target, then check the rules that feed it:
kubectl get gittarget -A
kubectl describe gittarget -n <namespace> <name>
kubectl get watchrule,clusterwatchrule -AA red GitTarget means the target itself cannot validate, run, or publish. Ready=False gives the
summary reason, while the domain conditions name the axis to fix:
| Condition | What to check |
|---|---|
Validated=False |
The referenced GitBranch, path conflicts, ClusterProvider, and namespace authorization. |
EncryptionConfigured=False |
The age/SOPS Secret and whether key generation is allowed. |
GitBranchReady=False |
The GitBranch: whether it holds its branch, and its GitProvider's URL, credential Secret, host keys, and push permissions. |
ClusterProviderReady=False or SourceClusterReachable=False |
Source-cluster credentials and access. |
GitPathAccepted=False |
Repository files under spec.path; the message includes the issue kind, path, and actor when known. |
RenderMatchesLive=False |
Fields that no longer match the rendered source, often transformer-owned values. |
ApplyTriggerReady=False |
The trigger named in spec.applyTriggerRef, and its applier: its source, its automated sync (an Application), and the permission to write it. Commits still flow; apply requests may not. |
A green GitTarget with a red WatchRule or ClusterWatchRule means the destination may be fine,
but the selected source objects are not being watched or authorized. Inspect the rule's Ready,
GitTargetReady, SourceNamespaceAuthorized, and StreamsRunning conditions.
After fixing a Git-side folder problem, request a fresh read of the path:
kubectl annotate gittarget -n <namespace> <name> \
reconcile.configbutler.ai/requestedAt="$(date -Iseconds)" --overwriteThe kstatus trio is also on Prometheus, so the same answer is alertable without a kubeconfig:
gitopsreverser_resource_condition{kind="GitTarget", type="Ready", status="False"} == 1 names every
target that has not been accepted, and reason says which gate refused. The metric covers Ready,
Reconciling and Stalled for every object on this page except CommitRequest; the per-kind axis
conditions above stay on the object, where kubectl describe reads them. See
interpreting-metrics.md.
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:
idleTimeout: "5s"
maxDuration: "1m"
message:
liveTemplate: "chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}"Configure automatic messages, submit a save message, and interpret its conditions.
A commit window collects one author's writes to this target and commits them together. Only writes open a window: an author's first write opens it, and nothing else does. It closes on two timers, whichever comes first:
idleTimeoutcloses it after this much silence. Every write it collects restarts it. The default is5s;0scommits every write on its own.maxDurationcloses it this long after it opened, however much keeps arriving, so continuous activity cannot postpone a commit indefinitely. The default is1m.
idleTimeout may not exceed maxDuration. A window takes the target's timers when it opens, so
editing them affects windows that open afterward, never one already running. A
CommitRequest that attaches to a window replaces both timers
for that window.
One branch worker holds one live window, bound to an author and target. Interleaved authors or
targets split a burst. Atomic writes, a resync, buffer limits, shutdown, an attach: Next request,
a push notice from the receiver, and the
flush before an apply can close a window early. Push cooldown
is independent: a zero timer does not promise an immediate remote push. See the
complete trigger rules.
Both values are Go duration strings with a mandatory unit, at most 24h, checked by the API
server: "750ms", "1.5m" and "1m30s" are accepted, while "30", ".5s", "5 seconds" and
"-1s" are refused at admission with the field named. Nothing downstream re-checks them, because a
malformed value can no longer be stored.
The units are Go's own (ns, us/µs, ms, s, m, h) rather than the shorter set Flux
allows, so that every accepted value survives being read and written back by a client: "0.5ms"
is re-serialized as "500µs", and a value that cannot be rewritten is one no controller could
ever update.
Three templates decide how a commit is phrased: liveTemplate for a live window,
reconcileTemplate for an atomic snapshot or resync, and requestTemplate for a window a
save request attached to. Each is a Go template over a documented context,
checked when the target reconciles rather than at commit time, and each has a built-in default, so a
target that sets none still writes useful messages.
spec:
commit:
message:
liveTemplate: "chore: sync {{.Count}} resource{{if ne .Count 1}}s{{end}}"See commit messages for the full reference: the precedence rules, every field of the template context, how to frame a save request's message with what was saved, and the validation a bad template fails.
Point one at a scratch branch.
kind: GitBranch
metadata: {name: gitops-preview}
spec:
gitProviderRef: {name: homelab}
branch: gitops-preview # not main
---
kind: GitTarget
spec:
gitBranchRef: {name: gitops-preview}
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.gitBranchRef 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.parentBranch is a GitBranch field. A GitTarget on a GitBranch whose spec.branch does
not exist on the remote waits on standby: it compares the cluster against the parent branch and
creates nothing while they agree. The first write that has
something to commit creates the branch from the parent's current tip, checked when it pushes, so a
target that waited a week does not publish onto a week-old checkout. A write that finds nothing to
change creates no branch at all.
The parent is the remote's default branch unless you name one:
kind: GitBranch
spec:
gitProviderRef: {name: homelab}
branch: reverser/prod-edits # the write branch: created on the first commit
parentBranch: env/prod # what it is created from- An explicit parent must exist when the write branch is created. Otherwise the
GitBranchreportsReady=Falsewith reasonParentBranchNotFound, itsGitTargets reportGitBranchNotReady, and nothing is written. - It recovers on its own once the parent is pushed, with no edit to the cluster and with periodic
refresh on or off. The branch worker looks for the parent with one ref advertisement after 10s,
then after twice as long each time, up to every 5 minutes, shared by every target on the branch.
When the parent is back it publishes the writes it held, with their own authors and messages;
until those are published the
GitBranchreportsReady=Falsewith reasonRecoveringParentBranch. - An omitted parent is the remote's default branch, as last discovered. In an empty repository (no
refs at all, tags included) the first commit starts a branch with no history. A repository that
is not empty but whose
HEADnames no branch it carries also reportsParentBranchNotFound, and writes nothing untilHEADis fixed orparentBranchis set; that never starts an orphan. - A detached
HEAD, or a server that does not say whatHEADpoints at, resolves the way go-git resolves it: tomasterwhen it is atHEAD's commit, otherwise to the alphabetically first branch that is. SetparentBranchwhen you need certainty rather than that guess. - When the remote's
HEADswitches to another branch, the next discovery (a refresh, or any fetch) follows it. A push cannot seeHEAD, so with periodic refresh off a write branch can still start from the previous default branch until then. - Once the write branch exists, it is used as it is: new commits on the parent do not reach it. Delete the write branch after its changes are merged, and the next edit starts a fresh one.
parentBranchis immutable, including adding or removing it: delete and recreate theGitBranchto change it. It is only read, so it does not need to be inallowedBranches.- While the write branch is absent, the
GitBranch'sstatus.remote.parentshows the parent, whether it wasFound,MissingorUnborn, and the commit the first commit would build on. WithparentBranchomitted, that is how you see which default branch the remote resolved. - One
GitBranchholds a repository branch, so everyGitTargeton it has the same parent.
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.
A CommitRequest that ends on a suspended target fails with FinalizeFailed and the reason, rather
than resolving Ready=True with nothing written. The exception is WindowMismatch: a request that
only saw another author's window still reports that. One whose commit was already made locally when
you set suspend is pushed with it and resolves normally.
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.
Suspend stops writes, not deployments. A suspended target's applier is still asked to apply a
commit somebody else pushed, because with
spec.applyTriggerRef set nothing else asks it,
and a paused mirror must not stall Git-side changes. It requests nothing for a refusal, since that
request exists to act on a live edit the target is no longer publishing.
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"]
resolvedAtCommit: 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. -
resolvedAtCommitandresolvedAtdate 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.
status.remote is on the GitBranch: what its branch worker last proved about the branch on
the Git remote. It is an observation rather than a record of work, so it is there before anything
has been written:
status:
remote:
commit: "" # empty = the branch is not on the remote (yet)
lastVerifiedAt: "2026-09-23T10:14:02Z"
verifiedBy: Push # Push | Fetch
parent: # only while the branch is not on the remote
state: Found # Found | Missing | Unborn
branch: main # spec.parentBranch, or the remote's default branch
commit: 9a1e07c3d2... # set only when Foundcommitis where the branch is. Empty means the branch is not on the remote at all, which is not an error: a branch does not exist without a commit.lastVerifiedAtanswers "has anything looked", which is the questionplacement.resolvedAtCommitdeliberately does not: that one dates the resolution, so an old value there means the folder's shape has been stable.verifiedBysays what proved it.Pushmeans the server accepted a ref update of ours, so this commit is Reverser's own work.Fetchmeans it went and looked, and this is what was there. AFetchbeside a commit none of your publications produced is how a foreign push to the branch is read offkubectl.parentappears only while the branch is not on the remote: it says what the branch would be created from, observed together with the branch's absence.stateisFound(the first commit builds oncommit, the parent's tip),Missing(the parent is not on the remote either; theGitBranchreportsParentBranchNotFoundand nothing is written) orUnborn(an empty repository, with no refs at all, and no parent configured; the first commit starts the branch with no history). See starting the write branch from another branch.
kubectl get gitbranch shows commit in the Commit column, and -o wide shows
lastVerifiedAt as an age, in the Verified column.
Two things renew it. A push does, for free: the push session reads the remote's advertisement
and the server names the hash it accepted, on the connection the push was making anyway. So a
target that is publishing never needs anything else. An idle target is asked to re-prove it on
its reconcile tick; see
--git-refresh-interval below.
What is proved and what is published are two different rates. Every push and every fetch
proves where the branch is, and the worker wakes the GitBranch each time. Writing status on every
push would invalidate every watcher's cached copy of the type at the pace the branch moves, so the
GitBranch writes this stanza at most once a minute, and comes back when that floor lifts. Push and
read status immediately, and you may see the previous commit for up to a minute.
Two corrections skip the once-a-minute floor: the first observation, and taking back a
commit that names a repository the GitBranch no longer points at (a recreated GitProvider with
a different URL). And none of this promises
anything about the branch itself: an unreachable remote yields no observation at all, which is
exactly what the pair commit + lastVerifiedAt is there to say.
GitProvider.status.lastVerifiedAt is the same word for the connection rather than the branch:
when the credential and the repository were last proved together. It is never cleared, so
Ready=False beside it reads as "broken for this long".
A target that is writing keeps its own view of Git current. One that has gone quiet used to hold its previous answer indefinitely, because it is converged and its periodic passes publish status without touching Git, until somebody annotated it or it wrote again.
--git-refresh-interval (Helm: git.refreshInterval, default 10m) bounds that.
On the target's reconcile tick, a branch whose last observation is older than the interval is
re-proved:
| Target state | What it costs |
|---|---|
| Actively writing | Nothing, ever. Each cycle ends in a push that reads the advertisement, so no refresh is scheduled |
| Refused | Nothing. It is not converged, so it already re-reads every ten seconds |
| Healthy and idle | One ref advertisement per interval, plus a fetch only on the intervals where the branch had actually moved |
A refresh republishes status.remote, and re-reads the folder to republish status.placement and
to answer whether the folder can still be written at all. That last part is why it exists as much
as the first: somebody else's push can leave content Reverser cannot write, and a push emits no
Kubernetes event, so without a read nothing notices until your next live edit. That edit is then
refused, and its events are dropped until a resync recovers them. A refresh that finds such content
sets GitPathAccepted=False naming the file and the reason, and a later refresh that finds the
folder fixed clears it again.
Raising that condition puts the target on the ten-second re-check loop, which is the recovery path: a human fixing a folder in Git produces no event either, so a periodic re-read is the only thing that can notice. What a refresh still never does is write anything. It plans nothing, commits nothing and pushes nothing.
That includes spec.onRefusal: Reapply, which a folder
refusal never triggers. That setting reverts a refused edit, and reapplying Git cannot fix a
folder: only a human can, and the applier may be part-way through its own corrections there.
A refusal a write found is not cleared by a refresh. A structural read sees the files; it cannot see that a particular write may not touch one of them, so only the resync that writes the affected type clears that kind.
Set 0 to turn it off. Reverser then holds no timer against your Git host at all and an idle
target generates no Git traffic; status.remote still works, because pushes still renew it. The
effective granularity is the 5-minute control-plane reconcile, so a value below that means "every
tick". gitopsreverser_git_fetches_total{reason="refresh"}
is what it costs.
A refresh that finds a commit GitOps Reverser did not push is a foreign commit like any other: the target's applier is asked to apply it after a flush. That makes the refresh the backstop for deployments when no push webhook reaches the operator. It is not a latency mechanism: it buys bounded staleness with no inbound surface and no Git-host configuration. Seconds-fresh is the job of the push receiver.
Set this only when Flux or Argo CD applies the folder this GitTarget writes. A folder nothing
applies, and every folder Flux or Argo CD applies that GitOps Reverser does not write, needs no
trigger: leave the field out, and GitOps Reverser never touches Flux or Argo CD.
When the folder is shared, an apply trigger holds the object that applies it (its applier), and
GitOps Reverser decides when it applies: a FluxTrigger holds a Flux Kustomization, an
ArgoCDTrigger an Argo CD Application. The GitTarget names the trigger, in its own namespace.
Bi-directional GitOps is the model, and the complete setups, with the
lifecycle of an apply, every condition reason and the permissions, are
bi-directional GitOps with Flux and
bi-directional GitOps with Argo CD. The reason it has to be this way is
the apply trigger design.
A trigger acts only while an eligible GitTarget references it: one in its namespace that names
it, is admitted by its ClusterProviders, and writes the repository and branch the applier fetches.
Until one does, the trigger reads and writes nothing in the applier's cluster, and reports
Configured=Unknown and Ready=True, both NotReferenced, so the two may be created in either
order. A FluxTrigger that held its Kustomization hands it back once no eligible GitTarget
references it any more.
apiVersion: configbutler.ai/v1alpha3
kind: FluxTrigger
metadata:
name: checkout
namespace: team-a
spec:
clusterProviderRef:
name: default # the cluster Flux runs in
kustomizationRef:
namespace: flux-system # default: the trigger's namespace
name: checkout
---
apiVersion: configbutler.ai/v1alpha3
kind: GitTarget
metadata:
name: checkout
namespace: team-a
spec:
# gitBranchRef, path ...
applyTriggerRef:
kind: FluxTrigger # or ArgoCDTrigger
name: checkout| Field | Default | What it does |
|---|---|---|
FluxTrigger.spec.clusterProviderRef, ArgoCDTrigger.spec.clusterProviderRef |
{name: default} |
The ClusterProvider whose cluster holds the applier, defaulted as a GitTarget's is. Immutable |
FluxTrigger.spec.kustomizationRef |
required | A Flux Kustomization whose sourceRef is a GitRepository that follows a branch. Immutable |
ArgoCDTrigger.spec.applicationRef |
required | An Argo CD Application on a single source whose targetRevision names a branch, with automated sync off. Immutable |
GitTarget.spec.applyTriggerRef |
none | The trigger, by kind and name, in the GitTarget's namespace |
One trigger, several folders. Several GitTargets may name one trigger: their folders are
applied by the same applier, and it is asked once for all of them. Each one's repository and branch
must be the ones the applier applies, or that GitTarget's ApplyTriggerReady is False with
SourceMismatch; one that matches is enough for the trigger to act. One applier is held by one
trigger: a second trigger of the same kind in line for the same applier in the same cluster reports
HeldByAnotherTrigger and does nothing. A trigger no eligible GitTarget references is not in line,
so it keeps no other out.
Where the applier is looked up. In the cluster the trigger's spec.clusterProviderRef names,
with the identity that ClusterProvider uses. Name a provider for a management
cluster when Flux or Argo CD runs there; a provider without spec.kubeConfig is the operator's own
cluster. That provider's spec.accessFrom must admit the trigger's namespace, as for mirroring, or
the trigger is Configured=False with NamespaceNotAuthorized (or ClusterProviderNotFound). An
applier in another namespace than the trigger's, such as flux-system in the example above, also
needs the provider's allowCrossNamespace.appliers,
or the trigger is Configured=False with ApplierNamespaceNotAllowed. For the operator's own
cluster, the chart grants the applier permissions with applierAccess.flux: true and
applierAccess.argocd: true.
apiVersion: configbutler.ai/v1alpha3
kind: FluxTrigger
metadata:
name: prod-eu-1-apps
namespace: team-a
spec:
clusterProviderRef:
name: management # Flux runs here, and applies to prod-eu-1
kustomizationRef:
namespace: flux-system
name: prod-eu-1-appsWhen a request is made. After a flush: when an apply comes
due, the branch worker closes the open commit window, publishes the writes decided up to that
moment at once, and asks for the tip that publication produced. An edit decided after that is
recorded in the apply window, so an apply that writes over it is withheld from Git and repaired. The
branch workers of other GitProviders writing the same repository and branch flush too, and the
request waits until they have. A registered CommitRequest (a save sets its own commit boundary), a
parent branch being recovered, or a checkout a failed write left dirty holds the request back. No
applier is asked for anything until every initial snapshot of every GitTarget naming its trigger
is published, and nothing is asked while the remote does not carry the branch. The rule is the same
for a Kustomization and an Application.
reason |
When | Sent to |
|---|---|---|
ForeignCommit |
The branch gained a commit GitOps Reverser did not push. The pending writes are replayed onto it and pushed first, so the request names that pushed commit | Every applier declared on the branch, once per applier |
Refusal |
An eligible refused edit, with spec.onRefusal: Reapply |
This target's applier |
Repair |
An apply reverted an API edit made after the flush; Git kept the edit | That applier |
CatchUp |
To adopt: the API created an object in the folder, or a resync added an object to Git. To converge: once when the operator starts, and once when a GitTarget first names the trigger |
To adopt, that folder's applier; to converge, every applier declared on the branch at start, and the newly named one |
An API edit asks for no apply, and neither does a push of GitOps Reverser's own: the cluster already
runs what they hold. A create is different, because an applier prunes only what it applied: an
object the API created (an ADDED event whose newest field managers are not an applier's) carries
neither Flux's owner labels nor Argo CD's tracking annotation, so a later Git-side delete of its
file would leave it running. An adopt is therefore applied even at the commit the applier last
applied, since that apply is what stamps the object. A converge asks nothing when the applier's last
successful operation, as status.operation records it, applied the tip.
An object another applier owns is not mirrored into the folder. When a GitTarget names a
trigger, an object whose Flux owner labels (kustomize.toolkit.fluxcd.io/name and
kustomize.toolkit.fluxcd.io/namespace) or Argo CD tracking annotation
(argocd.argoproj.io/tracking-id) name an applier other than the trigger's is excluded: a live
change to it is not mirrored, and a resync neither writes it nor removes its document, even under
prune.mode: Always. Two appliers applying one object fight each other. Each excluded object is
logged once and counted in gitopsreverser_apply_ownership_conflicts_total. A GitTarget without
spec.applyTriggerRef excludes nothing: mirroring another applier's objects one way is what such a
folder can exist for.
What it reports. The trigger reports its applier and the operation it is running. The branch
worker writes status.operation and status.repairPending at every move of the operation, so they are
current without anything reconciling:
kind: FluxTrigger
status:
conditions:
- type: Configured
status: "True"
reason: Succeeded
message: Kustomization flux-system/checkout applies GitRepository flux-system/platform, ...
- type: Held # FluxTrigger only
status: "True"
reason: Suspended # Suspended | Applying | Suspending | Releasing | NotReferenced | NotConfigured
- type: Ready
status: "True"
source:
gitRepository: {namespace: flux-system, name: platform}
url: https://github.com/acme/platform
branch: main
otherConsumers: # what else applies this GitRepository, held by no FluxTrigger
- {kind: HelmRelease, namespace: platform, name: ingress}
operation:
phase: Succeeded # Requested | Running | Settling | Unresolved | Succeeded | Failed
reason: ForeignCommit # ForeignCommit | Refusal | Repair | CatchUp
commit: 4f2c1ab9...
token: "2026-10-20T10:14:03.123456789Z"
suspendToken: "2026-10-20T10:14:20.987654321Z"
appliedRevision: main@sha1:4f2c1ab9...
requestedAt: "2026-10-20T10:14:03Z"
finishedAt: "2026-10-20T10:14:21Z"
repairPending: false| Field | Means |
|---|---|
operation.phase |
Requested, Running, Settling (Flux only: applied, not yet seen suspended again) and Unresolved (not completed in 5 minutes, still followed) are in flight; Succeeded and Failed have completed. The life of an apply draws the transitions |
operation.token |
Identifies the request in the applier's own status: the reconcile.fluxcd.io/requestedAt value of the resume, or the gitops-reverser request entry in an Argo CD operation's info |
operation.suspendToken |
Flux only: the token of the suspend after the apply. Once it is handled, nothing that started before the suspend is still running |
operation.appliedRevision |
The revision the applier reports it applied. It can differ from commit when the branch moved between the check and the apply |
repairPending |
true while an apply reverted an API edit made after its flush: Git kept the edit, and the cluster runs the older value until the Repair |
source.otherConsumers |
Flux only: the Kustomizations and HelmReleases that apply the same GitRepository and that no FluxTrigger holds. Information only; it never affects Ready |
A Kustomization resumed outside GitOps Reverser (flux resume) is suspended again and recorded as
an Unresolved operation with no reason and no commit, which completes once the suspend is
handled.
Conditions. Configured is a gate, and the trigger's Ready aggregates it; a FluxTrigger is
also Ready only once Held is True (Suspended, or Applying while an apply of GitOps
Reverser's is in flight). Either kind is Ready=False (OperationUnresolved) while its operation
is Unresolved. An unreferenced trigger is Ready=True (NotReferenced). The
GitTargets naming the trigger are reported as NotBound (Unknown while one cannot be judged
yet, False once each is refused for different reasons), or as the refusal they share,
SourceMismatch or NamespaceNotAuthorized, every GitTarget and its reason in the message. The
other False reasons are ApplierNotFound, UnsupportedSource, AutomatedSyncEnabled (Argo CD),
SuspendOwnedElsewhere (Flux), Forbidden, NamespaceNotAuthorized (the trigger's own namespace),
ClusterProviderNotFound, ApplierNamespaceNotAllowed and HeldByAnotherTrigger; each guide's
table says what they mean for its applier and how to fix them
(Flux, Argo CD).
The GitTarget reports ApplyTriggerReady, and its Ready aggregates it: True when the trigger
is Ready and its applier applies this target's repository and branch, otherwise the trigger's own
Ready reason, ApplyTriggerNotFound, SourceMismatch, or Unknown (NotReferenced) until the
trigger has seen that the GitTarget names it. Each delivered request records an
ApplyRequested Event on the GitTarget, and one that could not be delivered records
ApplyRequestFailed once.
The applier is read on the trigger's own reconcile tick rather than watched, so a cluster without
the Flux or Argo CD CRDs costs nothing: every 15 seconds while it is expected to clear on its own,
such as an applier created after its trigger, and otherwise every minute for a FluxTrigger (that
read is what finds a flux resume) and every 5 minutes for an ArgoCDTrigger. A FluxTrigger that
is Suspending is read every 2 seconds. Annotate the trigger with
reconcile.configbutler.ai/requestedAt to have it read now.
Deleting a FluxTrigger hands the Kustomization back to Flux, through the finalizer
configbutler.ai/flux-trigger, unless another FluxTrigger is in line for it, and so does the last
eligible GitTarget that stops naming it. A trigger that never held its Kustomization carries no
finalizer. The Flux guide has the
details.
A refusal means the acceptance gate or a write-boundary check found no legal destination for the
write. Nothing is committed for it, GitPathAccepted goes False, and the live edit stays until
something applies Git over it. spec.onRefusal decides whether GitOps Reverser asks for that:
spec:
path: apps/checkout
applyTriggerRef: {kind: FluxTrigger, name: checkout}
onRefusal: Reapply # default: Ignore| Value | What happens |
|---|---|
Ignore (default) |
The refusal is reported and nothing else happens |
Reapply |
After a flush, this target's applier is asked to apply the current tip, with reason Refusal. The refused edit is reverted when the applier applies it; the request asks, and the applier's own status says whether it did |
Reapply requires spec.applyTriggerRef; a target without one is rejected at admission. The request goes
to this target's applier alone, and it goes out after a flush, so an allowed edit still in a
commit window is published before the apply and is not reverted with the refused one. An
Application is asked to sync the tip. When a Kustomization's GitRepository has to fetch to
reach the tip, the Kustomizations a FluxTrigger holds stay suspended and ignore the new artifact;
only the consumers no trigger holds, listed in the trigger's status.source.otherConsumers, apply
it, as they would for any push. The
bi-directional guide covers when to choose it.
It only fires for an edit to a document the folder already holds, and never for a write that removes one. That is the pruning guard. Re-applying corrects drift on an object Git manages, which is the point. For an object Git does not manage, re-applying either does nothing (Flux prunes from its inventory and Argo CD from the resources it tracks, and a live-created object is in neither) or, if the object was managed and has since been removed from Git, prunes it. Hurrying along somebody else's delete is not this operator's call, and the two cases cannot be told apart from here, so both are excluded.
Establishing that takes more than the refusal's kind, and more than the file. A render refusal fires
for a brand-new resource an images: entry would override; removing one document from a file that
holds two leaves the file in place, which looks exactly like an edit. Eligibility is therefore
decided per document, and a write that removes one anywhere is excluded whole.
Folder-level refusals, such as invalid YAML or unsupported content, never trigger it: they need a
correction to the folder.
Three more guards. A suspended target requests nothing. A GitTarget that cannot be read is
treated as Ignore, since missing evidence is not consent. And requests are coalesced per target:
a controller rewriting a base-owned field refuses on every one of its own reconciles, so refusals
that arrive while a request is outstanding share the next one rather than each making their own.
A refusal nobody corrects is requested once, not once per recheck. The target keeps re-reading its folder and keeps refusing, and an observation of the same objects with the same issues is already covered by the request made for it. Anything different (a new value, another object, a different refusal) is a new trigger. So is the same edit made a second time after the applier reverted it, because a write of that resource type was accepted in between.
Reapplying does not clear the refusal condition: recovery still requires a successful resync covering the refused scope. It removes the offending live difference, but it cannot repair an unsupported folder or give a field a writable destination.
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,"lastChangedAt":"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_git_documents_total{outcome="retained"}, labeled bygittarget_namespace,gittarget_name, and the resource type (group,version,resource). The type is what makes a retention actionable: a bare total names nothing to go and look at. It shares one counter with the writer's other document outcomes because they are one population (every document the writer decided about), so a dashboard reads them as one stacked series.
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.
lastChangedAt records when the count or the effective mode last changed. It is not a
freshness signal and must not be read as one: a resync that re-reports the same numbers leaves it
untouched, so an old timestamp is equally consistent with stable retention and with nothing having
measured it since. The field is named for what it does.
For "are resyncs still running", read gitopsreverser_git_resync_failures_total and
gitopsreverser_watch_recovery_total. A rising
gitopsreverser_git_documents_total{outcome="retained"} is positive evidence that retention was
evaluated and found something, but a flat one proves nothing: a healthy resync retaining zero
produces no increment either.
spec.prune is mutable (unlike gitBranchRef 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.
There is no re-layout operation today. The practical consequence is worth stating plainly: a
folder laid out under an older policy keeps that layout for as long as its documents live, however
you change spec.placement afterwards. Placement governs the first write and nothing else. This is
a description of current behavior rather than a promise: a deliberate migration that moves documents
in a new commit is a coherent feature and is not ruled out.
To re-place a document by hand today, get it out of the folder and let the mirror write it again. The writer rebuilds its identity index from the current scan of the folder, so a resource with no document there is placed as new, under the current policy.
Check spec.prune.mode first. The route below deletes the cluster object and relies on the
mirror removing its document, which needs OnEvent (the default) or Always. Under Never the
mirror removes nothing, so step 2 never completes and you would be left waiting on a deletion that
is not coming.
- Delete the object in the cluster.
- Wait until the deletion is committed and pushed and the document is gone from the branch.
- Recreate the object. It now has no document to update, so the write goes through the current placement policy.
Step 2 is not a formality. A delete and a recreate inside one commit window, by the same author, coalesce by destination path into a single retained entry, so the pair cancels out: the deletion never reaches Git, the document is still there, and the recreated object is updated in place at its old location. From the outside that looks exactly like placement being ignored.
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 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="builtin"}[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 built-in 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: "{namespace}/{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, or the literal _cluster for a cluster-scoped resource |
team-a (a Node → _cluster) |
{namespace|fallback} |
the same, but with your own bucket in place of _cluster |
_global |
{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 |
{kindLower} |
manifest kind, lower-cased | 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) |
{label:key} |
the value of that label on the resource, or _unlabeled if it has none; the key may be prefixed ({label:app.kubernetes.io/instance}) |
voter |
{label:key|fallback} |
the same, but with your own bucket in place of _unlabeled |
unassigned |
{namespace}always renders something. For a cluster-scoped resource it is the literal_cluster, so{namespace}/{resource}/{name}.yamlfiles a ClusterRole at_cluster/clusterroles/admin.yamland namespaced and cluster-scoped resources stay cleanly separated without a second template._clusteris not a legal namespace name (DNS-1123 forbids_), so it can never collide with a real one. To name that bucket yourself, write{namespace|_global}(see Naming the bucket yourself).{scope}is a descriptor (cluster/namespaced), not a substitute, so don't use it as the folder for cluster resources.
{kindLower} gives the deployment-api.yaml naming convention, which none of the automatic steps
produce:
placement:
default: "{kindLower}-{name}{sensitiveSuffix}" # deployment-api.yaml, configmap-cache.yamlThis template is not identity-complete: it has no {namespace}, so a Deployment named api in
team-a and one in team-b render the same path and land as two documents in one file. Use it for a
folder that mirrors one namespace. For a folder that mirrors several, keep the namespace in the path,
for example "{namespace}/{kindLower}-{name}{sensitiveSuffix}". A Secret still needs an
identity-complete byType route of its own, as it does with any bundling default.
It is a variable rather than a lower function on purpose: placement templates are not an expression
language.
One variable reads the object's metadata rather than its identity. {label:team} renders the value
of the resource's team label, and the key may be prefixed: {label:app.kubernetes.io/instance} is
one variable, not a variable followed by a directory, because the template scanner takes the whole
{…} including the / inside it. It works in byType and in default alike, and a template may
read more than one label.
placement:
byType:
v1/configmaps: "{label:app.kubernetes.io/instance}/configmaps.yaml"Resources sharing a label value bundle into one file, which is usually the point: every ConfigMap
labeled app.kubernetes.io/instance: voter lands in voter/configmaps.yaml. The commit that
writes them can name the same label: see Kind, scope, and labels.
There is no "not placed" state to design around. A resource that does not carry the label (or carries it with an empty value, which Kubernetes permits) renders a bucket instead:
| The resource | {label:team} |
{label:team|unassigned} |
{label:team|} |
|---|---|---|---|
sets team: payments |
payments |
payments |
payments |
does not set team |
_unlabeled |
unassigned |
nothing; the segment collapses |
sets team: "" |
_unlabeled |
unassigned |
nothing; the segment collapses |
_unlabeled is the built-in bucket, and it is chosen the same way _cluster is for a
cluster-scoped {namespace}: a label value has to start and end alphanumeric, so no real one can
ever be _unlabeled and no real resource can land in that bucket by accident.
{label:key|fallback} replaces _unlabeled with a bucket you name. A fallback is at most 63
characters of [A-Za-z0-9._-], may not be . or .., and may be empty. Three consequences are
worth knowing before you pick one:
- A fallback that is a legal label value shares its bucket with resources labeled it.
{label:team|unassigned}files unlabeled ConfigMaps exactly whereteam: unassignedones go, and nothing afterwards can tell the two apart. When you need them distinguishable, begin the fallback with_: a label value may not start with one, so{label:team|_none}is a bucket only your fallback can reach. That is why a fallback is allowed a leading_where a label value is not. - An empty fallback is a request to render nothing, and the empty segment is then dropped, so
unlabeled resources land one directory up:
{label:team|}/{name}.yamlputs them atapi.yamlwhile labeled ones go topayments/api.yaml. Write it when you want the labeled resources filed into folders and the rest left where they are. Only whole segments collapse, so inside a file name{label:team|}-{name}.yamlrenders-api.yamlrather thanapi.yaml. - A fallback can never add a directory or escape
spec.path, because the character set has no/and..is refused. A template that tries is rejected by the GitTarget'sValidatedcondition withInvalidConfig, before anything is written; it is not silently repaired.
Placement is match-first and runs only for a resource with no document in Git yet, so the rendered
path is a snapshot of the label at the moment the file was created. Labeling a resource
afterwards does not move it out of _unlabeled/, changing the label does not move it to the new
bucket, and removing the label does not move it into one. Nothing reconciles a file's location
against the label it carries today; if you want a resource moved, move it in Git.
The writer removes controller bookkeeping (kustomize.toolkit.fluxcd.io/*, kro.run/*,
applyset.kubernetes.io/*) before a document reaches Git, so those values are already gone by the
time placement runs. A template reading one would not merely be unhelpful; every resource of its
type would render the same fallback forever. The Validated gate rejects such a template by name
rather than letting it fail silently per resource. app.kubernetes.io/instance is deliberately
not stripped (it is indistinguishable from the standard recommended label) and stays usable.
- A label is not identity. Two resources can carry the same one, so
{label:key}adds discrimination to a path but never counts toward the identity-completeness a sensitive route requires: keep{name}and a scope variable in those templates. {annotation:key}does not exist. A label value is at most 63 path-safe characters; an annotation value is unbounded text (kubectl.kubernetes.io/last-applied-configurationis a whole JSON document), so there is no truncation rule that would not surprise somebody. Nothing else on the object is exposed either; the variables above are the whole language.
Two variables can be absent for a resource that is otherwise perfectly placeable, and they are
the same problem wearing two hats: {label:key} on a resource that does not carry the label, and
{namespace} on a cluster-scoped resource, which has no namespace at all. Neither is a refusal.
Each renders a built-in bucket (_unlabeled and _cluster), and each accepts the same
|fallback suffix to let you name that bucket instead:
placement:
default: "{namespace|_global}/{groupPath}/{resource}/{name}{sensitiveSuffix}"
byType:
v1/configmaps: "{label:team|unassigned}/configmaps.yaml"The rules a fallback obeys are the same for both, because both render into one path segment: at
most 63 characters of [A-Za-z0-9._-], never . or .., and an empty fallback ({label:team|},
{namespace|}) renders nothing so the segment collapses. Nothing else is off limits. A template
that breaks those rules is rejected by the Validated condition with InvalidConfig before
anything is written.
Only these two take a fallback, and a | written on any other variable is refused with that
explanation rather than accepted as syntax that can never fire. That is not because the others
always have a value: {groupPath} and {group} render empty for a core resource. It is because an
empty group is not a resource missing something, it is a resource whose identity has no group
segment, so the segment collapses and the built-in path is what it was always meant to be. Only
{namespace} and {label:key} have an absence worth naming a bucket for.
Nothing stops you writing {namespace|team-a}, and it does what it says: cluster-scoped resources
land in team-a/, alongside whatever namespace team-a writes there. That is a bundling choice,
not a collision, and it is allowed for the same reason bundling is.
It cannot silently merge two resources. Scope is a property of the type, not the object, so two
resources rendering the same {groupPath}/{resource} are either both cluster-scoped or both
namespaced, and the fallback only ever fires for the first kind. Under
{namespace|team-a}/{groupPath}/{resource}/{name}.yaml a ClusterRole named admin goes to
team-a/rbac.authorization.k8s.io/clusterroles/admin.yaml, and no namespaced resource can render
that path, because no namespaced resource is a ClusterRole.
What a real-namespace fallback does do is put cluster-scoped resources in a folder a human reads as
a namespace. If you would rather they stayed unmistakable, begin the fallback with _
({namespace|_global}): no namespace may contain one, so the bucket is yours alone. That is a
readability preference, not a safety rule.
The one case worth thinking about is a template that drops the type variables (a bundle such as
{namespace|team-a}/all.yaml), where cluster-scoped resources join the file namespace team-a
writes. Documents keep their own identity inside a bundle, and sensitive resources are never
allowed into a shared file, so the fence that matters still holds. For Secrets specifically, a
bundling default is rejected outright unless Secrets have their own identity-complete byType
route; picking a route that keeps them apart is yours to get right.
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
ClusterProvidersetsallowCrossNamespace.sources: 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. Connection count is bounded per matched type, but object count, traffic, and memory
still grow with the cluster. Access is bounded by the source credential's RBAC
and by nothing else, which is why it is refused outright while allowCrossNamespace.sources is false.
A "*" item and a named-namespace item for the same type overlap, so one GitTarget cannot
hold both: the newer rule is refused (see overlapping collections). Keep
the wider scope, or send the narrower one to its own GitTarget.
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 allowCrossNamespace.sources 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. A rule selects
whole resource collections: every selected collection is observed through creates, updates, and
deletions, and the GitTarget's deletion policy decides whether an
observed removal deletes the Git document. There is no per-rule operation filter. The rule fields are:
apiGroups:""for the core group,*for all groups, or omitted to resolve the named resource across the served API surface. A name served by more than one group is ambiguous when the group is omitted, and is watched in none of them: name the group.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:
- apiGroups: [""]
apiVersions: ["v1"]
resources: ["configmaps", "secrets"]Use WatchRule for every namespaced resource, whether or not it lives in the GitTarget's own
namespace.
spec.rules[].objectSelector is a standard Kubernetes label selector (matchLabels and
matchExpressions). It narrows a rule item to the objects whose labels match, on a WatchRule and
on a ClusterWatchRule:
rules:
- apiGroups: [""]
resources: ["secrets"]
objectSelector:
matchLabels:
internal.cozystack.io/tenantresource: "true"The cluster is the source of truth, and the mirror follows the selection. The API server applies the selector to every LIST and WATCH the item opens, with list/watch semantics. An object that gains a matching label enters the mirror as a create. An object that loses one leaves the mirror exactly as a deleted object does. The operator does no label matching of its own, and never asks whether an object that left was deleted or relabeled.
What a removal does to Git is the target's deletion policy:
| Mode | Object deleted or no longer matching | Absent from a complete snapshot |
|---|---|---|
Never |
kept | kept |
OnEvent (default) |
removed | kept |
Always |
removed | removed |
Under Always, a selected mirror owns every document of its type in its namespace scope, not
only the ones the selector matched. If Git holds Secrets a and b in tenant-a and the selector
returns only a, the next complete snapshot removes b, even if b never matched. Labels in Git,
and whether a document was ever mirrored, do not protect it. To keep documents the selector does not
return, use OnEvent or Never, or give them a different target path. Under the retaining modes,
each such document counts as retained: it is absent from the selected
mirror, whether or not the object still exists in the cluster.
Consequences to plan for:
- Narrowing a selector is a removal. Editing
team in (a,b)toteam=astarts a new collection and a complete snapshot, and underAlwaysevery document it does not return goes. Widening adds the newly selected objects without waiting for them to change. A mistaken selector and an intentionally smaller mirror look the same to the operator. - Removing a document can delete the live object. When a GitOps tool such as Flux or Argo CD applies this folder with pruning on, a document this mirror removes is an object that tool deletes.
- Enable
Alwaysonly once the cluster is authoritative. A document committed to Git that has not yet been installed, or that another tool has yet to apply, is absent from the snapshot and is removed. - Selectors match the objects stored in the cluster. A selector may use labels that the operator strips
before writing, such as
kustomize.toolkit.fluxcd.io/name; the labels do not need to survive in Git.
The field is the Kubernetes LabelSelector type, so the CRD schema checks only its shape. The rule
compiler checks the rest (operators, values, and label syntax, exactly as Kubernetes parses a
selector) and refuses the whole rule before it opens any watch, with ResourcesResolved=False,
reason InvalidObjectSelector. A valid selector that matches nothing is a valid, empty collection.
Kustomize layouts. A removal from a supported Kustomize folder is the same edit whether a
watch event or a snapshot caused it. A document is removed from its own file, keeping the other
documents in that file, and a file that empties loses its resources: entry, so the root still
builds. An object the overlay inherits from a read-only base is removed with a $patch: delete
the operator writes in the overlay and lists under patches:. The operator owns that file: its
first line says so, and when the object is back in the selection the operator removes the file
and its entry. The file's exact contents are the ownership, so an edited patch, or an unrelated
file at the same path, refuses the write (GitPathAccepted=False, reason WriteBoundaryRefused)
rather than being overwritten or kept. Every such write is re-rendered before commit to prove the
object left, or came back, and that nothing else in the render changed. The
Kustomize support boundary still decides which folders are
writable at all.
Attribution of a removal. With audit attribution on, an object leaving a selected collection is attributed only to the write that produced that removal: the label change. A deletion from a selected collection never carries that evidence, so its removal commit is authored unresolved; a deletion that waited on a finalizer was already attributed when it was requested. The document is removed either way. See the attribution contract.
A GitTarget watches each object through one collection. Two items on one GitTarget overlap
when they select the same type in the same namespace, or when one of them selects all namespaces.
Only an exact duplicate is allowed: the same type, namespace scope, and selector, written in any
equivalent form (matchLabels: {team: a} and
matchExpressions: [{key: team, operator: In, values: [a]}] are one selector). It shares one stream.
Any other overlap is refused, whether the selectors are equal or differ: the older rule, then the
rule with the lower name, keeps its collections, and the newer rule is refused as a whole with
ResourcesResolved=False, reason CollectionOverlap. The message names both rules and both
scopes. A rule whose own items overlap is refused whatever its age. Different namespaces, and
different GitTargets, never overlap. To select several values, use one set-based selector such as
team in (a,b). The check re-runs when rules change or the source cluster starts serving a type. A
collection that leaves the plan stops before an overlapping one that replaces it starts.
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:
- 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" signal for a same-namespace GitTarget. It does not create or
change watch rules. It attaches to its author's commit window, gives that window its message, and
decides when the window closes, instead of the target's timers.
The important fields are:
spec.gitTargetRef.name: the target whose window to attach tospec.message: optional literal commit message, preserved verbatimspec.window: which window to attach to and when it closes; see the request windowspec.whenNothingToCommit: what to do when the request ends with nothing to commit; see when there is nothing to commit
Example:
apiVersion: configbutler.ai/v1alpha3
kind: CommitRequest
metadata:
name: save-now
namespace: default
spec:
gitTargetRef:
name: example-target
message: |-
fix(api): correct the service port
Route traffic to the port exposed by the API container.
window:
attach: CurrentOrNext
attachTimeout: "2s"
maxDuration: "2s"The entire spec is immutable. Create a new CommitRequest for each save attempt.
A present message accepts 1–1024 Unicode characters, including newline. All other ASCII control
characters, including tab, carriage return, and DEL, are rejected, as is whitespace-only text.
Accepted surrounding spaces are preserved. Braces such as {{.Author}} remain literal; omission
uses the live template. To frame a save message with what was saved,
configure requestTemplate on the GitTarget. The request is still never
parsed as a template. A rejected request leaves automatic mirroring
available. The submitter chooses any semantic prefix; free-form messages are accepted.
A request never opens a window; only writes do. It waits for one, attaches to at most one, and replaces that window's timers with its own, shorter or longer:
| Field | Meaning | Default |
|---|---|---|
attach |
CurrentOrNext attaches to the author's window if one is open when the worker registers the request, and otherwise to the next one a write opens. Next first closes the author's open window, under the message it already carries, and attaches to the next one |
CurrentOrNext |
attachTimeout |
stop waiting for a window this long after the worker registers the request | 2s |
idleTimeout |
close the attached window after this much silence; every write restarts it | no idle close |
maxDuration |
close the attached window this long after the attach, however much keeps arriving | 2s |
The defaults are a fixed two-second cutoff after the attach, so an explicit save finishes promptly.
For a save whose writes trickle in, ask for rolling collection, which maxDuration still ends:
idleTimeout: "1s" with maxDuration: "10s". Durations are at most 5m, idleTimeout may not
exceed maxDuration, and attach: Next needs a positive attachTimeout.
Each value means one thing: attachTimeout: "0s" attaches to a window already open, or gives up at
once, and still collects for the full maxDuration; maxDuration: "0s" finalizes right after the
attach; idleTimeout: "0s" closes on the first silence. A request whose attachTimeout has run out
never takes a later window: the write that arrives afterward commits on its own, without the
request's message. Author or target changes, the memory limit, a drain before a resync, and
shutdown still close an attached window early, and its message goes with it.
Pick attach by when the request is created. A save made after its writes, like the
commit-window example, wants CurrentOrNext. A save created before its
writes wants Next, so it does not sweep up the author's earlier, unrelated edits. Next
separates work the worker already collected from work that reaches it afterward. It cannot prove a
write was made after the request: a write still held for its audit fact arrives later and lands in
the next window.
Competing requests from one author are served first come, first served. A window carries at most
one request; the next waits for the next window. A re-sent request is recognized by its identity
and restarts nothing: its deadlines and, for Next, its one close are fixed at first registration.
attachTimeout covers the gap between creating the request and its first write reaching the
branch worker. A write is held in the watch path until its audit fact arrives, so the floor is the
API server's --audit-webhook-batch-max-wait plus the attribution join (roughly 1 to 1.5 seconds at
the reference configuration of 1s). The default of "2s" clears that; a loaded or distant cluster
may want "4s" to "5s", and a save created before its writes needs the time to its first write
on top.
maxDuration, and idleTimeout when set, cover the span of the save's own writes after the
attach. The worst case from registration to the finalize is attachTimeout plus maxDuration; the
push follows, with its cooldown and any retries.
Do not size either against --author-attribution-grace. When that grace expires with no fact the
write still ships, as a window that names no actor, and a request naming a submitter can never
attach to it: the outcome is WindowMismatch no matter how long the request waits.
The two directions are not symmetric. Overshooting costs a few seconds of latency on the commit;
undershooting attachTimeout resolves the request Ready=True with reason NoWindow while the
edit commits seconds later under the target's liveTemplate, which reads as a save button that did
nothing.
A request cannot rename a finalized commit, including a local commit waiting for push. Between the
finalize and the push the request is still in flight, and the controller keeps re-sending its
attach until it reads an outcome; the worker recognizes the re-send as the same request, so a
request never resolves NoWindow while its commit waits out the push cooldown. Applying resources
and a request together gives no ordering guarantee between controllers and watch streams. See the
request contract.
spec.whenNothingToCommit decides what a request does when it ends with nothing to commit: its
attachTimeout ran out with no eligible window, or the window it attached to closed, for any
reason, without changing Git.
Resolve(the default) finishes without a commit.CommitEmptyrecordsspec.messagein an empty commit, authored like any other commit by the request's submitter. It requiresspec.message.
| Outcome | Resolve |
CommitEmpty |
|---|---|---|
| The writes changed files | commit, Committed |
commit, Committed |
| The writes already matched Git | no commit, AlreadyPresent |
empty commit, AlreadyPresent |
No eligible window before attachTimeout |
no commit, NoWindow |
empty commit, NoWindow |
| Only another author's window was open | no commit, WindowMismatch |
no commit, WindowMismatch |
| The target is suspended, or its render fidelity is not established | FinalizeFailed, the cause |
FinalizeFailed, the cause |
| The commit or the empty commit failed | FinalizeFailed |
FinalizeFailed |
| The push failed | WaitingForPush, with the cause, until a push lands; FinalizeFailed if the worker stops first |
the same |
The Ready reason keeps the cause, and status.commit with Pushed=True says the empty commit
reached the remote. A hash proves the message is in Git; NoWindow says the request saw no writes,
so a write delayed past attachTimeout is not in that commit. A request that only saw another
author's window never records one, because the history would then claim more than happened.
A crash between the push and the status write can record the message twice: the worker recognizes a repeated request only while it runs.
Progress and outcome are reported through kstatus-compatible conditions (no phase string).
kubectl get commitrequest shows Ready, its Reason and the Commit; -o wide adds
AuthorAttributed and Pushed. Automation must stop on either Ready=True or Stalled=True;
kubectl wait --for=condition=Ready alone keeps waiting on terminal failures. Ready=True includes
successful no-commit outcomes. Require Pushed=True and status.commit for evidence that the
request produced a pushed commit:
-
Ready (summary):
Trueonce the request reached a non-error terminal outcome. TheReadycondition'sreasonsays which:Committed(a commit was pushed;status.branch/status.commitset), or an outcome with nothing to commit:NoWindow,WindowMismatch, orAlreadyPresent. WithCommitEmpty,NoWindowandAlreadyPresentcan come with a recorded empty commit. A failed finalize isReady=Falsewith reasonFinalizeFailed.AlreadyPresentmeans the remote confirmed there was nothing to add, so it is reported after the push rather than when the local plan found no difference. That costs a few seconds and buys a true answer: a competing Git-side edit can turn the same captured object into a real commit when the worker replays onto the moved branch, and such a request resolvesCommittedinstead. A worker that stops before the push fails the request rather than leaving it to time out. -
Reconciling / Stalled: the kstatus progress/blocked pair.
Reconciling=Truewhile the request is in progress, with the phase the branch worker reports as its reason:Progressing(sent, not yet registered),WaitingForWorker(the GitTarget has no branch worker yet),WaitingForWindow,CollectingWindow, orWaitingForPush.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, empty or not, is in the remote repository.
Every save leaves one CommitRequest behind. When a request finishes (Ready=True or
Stalled=True), the controller writes the time it will be deleted onto it:
metadata:
annotations:
configbutler.ai/delete-after: "2026-10-01T09:30:00Z"Once that time has passed, the controller deletes the request. The annotation is the whole contract, so it is also how you change the outcome for one request:
- Remove the annotation to keep the request.
- Edit the time, in RFC 3339 format, to delete it earlier or later. A value that does not parse keeps the request.
- Set it yourself when you create the request to choose the time up front. The controller never replaces a value that is already there.
The period comes from --commit-request-ttl (Helm: controllerManager.commitRequestTTL, default
48h). Changing it only affects requests that finish afterwards. 0 writes no annotation, so new
requests are kept. A request that finished before this feature existed has no annotation and is
never deleted.
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 authenticated to the shared audit CA. It is not gated on a ClusterProvider
carrying that route, and deliberately so: a route is a partition name, not a claim about an object,
and refusing unknown routes dropped audit batches in flight while a provider was being created or
recreated, which the API server does not retry after a 404. A fact filed under a route nobody reads
costs one key that expires on the fact TTL. Ingress also does not yet bind a particular client
certificate to a 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. This is the one route-shaped rejection there is, and it is counted as gitopsreverser_audit_eventlist_duration_seconds_count{outcome="bare_endpoint_disabled"}. |
| 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.Inside that window a fact can name a later write by a different identity. This is a known limitation of the weakest join tier, not a desired property. The join tries the strongest evidence first, and its floor is a
(namespace, name)tier that is last-writer-wins and bounded only by this TTL. A write that misses the exact tier can settle for that floor. So an out-of-band change, such as a hand-runkubectlcommand against a mirrored object, leaves a fact that a later write to the same name can match while the TTL still holds it, and that commit is then attributed to whoever made the out-of-band change. It clears itself once the fact expires.The floor exists because some writes carry nothing stronger: an aggregated-API write's audit record holds a name and neither a uid nor a resourceVersion, and removing the tier would leave that whole class unattributed. Narrowing the TTL narrows the exposure, at the cost of losing actors whose audit delivery is slow. The full ranking is in
spec/attribution.md. -
--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 unresolved, because the fact it is waiting for was never recorded. If a watched type's commits are consistently authored
unknown (attribution unresolved), check the audit policy before anything else: a type in the policy'slevel: 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: 10000The push receiver is its own HTTP listener, separate from the audit listener. A Git host authenticates with an HMAC or a token over the body and cannot present the client certificate the audit listener requires, so the two cannot share one.
| Flag | Helm value | Default | What it does |
|---|---|---|---|
| (none) | servers.receiver.enabled |
false |
Serve the receiver; the chart then passes the flag below |
--receiver-bind-address |
servers.receiver.bindAddress |
0 (off); Helm: :9292 |
host:port the receiver listens on. 0 or empty serves no receiver |
| (none) | servers.receiver.port |
9292 |
The container port; must match the port in bindAddress |
The chart creates a <release>-receiver Service when the listener is on. Expose it to your
Git host with an Ingress or a Gateway route of your own that terminates TLS, the way Flux's
webhook-receiver Service is exposed. Every request still has to carry a valid signature for the
route it names; a route whose GitProvider has no spec.receiver does not serve.
The listener runs in the pod that runs the branch workers. The chart runs exactly one replica (it
refuses replicaCount above 1), so that is the only pod; running more than one needs ownership
coordination first, and a delivery would then have to reach the pod that owns the branch. The
Service targets port receiver and is exposed on service.ports.receiver (80 by default).
Keep using the 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