Skip to content
Merged
92 changes: 92 additions & 0 deletions api/v1alpha3/gittarget_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,52 @@ type GitTargetSpec struct {
// +optional
Placement *GitTargetPlacementSpec `json:"placement,omitempty"`

// Design rationale, kept out of the generated CRD description by the blank line below.
//
// It sits at the TOP LEVEL rather than inside placement, and the line between the two is
// retroactivity. spec.placement decides where a NEW document goes and never moves one already
// written; this governs the bytes of EVERY write, and it also decides how a managed document
// is FOUND — a document whose namespace is inherited is located in the file bytes by a
// namespace-less identity. A field with that blast radius nested inside a struct documented as
// "new files only" would be a trap.
//
// It is a *bool because no plain default preserves today's behavior: false breaks a flat
// folder, whose documents must carry their own namespace or they are ambiguous, and true
// writes a redundant line into every kustomize folder that already supplies one. nil means
// infer, which is what the operator has always done.
//
// The name deliberately avoids writeNamespace. "Write" is the most loaded word in this API —
// the write boundary, the write jail, WriteBoundaryRefused — so writeNamespace: false invites
// the reading "never write to this namespace", a permission, which is precisely what the
// neighbouring sourceNamespace fields are.
//
// See docs/layout/model.md § "serializeNamespace".

// SerializeNamespace declares whether a committed document carries its own
// metadata.namespace. It governs every write this target makes, not just the first one, and it
// applies to NAMESPACED resources only — a cluster-scoped document has no namespace, so the
// field is ignored for it rather than being an error.
//
// Omitted, the namespace is INFERRED per document, which is the behavior a target that says
// nothing has always had: metadata.namespace is omitted only when the kustomization governing
// the document's path sets namespace: to that resource's own namespace, and written explicitly
// in every other case. Leave it unset for a folder that is legitimately non-uniform — a tree of
// nested kustomize roots, each supplying its own namespace — because inference resolves each
// document against the root governing its own path.
//
// true always writes it: the setting for a flat folder applied directly, where nothing
// downstream supplies a namespace and a document without one is ambiguous.
//
// false never writes it, and is a claim about the whole folder: something outside this
// repository — a Flux Kustomization's targetNamespace, an Argo Application's
// destination.namespace, or a kustomization this target maintains itself — supplies the
// namespace instead. Because a namespace-less document takes its namespace from a single
// supplier, an explicit false admits exactly ONE source namespace: a second WatchRule bringing
// another namespace to this target is refused, with GitPathAccepted=False and reason
// MultipleSourceNamespaces.
// +optional
SerializeNamespace *bool `json:"serializeNamespace,omitempty"`

// Design rationale, kept out of the generated CRD description by the blank line below.
//
// It defaults to a concrete {name: "default"} rather than an implicit nil so a target that omits
Expand Down Expand Up @@ -228,6 +274,52 @@ type GitTargetPlacementSpec struct {
// — give every sensitive type an explicit identity-complete ByType entry.
// +optional
Default string `json:"default,omitempty"`

// Design rationale, kept out of the generated CRD description by the blank line below.
//
// It has exactly ONE job, and the name says less than the field does. Registering a new file
// with the kustomization that already governs its directory is an INVARIANT rather than a
// setting (#295, fixed by #319): a file no kustomization lists is a file nothing renders, so
// that happens in both columns. What this flag decides is only what to do when there is no
// root at all.
//
// So useKustomize: false does not mean "leave kustomize alone". If a folder's root must not be
// touched, do not point a GitTarget at that folder: the ancestor walk is bounded by the write
// jail, so a kustomization ABOVE spec.path is never edited, and rooting the target lower is
// the existing, better-tested way to say it.
//
// It belongs inside placement, unlike spec.serializeNamespace, because it is retroactive in
// the same way the rest of this struct is: it decides whether a NEW file's directory has a
// root to join, and creates one if not. Nothing already written moves or changes.
//
// See docs/layout/model.md § "useKustomize".

// UseKustomize declares that this folder is a kustomize folder whose root the operator
// maintains. It controls one thing: what happens when NO kustomization governs the path a new
// document lands at.
//
// Omitted or false, the document is written and nothing else is touched. True, a
// kustomization.yaml is created at spec.path and the new document is registered in it as part
// of the same commit.
//
// A created root ADOPTS the folder: its resources: lists every managed document already there
// as well as the new one. Turning a folder into a kustomize folder means the folder, and a
// root naming one file would leave every other file in Git but out of every render.
//
// A created root carries NO namespace:. It holds an apiVersion, a kind and the resources: list
// and nothing else, so the namespace still comes from the documents (serializeNamespace unset
// or true) or from whatever installs the folder (serializeNamespace false).
//
// A folder that already has a kustomize render root never gains a second one, because two
// render roots is an ambiguous folder that stops accepting new documents altogether. If a
// byType or default template places a document outside the existing root, that placement is
// REFUSED rather than committed as a file no kustomization would render.
//
// It has NO bearing on a folder that already has a root. A new file is always registered with
// the nearest kustomization governing it, whatever chose its path, because a file no
// kustomization lists is a file kustomize never builds.
// +optional
UseKustomize bool `json:"useKustomize,omitempty"`
}

// GitTargetStatus defines the observed state of GitTarget.
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha3/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
{{- if .Values.servers.admission.enabled }}
---
# Captures the authenticated submitter of our own command kinds (a CommitRequest today)
# at admission, into the command-author Redis corner the controller reads back with no
# wait. Narrow by construction: one rule per command kind. failurePolicy is Ignore and
# the handler always allows, so a user's CommitRequest never depends on this webhook —
# a miss leaves the request without a claimed actor (AuthorAttributed=False).
# See docs/spec/commitrequest-admission-authorship.md.
# Two unrelated jobs on one endpoint, both narrow by construction.
#
# It captures the authenticated submitter of our own command kinds (a CommitRequest today)
# into the command-author Redis corner the controller reads back with no wait; a miss
# leaves the request without a claimed actor (AuthorAttributed=False). See
# docs/spec/commitrequest-admission-authorship.md.
#
# It also rejects a WatchRule that would bring a second source namespace to a GitTarget
# declaring spec.serializeNamespace: false. That rejection is FEEDBACK, not enforcement:
# the writer refuses the same configuration at the write whether or not this webhook ran,
# which is why failurePolicy stays Ignore. See docs/layout/model.md.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
Expand All @@ -27,7 +32,8 @@ webhooks:
path: /validate-operator-types
port: {{ .Values.servers.admission.port }}
# Ignore, not Fail: a missed author capture must leave the request unnamed, never
# reject a user's CommitRequest.
# reject a user's CommitRequest, and the WatchRule check is feedback the write path
# already enforces on its own.
failurePolicy: Ignore
matchPolicy: Equivalent
rules:
Expand All @@ -40,6 +46,18 @@ webhooks:
resources:
- commitrequests
scope: Namespaced
# UPDATE as well as CREATE: a rule can grow a second sourceNamespace long after it
# was created, and that edit is the same mistake.
- apiGroups:
- configbutler.ai
apiVersions:
- v1alpha3
operations:
- CREATE
- UPDATE
resources:
- watchrules
scope: Namespaced
# Redis write on real requests; nothing on dry-run (the handler honors this).
sideEffects: NoneOnDryRun
timeoutSeconds: {{ .Values.servers.admission.timeoutSeconds }}
Expand Down
5 changes: 3 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1155,15 +1155,16 @@ func newManager(

// setupAdmissionWebhooks registers both handlers on the one admission server: the
// always-allow observer (a future-policy extension point) and the validate-operator-types
// handler that captures the submitter of our own command kinds into commandAuthorStore.
// handler, which captures the submitter of our own command kinds into commandAuthorStore and
// validates a WatchRule's source namespaces against the GitTarget it names.
func setupAdmissionWebhooks(mgr ctrl.Manager, commandAuthorStore *queue.CommandAuthorStore) {
mgr.GetWebhookServer().Register(
webhookhandler.ValidateAllPath,
&ctrladmission.Webhook{Handler: webhookhandler.AdmissionAllowHandler{}},
)
// Leave Store as a nil interface when there is no Redis-backed store, so the handler
// no-ops rather than dereferencing a typed-nil *CommandAuthorStore.
operatorTypesHandler := &webhookhandler.ValidateOperatorTypesHandler{}
operatorTypesHandler := &webhookhandler.ValidateOperatorTypesHandler{Client: mgr.GetClient()}
if commandAuthorStore != nil {
operatorTypesHandler.Store = commandAuthorStore
}
Expand Down
52 changes: 52 additions & 0 deletions config/crd/bases/configbutler.ai_gittargets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,33 @@ spec:
such as "all.yaml") is only valid when a sensitive resource can never reach it
— give every sensitive type an explicit identity-complete ByType entry.
type: string
useKustomize:
description: |-
UseKustomize declares that this folder is a kustomize folder whose root the operator
maintains. It controls one thing: what happens when NO kustomization governs the path a new
document lands at.

Omitted or false, the document is written and nothing else is touched. True, a
kustomization.yaml is created at spec.path and the new document is registered in it as part
of the same commit.

A created root ADOPTS the folder: its resources: lists every managed document already there
as well as the new one. Turning a folder into a kustomize folder means the folder, and a
root naming one file would leave every other file in Git but out of every render.

A created root carries NO namespace:. It holds an apiVersion, a kind and the resources: list
and nothing else, so the namespace still comes from the documents (serializeNamespace unset
or true) or from whatever installs the folder (serializeNamespace false).

A folder that already has a kustomize render root never gains a second one, because two
render roots is an ambiguous folder that stops accepting new documents altogether. If a
byType or default template places a document outside the existing root, that placement is
REFUSED rather than committed as a file no kustomization would render.

It has NO bearing on a folder that already has a root. A new file is always registered with
the nearest kustomization governing it, whatever chose its path, because a file no
kustomization lists is a file kustomize never builds.
type: boolean
type: object
providerRef:
description: |-
Expand Down Expand Up @@ -377,6 +404,31 @@ spec:
- Always
type: string
type: object
serializeNamespace:
description: |-
SerializeNamespace declares whether a committed document carries its own
metadata.namespace. It governs every write this target makes, not just the first one, and it
applies to NAMESPACED resources only — a cluster-scoped document has no namespace, so the
field is ignored for it rather than being an error.

Omitted, the namespace is INFERRED per document, which is the behavior a target that says
nothing has always had: metadata.namespace is omitted only when the kustomization governing
the document's path sets namespace: to that resource's own namespace, and written explicitly
in every other case. Leave it unset for a folder that is legitimately non-uniform — a tree of
nested kustomize roots, each supplying its own namespace — because inference resolves each
document against the root governing its own path.

true always writes it: the setting for a flat folder applied directly, where nothing
downstream supplies a namespace and a document without one is ambiguous.

false never writes it, and is a claim about the whole folder: something outside this
repository — a Flux Kustomization's targetNamespace, an Argo Application's
destination.namespace, or a kustomization this target maintains itself — supplies the
namespace instead. Because a namespace-less document takes its namespace from a single
supplier, an explicit false admits exactly ONE source namespace: a second WatchRule bringing
another namespace to this target is refused, with GitPathAccepted=False and reason
MultipleSourceNamespaces.
type: boolean
suspend:
description: |-
Suspend stops this target from writing to Git, without deleting it. It is the knob to turn
Expand Down
24 changes: 21 additions & 3 deletions config/webhook/validating-webhook.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,18 @@ webhooks:
# reject a user's CommitRequest. The handler also returns Allowed even when the Redis
# write errors, so the command's success never depends on this webhook at all —
# Ignore only covers the webhook being entirely unreachable.
#
# It stays Ignore now that the endpoint can also REJECT a WatchRule, because that
# rejection is feedback rather than enforcement: the writer's own precondition refuses
# the same configuration whether or not this webhook ran. Fail here would make every
# WatchRule write depend on the operator being up, to gate something already gated.
failurePolicy: Ignore
matchPolicy: Equivalent
name: validate-operator-types.configbutler.ai
# Narrow: only our own command kinds (one rule per command kind). The submitter is
# captured into the command-author Redis corner and read back by the CommitRequest
# controller with no wait.
# Narrow: our own command kinds (one rule per command kind), plus the one config kind
# this endpoint validates. The submitter of a command is captured into the command-author
# Redis corner and read back by the CommitRequest controller with no wait; a WatchRule is
# checked against the GitTarget it names and may be rejected.
rules:
- apiGroups:
- configbutler.ai
Expand All @@ -97,6 +103,18 @@ webhooks:
resources:
- commitrequests
scope: Namespaced
# UPDATE as well as CREATE: a rule can grow a second sourceNamespace long after it was
# created, and that edit is the same mistake.
- apiGroups:
- configbutler.ai
apiVersions:
- v1alpha3
operations:
- CREATE
- UPDATE
resources:
- watchrules
scope: Namespaced
# Redis write on real requests; nothing on dry-run (the handler honors NoneOnDryRun).
sideEffects: NoneOnDryRun
timeoutSeconds: 2
Loading
Loading