Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .coverage-baseline
Original file line number Diff line number Diff line change
@@ -1 +1 @@
75.3
75.7
20 changes: 20 additions & 0 deletions api/v1alpha3/clusterwatchrule_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ type ClusterResourceRule struct {
// +kubebuilder:validation:items:Pattern=`^[^/]*$`
Resources []string `json:"resources"`

// ExcludeFieldManagers drops a live change whose last writer is one of these field
// managers, so a GitOps forward leg applying this branch back into the cluster does
// not have its own applies mirrored into Git. It is read from metadata.managedFields
// and needs no audit fact. Not evaluated for DELETE. See
// ResourceRule.ExcludeFieldManagers for the full semantics.
// +optional
// +kubebuilder:validation:MaxItems=32
// +kubebuilder:validation:items:MinLength=1
// +kubebuilder:validation:items:MaxLength=128
ExcludeFieldManagers []string `json:"excludeFieldManagers,omitempty"`

// ExcludeUsers drops a live change attributed to one of these identities. It requires
// author attribution and fails open when the author cannot be resolved. See
// ResourceRule.ExcludeUsers for the full semantics.
// +optional
// +kubebuilder:validation:MaxItems=32
// +kubebuilder:validation:items:MinLength=1
// +kubebuilder:validation:items:MaxLength=316
ExcludeUsers []string `json:"excludeUsers,omitempty"`

// Scope defines whether this rule watches Cluster-scoped or Namespaced resources.
// - "Cluster": For cluster-scoped resources (Nodes, ClusterRoles, CRDs, etc.).
// - "Namespaced": For namespaced resources (Pods, Deployments, Secrets, etc.),
Expand Down
65 changes: 61 additions & 4 deletions api/v1alpha3/commitrequest_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,60 @@ type CommitRequestSpec struct {
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=300
CloseDelaySeconds int32 `json:"closeDelaySeconds,omitempty"`

// Author names the human this commit is for, instead of deriving them from an
// apiserver audit fact. It exists for an authenticated control plane that already
// knows who the user is — one that verified their token before impersonating them —
// and for any cluster whose apiserver audit flags the operator cannot set.
//
// Asserting an author is a privilege, not a field anyone may set. It is honored only
// when the requester holds the `assert-author` verb on the named GitTarget, in the
// style of `bind`, `escalate` and `impersonate`:
//
// rules:
// - apiGroups: ["configbutler.ai"]
// resources: ["gittargets"]
// resourceNames: ["tenants"]
// verbs: ["assert-author"]
//
// The check runs in the validate-operator-types admission webhook, which denies an
// unauthorized create. Because that webhook is failurePolicy: Ignore by design, the
// controller is the real gate: it honors this field only when an admission record
// exists for the object AND that record carries the authorized verdict. Without one —
// the webhook is off, was bypassed, or Redis is not configured — the assertion is
// ignored, the commit is authored by the configured committer, and the request reports
// AuthorAttributed=False with reason AuthorAssertionUnverified.
//
// An asserted author attaches to any open commit window for the target, not only to
// one whose audit-derived author matches: the assertion is a statement about the
// commit being made, not a claim to be the actor the audit stream recorded. It becomes
// the commit's author signature; the committer stays the operator's configured
// identity.
// +optional
Author *CommitAuthor `json:"author,omitempty"`
}

// CommitAuthor is the Git author identity a privileged client asserts for a commit.
// Neither field is verified to correspond to a real identity: they are what the trusted
// control plane says they are. Granting `assert-author` grants the ability to write any
// author into the repository's history — treat it exactly like granting `impersonate`.
type CommitAuthor struct {
// Name is the Git author name, used verbatim in the commit's author header.
// ASCII control characters, angle brackets and newlines are rejected because they
// would let a name forge a signature header.
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=128
// +kubebuilder:validation:Pattern=`^[^\x00-\x1F\x7F<>]*$`
Name string `json:"name"`

// Email is the Git author email. When omitted, a stable synthetic address is derived
// from Name, exactly as it is for an audit-attributed author with no email claim.
// +optional
// +kubebuilder:validation:MinLength=3
// +kubebuilder:validation:MaxLength=254
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`
Email string `json:"email,omitempty"`
}

// CommitRequestStatus defines the observed state of CommitRequest. Progress and
Expand All @@ -54,10 +108,13 @@ type CommitRequestSpec struct {
// - Reconciling / Stalled: the kstatus progress / blocked pair. Reconciling=True
// while finalizing; Stalled=True when the finalize failed and needs attention.
// - AuthorAttributed (domain): binary and settled immediately. True
// (AttributedFromAdmission) when the submitter captured at admission named the
// commit author; False (CommitterFallback) when no admission record exists — the
// validate-operator-types webhook is not configured — and the commit is authored by the
// configured committer. False is not a failure and does not affect Ready.
// (AuthorAsserted) when spec.author named the commit author and the requester was
// authorized to assert it; True (AttributedFromAdmission) when the submitter captured
// at admission named the commit author; False (CommitterFallback) when no admission
// record exists — the validate-operator-types webhook is not configured — and the
// commit is authored by the configured committer; False (AuthorAssertionUnverified)
// when spec.author was set but no authorized admission record backs it, so the
// assertion was ignored. False is not a failure and does not affect Ready.
// - Pushed (domain): True once the commit is in the remote repository.
type CommitRequestStatus struct {
// ObservedGeneration is the most recent generation observed by the controller.
Expand Down
164 changes: 150 additions & 14 deletions api/v1alpha3/gittarget_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,44 @@ type GitProviderReference struct {

// GitTargetSpec defines the desired state of GitTarget.
//
// The destination fields — providerRef, branch, and path — are immutable. A
// GitTarget materializes the watched resources at exactly one (provider, branch,
// folder); changing where it writes would orphan the old materialization and require
// migrating manifests between repositories/branches/folders. Instead of reconciling
// that move, the destination is fixed: to relocate a GitTarget, delete it and create a
// new one. This keeps the one-owner-per-folder invariant and the initial-snapshot gate
// simple — a successful snapshot can never be silently invalidated by a destination
// change.
// A GitTarget materializes the watched resources at exactly one (provider, branch,
// folder). branch and path are mutable: changing either is a supported "retarget", in
// which the controller tears the old materialization down and rebuilds the folder from a
// fresh full snapshot at the new destination. The old folder's files are never deleted —
// deleting from Git is the one irreversible thing this operator can do, and a destination
// change is the moment an operator is least sure of what they meant. See
// docs/design/multi-tenant/gittarget-retarget.md.
//
// +kubebuilder:validation:XValidation:rule="self.providerRef == oldSelf.providerRef",message="spec.providerRef is immutable; delete and recreate the GitTarget to change its destination"
// +kubebuilder:validation:XValidation:rule="self.branch == oldSelf.branch",message="spec.branch is immutable; delete and recreate the GitTarget to change its destination"
// +kubebuilder:validation:XValidation:rule="self.path == oldSelf.path",message="spec.path is immutable; delete and recreate the GitTarget to change its destination"
// providerRef stays immutable: pointing at a different repository is not a move, it is a
// different object. There is nothing to migrate and nothing to observe.
//
// The initial-snapshot gate is preserved by making the destination a thing status
// OBSERVES rather than a thing the spec cannot change: a successful snapshot is valid for
// the destination recorded in status.observedDestination. When spec and
// status.observedDestination disagree, the snapshot is by definition stale, and the
// GitTarget reports Retargeting=True until the new folder is built.
//
// +kubebuilder:validation:XValidation:rule="self.providerRef == oldSelf.providerRef",message="spec.providerRef is immutable; delete and recreate the GitTarget to change its repository (spec.branch and spec.path are mutable — the controller retargets, see docs/design/multi-tenant/gittarget-retarget.md)"
type GitTargetSpec struct {
// ProviderRef references the GitProvider that backs this target.
// Immutable: delete and recreate the GitTarget to change its destination.
// Immutable: delete and recreate the GitTarget to write to a different repository.
// +required
ProviderRef GitProviderReference `json:"providerRef"`

// Branch to use for this target.
// Must be one of the allowed branches in the provider.
// Immutable: delete and recreate the GitTarget to change its destination.
//
// Mutable: changing it retargets the GitTarget. The old branch's folder is left
// behind as ordinary, unmanaged Git content; the new branch's folder is built from a
// fresh full snapshot. status.observedDestination names the destination the current
// materialization belongs to.
//
// A retarget tears the old materialization down BEFORE the new destination is
// validated, because the writer resolves spec.path fresh on every write while the
// branch worker is bound to the branch its event stream was registered against — so a
// live event arriving mid-move would otherwise land at the new path on the old branch.
// A retarget onto an invalid or occupied destination therefore leaves the GitTarget
// mirroring NOTHING until a valid one is chosen. It does not fall back.
// +required
// +kubebuilder:validation:MinLength=1
Branch string `json:"branch"`
Expand All @@ -65,11 +82,44 @@ type GitTargetSpec struct {
// rejected because it is too easy to leave blank by accident to be a deliberate
// root choice. Any leading slash (absolute path) and ".." are rejected, and a
// trailing slash is normalized away.
// Immutable: delete and recreate the GitTarget to change its destination.
//
// Mutable: changing it retargets the GitTarget, exactly as changing branch does. The
// new path must not overlap another GitTarget on the same provider and branch. A
// retargeting GitTarget is a newcomer to the folder it is moving into, whatever its
// age, so it loses the overlap conflict to whoever is already materialized there —
// and, because the teardown precedes validation, it then mirrors nothing until a free
// path is chosen.
// +required
// +kubebuilder:validation:MinLength=1
Path string `json:"path"`

// SourceCluster names the cluster this GitTarget mirrors FROM. Omitted means the
// cluster the operator runs in, which is the single-cluster default and needs no
// configuration.
//
// Setting it separates the two jobs one kubeconfig used to serve: the operator reads
// its own CRs and Git credentials from the cluster it runs in (the config plane) and
// watches resources on the cluster named here. Nothing but the watched resources then
// has to live on the watched cluster — no Secret, no configbutler.ai CRDs at all —
// and one operator can mirror many clusters, because each GitTarget carries its own
// source. It is deliberately shaped like Flux's `Kustomization.spec.kubeConfig`.
//
// It sits on GitTarget rather than on WatchRule because a GitTarget already owns
// exactly one materialization. Adding the source cluster makes that one
// (cluster, provider, branch, folder) — still one owner, one folder, one desired
// state. On WatchRule, two rules could name different clusters for one folder and the
// mark-and-sweep would alternately delete each cluster's objects.
//
// WatchRule keeps its meaning: it watches the namespace it lives in, resolved on the
// source cluster. A WatchRule in config-plane namespace "team-a" watches namespace
// "team-a" on the source cluster. A ClusterWatchRule watches the whole source cluster.
//
// Mutable: changing it retargets the GitTarget, exactly as changing branch or path
// does — the folder's contents would otherwise mean something different. Rotating the
// Secret's CONTENTS is transparent and is not a retarget.
// +optional
SourceCluster *SourceClusterSpec `json:"sourceCluster,omitempty"`

// Encryption defines encryption settings for Secret resource writes.
// +optional
Encryption *EncryptionSpec `json:"encryption,omitempty"`
Expand All @@ -82,6 +132,32 @@ type GitTargetSpec struct {
Placement *GitTargetPlacementSpec `json:"placement,omitempty"`
}

// SourceClusterSpec names a remote cluster to mirror from, by the Secret holding its
// kubeconfig. The Secret is read from the GitTarget's own namespace — the config plane —
// so the credential for a cluster never has to live on that cluster.
type SourceClusterSpec struct {
// KubeConfigSecretRef names the Secret holding the kubeconfig for the source cluster.
// +required
KubeConfigSecretRef SecretKeyReference `json:"kubeConfigSecretRef"`
}

// SecretKeyReference points at one key inside a Secret in the referring object's namespace.
type SecretKeyReference struct {
// Name of the Secret, in the same namespace as the referring object.
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
Name string `json:"name"`

// Key within the Secret's data holding the kubeconfig. Defaults to "value.yaml",
// Flux's convention, so a Secret produced for a Flux Kustomization works unchanged.
// +optional
// +kubebuilder:default="value.yaml"
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
Key string `json:"key,omitempty"`
}

// GitTargetPlacementSpec declares where NEW resources are written when no document
// for their identity exists yet in Git — one exact-type map plus a fallback
// default template (Option B2 of
Expand Down Expand Up @@ -117,12 +193,70 @@ type GitTargetPlacementSpec struct {
Default string `json:"default,omitempty"`
}

// GitTargetDestination is one materialization site: the source cluster whose resources a
// GitTarget mirrors, and the branch and folder they live at.
type GitTargetDestination struct {
// Branch the materialization lives on.
// +required
Branch string `json:"branch"`

// Path the materialization lives under, relative to the repository root.
// +required
Path string `json:"path"`

// SourceCluster names the kubeconfig Secret whose cluster this materialization was
// mirrored from. Empty means the cluster the operator runs in.
// +optional
SourceCluster string `json:"sourceCluster,omitempty"`
}

// SourceClusterID identifies the cluster a GitTarget mirrors from, as the watch data plane
// keys its per-cluster clients, catalogs, and type registries. The empty string is the
// cluster the operator runs in.
//
// The Secret's key is part of the identity: two GitTargets naming the same Secret under
// different keys are pointed at different kubeconfigs, and so at different clusters.
func (g *GitTarget) SourceClusterID() string {
if g.Spec.SourceCluster == nil {
return ""
}
ref := g.Spec.SourceCluster.KubeConfigSecretRef
key := ref.Key
if key == "" {
key = DefaultKubeConfigSecretKey
}
return g.Namespace + "/" + ref.Name + "/" + key
}

// DefaultKubeConfigSecretKey is the Secret data key a source-cluster kubeconfig is read
// from when none is given. It matches Flux's convention.
const DefaultKubeConfigSecretKey = "value.yaml"

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

// ObservedDestination is the destination the current materialization belongs to. It
// is written only once a snapshot has actually landed there and the path was
// accepted, so it is the answer to "which folder is this GitTarget's content in?" —
// which is not always what spec says.
//
// Absent means nothing has been materialized yet. When it disagrees with spec, the
// GitTarget is retargeting and Retargeting=True; the folder it names is the one being
// abandoned, so an operator can `git rm` it deliberately once the move settles.
// +optional
ObservedDestination *GitTargetDestination `json:"observedDestination,omitempty"`

// RetargetingTo is the destination a retarget in progress is building, set while
// Retargeting=True and cleared once the move settles. It exists so a move is
// self-describing, and so a SECOND destination change arriving mid-move can name the
// intermediate folder the first move had already begun writing — otherwise that folder
// would be orphaned without ever being named. See the RetargetSuperseded event.
// +optional
RetargetingTo *GitTargetDestination `json:"retargetingTo,omitempty"`

// Conditions represent the latest available observations of an object's state
// +optional
// +listType=map
Expand Down Expand Up @@ -179,6 +313,8 @@ type GitTargetStreamsStatus struct {
// +kubebuilder:printcolumn:name="Streams",type=string,JSONPath=`.status.streams.summary`
// +kubebuilder:printcolumn:name="GitPathAccepted",type=string,JSONPath=`.status.conditions[?(@.type=="GitPathAccepted")].status`,priority=1
// +kubebuilder:printcolumn:name="StreamsRunning",type=string,JSONPath=`.status.conditions[?(@.type=="StreamsRunning")].status`,priority=1
// +kubebuilder:printcolumn:name="Retargeting",type=string,JSONPath=`.status.conditions[?(@.type=="Retargeting")].status`,priority=1
// +kubebuilder:printcolumn:name="ObservedPath",type=string,JSONPath=`.status.observedDestination.path`,priority=1
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1
// +kubebuilder:printcolumn:name="Encryption",type=string,JSONPath=`.spec.encryption.provider`,priority=1
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
Expand Down
Loading
Loading