Skip to content

Latest commit

 

History

History
1531 lines (1217 loc) · 79.1 KB

File metadata and controls

1531 lines (1217 loc) · 79.1 KB

Configuration model

This guide explains the real configuration objects that drive gitops-reverser after the install steps in the root README.

The short version:

  • GitProvider defines where and how to push
  • ClusterProvider defines the Kubernetes source cluster a target mirrors from
  • GitTarget defines which branch and repository path to write into
  • WatchRule defines which namespaced resources should produce Git writes, and in which source namespaces
  • ClusterWatchRule does the same for cluster-scoped resources
  • CommitRequest optionally asks the operator to close the current commit window now

The chart's optional quickstart values are a convenience layer that creates starter instances of those same resources.

For a first trial, use the root README quick start. It runs configured-author: Git writes work without kube-apiserver audit delivery, and every commit uses the configured committer identity. Add audit attribution later only when you need named Kubernetes users or service accounts in Git history.

How the objects fit together

Config basics diagram showing the relationship between GitProvider, GitTarget, and WatchRule

The usual flow is:

  1. Create a GitProvider for repository access and commit behavior.
  2. Create a ClusterProvider for the source cluster, including the default provider when a target omits its source reference.
  3. Create a GitTarget that points at the Git provider, source cluster, branch, and repository path.
  4. Create one or more WatchRule or ClusterWatchRule objects that point at that target.
  5. Create a CommitRequest only when you want to flush an open window before the normal timer.

That means one repository connection can back multiple targets, and one target can be fed by multiple watch rules.

Why the two provider types have different scopes

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

GitProvider defines the Git remote, credentials, allowed branches, push strategy, and commit behavior.

The important fields are:

  • spec.url: repository URL
  • spec.secretRef.name: Secret with Git credentials such as SSH or HTTPS auth
  • spec.knownHostsRef: optional ConfigMap/Secret with SSH known_hosts shared across providers
  • spec.allowedBranches: branches this provider is allowed to write
  • spec.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:
    - main

GitProvider.spec.secretRef: the credentials Secret

The 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.

Reusing a Flux or Argo CD credentials Secret

The credential reader's design is inspired by both Flux and Argo CD: it accepts their Secret key names alongside the native ones, so you can reuse a Git credentials Secret you already have instead of re-authoring it. The keys read for each auth method:

Credential Native key (recommended) Flux key (also read) Argo CD key (also read)
SSH private key ssh-privatekey identity sshPrivateKey
SSH key passphrase ssh-password password (when an SSH key is present) (unsupported by Argo)
SSH host keys known_hosts known_hosts external ConfigMap → supply via spec.knownHostsRef
HTTP basic auth username + password username + password username + password
HTTP bearer token bearerToken bearerToken bearerToken

Auth precedence is SSH key → HTTP basic → bearer token. Client certificates (mTLS), custom CA certificates, and GitHub App credentials are not supported.

A reused Secret needs write access. Flux and Argo CD only clone, so their Git credentials are often read-only (a read-only deploy key, a read-scoped token). GitOps Reverser pushes commits, so a reused Secret's key or token must have write access on the repository; otherwise the commits will fail to push.

SSH host keys are resolved in priority order: the credentials Secret's own known_hosts, then spec.knownHostsRef (a namespace-local ConfigMap or Secret keyed known_hosts, or ssh_known_hosts for data copied out of Argo's argocd-ssh-known-hosts-cm), then an install-level default known-hosts ConfigMap in the controller's namespace (--default-known-hosts-configmap). If none yields a valid host key, SSH fails closed. Host-key rotation is an admin-owned declarative update; verify fingerprints out of band. The controller flag --insecure-allow-missing-known-hosts relaxes this for throwaway/dev clusters only: it permits SSH when no source provided any known_hosts; a known_hosts that is present but unparseable is always a hard error.

GitProvider.spec.commit

spec.commit configures the identity this connection commits under:

  • committer: the operator identity written as the Git committer
  • signing: 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.

Author vs committer

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=alice answers "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.

Committer identity

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.com

Defaults:

  • name: GitOps Reverser
  • email: 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.

Commit signing

GitOps Reverser signs commits from spec.commit.signing.

The signing Secret uses these data keys:

  • signing.key: PEM-encoded SSH private key
  • passphrase: optional passphrase for encrypted private keys
  • signing.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: true

Bring 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.pub
spec:
  commit:
    committer:
      name: GitOps Reverser
      email: 12345678+gitops-reverser-bot@users.noreply.github.com
    signing:
      secretRef:
        name: gitops-reverser-signing-key

If you start from the Helm chart quickstart, edit the generated GitProvider directly when you want custom spec.commit behavior because the starter values do not currently expose those fields.

For the platform-facing behavior behind "valid signature" versus "verified badge", see commit-signing.md.

ClusterProvider

ClusterProvider names the Kubernetes cluster a GitTarget mirrors from. It is the read-side peer of GitProvider: a target has one source cluster and one Git destination.

default is the conventionally opinionated provider name, not an operator-generated object and not a synonym for the local cluster. Its only special behavior is that a GitTarget which omits spec.clusterProviderRef references a user-created ClusterProvider named default. That provider may omit spec.kubeConfig to use the operator's in-cluster configuration, or set it to mirror a remote cluster.

For a remote source cluster, create a provider with a kubeconfig Secret. The Secret is resolved from the operator's namespace; it is connection material for the operator, not a per-target setting.

apiVersion: configbutler.ai/v1alpha3
kind: ClusterProvider
metadata:
  name: prod-eu-1
spec:
  kubeConfig:
    secretRef:
      name: default-source-kubeconfig
  accessFrom:
    names: [team-a]
    selector:
      matchLabels:
        gitops.configbutler.ai/source-access: "true"

accessFrom is evaluated against namespaces in the control cluster, where GitTargets live. In this example, a GitTarget in team-a, or in a control-cluster namespace with the shown label, may reference prod-eu-1. names and selector are ORed, and an omitted policy admits no control-cluster namespace.

Which namespaces are read from the source cluster is bounded by the source connection's Kubernetes RBAC: the credential's own RBAC is the hard maximum, and there is no second allow-list beside it. A WatchRule may name a source namespace other than its own only when this provider also sets:

  # Deny-by-default. While false, a WatchRule mirroring through this provider may
  # watch only its OWN namespace.
  allowAnySourceNamespace: true

That flag delegates the choice of source namespace to the GitTargets this provider admits; it grants nothing on its own, since the credential still has to be able to read what is chosen. It is required for every cross-source-namespace request, including sourceNamespace: "*". Setting it on an in-cluster provider is a much sharper decision than on a remote one: there the config plane is the watched cluster, so it deliberately bypasses live namespace RBAC and lets the owner of an admitted GitTarget mirror another namespace's objects into a Git destination they control. That is legitimate to grant on purpose, which is why it is explicit and defaults to false.

spec.kubeConfig and GitTarget.spec.clusterProviderRef are immutable: changing either would silently make an existing materialization mean a different source cluster. Rotate credential contents in the referenced Secret instead. qps and burst optionally tune a remote provider's client; the ClusterProvider conditions validate its configuration, while the consuming GitTarget reports the live source reachability and stream state.

Audit route and AuditFactsReceived

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: default

The AuditFactsReceived condition reports whether that route has ever delivered, with a default FACTS printer column:

$ kubectl get clusterprovider
NAME               READY   REASON      FACTS     AGE
default            True    Succeeded   True      31m
srcns-delegating   True    Succeeded   Unknown   4m
  • True / 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 authored unknown (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.

Creating and managing the default provider

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 ClusterProvider named default, including its accessFrom, 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-1

The provider name is deliberately stable. It is the source-cluster identity used for watches and, when audit attribution is enabled, for joining an audit event to the corresponding watch event. Changing a target's source cluster changes what its folder means, so clusterProviderRef is immutable.

GitTarget

GitTarget decides where inside the repository resources are written.

The important fields are:

  • spec.gitProviderRef: which GitProvider backs this target
  • spec.clusterProviderRef: which ClusterProvider supplies resources; omit it to reference the user-created default provider
  • spec.branch: which allowed branch to write to
  • spec.path: required relative path inside the repository; use . only when you deliberately want the repository root
  • spec.encryption: how Secret resources should be encrypted before commit
  • spec.placement: optional policy for where new resources are written (see Where new resources are written); omit it and a new resource takes the folder's one kustomization root, or the built-in canonical path
  • spec.placement.useKustomize: whether the operator maintains a kustomization.yaml for this folder, creating one when the folder has none (see Keeping the folder a kustomize folder)
  • spec.serializeNamespace: whether written documents carry their own metadata.namespace (see Whether documents carry their namespace); omit it and each document's namespace is inferred from the folder
  • spec.prune: which deletion paths may remove documents from this target's folder (see Deletion policy); omit it for the safe default

Example:

apiVersion: configbutler.ai/v1alpha3
kind: GitTarget
metadata:
  name: example-target
  namespace: default
spec:
  gitProviderRef:
    name: example-provider
  # Omit clusterProviderRef to reference the user-created ClusterProvider named "default".
  # clusterProviderRef: {name: prod-eu-1} selects a different source provider.
  branch: main
  path: live-cluster

spec.path is required so a target never writes to the repository root by accident. Use a path such as live-cluster for the first install. To deliberately target the repository root, set path: ".". Do not use a leading slash, and do not add a trailing slash.

The target path is authoritative for snapshot reconciliation. A root target can create, update, and delete managed manifest files at the repository root, so use . only for a repository layout that is dedicated to this target.

If you enable spec.encryption, that applies to Secret resource writes for this target. For SOPS and age details, see sops-age-guide.md.

spec.gitProviderRef references a GitProvider in the same namespace as the GitTarget, by name. The field name says what it points at, so the reference itself carries nothing but the name.

spec.clusterProviderRef references a cluster-scoped ClusterProvider. It defaults to {name: default} when omitted. That is intentionally different from gitProviderRef: a source cluster is a shared physical identity, while a Git destination and its credential normally belong to the target's namespace. The default name can represent either an in-cluster or remote source according to the ClusterProvider the user created.

The most useful status fields are:

  • Ready: true when the target is valid, the Git path is accepted, and watched streams are running.
  • Reconciling: true while initial replay, a recheck, or another coarse progress step is in flight.
  • Stalled: true when the target is blocked until a human fixes configuration, RBAC, or Git path content.
  • Validated and EncryptionConfigured: 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 documents spec.prune.mode is keeping, and under which mode.
  • LayoutResolved: what the last scan resolved about the folder's shape, with status.placement carrying the detail. See below.

Use conditions for automation.

GitTarget.spec.commit: how writes become commits

spec.commit says how this target's writes are batched into commits and how those commits are phrased. Both describe the folder being written rather than the route to the repository, which is why they sit here and not on the GitProvider: two GitTargets sharing one connection can disagree about both, so an RBAC folder that wants a commit per change and an app folder that wants a burst coalesced need not be two connections.

spec:
  commit:
    window: "5s"
    message:
      groupTemplate: "{{.Author}} on {{.GitTarget}}: {{.Count}} resource(s)"

The commit window (spec.commit.window)

spec.commit.window controls how arriving events are grouped into commits. The timer resets on every event; when it has been silent for the configured duration, the buffered events for a given author are written as one commit. The default is 5s. Setting 0s opts into per-event commits in the steady-state.

A burst (kubectl apply -k, helm upgrade, an ArgoCD sync wave) becomes one commit per author with a summary subject; isolated edits still produce one commit each.

An unparseable or negative value is rejected on the object (Validated=False, reason InvalidConfig). A value already stored before that check falls back to the 5s default at the write rather than stopping the mirror.

Commit message templates

There are three templates, one per commit shape:

  • spec.commit.message.eventTemplate: per-event commits (only used when spec.commit.window is 0s).
  • spec.commit.message.groupTemplate: grouped commits produced by the commit window (the common case).
  • spec.commit.message.reconcileTemplate: reconcile commits (the mark-and-sweep reconcile path; one commit per synced type).
spec:
  commit:
    message:
      eventTemplate: "[{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Name}}"
      groupTemplate: "{{.Author}} on {{.GitTarget}}: {{.Count}} resource(s)"
      reconcileTemplate: "reconciled {{.Count}} {{.Resource}}"

eventTemplate can use:

  • Operation
  • Group
  • Version
  • Resource
  • Namespace
  • Name
  • APIVersion
  • Username
  • GitTarget

Username is empty whenever no actor was named, both in configured-author mode and when attribution ran and did not resolve. The attribution-unresolved sentinel is scoped to the Git author header and deliberately does not reach templates or message bodies, so a template rendering {{.Username}} never has to special-case it. Use git log (or author_kind="unresolved") to tell the two apart.

groupTemplate can use:

  • Author
  • GitTarget
  • Count
  • Operations (map of CREATE/UPDATE/DELETE counts)
  • Resources (slice of {Group, Version, Resource, Namespace, Name})

reconcileTemplate can use:

  • Count
  • GitTarget
  • Group
  • Version
  • Resource
  • APIVersion
  • Revision

Group/Version/Resource/APIVersion name the synced type for a per-type reconcile and Revision is the cluster resourceVersion the reconcile was pinned to. The default, reconciled {{.Count}} {{if .Resource}}{{.Resource}}{{else}}resources{{end}}{{if .Revision}} (last resourceVersion: {{.Revision}}){{end}}, renders e.g. reconciled 6 secrets (last resourceVersion: 1331). The type and revision fields are empty for a whole-target reconcile or a pure sweep, so guard a template that references them (the default uses {{if .Resource}} / {{if .Revision}}) to avoid an identity-less subject.

Examples:

spec:
  commit:
    message:
      eventTemplate: "chore: [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Name}}"
spec:
  commit:
    message:
      eventTemplate: "[{{.Operation}}] {{.Resource}}/{{.Name}} ({{.Username}})"
spec:
  commit:
    message:
      reconcileTemplate: "reconciled {{.Count}} {{.Resource}}@{{.Revision}}"

Seeing what a target will do, before it does it

Point one at a scratch branch.

spec:
  gitProviderRef: {name: homelab}
  branch: gitops-preview          # not main
  path: apps/checkout

It commits, and you read the commits: the real files, the real resources: registrations, the real deletes, in a diff you can review. Nothing is simulated, so nothing can be wrong about it. When you are happy, delete the preview target and declare the real one. spec.branch is immutable, so the two are always separate objects, and deleting the preview cannot disturb the real folder.

For inspecting a repository without a cluster at all, use the manifest-analyzer CLI.

spec.suspend is not this. It is the next section.

Stopping a target from writing (spec.suspend)

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: true

A suspended target keeps its watches, keeps receiving events, and keeps scanning its folder. A valve that also stopped looking would leave status.placement frozen at whatever the folder looked like the moment you turned it, which is exactly when a stale answer costs the most. It plans no new commit. Ready stays True with reason Suspended, because not writing is the configured outcome rather than a fault; every other gate still applies, so a suspended target with a broken GitProvider is still not ready.

status.retention is not published while a target is suspended: no resync sweeps, so nothing is counted, and a published zero would read as "converged" when it means "not measured".

It takes effect at the next planning boundary, not instantly. Work already committed locally when you set it is still pushed, so a target suspended during a push cooldown can publish one more commit seconds later. That is deliberate: a local commit that is never pushed would sit in the operator's checkout and surface later, out of order, when you resume. Suspend is a valve on new work, not an undo.

Clearing suspend resumes from the cluster's current state on the next resync. The writes suppressed while it was set are not replayed, so what lands is what the cluster holds then, not a backlog of the values it passed through.

To re-read the folder now rather than on the periodic cadence, stamp the reconcile-request annotation with any value that changes:

kubectl annotate gittarget checkout \
  reconcile.configbutler.ai/requestedAt="$(date -u +%FT%TZ)" --overwrite

The spelling is Flux's reconcile.fluxcd.io/requestedAt with our own group; the value carries no meaning, only its change does.

What the folder resolved to (status.placement)

status.placement is what the last scan learned about the folder, and it is available before the target has ever written. It answers one question: why did a write take the shape it did, or why was it refused? It deliberately restates nothing the spec already carries:

status:
  conditions:
    - type: LayoutResolved
      status: "True"
      reason: SingleKustomization       # SingleKustomization | Ambiguous | None
      message: 'render root "." governs new files; it renders ../../base, which is read-only input'
  placement:
    mode: KustomizeOverlay              # Plain | KustomizeRoot | KustomizeOverlay
    renderRoot: .
    readOnlyBases: ["../../base"]
    resolvedAtRevision: 9f3c1ab
    resolvedAt: "2026-07-30T09:14:22Z"
  • mode is how this folder is written, and it is the field that predicts the surprises:

    Plain KustomizeRoot KustomizeOverlay
    a new document written written and registered in resources: same
    a delete file removed file removed and its resources: entry dropped same, unless the object is inherited
    deleting an object the folder inherits from its base n/a n/a a $patch: delete is authored into the overlay; nothing is removed
    editing a field the base owns n/a n/a authored into the overlay for images:/replicas:, refused otherwise
    can 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.

  • renderRoot is the kustomization directory that governs new documents, relative to spec.path; . is the folder itself. Empty for Plain, and when the folder has several roots.

  • readOnlyBases are directories the folder renders but may never write to, spelled the way the overlay's own resources: spells them. Non-empty exactly when mode is KustomizeOverlay, and an edit landing on a document under one of them is what a WriteBoundaryRefused refusal is about.

  • resolvedAtRevision and resolvedAt date 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.

Deletion policy (spec.prune.mode)

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: Always

Omitting 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.

Seeing what was kept

A retained document is invisible in Git by design: nothing is written, so a retaining mirror and a converged one look identical in the folder and in git log. Three signals report it instead, and none of them is a failure: retention is the configured outcome, so no condition goes False for it.

$ kubectl get gittarget acme -o jsonpath='{.status.retention}'
{"mode":"OnEvent","retainedDocuments":3,"observedTime":"2026-07-21T13:20:00Z"}
  • status.retention.retainedDocuments is how many managed documents a converged mirror would not hold. 0 means a resync ran and found nothing to retain; an absent retention block means no resync has reported yet, which is not the same thing. mode is the effective mode the count was produced under, and the only place a GitTarget that predates spec.prune shows one at all.
  • A throttled log line names the target, its path, and the scope (one per target folder per 10 minutes; the full detail is at -v1).
  • gitopsreverser_prune_retained_documents_total, labeled by gittarget_namespace, gittarget_name, and prune_mode.

status.retention covers the resync sweep only. Under Never a suppressed source DELETE is not counted, so a Never target can report 0 while still declining to mirror deletes.

The count is refreshed when a resync runs, so it lags a change in the cluster until the next one. Read observedTime before treating a 0 as live.

spec.prune is mutable (unlike gitProviderRef, branch, and path), so a target can be moved to Always once its watch scope is confirmed, without recreating it. Widening it to Always re-lists the target's watched scopes, so the cleanup runs on the edit instead of waiting for the next replay. Tightening it applies to the next write and leaves the streams alone, which is what makes it usable as a stop button.

Where new resources are written (spec.placement)

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.

How a path is chosen (the resolution ladder)

For each new resource the operator walks this order and stops at the first that produces a path:

  1. spec.placement.byType[<exact type>]: an explicit template for that resource's type, if you declared one.
  2. spec.placement.default: your explicit catch-all template, if you declared one.
  3. 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 its resources: 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.
  4. Built-in canonical path: {namespace}/{group}/{resource}/{name}.yaml, namespace first, the group omitted for core resources, no version segment, _cluster/ in place of the namespace for cluster-scoped resources (an illegal namespace name, so it can never clash with a real one), and a .sops.yaml suffix 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.

Knowing when you need a rule

Every placement is counted, labeled with the GitTarget and the resource type, so "does this folder need a byType line?" is a query rather than a folder inspection:

# types landing on the built-in path, per target: each is a candidate for a byType entry
sum by (gittarget_namespace, gittarget_name, group, version, resource) (
  increase(gitopsreverser_placements_total{source="canonical"}[24h])
)

source="by_type" is the exact rule you wrote for that type, source="default" is your catch-all answering for a type no byType line names, and source="kustomize_root" is a folder whose own structure answered. None needs attention, though a default series climbing where you expected by_type is worth a look: it means a rule you thought you had written is not matching. Two companions matter as much:

  • gitopsreverser_placement_refusals_total{reason} counts resources the operator did not write: a template that escapes spec.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 whose resources: 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.

Namespaces in a kustomize folder

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.

Declaring a layout (byType / default)

Set spec.placement when the layout you want is neither of the two things the operator can work out for itself (a folder's single kustomization root, or the canonical path). For example, a bundle every ConfigMap joins, or a per-namespace layout:

spec:
  placement:
    byType:
      v1/configmaps: "{namespace}/configmaps.yaml"     # bundle every ConfigMap of a namespace into one file
      v1/secrets: "{namespace}/secrets/{name}.yaml"    # one file per Secret
    default: "{namespaceOrCluster}/{group}/{resource}/{name}.yaml"
  • byType maps an exact [group/]version/resource key (core resources omit the group, e.g. v1/configmaps; grouped resources include it, e.g. apps/v1/deployments) to a path template.
  • default is the template for any type with no byType entry. 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 Validated gate: an unknown variable, a path that escapes spec.path (a leading / or ..), or a non-.yaml/.yml suffix fails the target before any write.

Template variables

Every value is sanitized for use as a single path segment. An empty segment (an omitted variable, e.g. {group} for a core resource) is dropped from the final path, so {group}/{resource}/{name}.yaml renders configmaps/app.yaml, not /configmaps/app.yaml. Example values are for an apps/v1 Deployment named api in namespace team-a:

Variable Renders Example
{name} resource name api
{namespace} the resource's namespace; empty for a cluster-scoped resource team-a
{namespaceOrCluster} the namespace, or the literal _cluster for a cluster-scoped resource team-a (a Node → _cluster)
{resource} plural resource name deployments
{group} API group; empty for core resources apps (a ConfigMap → empty)
{groupPath} the API group as a path segment; equivalent to {group} today (the empty core-group segment is dropped either way) apps
{version} API version v1
{apiVersion} manifest apiVersion: group/version, or version alone for core apps/v1 (a ConfigMap → v1)
{kind} manifest kind Deployment
{scope} namespaced or cluster (a readable label, not a namespace-position value) namespaced
{sensitiveSuffix} .sops.yaml for a sensitive resource, .yaml otherwise .yaml (a Secret → .sops.yaml)

{namespace} vs {namespaceOrCluster}, the one to get right. For a cluster-scoped resource {namespace} is empty, so its whole path segment vanishes: a template {namespace}/{resource}/{name}.yaml renders clusterroles/admin.yaml for a ClusterRole (no scope folder at all). Use {namespaceOrCluster} when a single template must also place cluster-scoped resources; it keeps a stable _cluster/ segment (_cluster/clusterroles/admin.yaml) so namespaced and cluster-scoped resources stay cleanly separated. {scope} is a descriptor (cluster/namespaced), not a substitute, so don't use it as the folder for cluster resources.

Sensitivity is a write-safety rule, not a placement setting

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 byType route 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 default that is not identity-complete (e.g. "all.yaml") is rejected unless every sensitive type has its own identity-complete byType entry, 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 as placementSkipped) rather than written unsafely. It is not surfaced as a dedicated status condition today.

Choosing between the two layout fields

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.

Whether documents carry their namespace (spec.serializeNamespace)

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:

  • true for 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.
  • false for a folder whose namespace comes from outside it: a Flux Kustomization's spec.targetNamespace, an Argo CD Application's spec.destination.namespace, or a hand-written kustomization.yaml in the folder that sets namespace:. 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.

Keeping the folder a kustomize folder (spec.placement.useKustomize)

spec:
  path: apps/checkout
  serializeNamespace: false
  placement:
    useKustomize: true

It 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.

Additional sensitive resources

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/tenantsecrets

Entries 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.

Kustomize support in the target path

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, and metadata.namespace is kept out of the file on write.
  • images: and replicas: overrides: a live change produced by an override entry (an image tag, name, or digest pinned by images:, or a replica count pinned by replicas: (including kubectl 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 inside spec.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

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 to
  • spec.rules: one or more resource-match rules
  • spec.rules[].sourceNamespace: the source-cluster namespace that item watches; omitted means the rule's own namespace

Watching a different source 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:

  1. the GitTarget's namespace is admitted by its ClusterProvider's accessFrom; and
  2. that ClusterProvider sets allowAnySourceNamespace: true.

There is no third condition on the GitTarget. What a target may read from its source cluster is bounded by that cluster's RBAC for the provider's credential: a request these two conditions permit still fails with a clean 403 if the credential cannot read the namespace.

apiVersion: configbutler.ai/v1alpha3
kind: WatchRule
metadata:
  name: repo-config
  namespace: tenant-acme
spec:
  gitTargetRef:
    name: acme
  rules:
    - resources: [configmaps]        # omitted → tenant-acme, this rule's own namespace
    - resources: [secrets]
      sourceNamespace: repo-config   # one admitted source namespace
    - resources: [deployments]
      sourceNamespace: "*"           # every namespace the credential can read

"*" is one cluster-wide list and one cluster-wide watch per matched type, not one of each per namespace, so its cost does not grow with the cluster. It is bounded by the source credential's RBAC and by nothing else, which is why it is refused outright while allowAnySourceNamespace is false.

A "*" item and a named-namespace item for the same type are peers, not duplicates: each rule carries its own operations filter, so a target holding both runs two streams over overlapping objects. That is correct rather than something to tune away.

The outcome for all items is aggregated into one SourceNamespaceAuthorized condition, also shown by kubectl get watchrules -o wide. A denied explicit name refuses the whole WatchRule (Ready=False, Stalled=True, no streams) rather than silently trimming that item and mirroring part of what you asked for; the message names the failing item by index and by what it selects. Authorization is re-evaluated on every reconcile, so withdrawing allowAnySourceNamespace revokes a running rule rather than only affecting new ones.

This changes only which namespace is watched. Git placement always follows each mirrored object's own namespace, so the rule above writes secrets under repo-config/…, not tenant-acme/….

A namespace allow-list cannot partition cluster-scoped objects, which have no namespace. A ClusterWatchRule receives every such object its source credential can read. If a tenant must not see another tenant's cluster-scoped objects, give each tenant its own ClusterProvider and credential, so that credential's RBAC is the boundary.

Each entry in spec.rules is a logical OR. A resource matching any rule is watched. The rule fields are:

  • operations: CREATE, UPDATE, DELETE, or *; omitted means all operations.
  • apiGroups: "" for the core group, * for all groups, or omitted to resolve the named resource across the served API surface.
  • apiVersions: a served version such as v1; omitted means the preferred served version.
  • resources: plural resource names such as configmaps, secrets, or *.

Subresources such as deployments/scale are not valid rule resources. GitOps Reverser mirrors top-level resources; selected subresource effects are handled separately by the controller.

Example:

apiVersion: configbutler.ai/v1alpha3
kind: WatchRule
metadata:
  name: example-watchrule
  namespace: default
spec:
  gitTargetRef:
    name: example-target
  rules:
    - operations: [CREATE, UPDATE, DELETE]
      apiGroups: [""]
      apiVersions: ["v1"]
      resources: ["configmaps", "secrets"]

Use WatchRule for every namespaced resource, whether or not it lives in the GitTarget's own namespace.

ClusterWatchRule

ClusterWatchRule is the cluster-scoped variant. Use it for cluster-scoped resources such as nodes, clusterroles, or CRDs. It has no scope choice and no source-namespace selection: to mirror namespaced resources across namespaces, use a WatchRule with rules[].sourceNamespace.

Because it is cluster-scoped, its gitTargetRef must include the namespace of the referenced GitTarget, and that namespace must be admitted by the target's ClusterProvider.

Example:

apiVersion: configbutler.ai/v1alpha3
kind: ClusterWatchRule
metadata:
  name: cluster-rbac
spec:
  gitTargetRef:
    name: example-target
    namespace: default
  rules:
    - operations: [CREATE, UPDATE, DELETE]
      apiGroups: ["rbac.authorization.k8s.io"]
      apiVersions: ["v1"]
      resources: ["clusterroles", "clusterrolebindings"]

Cluster-scoped objects have no namespace, so no namespace policy bounds a ClusterWatchRule at all: it is intentionally cluster-global, limited only by its source credential's Kubernetes RBAC. Use this sparingly. It grants the widest reach of any rule kind and usually belongs to cluster-admin-managed setups.

CommitRequest

CommitRequest is a one-shot "save now" signal for a same-namespace GitTarget. It does not create or change watch rules. Instead, it asks the branch worker to finalize a matching open commit window for the request's author instead of waiting for GitTarget.spec.commit.window.

The important fields are:

  • spec.gitTargetRef.name: target whose open window should be finalized
  • spec.message: optional verbatim commit message
  • spec.closeDelaySeconds: optional 0-300 second delay before the open window is closed, after the request author is known, an extra collect window

Example:

apiVersion: configbutler.ai/v1alpha3
kind: CommitRequest
metadata:
  name: save-now
  namespace: default
spec:
  gitTargetRef:
    name: example-target
  message: "save default/example-target"
  closeDelaySeconds: 2

The entire spec is immutable. Create a new CommitRequest for each save attempt.

Progress and outcome are reported through kstatus-compatible conditions (no phase string). kubectl get commitrequest surfaces Ready, AuthorAttributed, and Pushed; kubectl wait --for=condition=Ready blocks until the request settles:

  • Ready (summary): True once the request reached a non-error terminal outcome. The Ready condition's reason says which: Committed (a commit was pushed; status.branch/status.sha set), or a benign no-commit: NoWindowInGrace, WindowMismatch, or AlreadyPresent. A failed finalize is Ready=False with reason FinalizeFailed.
  • Reconciling / Stalled: the kstatus progress/blocked pair. Reconciling=True while the request is finalizing or waiting through closeDelaySeconds; Stalled=True when the finalize failed and needs attention (kstatus reports the object Failed).
  • AuthorAttributed: True with reason AttributedFromAdmission when the internal commands admission webhook captured the request submitter. False with reason CommitterFallback means capture ran but no admission record exists; False with reason AuthorCaptureDisabled means 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: True once the commit is in the remote repository.

Audit ingestion settings

Object state comes from Kubernetes watch, not from audit. Audit is an optional attribution lookup: kube-apiserver posts audit events to a named path, /audit-webhook/<audit-route>, where the route is ClusterProvider.spec.attribution.auditRoute and defaults to the provider's own name. The operator extracts a minimal attribution fact from each (auditID, user, verb, resourceVersion, GVR, namespace, name, UID, status, timestamps) and appends it to a per-type fact log: one append per type per request, not one per event. The watch side follows the log for the types it is watching, holds the facts in a bounded, TTL'd in-memory index, and attaches the commit author to each watch event by matching a fact within a bounded grace window. Redis also stores per-watch resume cursors, so short reconnects can resume a normal watch from the last processed resourceVersion when the apiserver can still serve that history.

Named ingress is currently authenticated to the shared audit CA and gated on the provider name existing; it does not yet bind a particular client certificate to that provider. Do not use one shared audit client credential to attribute several independently administered source clusters. A deployment that needs that boundary should keep sources isolated until provider-bound ingress authentication is shipped.

Route a shared audit stream by event annotation

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-cluster

When this option is set, the receiver reads example.io/source-cluster from each event. Its value is the audit route the event belongs to, so events in the same batch may route to different partitions. A ClusterProvider joins a route by setting spec.attribution.auditRoute to the same value; it defaults to the provider's own name.

The bare endpoint never guesses a route. Rejection happens at two levels:

Situation Result
A request reaches /audit-webhook while auditRouteAnnotationKey is unset The whole request is rejected with 400. The bare endpoint is not enabled, so a producer posting to it is misconfigured.
An event carries no annotation That event is rejected: it produces no attribution fact and is never credited to a fallback route. The request still returns 200, so correctly-annotated events in the same batch are kept.

An annotation naming a route no ClusterProvider has declared is not rejected. The route is a partition name rather than a claim about an object, so the fact is stored and expires unread if nothing joins it. That keeps ingestion free of Kubernetes reads, and lets a provider created after its events started flowing pick them up.

The second row is a per-event rejection rather than a per-request one on purpose. A shared stream is heterogeneous by definition, so failing the whole batch would discard events that routed correctly and leave the apiserver retrying a batch that can never succeed. Rejected events are counted and logged, so a producer that is not stamping the annotation is visible rather than silent. If that count rises, point the producer at /audit-webhook/<audit-route> instead.

Use an annotation that the producing control plane sets consistently as source metadata. This is routing metadata only: it keeps the audit fact and the watch event in the same source-cluster partition, so a user from one logical cluster can never be credited for a matching object in another.

Valkey/Redis is optional: when --redis-addr is set, watch resume cursors are stored so restarts pick up where they left off; when left empty, watches cold-replay from scratch on restart instead. The Helm chart defaults to configured-author (attribution.enabled: false): the audit webhook is unused and every mirrored-resource commit is authored by the configured committer.

Attribution needs a fact transport, which is not the same as needing Redis:

--author-attribution-transport What carries the facts --redis-addr
redis (default) Redis streams, one per (audit route, type) required
memory an in-process ring buffer may be empty

Choose redis for anything you would call production. It is the only transport whose facts survive a restart, and the only one that can reach a second process, so it is what an eventual HA topology needs. Choose memory for a single-pod install where running a Valkey StatefulSet to name commit authors is out of proportion to the benefit. The cost is worth stating plainly: in-memory facts do not survive a restart, so events in flight across one lose their author. That is already true of any restart today, which is what keeps the difference small.

memory is refused at startup with more than one replica. The transport only carries facts within one process, so with two replicas an audit request answered by one pod leaves a watch running on the other with nothing to join, and every commit through it would be authored attribution-unresolved with nothing saying why. The chart passes replicaCount in for this check.

queue:
  redis:
    addr: "valkey:6379"
    auth:
      existingSecret: "valkey-auth"
      existingSecretKey: "password"

Every key this operator writes is rooted at queue.redis.keyPrefix (--redis-key-prefix, default gitops-reverser). Give each reverser its own prefix when several share one Redis/Valkey: --redis-db separates only 16 logical databases, and one reverser per tenant or per branch environment passes that long before it reaches any real Redis limit.

When attribution is enabled, these flags tune the join:

  • --author-attribution-ttl (default 10m): how long an attribution fact is retained waiting for the matching watch event to join it.

  • --author-attribution-grace (default 3s): bounded per-event wait for a matching audit fact before a watch event ships authored by the attribution-unresolved sentinel. Note the delivery floor: the apiserver's own --audit-webhook-batch-max-wait delays every fact by up to that much, so a grace at or below it will lose actors systematically. The wait ends the moment the fact arrives, so a generous grace costs latency only when a fact never comes.

    A removal is the exception, and it is worth budgeting for. It waits for evidence about the DELETION rather than settling for the object's last write, so an object edited by one person and deleted by another is credited to the deleter rather than to the editor. Sometimes no delete fact ever arrives, most often because the cluster's audit policy excludes the type (which is true of every type in the recommended policy's runtime-noise list). In that case the removal spends the whole grace before naming the last writer, which is the same answer it would have given immediately. Measured across one e2e run, where the grace is 10s, the cost lands on exactly that case and nowhere else: a removal that finds its delete evidence resolves in about 70ms, while one that never does averages about 3.1s before falling back. Creates and updates do not consult the deletion tiers at all. The watch shard is single-threaded, so the wait also delays the events queued behind it on the same (GitTarget, type, scope). Lowering this flag bounds both directly.

    Watching a type your audit policy excludes costs more than attribution. Every removal on such a type spends the full grace before shipping as the committer, because the fact it is waiting for was never recorded. If a watched type's commits are consistently committer-authored, check the audit policy before anything else: a type in the policy's level: None list can never be attributed, and no operator-side setting changes that.

  • --author-attribution-max-facts-per-type (default 4096) and --author-attribution-max-facts (default 65536): 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 on attribution_fact_index_evictions_total{reason}.

  • --author-attribution-collection-window (default 30s): how long after a deletecollection a 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 (default 10000): how many object UIDs a deletecollection fact 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 on attribution_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: 10000

Quickstart vs hand-managed resources

Keep using the root README quickstart when you want the fastest first commit. The chart's quickstart values create a starter GitProvider, GitTarget, and WatchRule for you.

The starter GitTarget writes under live-cluster by default. Override quickstart.gitTarget.path=. only when you want the starter target to own the repository root.

Move to hand-managed resources when you want:

  • more than one GitTarget
  • more than one watch rule
  • cluster-scoped watching with ClusterWatchRule
  • ad hoc save requests with CommitRequest
  • direct control over GitProvider.spec.commit
  • direct control over encryption settings

The chart value reference for the starter quickstart block lives in charts/gitops-reverser/README.md.

What to read next