diff --git a/.vale/styles/HouseStyle/Headings.yml b/.vale/styles/HouseStyle/Headings.yml index d7b6b3eb5..a42f47586 100644 --- a/.vale/styles/HouseStyle/Headings.yml +++ b/.vale/styles/HouseStyle/Headings.yml @@ -53,7 +53,7 @@ exceptions: - GitProvider - WatchRule - ClusterWatchRule - - ClusterProvider + - KubeProvider - CommitRequest - APIResourceCatalog - HelmRelease diff --git a/PROJECT b/PROJECT index 62e4671ea..933a58b15 100644 --- a/PROJECT +++ b/PROJECT @@ -51,10 +51,10 @@ resources: version: v1alpha3 - api: crdVersion: v1 - namespaced: false + namespaced: true controller: true domain: configbutler.ai - kind: ClusterProvider + kind: KubeProvider path: github.com/ConfigButler/gitops-reverser/api/v1alpha3 version: v1alpha3 version: "3" diff --git a/README.md b/README.md index 2af3c6d87..40c0f4cfe 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ agent over MCP. Custom resources configure it: `GitProvider` for repository and credentials, `GitBranch` for one branch of that repository, `GitTarget` for a folder on it, `WatchRule` or `ClusterWatchRule` for what -to capture, and `ClusterProvider` for the source cluster, created as `default` on install. A rule -selects whole resource collections, including their deletions; the `GitTarget`'s prune mode decides -whether a deletion removes the file from Git. +to capture, and `KubeProvider` for the source cluster, created as `local` in the operator's +namespace on install. A rule selects whole resource collections, including their deletions; the +`GitTarget`'s prune mode decides whether a deletion removes the file from Git. See [Configuration](docs/configuration.md). By default it watches its own cluster. Add more of each as you need them: one operator can serve diff --git a/SECURITY.md b/SECURITY.md index 8e0a7121d..9b419074c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,9 +11,9 @@ For what the controller can access and which pieces are sensitive, see ## Shared audit-ingress trust model Audit ingress uses mutual TLS, but the current multi-cluster routes use the client certificate to -authenticate membership in the shared audit CA—not to bind a sender to one `ClusterProvider`. A +authenticate membership in the shared audit CA, not to bind a sender to one `KubeProvider`. A source that holds the shared client credential can therefore submit audit facts for any configured -provider, whether the provider is selected by `/audit-webhook/` or by shared-stream +provider, whether the provider is selected by `/audit-webhook/` or by shared-stream annotation routing. This is an explicit, accepted operating assumption for now: use shared ingress only when a highly diff --git a/Taskfile-build.yml b/Taskfile-build.yml index 55e24f0dd..9d34849e4 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -756,10 +756,10 @@ tasks: cmds: - | mkdir -p dist - # CRDs ship as their OWN file, applied FIRST. The bundle contains the reserved `default` - # ClusterProvider — a custom resource that cannot be applied in the same `kubectl apply` as + # CRDs ship as their OWN file, applied FIRST. The bundle contains the `local` + # KubeProvider — a custom resource that cannot be applied in the same `kubectl apply` as # the CRD defining it: kubectl builds its RESTMapper up front and fails with - # `no matches for kind "ClusterProvider"`. So: kubectl apply -f dist/crds.yaml, then + # `no matches for kind "KubeProvider"`. So: kubectl apply -f dist/crds.yaml, then # kubectl apply -f dist/install.yaml. `helm install` is unaffected — it installs # charts/gitops-reverser/crds/ in its own first phase, which is why --include-crds is gone # here. Each chart CRD file already begins with its own `---`, so a plain cat is valid diff --git a/api/v1alpha3/applytrigger_types.go b/api/v1alpha3/applytrigger_types.go index 0766cac63..f141587fd 100644 --- a/api/v1alpha3/applytrigger_types.go +++ b/api/v1alpha3/applytrigger_types.go @@ -33,7 +33,7 @@ const ( // Why a kind and a name, and no namespace: the kind keeps each trigger its own schema, the way // Flux's sourceRef names a kind, and the namespace is the GitTarget's own because the trigger's -// ClusterProvider admits the namespace it is in (spec.accessFrom). A GitTarget that could name a +// KubeProvider admits the namespace it is in. A GitTarget that could name a // trigger elsewhere would drive an applier that its own namespace was never admitted to. // ApplyTriggerReference names an apply trigger in the GitTarget's own namespace. @@ -151,8 +151,8 @@ type ApplyTrigger interface { // TriggerKind is the trigger's kind (ApplyTriggerKind*). TriggerKind() string - // ClusterProvider is the name of the ClusterProvider whose cluster holds the applier. - ClusterProvider() string + // KubeProviderKey is the KubeProvider whose cluster holds the applier. + KubeProviderKey() types.NamespacedName // ApplierKind is the kind of applier the trigger holds (ApplierKind*). ApplierKind() string // Applier is the object the trigger holds, its namespace defaulted to the trigger's own. @@ -193,11 +193,11 @@ func defaultedNamespace(namespace, fallback string) string { return namespace } -// clusterProviderName is ref's name, or DefaultClusterProviderName when ref names none, the way -// GitTarget.SourceCluster resolves an omitted clusterProviderRef. -func clusterProviderName(ref *meta.LocalObjectReference) string { - if ref == nil || ref.Name == "" { - return DefaultClusterProviderName +// kubeProviderKey is ref, its namespace defaulted to the referrer's own, or the zero key when ref +// names nothing. +func kubeProviderKey(ref meta.NamespacedObjectReference, referrerNamespace string) types.NamespacedName { + if ref.Name == "" { + return types.NamespacedName{} } - return ref.Name + return types.NamespacedName{Namespace: defaultedNamespace(ref.Namespace, referrerNamespace), Name: ref.Name} } diff --git a/api/v1alpha3/argocdtrigger_types.go b/api/v1alpha3/argocdtrigger_types.go index 71d831c64..d06f60cf7 100644 --- a/api/v1alpha3/argocdtrigger_types.go +++ b/api/v1alpha3/argocdtrigger_types.go @@ -10,20 +10,28 @@ import ( "k8s.io/apimachinery/pkg/types" ) +// spec.clusterProviderRef was replaced by spec.kubeProviderRef, with the cluster-scoped kind it +// named. It stays in the schema only to be refused by name; the rule sits on spec so CRD validation +// ratcheting cannot wave a stored value through on the next edit. + // ArgoCDTriggerSpec names the Argo CD Application the trigger holds, and the cluster it is in. +// +kubebuilder:validation:XValidation:rule="!has(self.clusterProviderRef)",message="spec.clusterProviderRef was replaced by spec.kubeProviderRef, which names a namespaced KubeProvider" type ArgoCDTriggerSpec struct { // Immutable for the reason FluxTriggerSpec's references are: the trigger holds what it names. - // ClusterProviderRef names the ClusterProvider whose cluster holds the Application, such as a - // management cluster running Argo CD for the source cluster. A provider without - // spec.kubeConfig is the operator's own cluster. The provider must admit this trigger's - // namespace (spec.accessFrom). Its identity needs get and update on the Application. Omitted, - // it is the ClusterProvider named "default", as for a GitTarget. Immutable. + // KubeProviderRef names the KubeProvider whose cluster holds the Application, such as a + // management cluster running Argo CD for the source cluster. Namespace omitted means this + // trigger's own namespace. The provider must admit this trigger's namespace: its own, or one its + // spec.accessFrom admits. Its identity needs get and update on the Application. Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty" + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.kubeProviderRef is immutable; delete and recreate the ArgoCDTrigger to hold an Application elsewhere" + KubeProviderRef meta.NamespacedObjectReference `json:"kubeProviderRef"` + + // RemovedClusterProviderRef is the replaced spelling of kubeProviderRef. It is never read; + // setting it is refused. // +optional - // +kubebuilder:default={name: "default"} - // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.clusterProviderRef.name must not be empty" - // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.clusterProviderRef is immutable; delete and recreate the ArgoCDTrigger to hold an Application elsewhere" - ClusterProviderRef *meta.LocalObjectReference `json:"clusterProviderRef,omitempty"` + RemovedClusterProviderRef *meta.LocalObjectReference `json:"clusterProviderRef,omitempty"` // ApplicationRef is the Argo CD Application that applies the folders of the GitTargets naming // this trigger. It must have a single spec.source whose targetRevision names a branch, and @@ -48,7 +56,7 @@ type ArgoCDTriggerStatus struct { // syncs a single source, has automated sync off, may be updated, and no other ArgoCDTrigger // holds it) and the kstatus Ready/Reconciling/Stalled trio. A trigger acts only while an // eligible GitTarget references it: one in its namespace that names it, is admitted by its - // ClusterProviders, and writes the repository and branch the Application syncs. A trigger no + // KubeProviders, and writes the repository and branch the Application syncs. A trigger no // GitTarget names reads nothing in the Application's cluster and is Configured=Unknown, // Ready=True, both NotReferenced; one named only by GitTargets that are not eligible is // Configured NotBound or a refusal. Of the ArgoCDTriggers in line for one Application in one cluster, the one @@ -98,7 +106,7 @@ type ArgoCDTriggerSource struct { // +kubebuilder:printcolumn:name="Application",type=string,JSONPath=`.spec.applicationRef.name` // +kubebuilder:printcolumn:name="Operation",type=string,JSONPath=`.status.operation.phase` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.operation.commit`,priority=1 -// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterProviderRef.name`,priority=1 +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.kubeProviderRef.name`,priority=1 // +kubebuilder:printcolumn:name="RepairPending",type=boolean,JSONPath=`.status.repairPending`,priority=1 // +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` @@ -142,10 +150,9 @@ var _ ApplyTrigger = (*ArgoCDTrigger)(nil) // TriggerKind is ArgoCDTrigger. func (t *ArgoCDTrigger) TriggerKind() string { return ApplyTriggerKindArgoCD } -// ClusterProvider is spec.clusterProviderRef.name, or "default" when it is unset, as the schema -// defaults it: a literal built in a test or by an older client still names a concrete provider. -func (t *ArgoCDTrigger) ClusterProvider() string { - return clusterProviderName(t.Spec.ClusterProviderRef) +// KubeProviderKey is spec.kubeProviderRef, its namespace defaulted to the trigger's own. +func (t *ArgoCDTrigger) KubeProviderKey() types.NamespacedName { + return kubeProviderKey(t.Spec.KubeProviderRef, t.Namespace) } // ApplierKind is Application. diff --git a/api/v1alpha3/audit_route_test.go b/api/v1alpha3/audit_route_test.go index a03af9bdd..868f1cf34 100644 --- a/api/v1alpha3/audit_route_test.go +++ b/api/v1alpha3/audit_route_test.go @@ -9,61 +9,26 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// TestClusterProvider_AuditRoute covers the defaulting rule the whole feature rests on: an unset -// auditRoute resolves to the provider's own name, which is what every install already partitions its -// facts by, so adding the field changes nothing until someone sets it. -func TestClusterProvider_AuditRoute(t *testing.T) { - tests := []struct { - name string - provider ClusterProvider - want string - }{ - { - name: "no attribution block falls back to the object name", - provider: ClusterProvider{ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}}, - want: "prod-eu-1", - }, - { - name: "an empty auditRoute falls back to the object name", - provider: ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, - Spec: ClusterProviderSpec{Attribution: &ClusterProviderAttribution{}}, - }, - want: "prod-eu-1", - }, - { - name: "a declared route wins over the object name", - provider: ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "tenant-acme-delegating"}, - Spec: ClusterProviderSpec{ - Attribution: &ClusterProviderAttribution{AuditRoute: "prod-eu-1"}, - }, - }, - want: "prod-eu-1", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, tt.provider.AuditRoute()) - }) - } -} - -// TestClusterProvider_AuditRoute_SharedBySeveralProviders is the reported bug, stated as an API +// TestKubeProvider_AuditRoute_SharedBySeveralProviders is the reported bug, stated as an API // invariant. An API server has one audit webhook backend and posts under one route, so two -// ClusterProviders naming one physical cluster can only both resolve authors if they agree on that -// route. Before this field they could not: each read under its own name, and every commit through -// the provider the API server did NOT post under was authored attribution-unresolved. +// KubeProviders naming one physical cluster can only both resolve authors if they agree on that +// route. Each defaults to its own ., so without a declared route every commit +// through the provider the API server did NOT post under is authored attribution-unresolved. // -// Locality is deliberately not involved. Both providers here omit kubeConfig, which is what makes -// them in-cluster, and it is the declared route rather than that fact which joins them. -func TestClusterProvider_AuditRoute_SharedBySeveralProviders(t *testing.T) { - fedByTheAPIServer := ClusterProvider{ObjectMeta: metav1.ObjectMeta{Name: "default"}} - delegating := ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "srcns-delegating"}, - Spec: ClusterProviderSpec{ - AllowCrossNamespace: &ClusterProviderCrossNamespace{Sources: true}, - Attribution: &ClusterProviderAttribution{AuditRoute: "default"}, +// Locality is deliberately not involved. Both providers here set spec.inCluster, and it is the +// declared route rather than that fact which joins them. (The default route defaulting itself is +// TestKubeProvider_AuditRoute.) +func TestKubeProvider_AuditRoute_SharedBySeveralProviders(t *testing.T) { + fedByTheAPIServer := KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "local"}, + Spec: KubeProviderSpec{InCluster: &KubeProviderInCluster{}}, + } + delegating := KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "srcns-delegating"}, + Spec: KubeProviderSpec{ + InCluster: &KubeProviderInCluster{}, + AllowCrossNamespace: &KubeProviderCrossNamespace{Sources: true}, + Attribution: &KubeProviderAttribution{AuditRoute: "platform.local"}, }, } @@ -76,6 +41,10 @@ func TestClusterProvider_AuditRoute_SharedBySeveralProviders(t *testing.T) { // cross-credit. This is what a declared route buys that keying by object UID alone would not: // an etcd-snapshot clone reproduces object UIDs exactly, and only a human-chosen name separates // the copy from its origin. - clone := ClusterProvider{ObjectMeta: metav1.ObjectMeta{Name: "prod-restored"}} + clone := KubeProvider{ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "prod-restored"}} assert.NotEqual(t, fedByTheAPIServer.AuditRoute(), clone.AuditRoute()) + + // One provider name in another namespace is another partition by default. + sameName := KubeProvider{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a", Name: "local"}} + assert.NotEqual(t, fedByTheAPIServer.AuditRoute(), sameName.AuditRoute()) } diff --git a/api/v1alpha3/clusterprovider_types.go b/api/v1alpha3/clusterprovider_types.go deleted file mode 100644 index 31a5bed1c..000000000 --- a/api/v1alpha3/clusterprovider_types.go +++ /dev/null @@ -1,315 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha3 - -import ( - meta "github.com/fluxcd/pkg/apis/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// DefaultClusterProviderName is the conventionally opinionated ClusterProvider name that an -// omitted GitTarget.spec.clusterProviderRef points at. That defaulting is its ONLY special -// behavior: it is an ordinary user-created object that may omit kubeConfig (the operator's own -// in-cluster config) or set it to mirror a remote cluster, its audit route defaults to its name like -// every other provider's, and it is never created by the operator. -const DefaultClusterProviderName = "default" - -// ClusterProviderSpec defines the desired state of ClusterProvider. -// -// kubeConfig is IMMUTABLE and OPTIONAL: which physical cluster a provider name means must not -// silently change under the GitTargets bound to it, and an OMITTED kubeConfig means the operator's -// own (in-cluster) cluster. That choice is free for EVERY name, "default" included — a provider -// named "default" may just as well carry a kubeConfig and mirror a remote cluster. The name is an -// identity, not a claim about which cluster it points at. -// -// +kubebuilder:validation:XValidation:rule="has(self.kubeConfig) == has(oldSelf.kubeConfig) && (!has(self.kubeConfig) || self.kubeConfig == oldSelf.kubeConfig)",message="spec.kubeConfig is immutable; delete and recreate the ClusterProvider to point a name at a different cluster" -// -// configMapRef (Flux workload-identity auth) is present in meta.KubeConfigReference's schema but -// deferred here; reject it so the v1alpha3 contract is "secretRef only". -// +kubebuilder:validation:XValidation:rule="!has(self.kubeConfig) || !has(self.kubeConfig.configMapRef)",message="spec.kubeConfig.configMapRef (workload-identity auth) is not yet supported; use secretRef" -// -// secretRef.name comes from the external meta.KubeConfigReference schema, which marks it required -// but permits the empty string; an empty name can never resolve a Secret, so reject it here. -// +kubebuilder:validation:XValidation:rule="!has(self.kubeConfig) || !has(self.kubeConfig.secretRef) || size(self.kubeConfig.secretRef.name) > 0",message="spec.kubeConfig.secretRef.name must not be empty" -// -// spec.qps and spec.burst moved to spec.client. A pruned field would apply cleanly and drop the -// throttle without a word, so the old spellings stay in the schema only to be refused by name. -// +kubebuilder:validation:XValidation:rule="!has(self.qps) && !has(self.burst)",message="spec.qps and spec.burst moved to spec.client.qps and spec.client.burst" -// -// spec.allowAnySourceNamespace was removed for spec.allowCrossNamespace.sources. A pruned field -// would apply cleanly and stop the cross-namespace mirroring without a word, so it stays in the -// schema only so that true is refused by name. The rule sits on spec, not on the field: any spec -// change re-runs it, so CRD validation ratcheting cannot wave a stored true through on the next -// edit. false stays accepted, because the old default stored it on every provider. -// +kubebuilder:validation:XValidation:rule="!has(self.allowAnySourceNamespace) || !self.allowAnySourceNamespace",message="allowAnySourceNamespace was removed; set allowCrossNamespace.sources" -type ClusterProviderSpec struct { - // KubeConfig names the SOURCE CLUSTER this provider represents and the credentials to reach it - // (Flux's meta.KubeConfigReference, embedded verbatim). OMITTED means the operator's own - // in-cluster cluster, for any provider name. IMMUTABLE. - // - // The referenced Secret is resolved from the operator's namespace, so a cluster's credential - // never has to live on that cluster. Only secretRef is honored; configMapRef is rejected. When - // secretRef.key is empty the resolver reads "value" then "value.yaml" (Flux's order). Unsafe - // kubeconfigs (exec auth, insecure-skip-tls-verify) are rejected with Validated=False unless the - // operator opts in via flags. - // +optional - KubeConfig *meta.KubeConfigReference `json:"kubeConfig,omitempty"` - - // The one namespace policy that survived the source-scope deletion: source-cluster RBAC bounds - // what a credential may READ, and cannot express which control-plane tenant may WIELD it. - // Without this, a shared source credential is usable from any namespace that can create a - // GitTarget. The selector reads control-cluster labels locally, so it has no degradation path. - - // AccessFrom is the deny-by-default policy for which CONTROL-CLUSTER namespaces may reference - // this provider from a GitTarget. Empty (or omitted) means no namespace may reference it. Its - // selector matches labels on Namespaces in the control cluster (the cluster the operator's own - // CRs live in), never on the source cluster this provider names. - // +optional - AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"` - - // AllowCrossNamespace grants what a namespace this provider admits may reach outside itself, - // through this provider's credential. Omitted, it may reach nothing outside itself. - // +optional - AllowCrossNamespace *ClusterProviderCrossNamespace `json:"allowCrossNamespace,omitempty"` - - // RemovedAllowAnySourceNamespace is the removed spelling of allowCrossNamespace.sources. It is - // never read; setting it to true is refused. - // +optional - RemovedAllowAnySourceNamespace *bool `json:"allowAnySourceNamespace,omitempty"` - - // Client overrides the operator's outgoing kube-client throttles for this cluster. Omitted, - // the built-in defaults apply (20 QPS, burst 30). Ignored when kubeConfig is omitted (the - // in-cluster client is not per-provider). - // +optional - Client *ClusterProviderClient `json:"client,omitempty"` - - // RemovedQPS is the old spelling of client.qps. It is never read; setting it is refused. - // +optional - RemovedQPS *int32 `json:"qps,omitempty"` - - // RemovedBurst is the old spelling of client.burst. It is never read; setting it is refused. - // +optional - RemovedBurst *int32 `json:"burst,omitempty"` - - // Attribution groups this cluster's author-attribution settings. The block is spelled - // "attribution" rather than "authorAttribution" even though the operator flags are - // --author-attribution-*: the prefix groups a flat flag namespace, and a block on a - // source-cluster object already supplies that scope. - // +optional - Attribution *ClusterProviderAttribution `json:"attribution,omitempty"` -} - -// Each field is a power of its own, granted on purpose: sources lets a namespace READ other -// namespaces' objects into its own Git, appliers lets it suspend and apply other namespaces' -// deployments. On an IN-CLUSTER provider (kubeConfig omitted) same-name coupling WAS the boundary, -// so either one bypasses live namespace RBAC: the owner of an admitted object acts on another -// namespace through the operator's own credential. It is a delegation, not owner consent: it does -// not prove that the namespace owns the objects it reaches, or the repository it writes. - -// ClusterProviderCrossNamespace is what a namespace a ClusterProvider admits may reach outside -// itself. The trust is in every admitted namespace, for everything in reach of the provider's -// identity; it does not prove that the namespace owns what it reaches. -type ClusterProviderCrossNamespace struct { - // Sources lets a WatchRule mirroring through this provider watch namespaces other than its own, - // including a rules[].sourceNamespace of "*", which reaches every namespace the credential can - // read. False (the default): a WatchRule watches only its own namespace. It grants no access by - // itself: the source credential's RBAC remains the hard maximum. It does not apply to - // ClusterWatchRule, which selects no namespaces at all. - // +optional - Sources bool `json:"sources,omitempty"` - - // Appliers lets a FluxTrigger or ArgoCDTrigger reaching its applier through this provider name a - // Kustomization or Application in another namespace than its own. False (the default): only - // the trigger's own namespace, which in a remote cluster is the namespace of the same name; a - // trigger naming another is Configured=False (ApplierNamespaceNotAllowed). - // +optional - Appliers bool `json:"appliers,omitempty"` -} - -// ClusterProviderClient holds the per-cluster kube-client throttles. -type ClusterProviderClient struct { - // QPS overrides the operator's outgoing kube-client query-per-second throttle for this - // cluster's watches and discovery. Omitted, the built-in default of 20 applies. - // +optional - // +kubebuilder:validation:Minimum=1 - QPS *int32 `json:"qps,omitempty"` - - // Burst overrides the operator's outgoing kube-client burst for this cluster. Omitted, the - // built-in default of 30 applies. - // +optional - // +kubebuilder:validation:Minimum=1 - Burst *int32 `json:"burst,omitempty"` -} - -// ClusterProviderAttribution holds the per-cluster author-attribution settings. It exists as a -// block so later per-cluster knobs (grace, mode) have a home beside auditRoute. -type ClusterProviderAttribution struct { - // AuditRoute is the route this cluster's audit events arrive on. The sender is the API server's - // webhook backend (https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/#webhook-backend), - // and this is the segment its configured URL ends in: /audit-webhook/. When several - // logical clusters share one backend it is instead the value of the audit-event annotation named - // by --author-attribution-audit-route-annotation-key. - // - // It partitions the attribution facts, so two ClusterProviders carrying the same route read one - // cluster's facts, and two carrying different routes can never cross-credit an author. - // - // Empty means metadata.name. Set it when several ClusterProviders name one cluster, since an API - // server has one webhook backend and so posts under one route: every other provider for that - // cluster must be pointed at the same route or it resolves no authors at all. Set it also when - // the events are labelled for something other than this object, such as a kcp logical cluster. - // +optional - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` - AuditRoute string `json:"auditRoute,omitempty"` -} - -// ClusterProviderStatus defines the observed state of ClusterProvider. -type ClusterProviderStatus struct { - // ObservedGeneration is the latest generation observed by the controller. - // +optional - ObservedGeneration int64 `json:"observedGeneration,omitempty"` - - // AuditFactsReceived is separate from Ready because a provider with no facts yet mirrors - // correctly; only the commit AUTHOR is lost. Reporting it through Ready would tell every - // kstatus reader the object is broken. - - // AuditRoute exists because the AuditFactsReceived latch needs a KEY. spec.attribution.auditRoute - // is mutable (only spec.kubeConfig is pinned), and without recording which route earned the - // latch, a provider repointed at a new route would carry True forward and report facts it has - // never received there — the exact silent misconfiguration the condition exists to catch. - - // AuditRoute is the route this provider's attribution facts are partitioned under, as resolved: - // spec.attribution.auditRoute, or metadata.name when that is empty. It is the route the - // AuditFactsReceived condition describes, so when it changes that condition starts over — - // proof that one route delivered says nothing about another. - // +optional - AuditRoute string `json:"auditRoute,omitempty"` - - // Conditions report the provider's readiness: Validated (kubeconfig inputs are safe and - // resolvable, asserted without a network dial) plus the aggregated Ready and the kstatus - // Reconciling/Stalled pair. Runtime reachability/discovery health and a last-audit-event - // timestamp are deferred until authenticated remote ingest wires them from the watch engine. - // - // AuditFactsReceived reports whether an attribution fact has ever been accepted and recorded on - // this provider's audit route. It is True (Received) from the first one on and never goes back: - // later silence is a quiet cluster, not a fault. Until then it is Unknown, with the reason - // naming which silence it is and therefore where to look: TransportUnavailable (the fact - // transport is refusing writes, so no route can be judged), NoAuditDelivery (nothing is posting - // audit to this operator at all), or RouteUnused (audit is arriving and has never carried this - // route). It is absent when the operator runs with author attribution disabled. It is not part - // of Ready. - // +optional - // +listType=map - // +listMapKey=type - // +patchMergeKey=type - // +patchStrategy=merge - Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster -// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` -// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` -// +kubebuilder:printcolumn:name="FactsReceived",type=string,JSONPath=`.status.conditions[?(@.type=="AuditFactsReceived")].status` -// +kubebuilder:printcolumn:name="Validated",type=string,JSONPath=`.status.conditions[?(@.type=="Validated")].status`,priority=1 -// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1 -// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` - -// ClusterProvider is the cluster-scoped, read-side peer of GitProvider: it names a SOURCE cluster a -// GitTarget mirrors FROM, and owns that cluster's connectivity credential (spec.kubeConfig), -// namespace-access authorization (spec.accessFrom), and per-cluster status. Its NAME is the -// cluster's identity for the watch data plane, and the DEFAULT for its audit route: attribution -// facts are partitioned by spec.attribution.auditRoute, which falls back to this name. Several -// providers may name one cluster by declaring one route, which is what an API server with a single -// audit webhook backend requires. No name is special: "default" is merely what an omitted -// GitTarget.spec.clusterProviderRef points at, and it may just as well name a remote cluster, since -// in-cluster-ness follows from spec.kubeConfig (omitted = in-cluster) rather than from the name. -// -// It is cluster-scoped and requires platform-admin permissions to create. A GitTarget may reference -// it only from a namespace spec.accessFrom admits — deny-by-default, enforced at admission -// and again before any watch starts. -type ClusterProvider struct { - metav1.TypeMeta `json:",inline"` - - // metadata is a standard object metadata. - // +optional - metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` - - // spec defines the desired state of ClusterProvider. - // +required - Spec ClusterProviderSpec `json:"spec"` - - // status defines the observed state of ClusterProvider. - // +optional - Status ClusterProviderStatus `json:"status,omitempty,omitzero"` -} - -// +kubebuilder:object:root=true - -// ClusterProviderList contains a list of ClusterProvider. -type ClusterProviderList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - - Items []ClusterProvider `json:"items"` -} - -// IsInCluster reports whether this provider represents the operator's own (in-cluster) cluster — -// i.e. it has no kubeConfig. The provider name is irrelevant: any name, including "default", may -// either omit kubeConfig for the in-cluster client or set it for a remote cluster. -func (p *ClusterProvider) IsInCluster() bool { - return p.Spec.KubeConfig == nil -} - -// AuditRoute is the identity this provider's attribution facts are keyed under: the route its -// cluster's audit events arrive on, defaulting to the provider's own name. It is resolved through -// this method rather than read off the field so no caller ever handles the empty case, the same -// shape as (api/v1alpha3).GitTarget.SourceCluster(). -// -// The default is what makes this change invisible to an existing install: one ClusterProvider per -// cluster already partitions its facts by name, so an unset field resolves exactly what it always -// resolved. Deliberately NOT conditional on locality: defaulting an in-cluster provider to the -// literal "default" would make that name reserved for the local cluster again, a rule this project -// enforced with CEL and then reversed before shipping. -func (p *ClusterProvider) AuditRoute() string { - if p.Spec.Attribution == nil || p.Spec.Attribution.AuditRoute == "" { - return p.Name - } - return p.Spec.Attribution.AuditRoute -} - -// AllowsNamespace reports whether a namespace (by name and labels) may reference this provider -// from a GitTarget, per spec.accessFrom. It is DENY-BY-DEFAULT: a provider with no accessFrom -// policy (neither names nor selector) admits no namespace. Names and selector are ORed. Enforced on -// every reconcile and NOWHERE else: checkSourceAuthorization in -// internal/controller/gittarget_source_cluster.go is the only non-test caller, and it returns -// before DeclareForGitTarget, so an unauthorized target starts no watch and writes no Git. -// -// Reconcile-time is deliberate rather than incidental: it re-evaluates continuously, so it also -// covers a policy tightened after the GitTarget was created, which an admission webhook could not -// see. There is no admission webhook for this (docs/spec/where-validation-lives.md). A malformed -// selector is a configuration error surfaced to the caller, never a silent allow. -// -// The labels passed here are always CONTROL-cluster Namespace labels. This is now the only caller -// of NamespaceMatcher.Matches: the source-side twin was deleted with -// GitTarget.spec.allowedSourceNamespaces. -func (p *ClusterProvider) AllowsNamespace(nsName string, nsLabels map[string]string) (bool, error) { - return p.Spec.AccessFrom.Matches(nsName, nsLabels) -} - -// AllowsCrossNamespaceSources reports whether a WatchRule mirroring through this provider may -// watch namespaces other than its own (spec.allowCrossNamespace.sources). -func (p *ClusterProvider) AllowsCrossNamespaceSources() bool { - return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Sources -} - -// AllowsCrossNamespaceAppliers reports whether a trigger reaching its applier through this provider -// may name one in another namespace than its own (spec.allowCrossNamespace.appliers). -func (p *ClusterProvider) AllowsCrossNamespaceAppliers() bool { - return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Appliers -} - -func init() { - SchemeBuilder.Register(&ClusterProvider{}, &ClusterProviderList{}) -} diff --git a/api/v1alpha3/clusterwatchrule_types.go b/api/v1alpha3/clusterwatchrule_types.go index 158cff765..383eff568 100644 --- a/api/v1alpha3/clusterwatchrule_types.go +++ b/api/v1alpha3/clusterwatchrule_types.go @@ -117,7 +117,7 @@ type ClusterWatchRuleStatus struct { // Cluster-scoped objects have no namespace, so no namespace policy bounds them: this is // cluster-global, limited only by its source credential's RBAC. Isolating tenants takes separate -// ClusterProviders. +// KubeProviders. // +kubebuilder:object:root=true // +kubebuilder:subresource:status @@ -136,7 +136,7 @@ type ClusterWatchRuleStatus struct { // carried by the rule KIND, so it has no per-rule scope choice and no source-namespace selection. // // It is cluster-scoped and requires cluster-admin permissions. Its gitTargetRef names a GitTarget -// (namespace required), whose namespace must be admitted by that target's ClusterProvider. To +// (namespace required), whose namespace must be admitted by that target's KubeProvider. To // mirror NAMESPACED resources use a WatchRule in the tenant namespace and set // spec.rules[].sourceNamespace, whose "*" reaches every namespace the source credential can read. type ClusterWatchRule struct { diff --git a/api/v1alpha3/examples_test.go b/api/v1alpha3/examples_test.go index 525483fdb..ecd605078 100644 --- a/api/v1alpha3/examples_test.go +++ b/api/v1alpha3/examples_test.go @@ -128,8 +128,9 @@ func newExampleObject(kind string) any { return &GitBranch{} case "GitTarget": return &GitTarget{} - case "ClusterProvider": - return &ClusterProvider{} + + case "KubeProvider": + return &KubeProvider{} case "WatchRule": return &WatchRule{} case "ClusterWatchRule": diff --git a/api/v1alpha3/fluxtrigger_types.go b/api/v1alpha3/fluxtrigger_types.go index 74e1312b7..625ec6a06 100644 --- a/api/v1alpha3/fluxtrigger_types.go +++ b/api/v1alpha3/fluxtrigger_types.go @@ -16,23 +16,30 @@ import ( // Kustomization has none. const FluxTriggerFinalizer = "configbutler.ai/flux-trigger" +// spec.clusterProviderRef was replaced by spec.kubeProviderRef, with the cluster-scoped kind it +// named. It stays in the schema only to be refused by name; the rule sits on spec so CRD validation +// ratcheting cannot wave a stored value through on the next edit. + // FluxTriggerSpec names the Flux Kustomization the trigger holds, and the cluster it is in. +// +kubebuilder:validation:XValidation:rule="!has(self.clusterProviderRef)",message="spec.clusterProviderRef was replaced by spec.kubeProviderRef, which names a namespaced KubeProvider" type FluxTriggerSpec struct { // Why immutable: the trigger holds the Kustomization it names (it suspends it while idle and // hands it back when deleted), so pointing it elsewhere would leave the first one held by // nothing. Delete and recreate the trigger instead; its finalizer hands the old one back. - // ClusterProviderRef names the ClusterProvider whose cluster holds the Kustomization, such as a - // management cluster running Flux for the source cluster. A provider without spec.kubeConfig is - // the operator's own cluster. The provider must admit this trigger's namespace - // (spec.accessFrom). Its identity needs get and patch on the Kustomization and its - // GitRepository. Omitted, - // it is the ClusterProvider named "default", as for a GitTarget. Immutable. + // KubeProviderRef names the KubeProvider whose cluster holds the Kustomization, such as a + // management cluster running Flux for the source cluster. Namespace omitted means this + // trigger's own namespace. The provider must admit this trigger's namespace: its own, or one its + // spec.accessFrom admits. Its identity needs get and patch on the Kustomization and its GitRepository. Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty" + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.kubeProviderRef is immutable; delete and recreate the FluxTrigger to hold a Kustomization elsewhere" + KubeProviderRef meta.NamespacedObjectReference `json:"kubeProviderRef"` + + // RemovedClusterProviderRef is the replaced spelling of kubeProviderRef. It is never read; + // setting it is refused. // +optional - // +kubebuilder:default={name: "default"} - // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.clusterProviderRef.name must not be empty" - // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.clusterProviderRef is immutable; delete and recreate the FluxTrigger to hold a Kustomization elsewhere" - ClusterProviderRef *meta.LocalObjectReference `json:"clusterProviderRef,omitempty"` + RemovedClusterProviderRef *meta.LocalObjectReference `json:"clusterProviderRef,omitempty"` // KustomizationRef is the Flux Kustomization that applies the folders of the GitTargets naming // this trigger. Its spec.sourceRef must be a GitRepository that follows a branch. Its namespace @@ -55,7 +62,7 @@ type FluxTriggerStatus struct { // resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; // Releasing: being handed back; NotReferenced; NotConfigured), and the kstatus // Ready/Reconciling/Stalled trio. A trigger acts only while an eligible GitTarget references it: - // one in its namespace that names it, is admitted by its ClusterProviders, and writes the + // one in its namespace that names it, is admitted by its KubeProviders, and writes the // repository and branch the GitRepository fetches. A trigger no GitTarget names reads nothing // in the Kustomization's cluster and is Configured=Unknown, Ready=True, both NotReferenced; one // named only by GitTargets that are not eligible is Configured NotBound or a refusal. Of the @@ -121,7 +128,7 @@ type FluxTriggerSource struct { // +kubebuilder:printcolumn:name="Kustomization",type=string,JSONPath=`.spec.kustomizationRef.name` // +kubebuilder:printcolumn:name="Operation",type=string,JSONPath=`.status.operation.phase` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.operation.commit`,priority=1 -// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterProviderRef.name`,priority=1 +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.kubeProviderRef.name`,priority=1 // +kubebuilder:printcolumn:name="RepairPending",type=boolean,JSONPath=`.status.repairPending`,priority=1 // +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` @@ -165,9 +172,10 @@ var _ ApplyTrigger = (*FluxTrigger)(nil) // TriggerKind is FluxTrigger. func (t *FluxTrigger) TriggerKind() string { return ApplyTriggerKindFlux } -// ClusterProvider is spec.clusterProviderRef.name, or "default" when it is unset, as the schema -// defaults it: a literal built in a test or by an older client still names a concrete provider. -func (t *FluxTrigger) ClusterProvider() string { return clusterProviderName(t.Spec.ClusterProviderRef) } +// KubeProviderKey is spec.kubeProviderRef, its namespace defaulted to the trigger's own. +func (t *FluxTrigger) KubeProviderKey() types.NamespacedName { + return kubeProviderKey(t.Spec.KubeProviderRef, t.Namespace) +} // ApplierKind is Kustomization. func (t *FluxTrigger) ApplierKind() string { return ApplierKindKustomization } diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index 008a1ddd1..865635992 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -28,11 +28,11 @@ import ( // ratcheting cannot wave a stored value through on the next edit. // +kubebuilder:validation:XValidation:rule="!has(self.gitProviderRef) && !has(self.branch) && !has(self.parentBranch)",message="spec.gitProviderRef, spec.branch and spec.parentBranch moved to a GitBranch; set spec.gitBranchRef to it" // -// spec.clusterProviderRef names the SOURCE cluster a GitTarget mirrors FROM (see its field doc). It -// is immutable — a folder's source cluster is part of what the folder means, like -// gitBranchRef/path above — and defaults to a ClusterProvider named "default", so it is -// always populated (never nil) and always jumpable. -// +kubebuilder:validation:XValidation:rule="self.clusterProviderRef == oldSelf.clusterProviderRef",message="spec.clusterProviderRef is immutable; delete and recreate the GitTarget to change the cluster it mirrors" +// spec.clusterProviderRef was replaced by spec.kubeProviderRef, with the cluster-scoped kind it +// named. A pruned field would apply cleanly and leave a manifest written for the old API looking +// accepted, so it stays in the schema only to be refused by name, on spec for the same ratcheting +// reason as the branch fields above. +// +kubebuilder:validation:XValidation:rule="!has(self.clusterProviderRef)",message="spec.clusterProviderRef was replaced by spec.kubeProviderRef, which names a namespaced KubeProvider" // +kubebuilder:validation:XValidation:rule="!has(self.onRefusal) || self.onRefusal != 'Reapply' || has(self.applyTriggerRef)",message="spec.onRefusal: Reapply needs spec.applyTriggerRef, the trigger asked to reapply" type GitTargetSpec struct { // GitBranchRef names the GitBranch this target writes: the repository branch, and the credential @@ -202,19 +202,20 @@ type GitTargetSpec struct { // +optional SerializeNamespace *bool `json:"serializeNamespace,omitempty"` - // Defaults to a concrete {name: "default"}, not nil, so the persisted object names something a - // reader can jump to. The operator never creates that provider; a missing one holds the target - // unready rather than falling back to in-cluster access. + // KubeProviderRef names the KubeProvider this GitTarget mirrors FROM: the source cluster, and the + // credential it is read with. Namespace omitted means this GitTarget's own namespace. A + // KubeProvider in another namespace is used only while its spec.accessFrom admits this + // GitTarget's namespace. Immutable: a folder's source cluster is part of what the folder means; + // delete and recreate the GitTarget to change it. + // +required + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty" + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.kubeProviderRef is immutable; delete and recreate the GitTarget to change the cluster it mirrors" + KubeProviderRef meta.NamespacedObjectReference `json:"kubeProviderRef"` - // ClusterProviderRef names the SOURCE cluster this GitTarget mirrors FROM, by referencing a - // cluster-scoped ClusterProvider by name. That ClusterProvider owns the cluster's connectivity - // credential, namespace-access authorization, and author-attribution mode. The default provider - // name is "default" and must exist; it may be in-cluster or remote. - // Immutable: a folder's source cluster is part of what the folder means; delete and recreate. - // +kubebuilder:default={name: "default"} + // RemovedClusterProviderRef is the replaced spelling of kubeProviderRef. It is never read; + // setting it is refused. // +optional - // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.clusterProviderRef.name must not be empty" - ClusterProviderRef *meta.LocalObjectReference `json:"clusterProviderRef,omitempty"` + RemovedClusterProviderRef *meta.LocalObjectReference `json:"clusterProviderRef,omitempty"` // Mutable, unlike the destination fields above: recovering from a scope mistake must not // require recreating the GitTarget, which is the one operation that loses the folder's history. @@ -568,17 +569,11 @@ type GitTarget struct { } // SourceCluster is the identity the watch data plane keys a GitTarget's source cluster on: the -// referenced ClusterProvider's NAME. It defaults to "default" when clusterProviderRef is unset — -// so a source-cluster-unaware caller still gets a concrete, non-empty name, and there is no "" -// sentinel. That name is a convention, not a claim about which physical cluster it is. The name -// keys the GVK→GVR registry and the watch context. It is NOT the attribution partition: facts are -// keyed by the referenced ClusterProvider's AuditRoute(), which defaults to this name but may -// differ when several providers share one cluster's single audit stream. +// referenced KubeProvider's namespace/name, so two providers with one name in two namespaces are +// two clusters. It keys the GVK→GVR registry and the watch context. It is NOT the attribution +// partition: facts are keyed by the referenced KubeProvider's AuditRoute(). func (g *GitTarget) SourceCluster() string { - if g.Spec.ClusterProviderRef == nil || g.Spec.ClusterProviderRef.Name == "" { - return DefaultClusterProviderName - } - return g.Spec.ClusterProviderRef.Name + return g.KubeProviderKey().String() } // GitBranchKey is the GitBranch this GitTarget writes: spec.gitBranchRef, in this GitTarget's own @@ -595,12 +590,11 @@ func (g *GitTarget) GitBranchKey() types.NamespacedName { return types.NamespacedName{Namespace: namespace, Name: g.Spec.GitBranchRef.Name} } -// IsLocalSource reports whether this GitTarget references the "default" ClusterProvider, which the -// watch data plane maps to its local cluster context. It is a NAME test, not a claim about the -// physical cluster: a "default" provider may carry a kubeConfig. It only supplies the pre-discovery -// default for SourceClusterReachable, which the watch manager overwrites as soon as it is wired. -func (g *GitTarget) IsLocalSource() bool { - return g.SourceCluster() == DefaultClusterProviderName +// KubeProviderKey is the KubeProvider this GitTarget mirrors through: spec.kubeProviderRef, in this +// GitTarget's own namespace unless the reference names another one. An unset reference names +// nothing, and the zero key says exactly that. +func (g *GitTarget) KubeProviderKey() types.NamespacedName { + return kubeProviderKey(g.Spec.KubeProviderRef, g.Namespace) } // +kubebuilder:object:root=true diff --git a/api/v1alpha3/helpers_test.go b/api/v1alpha3/helpers_test.go index 6a6ebe7be..35b8d5905 100644 --- a/api/v1alpha3/helpers_test.go +++ b/api/v1alpha3/helpers_test.go @@ -9,50 +9,52 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) -// IsInCluster must follow spec.kubeConfig ALONE. Nothing may key "is this the operator's own -// cluster?" off the provider name: "default" is only the name an omitted clusterProviderRef -// resolves to, and it may just as well carry a kubeConfig for a remote cluster. If a name ever -// became a proxy for in-cluster-ness, a remote provider named "default" would be dialed with the -// operator's own credentials against the wrong cluster. -func TestIsInCluster_FollowsKubeConfigNotName(t *testing.T) { +// IsInCluster must follow spec.inCluster ALONE. Nothing may key "is this the operator's own +// cluster?" off the provider's name, and nothing may read an ABSENT credential as the operator's own +// identity: a provider that sets neither arm (unvalidated, or malformed) is not in-cluster. If a +// name or an omission ever became a proxy for in-cluster-ness, a provider meant for a remote cluster +// would be served with the operator's own credentials against the wrong cluster. +func TestIsInCluster_FollowsInClusterNotName(t *testing.T) { t.Parallel() - secretRef := &meta.KubeConfigReference{ - SecretRef: &meta.SecretKeyReference{Name: "remote-kubeconfig"}, - } + secretRef := &meta.SecretKeyReference{Name: "remote-kubeconfig"} tests := []struct { name string - provider ClusterProvider + provider KubeProvider want bool }{ { - name: "omitted kubeConfig means the operator's own cluster", - provider: ClusterProvider{ObjectMeta: metav1.ObjectMeta{Name: "prod"}}, - want: true, + name: "spec.inCluster means the operator's own cluster", + provider: KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "prod"}, + Spec: KubeProviderSpec{InCluster: &KubeProviderInCluster{}}, + }, + want: true, }, { name: "the name \"default\" does not by itself mean in-cluster", - provider: ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: DefaultClusterProviderName}, - Spec: ClusterProviderSpec{KubeConfig: secretRef}, + provider: KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "default"}, + Spec: KubeProviderSpec{SecretRef: secretRef}, }, want: false, }, { - name: "a non-default name without kubeConfig is still in-cluster", - provider: ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "some-remote-sounding-name"}, + name: "a provider setting neither arm never falls through to the operator's identity", + provider: KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "some-name"}, }, - want: true, + want: false, }, { - name: "any kubeConfig means remote", - provider: ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod"}, - Spec: ClusterProviderSpec{KubeConfig: secretRef}, + name: "a secretRef means remote", + provider: KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "prod"}, + Spec: KubeProviderSpec{SecretRef: secretRef}, }, want: false, }, @@ -67,12 +69,13 @@ func TestIsInCluster_FollowsKubeConfigNotName(t *testing.T) { } } -// AllowsNamespace is the authorization predicate behind the reconcile-time refusal, and there is +// AdmitsNamespace is the authorization predicate behind the reconcile-time refusal, and there is // no admission webhook backstopping it — that reconcile call site is the ENTIRE boundary. So every -// contract below is a security boundary rather than a routing convenience: a cluster-scoped -// provider holds a credential that can read a whole remote cluster, and referencing it from a -// GitTarget mirrors that cluster into the target's Git destination. -func TestAllowsNamespace_Authorization(t *testing.T) { +// contract below is a security boundary rather than a routing convenience: a provider holds a +// credential that can read a whole remote cluster, and referencing it from a GitTarget mirrors that +// cluster into the target's Git destination. Every namespace below is another than the provider's +// own ("platform"), which is always admitted and is covered separately. +func TestAdmitsNamespace_Authorization(t *testing.T) { t.Parallel() tests := []struct { @@ -220,9 +223,12 @@ func TestAllowsNamespace_Authorization(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - provider := &ClusterProvider{Spec: ClusterProviderSpec{AccessFrom: tc.policy}} + provider := &KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "remote"}, + Spec: KubeProviderSpec{AccessFrom: tc.policy}, + } - allowed, err := provider.AllowsNamespace(tc.nsName, tc.labels) + allowed, err := provider.AdmitsNamespace(tc.nsName, tc.labels) if tc.wantErr { require.Error(t, err) } else { @@ -233,40 +239,52 @@ func TestAllowsNamespace_Authorization(t *testing.T) { } } -// SourceCluster must always return a concrete, non-empty name so callers never have to handle a "" -// sentinel: it is the fact-index key, the GVK→GVR registry key, and the /audit-webhook/ -// route. An empty value there would collapse distinct clusters onto one key. -func TestSourceCluster_DefaultsToDefaultProviderName(t *testing.T) { +// AdmitsNamespace admits the provider's own namespace before accessFrom is read at all: a tenant's +// provider serves its own namespace with no policy written, and even a malformed policy (which fails +// closed for every other namespace) cannot lock the owner out. +func TestAdmitsNamespace_OwnNamespaceAlwaysAdmitted(t *testing.T) { + t.Parallel() + + invalid := &NamespaceMatcher{Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "NotAnOperator"}}, + }} + for _, policy := range []*NamespaceMatcher{nil, {}, invalid} { + provider := &KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "remote"}, + Spec: KubeProviderSpec{AccessFrom: policy}, + } + admitted, err := provider.AdmitsNamespace("platform", nil) + require.NoError(t, err) + assert.True(t, admitted, "policy %+v", policy) + } +} + +// SourceCluster is the id the watch data plane keys a source cluster on: the referenced +// KubeProvider's namespace/name, its namespace defaulted to the GitTarget's own. It keys the GVK→GVR +// registry and the watch context, so two providers sharing a name in two namespaces must yield two +// ids, or one tenant's watches would be served from another tenant's cluster. +func TestSourceCluster_IsTheProvidersNamespacedName(t *testing.T) { t.Parallel() tests := []struct { name string - ref *meta.LocalObjectReference + ref meta.NamespacedObjectReference want string }{ { - name: "omitted ref resolves to the default provider name", - ref: nil, - want: DefaultClusterProviderName, - }, - { - // An empty Name is rejected by CRD validation, but the helper must not depend on - // admission having run — an unvalidated object still has to yield a usable key. - name: "empty name resolves to the default provider name", - ref: &meta.LocalObjectReference{Name: ""}, - want: DefaultClusterProviderName, + name: "an omitted namespace is the GitTarget's own", + ref: meta.NamespacedObjectReference{Name: "prod-eu"}, + want: "tenant-a/prod-eu", }, { - name: "explicit name is returned verbatim", - ref: &meta.LocalObjectReference{Name: "prod-eu"}, - want: "prod-eu", + name: "an explicit namespace is returned verbatim", + ref: meta.NamespacedObjectReference{Namespace: "platform", Name: "prod-eu"}, + want: "platform/prod-eu", }, { - name: "explicitly naming default is the same as omitting the ref", - ref: &meta.LocalObjectReference{ - Name: DefaultClusterProviderName, - }, - want: DefaultClusterProviderName, + name: "restating the GitTarget's own namespace is the same as omitting it", + ref: meta.NamespacedObjectReference{Namespace: "tenant-a", Name: "prod-eu"}, + want: "tenant-a/prod-eu", }, } @@ -274,52 +292,26 @@ func TestSourceCluster_DefaultsToDefaultProviderName(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - target := &GitTarget{Spec: GitTargetSpec{ClusterProviderRef: tc.ref}} + target := &GitTarget{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a"}, + Spec: GitTargetSpec{KubeProviderRef: tc.ref}, + } assert.Equal(t, tc.want, target.SourceCluster()) - assert.NotEmpty(t, target.SourceCluster(), "SourceCluster must never return the empty sentinel") }) } -} - -// IsLocalSource is a NAME test, not a claim about the physical cluster — a provider named -// "default" is free to carry a kubeConfig and point at a remote cluster. It exists only to supply -// the pre-discovery default for SourceClusterReachable, so it must stay a pure derivation of -// SourceCluster and never be read as "this GitTarget is definitely watching the operator's own -// cluster". -func TestIsLocalSource_IsANameTest(t *testing.T) { - t.Parallel() - tests := []struct { - name string - ref *meta.LocalObjectReference - want bool - }{ - {name: "omitted ref is local", ref: nil, want: true}, - {name: "empty name is local", ref: &meta.LocalObjectReference{Name: ""}, want: true}, - { - name: "explicit default is local", - ref: &meta.LocalObjectReference{Name: DefaultClusterProviderName}, - want: true, - }, - {name: "any other name is not local", ref: &meta.LocalObjectReference{Name: "prod-eu"}, want: false}, - { - // Near-miss names must not be treated as the default; the comparison is exact. - name: "a name that merely contains \"default\" is not local", - ref: &meta.LocalObjectReference{Name: "default-eu"}, - want: false, - }, + other := &GitTarget{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-b"}, + Spec: GitTargetSpec{KubeProviderRef: meta.NamespacedObjectReference{Name: "prod-eu"}}, } + assert.Equal(t, "tenant-b/prod-eu", other.SourceCluster(), + "one provider name in another namespace is another source cluster") - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - target := &GitTarget{Spec: GitTargetSpec{ClusterProviderRef: tc.ref}} - - assert.Equal(t, tc.want, target.IsLocalSource()) - }) - } + // The config plane's id is the empty string; even an unset reference must never collapse onto + // it, or a GitTarget naming no provider would be served the operator's own identity. + unset := &GitTarget{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a"}} + assert.NotEmpty(t, unset.SourceCluster()) } // TestApplyTrigger_AppliersDefaultToTheTriggersNamespace: a reference with no namespace names an @@ -328,12 +320,12 @@ func TestApplyTrigger_AppliersDefaultToTheTriggersNamespace(t *testing.T) { flux := &FluxTrigger{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}, Spec: FluxTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: "hub"}, - KustomizationRef: meta.NamespacedObjectReference{Name: "apps"}, + KubeProviderRef: meta.NamespacedObjectReference{Name: "hub"}, + KustomizationRef: meta.NamespacedObjectReference{Name: "apps"}, }, } assert.Equal(t, "team-a/apps", flux.Applier().String()) - assert.Equal(t, "hub", flux.ClusterProvider()) + assert.Equal(t, "team-a/hub", flux.KubeProviderKey().String()) argo := &ArgoCDTrigger{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}, @@ -408,10 +400,10 @@ func TestApplyTrigger_StatusAccessors(t *testing.T) { assert.Equal(t, ApplierKindKustomization, flux.ApplierKind()) assert.Equal(t, ApplyTriggerKindArgoCD, argo.TriggerKind()) assert.Equal(t, ApplierKindApplication, argo.ApplierKind()) - assert.Equal(t, DefaultClusterProviderName, argo.ClusterProvider(), - "an omitted clusterProviderRef is the default provider, as for a GitTarget") - argo.Spec.ClusterProviderRef = &meta.LocalObjectReference{Name: "hub"} - assert.Equal(t, "hub", argo.ClusterProvider()) + assert.Equal(t, types.NamespacedName{}, argo.KubeProviderKey(), + "an omitted kubeProviderRef names nothing: there is no default provider") + argo.Spec.KubeProviderRef = meta.NamespacedObjectReference{Namespace: "platform", Name: "hub"} + assert.Equal(t, types.NamespacedName{Namespace: "platform", Name: "hub"}, argo.KubeProviderKey()) _, _, known := flux.SourceBinding() assert.False(t, known) diff --git a/api/v1alpha3/kubeprovider_types.go b/api/v1alpha3/kubeprovider_types.go new file mode 100644 index 000000000..04636a5af --- /dev/null +++ b/api/v1alpha3/kubeprovider_types.go @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha3 + +import ( + "sort" + "strings" + + meta "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// KubeProviderSpec defines the desired state of KubeProvider. +// +// Exactly one credential arm is set, and there is no third: an omitted, malformed or refused +// credential must never fall through to the operator's own identity, so that identity is an arm +// of its own (inCluster) rather than what an absent secretRef means. +// +kubebuilder:validation:XValidation:rule="has(self.secretRef) != has(self.inCluster)",message="set exactly one of spec.secretRef and spec.inCluster" +// +// The in-cluster client is the operator's own and is shared by everything that uses it, so a +// per-provider throttle cannot apply to it. Refused rather than ignored. +// +kubebuilder:validation:XValidation:rule="!has(self.inCluster) || !has(self.client)",message="spec.client is refused beside spec.inCluster: the operator's own client is not throttled per provider" +// +// Which cluster a provider means must not silently change under the GitTargets mirroring through +// it, so the credential is immutable, including switching arms. A rotated kubeconfig is a change +// to the Secret, which stays allowed. +// +kubebuilder:validation:XValidation:rule="has(self.inCluster) == has(oldSelf.inCluster) && (!has(self.secretRef) || self.secretRef == oldSelf.secretRef)",message="spec.secretRef and spec.inCluster are immutable; delete and recreate the KubeProvider to point it at a different cluster" +type KubeProviderSpec struct { + // SecretRef names a Secret in this KubeProvider's namespace holding a kubeconfig for the source + // cluster. When key is empty the kubeconfig is read from "value", then "value.yaml" (Flux's + // order). Unsafe kubeconfigs (exec auth, insecure-skip-tls-verify, credentials named by file + // path) are refused with Validated=False unless the operator opts in through its flags. + // Exactly one of secretRef and inCluster is set. Immutable. + // +optional + // +kubebuilder:validation:XValidation:rule="size(self.name) > 0",message="spec.secretRef.name must not be empty" + SecretRef *meta.SecretKeyReference `json:"secretRef,omitempty"` + + // InCluster makes this provider use the operator's own identity in the cluster it runs in. It is + // honored only for a KubeProvider the installation lists in --in-cluster-kube-providers; any + // other reports Stalled=True with reason InClusterNotAuthorized and is never used. Exactly one + // of secretRef and inCluster is set. Immutable. + // +optional + InCluster *KubeProviderInCluster `json:"inCluster,omitempty"` + + // The policy that decides who may WIELD the credential: source-cluster RBAC bounds what it may + // read, and cannot say which control-cluster tenant may use it. The selector reads control-cluster + // labels locally, so it has no degradation path. + + // AccessFrom admits other control-cluster namespaces to reference this provider. Its own + // namespace is always admitted; omitted, no other namespace is. Its selector matches labels on + // Namespaces in the control cluster, never on the source cluster this provider names. + // +optional + AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"` + + // AllowCrossNamespace grants what a namespace this provider admits may reach outside itself, + // through this provider's credential. Omitted, it may reach nothing outside itself. + // +optional + AllowCrossNamespace *KubeProviderCrossNamespace `json:"allowCrossNamespace,omitempty"` + + // Client overrides the operator's outgoing kube-client throttles for this cluster. Omitted, + // the built-in defaults apply (20 QPS, burst 30). Refused beside inCluster. + // +optional + Client *KubeProviderClient `json:"client,omitempty"` + + // Attribution groups this cluster's author-attribution settings. The block is spelled + // "attribution" rather than "authorAttribution" even though the operator flags are + // --author-attribution-*: the prefix groups a flat flag namespace, and a block on a + // source-cluster object already supplies that scope. + // +optional + Attribution *KubeProviderAttribution `json:"attribution,omitempty"` +} + +// KubeProviderInCluster selects the operator's own identity. It has no fields: the arm being set +// is the whole statement. +type KubeProviderInCluster struct{} + +// Each field is a power of its own, granted on purpose: sources lets a namespace READ other +// namespaces' objects into its own Git, appliers lets it suspend and apply other namespaces' +// deployments. On an inCluster provider same-name coupling WAS the boundary, so either one +// bypasses live namespace RBAC: the owner of an admitted object acts on another namespace through +// the operator's own credential. It is a delegation, not owner consent: it does not prove that the +// namespace owns the objects it reaches, or the repository it writes. + +// KubeProviderCrossNamespace is what a namespace a KubeProvider admits may reach outside itself. +// The trust is in every admitted namespace, for everything in reach of the provider's identity; it +// does not prove that the namespace owns what it reaches. +type KubeProviderCrossNamespace struct { + // Sources lets a WatchRule mirroring through this provider watch namespaces other than its own, + // including a rules[].sourceNamespace of "*", which reaches every namespace the credential can + // read. False (the default): a WatchRule watches only its own namespace. It grants no access by + // itself: the source credential's RBAC remains the hard maximum. It does not apply to + // ClusterWatchRule, which selects no namespaces at all. + // +optional + Sources bool `json:"sources,omitempty"` + + // Appliers lets a FluxTrigger or ArgoCDTrigger reaching its applier through this provider name a + // Kustomization or Application in another namespace than its own. False (the default): only + // the trigger's own namespace, which in a remote cluster is the namespace of the same name; a + // trigger naming another is Configured=False (ApplierNamespaceNotAllowed). + // +optional + Appliers bool `json:"appliers,omitempty"` +} + +// KubeProviderClient holds the per-cluster kube-client throttles. +type KubeProviderClient struct { + // QPS overrides the operator's outgoing kube-client query-per-second throttle for this + // cluster's watches and discovery. Omitted, the built-in default of 20 applies. + // +optional + // +kubebuilder:validation:Minimum=1 + QPS *int32 `json:"qps,omitempty"` + + // Burst overrides the operator's outgoing kube-client burst for this cluster. Omitted, the + // built-in default of 30 applies. + // +optional + // +kubebuilder:validation:Minimum=1 + Burst *int32 `json:"burst,omitempty"` +} + +// KubeProviderAttribution holds the per-cluster author-attribution settings. It exists as a block +// so later per-cluster knobs (grace, mode) have a home beside auditRoute. +type KubeProviderAttribution struct { + // AuditRoute is the route this cluster's audit events arrive on. The sender is the API server's + // webhook backend (https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/#webhook-backend), + // and this is the segment its configured URL ends in: /audit-webhook/. When + // several logical clusters share one backend it is instead the value of the audit-event + // annotation named by --author-attribution-audit-route-annotation-key. + // + // It partitions the attribution facts, so two KubeProviders carrying the same route read one + // cluster's facts, and two carrying different routes can never cross-credit an author. + // + // Empty means ., which no provider in another namespace can resolve to. Set it + // when several KubeProviders in this namespace name one cluster, since an API server has one + // webhook backend and so posts under one route. A route is held by one namespace: providers in + // another namespace that carry it report Stalled=True with reason AuditRouteConflict and are not + // used. The namespace a route starts with (.) holds it when a provider there carries + // it; otherwise the namespace of the oldest provider carrying it does. + // +optional + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + AuditRoute string `json:"auditRoute,omitempty"` +} + +// KubeProviderStatus defines the observed state of KubeProvider. +type KubeProviderStatus struct { + // ObservedGeneration is the latest generation observed by the controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // AuditFactsReceived is separate from Ready because a provider with no facts yet mirrors + // correctly; only the commit AUTHOR is lost. Reporting it through Ready would tell every + // kstatus reader the object is broken. + + // AuditRoute exists because the AuditFactsReceived latch needs a KEY. spec.attribution.auditRoute + // is mutable, and without recording which route earned the latch, a provider repointed at a new + // route would carry True forward and report facts it has never received there. + + // AuditRoute is the route this provider's attribution facts are partitioned under, as resolved: + // spec.attribution.auditRoute, or . when that is empty. It is the route the + // AuditFactsReceived condition describes, so when it changes that condition starts over — + // proof that one route delivered says nothing about another. + // +optional + AuditRoute string `json:"auditRoute,omitempty"` + + // Conditions report the provider's readiness: Validated (the credential is safe and + // resolvable, asserted without a network dial) plus the aggregated Ready and the kstatus + // Reconciling/Stalled pair. Stalled=True with reason InClusterNotAuthorized or + // AuditRouteConflict means the provider is not used at all. + // + // AuditFactsReceived reports whether an attribution fact has ever been accepted and recorded on + // this provider's audit route. It is True (Received) from the first one on and never goes back: + // later silence is a quiet cluster, not a fault. Until then it is Unknown, with the reason + // naming which silence it is and therefore where to look: TransportUnavailable (the fact + // transport is refusing writes, so no route can be judged), NoAuditDelivery (nothing is posting + // audit to this operator at all), or RouteUnused (audit is arriving and has never carried this + // route). It is absent when the operator runs with author attribution disabled. It is not part + // of Ready. + // +optional + // +listType=map + // +listMapKey=type + // +patchMergeKey=type + // +patchStrategy=merge + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` +// +kubebuilder:printcolumn:name="FactsReceived",type=string,JSONPath=`.status.conditions[?(@.type=="AuditFactsReceived")].status` +// +kubebuilder:printcolumn:name="Route",type=string,JSONPath=`.status.auditRoute`,priority=1 +// +kubebuilder:printcolumn:name="Validated",type=string,JSONPath=`.status.conditions[?(@.type=="Validated")].status`,priority=1 +// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1 +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// KubeProvider names a source cluster: the cluster a GitTarget mirrors from, or an apply trigger +// reaches its applier in. It holds the credential that reaches it (a kubeconfig Secret in its own +// namespace, or the operator's own identity where the installation allows it), which control-cluster +// namespaces may use it (its own, and those spec.accessFrom admits), and the audit route its +// attribution facts are partitioned under. +type KubeProvider struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata. + // +optional + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // spec defines the desired state of KubeProvider. + // +required + Spec KubeProviderSpec `json:"spec"` + + // status defines the observed state of KubeProvider. + // +optional + Status KubeProviderStatus `json:"status,omitempty,omitzero"` +} + +// +kubebuilder:object:root=true + +// KubeProviderList contains a list of KubeProvider. +type KubeProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []KubeProvider `json:"items"` +} + +// Key is this provider's namespace and name, the identity the watch data plane keys its source +// cluster on. +func (p *KubeProvider) Key() types.NamespacedName { + return types.NamespacedName{Namespace: p.Namespace, Name: p.Name} +} + +// IsInCluster reports whether this provider asks for the operator's own identity (spec.inCluster). +// Whether it may have it is the installation's decision, not this object's. +func (p *KubeProvider) IsInCluster() bool { + return p.Spec.InCluster != nil +} + +// AuditRoute is the route this provider's attribution facts are keyed under: spec.attribution.auditRoute, +// or DefaultAuditRoute when that is empty. It is resolved through this method rather than read off +// the field so no caller ever handles the empty case. +func (p *KubeProvider) AuditRoute() string { + if p.Spec.Attribution == nil || p.Spec.Attribution.AuditRoute == "" { + return DefaultAuditRoute(p.Key()) + } + return p.Spec.Attribution.AuditRoute +} + +// DefaultAuditRoute is the audit route of a provider that sets none: .. A namespace +// name has no dots, so the first segment always names the provider's own namespace, and no provider +// in another namespace can resolve to it by default. +func DefaultAuditRoute(provider types.NamespacedName) string { + return provider.Namespace + "." + provider.Name +} + +// AdmitsNamespace reports whether a consumer in namespace (by name and labels) may use this +// provider: its own namespace always, another one only as spec.accessFrom admits it. A malformed +// selector is an error, never a silent allow. The labels are always CONTROL-cluster Namespace +// labels. +func (p *KubeProvider) AdmitsNamespace(nsName string, nsLabels map[string]string) (bool, error) { + if nsName == p.Namespace { + return true, nil + } + return p.Spec.AccessFrom.Matches(nsName, nsLabels) +} + +// AllowsCrossNamespaceSources reports whether a WatchRule mirroring through this provider may +// watch namespaces other than its own (spec.allowCrossNamespace.sources). +func (p *KubeProvider) AllowsCrossNamespaceSources() bool { + return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Sources +} + +// AllowsCrossNamespaceAppliers reports whether a trigger reaching its applier through this provider +// may name one in another namespace than its own (spec.allowCrossNamespace.appliers). +func (p *KubeProvider) AllowsCrossNamespaceAppliers() bool { + return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Appliers +} + +// AuditRouteHolder returns the namespace that holds route among providers, and false when none of +// them carries it. A route is held by one namespace, so a provider in another namespace cannot read +// facts that belong to a tenant it was never admitted to: +// +// 1. The namespace the route names, its segment before the first dot, holds it when one of its +// providers carries it. This is what keeps a provider's default route (.) its +// own, even against an older provider elsewhere that spelled it out first. +// 2. Otherwise the namespace of the oldest carrier (creationTimestamp, then UID) holds it. +// +// A provider being deleted carries nothing, so its route is free the moment it goes. +func AuditRouteHolder(route string, providers []KubeProvider) (string, bool) { + carriers := make([]*KubeProvider, 0, len(providers)) + for i := range providers { + p := &providers[i] + if p.DeletionTimestamp != nil || p.AuditRoute() != route { + continue + } + carriers = append(carriers, p) + } + if len(carriers) == 0 { + return "", false + } + named, _, _ := strings.Cut(route, ".") + for _, p := range carriers { + if p.Namespace == named { + return named, true + } + } + sort.Slice(carriers, func(i, j int) bool { + a, b := carriers[i], carriers[j] + if !a.CreationTimestamp.Equal(&b.CreationTimestamp) { + return a.CreationTimestamp.Before(&b.CreationTimestamp) + } + if a.UID != b.UID { + return a.UID < b.UID + } + return a.Namespace+"/"+a.Name < b.Namespace+"/"+b.Name + }) + return carriers[0].Namespace, true +} + +func init() { + SchemeBuilder.Register(&KubeProvider{}, &KubeProviderList{}) +} diff --git a/api/v1alpha3/kubeprovider_types_test.go b/api/v1alpha3/kubeprovider_types_test.go new file mode 100644 index 000000000..772760652 --- /dev/null +++ b/api/v1alpha3/kubeprovider_types_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha3 + +import ( + "testing" + "time" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func kubeProvider(namespace, name, route string, created time.Time) KubeProvider { + p := KubeProvider{ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, Name: name, UID: types.UID(namespace + "-" + name), + CreationTimestamp: metav1.NewTime(created), + }} + if route != "" { + p.Spec.Attribution = &KubeProviderAttribution{AuditRoute: route} + } + return p +} + +// TestKubeProvider_AuditRoute covers the default every provider without a route resolves to: +// ., which names its own namespace first so no other namespace can resolve to it. +func TestKubeProvider_AuditRoute(t *testing.T) { + t0 := time.Now() + assert.Equal(t, "tenant-a.prod", new(kubeProvider("tenant-a", "prod", "", t0)).AuditRoute()) + + empty := kubeProvider("tenant-a", "prod", "", t0) + empty.Spec.Attribution = &KubeProviderAttribution{} + assert.Equal(t, "tenant-a.prod", empty.AuditRoute(), "an empty auditRoute is the default") + + assert.Equal(t, "prod-eu-1", new(kubeProvider("tenant-a", "prod", "prod-eu-1", t0)).AuditRoute()) +} + +func TestKubeProvider_AdmitsNamespace(t *testing.T) { + p := kubeProvider("platform", "remote", "", time.Now()) + + admitted, err := p.AdmitsNamespace("platform", nil) + require.NoError(t, err) + assert.True(t, admitted, "a provider admits its own namespace without accessFrom") + + admitted, err = p.AdmitsNamespace("tenant-a", nil) + require.NoError(t, err) + assert.False(t, admitted, "omitted accessFrom admits no other namespace") + + p.Spec.AccessFrom = &NamespaceMatcher{Names: []string{"tenant-a"}} + admitted, err = p.AdmitsNamespace("tenant-a", nil) + require.NoError(t, err) + assert.True(t, admitted) +} + +func TestKubeProvider_CredentialAndGrants(t *testing.T) { + p := kubeProvider("platform", "local", "", time.Now()) + assert.False(t, p.IsInCluster()) + assert.False(t, p.AllowsCrossNamespaceSources()) + assert.False(t, p.AllowsCrossNamespaceAppliers()) + + p.Spec.InCluster = &KubeProviderInCluster{} + p.Spec.AllowCrossNamespace = &KubeProviderCrossNamespace{Sources: true, Appliers: true} + assert.True(t, p.IsInCluster()) + assert.True(t, p.AllowsCrossNamespaceSources()) + assert.True(t, p.AllowsCrossNamespaceAppliers()) + assert.Equal(t, types.NamespacedName{Namespace: "platform", Name: "local"}, p.Key()) +} + +// TestAuditRouteHolder pins who holds a route that providers in several namespaces carry. +func TestAuditRouteHolder(t *testing.T) { + t0 := time.Now() + older, younger := t0, t0.Add(time.Minute) + + tests := []struct { + name string + route string + providers []KubeProvider + want string + held bool + }{ + { + name: "nobody carries the route", + route: "shared", + providers: []KubeProvider{kubeProvider("a", "p", "other", older)}, + }, + { + name: "the oldest carrier's namespace holds a route that names no namespace", + route: "shared", + providers: []KubeProvider{ + kubeProvider("b", "p", "shared", younger), + kubeProvider("a", "p", "shared", older), + }, + want: "a", held: true, + }, + { + name: "the namespace a route names holds it against an older carrier elsewhere", + route: "a.prod", + providers: []KubeProvider{ + kubeProvider("b", "squatter", "a.prod", older), + kubeProvider("a", "prod", "", younger), + }, + want: "a", held: true, + }, + { + name: "a route naming a namespace that does not carry it falls back to the oldest", + route: "c.prod", + providers: []KubeProvider{ + kubeProvider("b", "p", "c.prod", younger), + kubeProvider("a", "p", "c.prod", older), + }, + want: "a", held: true, + }, + { + name: "equal creation times break on UID", + route: "shared", + providers: []KubeProvider{ + kubeProvider("b", "p", "shared", older), + kubeProvider("a", "p", "shared", older), + }, + want: "a", held: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, held := AuditRouteHolder(tt.route, tt.providers) + assert.Equal(t, tt.held, held) + assert.Equal(t, tt.want, got) + }) + } + + deleting := kubeProvider("a", "p", "shared", older) + deleting.DeletionTimestamp = &metav1.Time{Time: younger} + got, held := AuditRouteHolder("shared", []KubeProvider{deleting, kubeProvider("b", "p", "shared", younger)}) + assert.True(t, held) + assert.Equal(t, "b", got, "a provider being deleted holds nothing") +} + +func TestKubeProviderKey_DefaultsToReferrerNamespace(t *testing.T) { + target := &GitTarget{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a"}} + assert.Equal(t, types.NamespacedName{}, target.KubeProviderKey(), "an unset reference names nothing") + + target.Spec.KubeProviderRef = meta.NamespacedObjectReference{Name: "remote"} + assert.Equal(t, types.NamespacedName{Namespace: "tenant-a", Name: "remote"}, target.KubeProviderKey()) + + flux := &FluxTrigger{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a"}} + flux.Spec.KubeProviderRef = meta.NamespacedObjectReference{Name: "mgmt", Namespace: "platform"} + assert.Equal(t, types.NamespacedName{Namespace: "platform", Name: "mgmt"}, flux.KubeProviderKey()) + + argo := &ArgoCDTrigger{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a"}} + argo.Spec.KubeProviderRef = meta.NamespacedObjectReference{Name: "mgmt"} + assert.Equal(t, types.NamespacedName{Namespace: "tenant-a", Name: "mgmt"}, argo.KubeProviderKey()) +} diff --git a/api/v1alpha3/namespace_matcher.go b/api/v1alpha3/namespace_matcher.go index da4322ce5..6b06f9a89 100644 --- a/api/v1alpha3/namespace_matcher.go +++ b/api/v1alpha3/namespace_matcher.go @@ -16,7 +16,7 @@ import ( // catastrophic one, so an absent field means "no policy declared" (which admits nothing here) while // a declared-but-empty one also admits nothing. // -// One field uses it: ClusterProvider.spec.accessFrom, whose selector matches labels on Namespaces +// One field uses it: KubeProvider.spec.accessFrom, whose selector matches labels on Namespaces // in the CONTROL cluster. It had a source-side twin, GitTarget.spec.allowedSourceNamespaces, // evaluated against Namespace labels in another cluster; that twin and everything it forced (a // three-valued verdict, a degradation path, source-cluster Namespace get/list/watch) were deleted. diff --git a/api/v1alpha3/namespace_matcher_test.go b/api/v1alpha3/namespace_matcher_test.go index aefad78ec..4b9b5b9cb 100644 --- a/api/v1alpha3/namespace_matcher_test.go +++ b/api/v1alpha3/namespace_matcher_test.go @@ -77,8 +77,8 @@ func TestNamespaceMatcher_InvalidSelectorIsAnError(t *testing.T) { // A present-but-EMPTY selector admits EVERY namespace, while a nil one admits none. // LabelSelectorAsSelector returns labels.Everything() for the first and labels.Nothing() for the -// second, and the chart's own default `default` ClusterProvider ships `accessFrom: {selector: {}}`, -// so if this ever flipped every install would stop admitting anything. +// second, and a provider shared with every namespace says so with `accessFrom: {selector: {}}`, so +// if this ever flipped every such install would stop admitting anything. func TestNamespaceMatcher_EmptySelectorMatchesEverything(t *testing.T) { declared := &NamespaceMatcher{Selector: &metav1.LabelSelector{}} admits, err := declared.Matches("any", nil) @@ -126,13 +126,13 @@ func TestResourceRule_EffectiveSourceNamespace(t *testing.T) { // The security default in one line: the delegation flag must be false on a provider that never // mentions it, so a WatchRule may watch only its own namespace until a platform admin says // otherwise. -func TestClusterProvider_DelegationFlagDefaultsClosed(t *testing.T) { - provider := &ClusterProvider{} +func TestKubeProvider_DelegationFlagDefaultsClosed(t *testing.T) { + provider := &KubeProvider{} assert.False(t, provider.AllowsCrossNamespaceSources(), "source-namespace delegation must never be on by default") assert.False(t, provider.AllowsCrossNamespaceAppliers(), "nor may a trigger reach another namespace's applier by default") - provider.Spec.AllowCrossNamespace = &ClusterProviderCrossNamespace{Appliers: true} + provider.Spec.AllowCrossNamespace = &KubeProviderCrossNamespace{Appliers: true} assert.False(t, provider.AllowsCrossNamespaceSources(), "each power is granted on its own") assert.True(t, provider.AllowsCrossNamespaceAppliers()) } diff --git a/api/v1alpha3/status_accessors.go b/api/v1alpha3/status_accessors.go index 0bfc25642..5c28d4d62 100644 --- a/api/v1alpha3/status_accessors.go +++ b/api/v1alpha3/status_accessors.go @@ -40,12 +40,12 @@ func (p *GitProvider) SetObservedGeneration(generation int64) { func (p *GitProvider) StatusConditions() *[]metav1.Condition { return &p.Status.Conditions } // SetObservedGeneration records the generation this object's status describes. -func (p *ClusterProvider) SetObservedGeneration(generation int64) { +func (p *KubeProvider) SetObservedGeneration(generation int64) { p.Status.ObservedGeneration = generation } // StatusConditions returns the condition set to write through. -func (p *ClusterProvider) StatusConditions() *[]metav1.Condition { return &p.Status.Conditions } +func (p *KubeProvider) StatusConditions() *[]metav1.Condition { return &p.Status.Conditions } // SetObservedGeneration records the generation this object's status describes. func (r *WatchRule) SetObservedGeneration(generation int64) { r.Status.ObservedGeneration = generation } diff --git a/api/v1alpha3/status_accessors_test.go b/api/v1alpha3/status_accessors_test.go index 6d58f4289..af52f5c2e 100644 --- a/api/v1alpha3/status_accessors_test.go +++ b/api/v1alpha3/status_accessors_test.go @@ -27,7 +27,7 @@ func TestStatusAccessors_ReachTheLiveStatus(t *testing.T) { gitTarget := &GitTarget{} gitProvider := &GitProvider{} - clusterProvider := &ClusterProvider{} + kubeProvider := &KubeProvider{} watchRule := &WatchRule{} clusterWatchRule := &ClusterWatchRule{} @@ -48,9 +48,9 @@ func TestStatusAccessors_ReachTheLiveStatus(t *testing.T) { stored: func() []metav1.Condition { return gitProvider.Status.Conditions }, }, { - name: "ClusterProvider", object: clusterProvider, - observed: func() int64 { return clusterProvider.Status.ObservedGeneration }, - stored: func() []metav1.Condition { return clusterProvider.Status.Conditions }, + name: "KubeProvider", object: kubeProvider, + observed: func() int64 { return kubeProvider.Status.ObservedGeneration }, + stored: func() []metav1.Condition { return kubeProvider.Status.Conditions }, }, { name: "WatchRule", object: watchRule, diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index a40cc1b6a..440b85aac 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -12,7 +12,7 @@ import ( // WatchRuleSpec defines the desired state of WatchRule. // WatchRule selects NAMESPACED resources on its GitTarget's source cluster. Each rules[] item // carries its own source namespace: omitted for this WatchRule's own namespace, an explicit name, -// or "*" for every namespace the source credential can read (refused unless the ClusterProvider +// or "*" for every namespace the source credential can read (refused unless the KubeProvider // sets allowCrossNamespace.sources). type WatchRuleSpec struct { // GitTargetRef names the GitTarget this rule feeds, in this WatchRule's own namespace. @@ -110,7 +110,7 @@ type ResourceRule struct { // // "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound // is the credential's own RBAC and nothing else. Naming any namespace other than this rule's - // own, "*" included, requires the GitTarget's ClusterProvider to admit the target's namespace + // own, "*" included, requires the GitTarget's KubeProvider to admit the target's namespace // AND to set spec.allowCrossNamespace.sources; while that is false, "*" is refused outright. // // This changes only which namespace is WATCHED, never where objects are written: Git placement @@ -126,7 +126,7 @@ type ResourceRule struct { // // It kept its spelling and changed its meaning: it used to resolve live through // GitTarget.spec.allowedSourceNamespaces into a concrete set. That field is gone, so the token is -// bounded by source-cluster RBAC alone and is refused while the ClusterProvider does not set +// bounded by source-cluster RBAC alone and is refused while the KubeProvider does not set // spec.allowCrossNamespace.sources. See docs/design/source-scope-simplification.md, which is the // definition of record. const SourceNamespaceWildcard = "*" @@ -152,7 +152,7 @@ func (r *ResourceRule) IsSourceNamespaceWildcard() bool { } // OverridesSourceNamespace reports whether this item asks for a source namespace OTHER than the -// WatchRule's own — the case that needs the ClusterProvider's delegation flag. A sourceNamespace +// WatchRule's own — the case that needs the KubeProvider's delegation flag. A sourceNamespace // that merely restates the rule's own namespace is not an override and stays the legacy case; "*" // always is one, since it reaches every namespace the credential can read. func (r *ResourceRule) OverridesSourceNamespace(ruleNamespace string) bool { diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 0092f6b38..365a5a556 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -160,8 +160,9 @@ func (in *ArgoCDTriggerSource) DeepCopy() *ArgoCDTriggerSource { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ArgoCDTriggerSpec) DeepCopyInto(out *ArgoCDTriggerSpec) { *out = *in - if in.ClusterProviderRef != nil { - in, out := &in.ClusterProviderRef, &out.ClusterProviderRef + out.KubeProviderRef = in.KubeProviderRef + if in.RemovedClusterProviderRef != nil { + in, out := &in.RemovedClusterProviderRef, &out.RemovedClusterProviderRef *out = new(meta.LocalObjectReference) **out = **in } @@ -210,197 +211,6 @@ func (in *ArgoCDTriggerStatus) DeepCopy() *ArgoCDTriggerStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProvider) DeepCopyInto(out *ClusterProvider) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProvider. -func (in *ClusterProvider) DeepCopy() *ClusterProvider { - if in == nil { - return nil - } - out := new(ClusterProvider) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ClusterProvider) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProviderAttribution) DeepCopyInto(out *ClusterProviderAttribution) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderAttribution. -func (in *ClusterProviderAttribution) DeepCopy() *ClusterProviderAttribution { - if in == nil { - return nil - } - out := new(ClusterProviderAttribution) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProviderClient) DeepCopyInto(out *ClusterProviderClient) { - *out = *in - if in.QPS != nil { - in, out := &in.QPS, &out.QPS - *out = new(int32) - **out = **in - } - if in.Burst != nil { - in, out := &in.Burst, &out.Burst - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderClient. -func (in *ClusterProviderClient) DeepCopy() *ClusterProviderClient { - if in == nil { - return nil - } - out := new(ClusterProviderClient) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProviderCrossNamespace) DeepCopyInto(out *ClusterProviderCrossNamespace) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderCrossNamespace. -func (in *ClusterProviderCrossNamespace) DeepCopy() *ClusterProviderCrossNamespace { - if in == nil { - return nil - } - out := new(ClusterProviderCrossNamespace) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProviderList) DeepCopyInto(out *ClusterProviderList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]ClusterProvider, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderList. -func (in *ClusterProviderList) DeepCopy() *ClusterProviderList { - if in == nil { - return nil - } - out := new(ClusterProviderList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ClusterProviderList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProviderSpec) DeepCopyInto(out *ClusterProviderSpec) { - *out = *in - if in.KubeConfig != nil { - in, out := &in.KubeConfig, &out.KubeConfig - *out = new(meta.KubeConfigReference) - (*in).DeepCopyInto(*out) - } - if in.AccessFrom != nil { - in, out := &in.AccessFrom, &out.AccessFrom - *out = new(NamespaceMatcher) - (*in).DeepCopyInto(*out) - } - if in.AllowCrossNamespace != nil { - in, out := &in.AllowCrossNamespace, &out.AllowCrossNamespace - *out = new(ClusterProviderCrossNamespace) - **out = **in - } - if in.RemovedAllowAnySourceNamespace != nil { - in, out := &in.RemovedAllowAnySourceNamespace, &out.RemovedAllowAnySourceNamespace - *out = new(bool) - **out = **in - } - if in.Client != nil { - in, out := &in.Client, &out.Client - *out = new(ClusterProviderClient) - (*in).DeepCopyInto(*out) - } - if in.RemovedQPS != nil { - in, out := &in.RemovedQPS, &out.RemovedQPS - *out = new(int32) - **out = **in - } - if in.RemovedBurst != nil { - in, out := &in.RemovedBurst, &out.RemovedBurst - *out = new(int32) - **out = **in - } - if in.Attribution != nil { - in, out := &in.Attribution, &out.Attribution - *out = new(ClusterProviderAttribution) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderSpec. -func (in *ClusterProviderSpec) DeepCopy() *ClusterProviderSpec { - if in == nil { - return nil - } - out := new(ClusterProviderSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProviderStatus) DeepCopyInto(out *ClusterProviderStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderStatus. -func (in *ClusterProviderStatus) DeepCopy() *ClusterProviderStatus { - if in == nil { - return nil - } - out := new(ClusterProviderStatus) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterResourceRule) DeepCopyInto(out *ClusterResourceRule) { *out = *in @@ -877,8 +687,9 @@ func (in *FluxTriggerSource) DeepCopy() *FluxTriggerSource { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FluxTriggerSpec) DeepCopyInto(out *FluxTriggerSpec) { *out = *in - if in.ClusterProviderRef != nil { - in, out := &in.ClusterProviderRef, &out.ClusterProviderRef + out.KubeProviderRef = in.KubeProviderRef + if in.RemovedClusterProviderRef != nil { + in, out := &in.RemovedClusterProviderRef, &out.RemovedClusterProviderRef *out = new(meta.LocalObjectReference) **out = **in } @@ -1392,8 +1203,9 @@ func (in *GitTargetSpec) DeepCopyInto(out *GitTargetSpec) { *out = new(bool) **out = **in } - if in.ClusterProviderRef != nil { - in, out := &in.ClusterProviderRef, &out.ClusterProviderRef + out.KubeProviderRef = in.KubeProviderRef + if in.RemovedClusterProviderRef != nil { + in, out := &in.RemovedClusterProviderRef, &out.RemovedClusterProviderRef *out = new(meta.LocalObjectReference) **out = **in } @@ -1471,6 +1283,202 @@ func (in *KnownHostsReference) DeepCopy() *KnownHostsReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProvider) DeepCopyInto(out *KubeProvider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProvider. +func (in *KubeProvider) DeepCopy() *KubeProvider { + if in == nil { + return nil + } + out := new(KubeProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KubeProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderAttribution) DeepCopyInto(out *KubeProviderAttribution) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderAttribution. +func (in *KubeProviderAttribution) DeepCopy() *KubeProviderAttribution { + if in == nil { + return nil + } + out := new(KubeProviderAttribution) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderClient) DeepCopyInto(out *KubeProviderClient) { + *out = *in + if in.QPS != nil { + in, out := &in.QPS, &out.QPS + *out = new(int32) + **out = **in + } + if in.Burst != nil { + in, out := &in.Burst, &out.Burst + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderClient. +func (in *KubeProviderClient) DeepCopy() *KubeProviderClient { + if in == nil { + return nil + } + out := new(KubeProviderClient) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderCrossNamespace) DeepCopyInto(out *KubeProviderCrossNamespace) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderCrossNamespace. +func (in *KubeProviderCrossNamespace) DeepCopy() *KubeProviderCrossNamespace { + if in == nil { + return nil + } + out := new(KubeProviderCrossNamespace) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderInCluster) DeepCopyInto(out *KubeProviderInCluster) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderInCluster. +func (in *KubeProviderInCluster) DeepCopy() *KubeProviderInCluster { + if in == nil { + return nil + } + out := new(KubeProviderInCluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderList) DeepCopyInto(out *KubeProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KubeProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderList. +func (in *KubeProviderList) DeepCopy() *KubeProviderList { + if in == nil { + return nil + } + out := new(KubeProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KubeProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderSpec) DeepCopyInto(out *KubeProviderSpec) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(meta.SecretKeyReference) + **out = **in + } + if in.InCluster != nil { + in, out := &in.InCluster, &out.InCluster + *out = new(KubeProviderInCluster) + **out = **in + } + if in.AccessFrom != nil { + in, out := &in.AccessFrom, &out.AccessFrom + *out = new(NamespaceMatcher) + (*in).DeepCopyInto(*out) + } + if in.AllowCrossNamespace != nil { + in, out := &in.AllowCrossNamespace, &out.AllowCrossNamespace + *out = new(KubeProviderCrossNamespace) + **out = **in + } + if in.Client != nil { + in, out := &in.Client, &out.Client + *out = new(KubeProviderClient) + (*in).DeepCopyInto(*out) + } + if in.Attribution != nil { + in, out := &in.Attribution, &out.Attribution + *out = new(KubeProviderAttribution) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderSpec. +func (in *KubeProviderSpec) DeepCopy() *KubeProviderSpec { + if in == nil { + return nil + } + out := new(KubeProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderStatus) DeepCopyInto(out *KubeProviderStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderStatus. +func (in *KubeProviderStatus) DeepCopy() *KubeProviderStatus { + if in == nil { + return nil + } + out := new(KubeProviderStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespaceMatcher) DeepCopyInto(out *NamespaceMatcher) { *out = *in diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index 99db0140b..3b301c83b 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -18,7 +18,7 @@ For the chart's optional starter `quickstart` block, see [`docs/configuration.md - ✅ **Git synchronization**: Push Kubernetes changes back to Git repositories - ✅ **Single-pod stability**: 1 replica by default while multi-pod support is in progress -- ✅ **Automatic CRD installation**: ClusterProvider, GitProvider, GitTarget, WatchRule, +- ✅ **Automatic CRD installation**: KubeProvider, GitProvider, GitBranch, GitTarget, WatchRule, ClusterWatchRule, CommitRequest, FluxTrigger, and ArgoCDTrigger CRDs installed automatically - ✅ **Watch-based ingestion**: object state comes from the Kubernetes watch API - ✅ **Optional audit attribution**: name commit authors from kube-apiserver audit events over HTTPS @@ -178,6 +178,7 @@ nodeSelector: | `servers.receiver.bindAddress` | host:port the push receiver binds to (`--receiver-bind-address`) | `:9292` | | `servers.receiver.port` | Push receiver container port; must match the port in `bindAddress` | `9292` | | `controllerManager.additionalSensitiveResources` | Extra Secret-shaped resource types encrypted as `resource` or `group/resource` | `[]` | +| `controllerManager.inClusterKubeProviders` | `KubeProvider`s, as `namespace/name`, that may use `spec.inCluster`, the operator's own identity (`--in-cluster-kube-providers`). Exact names, no wildcards. The provider the chart renders is added on its own when it uses `inCluster`, so list only the others. One that sets `inCluster` and is not listed reports `InClusterNotAuthorized` and is never used | `[]` | | `controllerManager.insecureAllowGitHTTP` | Permit credentials with `http://` GitProvider URLs for trusted in-cluster development Git servers | `false` | | `controllerManager.branchWorkerQueueDepth` | Event queue depth per branch worker. A full queue refuses the write (`git_queue_refusals_total`) instead of stalling the watch path; the watch delivers it again from its cursor, and only a cursor that expires first loses that write's own commit (and, under `prune.mode: OnEvent`, a delete's removal). Costs queue depth × payload of pod memory, which `branchBufferMaxSize` does **not** cover | `1000` | | `controllerManager.branchBufferMaxSize` | Maximum in-memory event buffer per branch worker. Bounds the open commit window plus the writes retained for replay until a push succeeds. While the remote cannot be reached, retained writes that reach it close the worker to new writes, saves and resyncs until they are published; the watch delivers a refused event again | `8Mi` | @@ -188,7 +189,7 @@ nodeSelector: | `auditService.type` | Service type for the dedicated audit Service | `NodePort` | | `auditService.nodePort` | Fixed NodePort for the audit Service when `auditService.type=NodePort` | `30444` | | `auditService.clusterIP` | Optional fixed ClusterIP for the dedicated audit Service | `""` | -| `quickstart.enabled` | Create starter `GitProvider`, `GitBranch`, `GitTarget`, and `WatchRule` resources | `false` | +| `quickstart.enabled` | Create starter `GitProvider`, `GitBranch`, `GitTarget`, and `WatchRule` resources. The `GitTarget` mirrors through the `KubeProvider` the chart renders, so it requires `kubeProvider.create` | `false` | | `quickstart.namespace` | Namespace for the starter quickstart resources (create it, and the git-creds Secret, before install) | `gitops-reverser-quickstart-demo` | | `quickstart.createNamespace` | Let the chart create `quickstart.namespace` (Helm then owns it) | `false` | | `quickstart.gitProvider.url` | Repository URL used by the starter `GitProvider` | `""` | @@ -211,13 +212,14 @@ nodeSelector: | `attribution.maxFacts` | Cap on the facts held in memory across every type. Must be at least `maxFactsPerType`; overflow evicts from the type holding the most | `65536` | | `attribution.collectionWindow` | 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, since the removal is attributed at delete-request time | `30s` | | `attribution.collectionUIDCap` | How many object UIDs a `deletecollection` fact carries before the set is dropped and the join falls back to scope matching, which is already correct | `10000` | -| `attribution.auditRouteAnnotationKey` | Audit-event annotation naming the **audit route** each event belongs to. Empty keeps audit routes named (`/audit-webhook/`). Set it only for a control plane emitting **one shared audit stream** for several logical clusters: it enables the bare `/audit-webhook`, which reads the route per event. A `ClusterProvider` joins a route via `spec.attribution.auditRoute` (default: its own name). An event with no annotation is rejected (counted and logged) and never credited to a fallback | `""` | -| `clusterProvider.createDefault` | Render and own a `ClusterProvider` named `default`: the source cluster a `GitTarget` mirrors from when it omits `spec.clusterProviderRef`. The **operator never creates one**, so without this you commit the object yourself. Chart-owned: turning it off makes Helm delete the provider it created, and a `GitTarget` referencing a missing provider is held unready (`ClusterProviderNotFound`). The `quickstart` values never create one | `true` | -| `clusterProvider.default.kubeConfig.secretRef.name` | Secret (release namespace) holding a kubeconfig for the rendered `default` provider. Empty means the operator's **own in-cluster** cluster; a name points `default` at a **remote** cluster instead. The name is a convention | `""` | -| `clusterProvider.default.kubeConfig.secretRef.key` | Key within that Secret. Empty reads `value` then `value.yaml` | `""` | -| `clusterProvider.default.accessFrom` | Deny-by-default policy (`names` and/or `selector`) for which **control-cluster** namespaces may reference this provider from a `GitTarget`. The default empty selector admits every namespace | `{selector: {}}` | -| `clusterProvider.default.allowCrossNamespace.sources` | Let a `WatchRule` in an admitted namespace watch other namespaces, `"*"` included. A delegation to every admitted namespace, not proof that it owns what it reads | `false` | -| `clusterProvider.default.allowCrossNamespace.appliers` | Let a `FluxTrigger` or `ArgoCDTrigger` in an admitted namespace suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`. A delegation to every admitted namespace, not proof that it owns the applier or the repository | `false` | +| `attribution.auditRouteAnnotationKey` | Audit-event annotation naming the **audit route** each event belongs to. Empty keeps audit routes named (`/audit-webhook/`). Set it only for a control plane emitting **one shared audit stream** for several logical clusters: it enables the bare `/audit-webhook`, which reads the route per event. A `KubeProvider` joins a route via `spec.attribution.auditRoute` (default: `.`). An event with no annotation is rejected (counted and logged) and never credited to a fallback | `""` | +| `kubeProvider.create` | Render and own a `KubeProvider` named `kubeProvider.name` in the release namespace. Every `GitTarget` names its provider in `spec.kubeProviderRef`, and nothing falls back to this one. The **operator never creates one**, so without this you commit the object yourself. Chart-owned: turning it off makes Helm delete the provider it created, and a `GitTarget` referencing a missing provider is held unready (`KubeProviderNotFound`). `quickstart` requires it | `true` | +| `kubeProvider.name` | Name of the rendered provider. Its audit route is `.`, the `/audit-webhook/` path the API server posts to | `local` | +| `kubeProvider.secretRef.name` | Secret in the release namespace holding a kubeconfig for a **remote** cluster. Empty renders `spec.inCluster`, the operator's **own** identity, and lists the provider in `--in-cluster-kube-providers`. Immutable once created: delete and recreate to repoint | `""` | +| `kubeProvider.secretRef.key` | Key within that Secret. Empty reads `value` then `value.yaml` | `""` | +| `kubeProvider.accessFrom` | Policy (`names` and/or `selector`) for which **other** control-cluster namespaces may reference this provider; the release namespace always may. The default empty selector admits every namespace | `{selector: {}}` | +| `kubeProvider.allowCrossNamespace.sources` | Let a `WatchRule` in an admitted namespace watch other namespaces, `"*"` included. A delegation to every admitted namespace, not proof that it owns what it reads | `false` | +| `kubeProvider.allowCrossNamespace.appliers` | Let a `FluxTrigger` or `ArgoCDTrigger` in an admitted namespace suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`. A delegation to every admitted namespace, not proof that it owns the applier or the repository | `false` | | `servers.admission.enabled` | Install the validate-operator-types admission webhook that captures CommitRequest authors (a form of author attribution). Enabled by default; a no-op until `queue.redis.addr` is set | `true` | | `rbac.create` | Create the manager ClusterRole and its binding | `true` | | `rbac.watchTypes.mode` | Which types a `WatchRule` may read. `any` grants cluster-wide read on everything, including every Secret in the cluster. `selected` grants read on `rbac.watchTypes.selected` only, so the reverser cannot list or watch Secrets (it keeps `get` on named Secrets it is pointed at). See [`docs/rbac.md`](../../docs/rbac.md) | `any` | @@ -257,9 +259,9 @@ See [`values.yaml`](values.yaml) for complete configuration options. When `attribution.enabled=true`, `https://:9444/audit-webhook/` receives audit events from `kube-apiserver`. The route is -`ClusterProvider.spec.attribution.auditRoute` and defaults to the provider's own name. -Audit routes are **named**, including -`/audit-webhook/default`. The bare `/audit-webhook` is rejected with **400** unless +`KubeProvider.spec.attribution.auditRoute` and defaults to `.`. +Audit routes are **named**, such as `/audit-webhook/gitops-reverser.local` for the chart's provider +in a `gitops-reverser` release namespace. The bare `/audit-webhook` is rejected with **400** unless `attribution.auditRouteAnnotationKey` is set, which turns it into the shared-stream endpoint that resolves each event's source cluster from that annotation. The operator extracts a minimal attribution fact from each (auditID, user, verb, resourceVersion, GVR, namespace, name, UID, status, timestamps) @@ -278,8 +280,9 @@ from scratch on restart. This chart automatically manages the following CRDs: -- **`clusterproviders.configbutler.ai`** - Source-cluster connections and namespace access policy +- **`kubeproviders.configbutler.ai`** - Source-cluster connections and namespace access policy - **`gitproviders.configbutler.ai`** - Git repository connectivity and credentials +- **`gitbranches.configbutler.ai`** - One branch of a repository, and which `GitProvider` writes it - **`gittargets.configbutler.ai`** - Branch/path and optional encryption configuration - **`watchrules.configbutler.ai`** - Namespaced watch rules - **`clusterwatchrules.configbutler.ai`** - Cluster-scoped watch rules @@ -305,7 +308,7 @@ kubectl apply -f https://github.com/ConfigButler/gitops-reverser/releases/downlo To manually remove CRDs after uninstallation: ```bash -kubectl delete crd gitproviders.configbutler.ai gittargets.configbutler.ai watchrules.configbutler.ai clusterwatchrules.configbutler.ai commitrequests.configbutler.ai fluxtriggers.configbutler.ai argocdtriggers.configbutler.ai clusterproviders.configbutler.ai +kubectl delete crd gitproviders.configbutler.ai gittargets.configbutler.ai watchrules.configbutler.ai clusterwatchrules.configbutler.ai commitrequests.configbutler.ai fluxtriggers.configbutler.ai argocdtriggers.configbutler.ai kubeproviders.configbutler.ai gitbranches.configbutler.ai ``` Delete the triggers while the operator still runs, before this ([Uninstallation](#uninstallation)). @@ -396,7 +399,7 @@ helm uninstall gitops-reverser --namespace gitops-reverser kubectl delete namespace gitops-reverser # Delete CRDs (optional, but removes all custom resources) -kubectl delete crd gitproviders.configbutler.ai gittargets.configbutler.ai watchrules.configbutler.ai clusterwatchrules.configbutler.ai commitrequests.configbutler.ai fluxtriggers.configbutler.ai argocdtriggers.configbutler.ai clusterproviders.configbutler.ai +kubectl delete crd gitproviders.configbutler.ai gittargets.configbutler.ai watchrules.configbutler.ai clusterwatchrules.configbutler.ai commitrequests.configbutler.ai fluxtriggers.configbutler.ai argocdtriggers.configbutler.ai kubeproviders.configbutler.ai gitbranches.configbutler.ai ``` ## Troubleshooting diff --git a/charts/gitops-reverser/templates/NOTES.txt b/charts/gitops-reverser/templates/NOTES.txt index 7cf5f3322..feb6da38a 100644 --- a/charts/gitops-reverser/templates/NOTES.txt +++ b/charts/gitops-reverser/templates/NOTES.txt @@ -6,7 +6,7 @@ {{- $auditNodePort := .Values.auditService.nodePort -}} {{- $auditClusterIP := .Values.auditService.clusterIP -}} {{- $tlsServerName := printf "%s.%s.svc" $auditServiceName .Release.Namespace -}} -{{- $auditPath := "/audit-webhook/default" -}} +{{- $auditPath := printf "/audit-webhook/%s" (include "gitops-reverser.kubeProviderAuditRoute" .) -}} {{- if .Values.attribution.auditRouteAnnotationKey -}} {{- $auditPath = "/audit-webhook" -}} {{- end -}} @@ -95,8 +95,9 @@ Suggested audit webhook server URL from the control-plane node: {{- if .Values.attribution.auditRouteAnnotationKey }} The bare audit endpoint reads the route from each event's `{{ .Values.attribution.auditRouteAnnotationKey }}` annotation. {{- else }} -The URL uses the named route `default`. For another ClusterProvider, replace `default` with -its `spec.attribution.auditRoute` (or its name when that field is omitted). +The URL uses the audit route of the chart's KubeProvider, `{{ include "gitops-reverser.kubeProviderAuditRoute" . }}`. +For another KubeProvider, use its `spec.attribution.auditRoute`, or `.` when that +field is omitted. {{- end }} The address must be reachable from every control-plane host. A loopback NodePort depends on your service implementation; use a reachable node address when loopback is unsupported. diff --git a/charts/gitops-reverser/templates/_helpers.tpl b/charts/gitops-reverser/templates/_helpers.tpl index 3e2f666bf..103ad0fb8 100644 --- a/charts/gitops-reverser/templates/_helpers.tpl +++ b/charts/gitops-reverser/templates/_helpers.tpl @@ -114,3 +114,26 @@ Name of the push receiver Service. {{- define "gitops-reverser.receiverServiceName" -}} {{- printf "%s-receiver" (include "gitops-reverser.fullname" .) | trunc 63 | trimSuffix "-" }} {{- end }} + +{{/* +The --in-cluster-kube-providers value: the KubeProvider the chart renders, when it uses the +operator's own identity, and every entry of controllerManager.inClusterKubeProviders. Empty when +neither lists one. +*/}} +{{- define "gitops-reverser.inClusterKubeProviders" -}} +{{- $entries := list -}} +{{- if and .Values.kubeProvider.create (not .Values.kubeProvider.secretRef.name) -}} +{{- $entries = append $entries (printf "%s/%s" .Release.Namespace .Values.kubeProvider.name) -}} +{{- end -}} +{{- range .Values.controllerManager.inClusterKubeProviders -}} +{{- $entries = append $entries . -}} +{{- end -}} +{{- join "," (uniq $entries) -}} +{{- end }} + +{{/* +The audit route of the KubeProvider the chart renders: ., its default. +*/}} +{{- define "gitops-reverser.kubeProviderAuditRoute" -}} +{{- printf "%s.%s" .Release.Namespace .Values.kubeProvider.name -}} +{{- end }} diff --git a/charts/gitops-reverser/templates/clusterprovider-default.yaml b/charts/gitops-reverser/templates/clusterprovider-default.yaml deleted file mode 100644 index ea36b1b09..000000000 --- a/charts/gitops-reverser/templates/clusterprovider-default.yaml +++ /dev/null @@ -1,31 +0,0 @@ -{{- if .Values.clusterProvider.createDefault }} ---- -# The "default" ClusterProvider — the source cluster a GitTarget mirrors FROM when it omits -# spec.clusterProviderRef. The operator never creates this object; the chart renders and OWNS it, so -# clusterProvider.createDefault=false makes Helm delete it again. "default" is only a name: omitting -# kubeConfig means the operator's own in-cluster cluster, and setting it points the same name at a -# remote cluster. accessFrom is deny-by-default; the chart default (an empty selector) admits -# every namespace — tighten it via clusterProvider.default.accessFrom. -apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: default - labels: - {{- include "gitops-reverser.labels" . | nindent 4 }} -spec: - {{- with .Values.clusterProvider.default.kubeConfig }} - {{- if .secretRef.name }} - kubeConfig: - secretRef: - name: {{ .secretRef.name }} - {{- with .secretRef.key }} - key: {{ . }} - {{- end }} - {{- end }} - {{- end }} - accessFrom: - {{- toYaml .Values.clusterProvider.default.accessFrom | nindent 4 }} - allowCrossNamespace: - sources: {{ .Values.clusterProvider.default.allowCrossNamespace.sources }} - appliers: {{ .Values.clusterProvider.default.allowCrossNamespace.appliers }} -{{- end }} diff --git a/charts/gitops-reverser/templates/deployment.yaml b/charts/gitops-reverser/templates/deployment.yaml index 1b5558b04..4f74ba4ff 100644 --- a/charts/gitops-reverser/templates/deployment.yaml +++ b/charts/gitops-reverser/templates/deployment.yaml @@ -102,6 +102,9 @@ spec: {{- with .Values.controllerManager.additionalSensitiveResources }} - {{ printf "--additional-sensitive-resources=%s" (join "," .) | quote }} {{- end }} + {{- with include "gitops-reverser.inClusterKubeProviders" . }} + - {{ printf "--in-cluster-kube-providers=%s" . | quote }} + {{- end }} {{- if .Values.controllerManager.insecureAllowGitHTTP }} - --insecure-allow-git-http {{- end }} diff --git a/charts/gitops-reverser/templates/kubeprovider.yaml b/charts/gitops-reverser/templates/kubeprovider.yaml new file mode 100644 index 000000000..83fbb5254 --- /dev/null +++ b/charts/gitops-reverser/templates/kubeprovider.yaml @@ -0,0 +1,34 @@ +{{- if .Values.kubeProvider.create }} +--- +# The KubeProvider the chart renders and OWNS, in the release namespace: kubeProvider.create=false +# makes Helm delete it again. Every GitTarget names its KubeProvider explicitly +# (spec.kubeProviderRef); nothing falls back to this one. With kubeProvider.secretRef.name empty it +# uses the operator's own identity (spec.inCluster), and the chart lists it in +# --in-cluster-kube-providers, which is the only way an inCluster provider is honored. accessFrom +# admits other namespaces; the chart default (an empty selector) admits every namespace — tighten +# it via kubeProvider.accessFrom. +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: + name: {{ .Values.kubeProvider.name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitops-reverser.labels" . | nindent 4 }} +spec: + {{- with .Values.kubeProvider.secretRef }} + {{- if .name }} + secretRef: + name: {{ .name }} + {{- with .key }} + key: {{ . }} + {{- end }} + {{- else }} + inCluster: {} + {{- end }} + {{- end }} + accessFrom: + {{- toYaml .Values.kubeProvider.accessFrom | nindent 4 }} + allowCrossNamespace: + sources: {{ .Values.kubeProvider.allowCrossNamespace.sources }} + appliers: {{ .Values.kubeProvider.allowCrossNamespace.appliers }} +{{- end }} diff --git a/charts/gitops-reverser/templates/quickstart.yaml b/charts/gitops-reverser/templates/quickstart.yaml index c53ce60d8..a0700bff9 100644 --- a/charts/gitops-reverser/templates/quickstart.yaml +++ b/charts/gitops-reverser/templates/quickstart.yaml @@ -49,6 +49,9 @@ metadata: spec: gitBranchRef: name: {{ .Values.quickstart.gitBranch.name }} + kubeProviderRef: + name: {{ .Values.kubeProvider.name }} + namespace: {{ .Release.Namespace }} path: {{ .Values.quickstart.gitTarget.path | quote }} commit: window: diff --git a/charts/gitops-reverser/templates/validate-quickstart.yaml b/charts/gitops-reverser/templates/validate-quickstart.yaml index b74430d1a..ab8e1bdca 100644 --- a/charts/gitops-reverser/templates/validate-quickstart.yaml +++ b/charts/gitops-reverser/templates/validate-quickstart.yaml @@ -17,6 +17,9 @@ {{- if eq (trim .Values.quickstart.gitTarget.encryption.secretRef.name) "" -}} {{- fail "quickstart.gitTarget.encryption.secretRef.name must be set when quickstart.enabled=true" -}} {{- end -}} +{{- if not .Values.kubeProvider.create -}} +{{- fail "quickstart.enabled=true mirrors through the KubeProvider the chart renders; set kubeProvider.create=true" -}} +{{- end -}} {{- if not .Values.quickstart.watchRule.rules -}} {{- fail "quickstart.watchRule.rules must contain at least one rule when quickstart.enabled=true" -}} {{- end -}} diff --git a/charts/gitops-reverser/values.schema.json b/charts/gitops-reverser/values.schema.json index ccf3d5596..efdcc9b4c 100644 --- a/charts/gitops-reverser/values.schema.json +++ b/charts/gitops-reverser/values.schema.json @@ -100,6 +100,11 @@ "items": { "type": "string" }, "description": "Resource or group/resource, e.g. core.cozystack.io/tenantsecrets." }, + "inClusterKubeProviders": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?/[a-z0-9]([-a-z0-9.]*[a-z0-9])?$" }, + "description": "KubeProviders (namespace/name) besides the one the chart renders that may use spec.inCluster, the operator's own identity. Exact names, no wildcards." + }, "insecureAllowGitHTTP": { "type": "boolean", "description": "Permit credentials with http:// GitProvider URLs for trusted in-cluster development Git servers." @@ -361,65 +366,57 @@ }, "auditRouteAnnotationKey": { "type": "string", - "description": "Audit-event annotation naming the audit route each event belongs to. A ClusterProvider joins a route through spec.attribution.auditRoute, which defaults to its own name. Empty (default) keeps audit routes named (/audit-webhook/); set it only for one shared audit stream carrying several logical clusters, which enables the bare /audit-webhook endpoint." + "description": "Audit-event annotation naming the audit route each event belongs to. A KubeProvider joins a route through spec.attribution.auditRoute, which defaults to .. Empty (default) keeps audit routes named (/audit-webhook/); set it only for one shared audit stream carrying several logical clusters, which enables the bare /audit-webhook endpoint." } } }, - "clusterProvider": { + "kubeProvider": { "type": "object", "additionalProperties": false, - "description": "Rendering of the 'default' ClusterProvider — the source cluster a GitTarget mirrors from when it omits spec.clusterProviderRef. The operator never creates one.", + "description": "The KubeProvider the chart renders in the release namespace. Every GitTarget names its KubeProvider explicitly; nothing falls back to this one. The operator never creates one.", "properties": { - "createDefault": { + "create": { "type": "boolean", - "description": "Render and own a ClusterProvider named 'default'. Off means Helm deletes the one it created; you then commit the object yourself." + "description": "Render and own the KubeProvider. Off means Helm deletes the one it created; you then commit the object yourself." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "The KubeProvider's name. Its audit route is .." }, - "default": { + "secretRef": { "type": "object", "additionalProperties": false, - "description": "Spec of the rendered 'default' ClusterProvider.", + "description": "Secret in the release namespace holding a kubeconfig for a remote cluster. An empty name renders spec.inCluster, the operator's own identity, and lists the provider in --in-cluster-kube-providers.", "properties": { - "kubeConfig": { - "type": "object", - "additionalProperties": false, - "description": "Which cluster 'default' names. An empty secretRef.name means the operator's own in-cluster cluster; a Secret name points it at a remote cluster instead.", - "properties": { - "secretRef": { - "type": "object", - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "description": "Secret (in the release namespace) holding the kubeconfig. Empty means in-cluster." - }, - "key": { - "type": "string", - "description": "Key within the Secret. Empty reads 'value' then 'value.yaml'." - } - } - } - } + "name": { + "type": "string", + "description": "Secret holding the kubeconfig. Empty means spec.inCluster." }, - "accessFrom": { - "type": "object", - "description": "Deny-by-default control-cluster namespace policy (names and/or selector) for which namespaces may reference this provider from a GitTarget.", - "additionalProperties": true + "key": { + "type": "string", + "description": "Key within the Secret. Empty reads 'value' then 'value.yaml'." + } + } + }, + "accessFrom": { + "type": "object", + "description": "Control-cluster namespace policy (names and/or selector) for which OTHER namespaces may reference this provider; the release namespace always may.", + "additionalProperties": true + }, + "allowCrossNamespace": { + "type": "object", + "additionalProperties": false, + "description": "What an admitted namespace may reach outside itself through this provider.", + "properties": { + "sources": { + "type": "boolean", + "description": "A WatchRule may watch other namespaces, \"*\" included." }, - "allowCrossNamespace": { - "type": "object", - "additionalProperties": false, - "description": "What an admitted namespace may reach outside itself through this provider.", - "properties": { - "sources": { - "type": "boolean", - "description": "A WatchRule may watch other namespaces, \"*\" included." - }, - "appliers": { - "type": "boolean", - "description": "A FluxTrigger or ArgoCDTrigger may name a Kustomization or Application in another namespace." - } - } + "appliers": { + "type": "boolean", + "description": "A FluxTrigger or ArgoCDTrigger may name a Kustomization or Application in another namespace." } } } diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 6d714c346..4f96af579 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -61,6 +61,13 @@ controllerManager: # Extra Secret-shaped resources that must use the encrypted Git write path. # Entries are resource or group/resource, for example core.cozystack.io/tenantsecrets. additionalSensitiveResources: [] + # KubeProviders, as namespace/name, that may use spec.inCluster: the operator's own identity in + # the cluster it runs in. The KubeProvider the chart renders (kubeProvider, below) is added on its + # own when it uses inCluster, so list only the others. Exact names, no wildcards: anyone who can + # create a KubeProvider with a listed name wields the operator's identity through it. A + # KubeProvider that sets inCluster and is not listed reports InClusterNotAuthorized and is never + # used. + inClusterKubeProviders: [] # Permit credentials with http:// GitProvider URLs. This is only for trusted # in-cluster development Git servers; production remotes should use HTTPS or SSH. insecureAllowGitHTTP: false @@ -323,51 +330,51 @@ attribution: # precise path is taken; drops are counted on attribution_collection_without_uidset_total{reason}. collectionUIDCap: 10000 # Audit-event annotation naming the AUDIT ROUTE each event belongs to. Audit routes are normally - # NAMED (/audit-webhook/, including /audit-webhook/default), and this is empty. + # NAMED (/audit-webhook/, such as /audit-webhook/gitops-reverser.local), and this is + # empty. # Set it only for a control plane that emits ONE shared audit stream for several logical # clusters: it enables the bare /audit-webhook endpoint, which reads this annotation from every - # event, so one batch may fan out to several routes. A ClusterProvider joins a route by setting - # spec.attribution.auditRoute to the same value; it defaults to the provider's own name. An event + # event, so one batch may fan out to several routes. A KubeProvider joins a route by setting + # spec.attribution.auditRoute to the same value; it defaults to .. An event # with no annotation is rejected (counted and logged) and never credited to a fallback. # See docs/configuration.md. auditRouteAnnotationKey: "" -# The source cluster a GitTarget mirrors FROM is a ClusterProvider, and a GitTarget that omits -# spec.clusterProviderRef references one named "default". The operator NEVER creates a -# ClusterProvider, so that object has to come from somewhere: either you commit it yourself, or the -# chart renders it from here. This is a rendering convenience, not runtime behavior — it is not -# limited to attribution (every mirror needs a provider) and "default" is just a name, so the -# provider it renders may be in-cluster or remote. -clusterProvider: - # Render and OWN a ClusterProvider named "default". Chart-owned: turn this off and Helm deletes - # the provider it created on the next upgrade, so ownership never silently splits — plan that - # switch together with committing your own object, because a missing provider holds its - # GitTargets unready via the "provider not found" path. The `quickstart` values never create one. - createDefault: true - # Spec of the rendered "default" ClusterProvider. - default: - # Which cluster "default" names. Leave secretRef.name empty for the operator's OWN in-cluster - # cluster; set it to a Secret (in the release namespace) holding a kubeconfig to make "default" - # mirror a REMOTE cluster instead. Immutable once created: to repoint the name, delete and - # recreate. key is optional — empty reads "value" then "value.yaml". - kubeConfig: - secretRef: - name: "" - key: "" - # Which namespaces may reference this provider from a GitTarget — deny-by-default authorization - # in the CONTROL cluster (names OR selector), not a filter on the source cluster. The chart - # default is an empty selector, which matches EVERY namespace, so a single-cluster install works - # out of the box; tighten it to a names list or a label selector in a real install. - accessFrom: - selector: {} - # What a namespace accessFrom admits may reach OUTSIDE itself through this provider's identity. - # Each is a power of its own: sources lets a WatchRule read other namespaces' objects into its - # own Git ("*" included); appliers lets a FluxTrigger or ArgoCDTrigger suspend and apply a - # Kustomization or Application in another namespace. It is a delegation to every admitted - # namespace, not proof that a namespace owns what it reaches. - allowCrossNamespace: - sources: false - appliers: false +# The source cluster a GitTarget mirrors FROM is a KubeProvider, which every GitTarget names +# explicitly (spec.kubeProviderRef, with a namespace when it is not the GitTarget's own). The +# operator NEVER creates one, so it has to come from somewhere: either you commit it yourself, or +# the chart renders one from here, in the release namespace. Nothing falls back to it. +kubeProvider: + # Render and OWN a KubeProvider named kubeProvider.name in the release namespace. Chart-owned: + # turn this off and Helm deletes the provider it created on the next upgrade, so ownership never + # silently splits — plan that switch together with committing your own object, because a + # GitTarget whose provider is missing is held unready (KubeProviderNotFound). + create: true + # The provider's name. Its audit route is ., which is the + # /audit-webhook/ path the API server's audit webhook posts to. + name: local + # Which cluster the provider names. Leave name empty for the operator's OWN identity in the + # cluster it runs in (spec.inCluster; the chart lists the provider in + # --in-cluster-kube-providers). Set it to a Secret in the release namespace holding a kubeconfig + # to mirror a REMOTE cluster instead. Immutable once created: to repoint, delete and recreate. + # key is optional — empty reads "value" then "value.yaml". + secretRef: + name: "" + key: "" + # Which OTHER namespaces may reference this provider; the release namespace always may. It is + # authorization in the CONTROL cluster (names OR selector), not a filter on the source cluster. + # The chart default is an empty selector, which matches EVERY namespace, so a single-cluster + # install works out of the box; tighten it to a names list or a label selector in a real install. + accessFrom: + selector: {} + # What a namespace this provider admits may reach OUTSIDE itself through the provider's + # identity. Each is a power of its own: sources lets a WatchRule read other namespaces' objects + # into its own Git ("*" included); appliers lets a FluxTrigger or ArgoCDTrigger suspend and apply + # a Kustomization or Application in another namespace. It is a delegation to every admitted + # namespace, not proof that a namespace owns what it reaches. + allowCrossNamespace: + sources: false + appliers: false # RBAC configuration rbac: diff --git a/cmd/main.go b/cmd/main.go index 6caba17ab..503601fbd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -142,12 +142,12 @@ func main() { RuleStore: ruleStore, EventRouter: nil, // Will be set below SensitiveResources: cfg.sensitiveResources, - // Resolve a source cluster (named by a GitTarget.spec.clusterProviderRef) into a - // rest.Config: look up the ClusterProvider by name, read its kubeConfig Secret from the - // operator namespace, and build the client. The manager client bypasses its cache for - // Secrets, so a rotated kubeconfig is seen without a Secret informer. - SourceClusters: watch.NewSecretSourceClusterResolver( - mgr.GetClient(), os.Getenv("POD_NAMESPACE"), cfg.kubeConfigSafety), + // Resolve a source cluster (named by a GitTarget.spec.kubeProviderRef) into a rest.Config: + // read the KubeProvider, then its kubeconfig Secret from the provider's own namespace, or + // the operator's own identity where --in-cluster-kube-providers allows it. The manager + // client bypasses its cache for Secrets, so a rotated kubeconfig is seen without a Secret + // informer. + SourceClusters: watch.NewSecretSourceClusterResolver(mgr.GetClient(), cfg.kubeConfigSafety), } // Initialize EventRouter with all dependencies. The streaming-snapshot resync @@ -251,7 +251,7 @@ func main() { auditCertWatcher *certwatcher.CertWatcher // routeHealth is the ONE per-audit-route registry in the process: the audit ingress records // each route's first published fact in it, the resolver keeps its never-resolved warning - // there, and the ClusterProvider controller latches AuditFactsReceived from it. It stays nil + // there, and the KubeProvider controller latches AuditFactsReceived from it. It stays nil // in configured-author mode, where no fact was ever expected and the condition is absent. routeHealth *attribution.RouteHealth ) @@ -311,7 +311,7 @@ func main() { } if cfg.auditRouteAnnotationKey == "" { setupLog.Info("audit routes are named: post to /audit-webhook/, where the route is " + - "ClusterProvider.spec.attribution.auditRoute and defaults to the provider's own name; " + + "KubeProvider.spec.attribution.auditRoute and defaults to .; " + "the bare /audit-webhook endpoint is disabled") } else { setupLog.Info("shared audit stream enabled on the bare /audit-webhook endpoint: each event's "+ @@ -339,20 +339,19 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "GitProvider") os.Exit(1) } - clusterProviderReconciler := &controller.ClusterProviderReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - OperatorNamespace: os.Getenv("POD_NAMESPACE"), - KubeConfigSafety: cfg.kubeConfigSafety, - Recorder: mgr.GetEventRecorderFor("clusterprovider"), + kubeProviderReconciler := &controller.KubeProviderReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + KubeConfigSafety: cfg.kubeConfigSafety, + Recorder: mgr.GetEventRecorderFor("kubeprovider"), } // A nil registry must stay a nil INTERFACE, not one wrapping a nil pointer: the reconciler // reads that field to decide whether AuditFactsReceived is reported at all. if routeHealth != nil { - clusterProviderReconciler.AuditFacts = routeHealth + kubeProviderReconciler.AuditFacts = routeHealth } - if err := clusterProviderReconciler.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "ClusterProvider") + if err := kubeProviderReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "KubeProvider") os.Exit(1) } if err := (&controller.GitBranchReconciler{ @@ -497,9 +496,10 @@ type appConfig struct { sensitiveResources types.SensitiveResourcePolicy sshHostKeys git.SSHHostKeyConfig credentialPolicy git.CredentialTransportPolicy - // kubeConfigSafety is the exec / insecure-TLS opt-in for source-cluster kubeconfigs. Both - // default OFF: an operator-supplied kubeconfig is attacker-adjacent input, so unsafe - // kubeconfigs are REJECTED (a legible Validated=False), diverging from Flux's silent strip. + // kubeConfigSafety is the exec / insecure-TLS opt-in for source-cluster kubeconfigs, and the + // KubeProviders allowed to use the operator's own identity. All default OFF: an + // operator-supplied kubeconfig is attacker-adjacent input, so unsafe kubeconfigs are REJECTED + // (a legible Validated=False), diverging from Flux's silent strip. kubeConfigSafety kubeconfig.SafetyPolicy zapOpts zap.Options } @@ -650,10 +650,11 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { fs.StringVar(&cfg.auditRouteAnnotationKey, "author-attribution-audit-route-annotation-key", "", "Audit-event annotation naming the AUDIT ROUTE each event belongs to. Setting it enables the "+ "bare /audit-webhook endpoint for a SHARED audit stream carrying several logical clusters: the "+ - "route is read per event, so one batch may fan out to several routes. A ClusterProvider joins "+ + "route is read per event, so one batch may fan out to several routes. A KubeProvider joins "+ "a route by setting spec.attribution.auditRoute to the same value (it defaults to the "+ - "provider's own name). An event carrying no annotation is rejected (counted and logged) rather "+ - "than credited to a fallback. Empty (the default) leaves the bare endpoint disabled, and every "+ + "provider's .). An event carrying no annotation is rejected (counted and "+ + "logged) rather than credited to a fallback. Empty (the default) leaves the bare endpoint "+ + "disabled, and every "+ "producer must post to /audit-webhook/.") fs.IntVar(&cfg.replicaCount, "replica-count", 1, "How many replicas of this Deployment run (default 1). The process cannot see this itself, so "+ @@ -693,6 +694,12 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { fs.BoolVar(&cfg.kubeConfigSafety.AllowInsecureTLS, "insecure-kubeconfig-tls", false, "Allow a source-cluster kubeconfig to set insecure-skip-tls-verify (disables server cert "+ "validation). Rejected by default; enabling this is a deliberate trust decision.") + var inClusterKubeProviders string + fs.StringVar(&inClusterKubeProviders, "in-cluster-kube-providers", "", + "Comma-separated namespace/name of each KubeProvider allowed to set spec.inCluster, the "+ + "operator's own identity in the cluster it runs in. Exact names only, no wildcards. Empty "+ + "(the default): none may, and a KubeProvider that sets spec.inCluster reports Stalled "+ + "(InClusterNotAuthorized) and is never used.") fs.StringVar(&cfg.sshHostKeys.DefaultKnownHostsConfigMap, "default-known-hosts-configmap", "", "Optional install-level ConfigMap (in the controller's namespace) supplying SSH known_hosts "+ "for Git hosts when neither the credentials Secret nor the GitProvider's knownHostsRef does.") @@ -761,6 +768,10 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { if err != nil { return appConfig{}, err } + cfg.kubeConfigSafety.InClusterProviders, err = kubeconfig.ParseInClusterProviders(inClusterKubeProviders) + if err != nil { + return appConfig{}, err + } // The install-level default known-hosts ConfigMap lives in the controller's own namespace, // supplied via the downward API. Without it, that resolution layer is simply unavailable. diff --git a/cmd/main_audit_server_test.go b/cmd/main_audit_server_test.go index bf72eb423..25f689318 100644 --- a/cmd/main_audit_server_test.go +++ b/cmd/main_audit_server_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/types" "github.com/ConfigButler/gitops-reverser/internal/queue" ) @@ -95,6 +96,27 @@ func TestParseFlagsWithArgs_InsecureCredentialedGitHTTP(t *testing.T) { assert.True(t, cfg.credentialPolicy.AllowInsecureGitHTTP) } +// TestParseFlagsWithArgs_InClusterKubeProviders: spec.inCluster is honored only for the +// KubeProviders this flag lists, by exact namespace/name. Unset, none is. +func TestParseFlagsWithArgs_InClusterKubeProviders(t *testing.T) { + cfg, err := parseFlagsWithArgs(flag.NewFlagSet("test-in-cluster-default", flag.ContinueOnError), nil) + require.NoError(t, err) + assert.Empty(t, cfg.kubeConfigSafety.InClusterProviders, "no KubeProvider may use spec.inCluster by default") + + cfg, err = parseFlagsWithArgs(flag.NewFlagSet("test-in-cluster", flag.ContinueOnError), + []string{"--in-cluster-kube-providers=gitops-reverser/local, platform/mgmt"}) + require.NoError(t, err) + assert.Equal(t, map[types.NamespacedName]struct{}{ + {Namespace: "gitops-reverser", Name: "local"}: {}, + {Namespace: "platform", Name: "mgmt"}: {}, + }, cfg.kubeConfigSafety.InClusterProviders) + + _, err = parseFlagsWithArgs(flag.NewFlagSet("test-in-cluster-wildcard", flag.ContinueOnError), + []string{"--in-cluster-kube-providers=platform/*"}) + require.Error(t, err, "a wildcard would hand the operator's identity to anyone who can create a provider") + assert.Contains(t, err.Error(), "in-cluster-kube-providers") +} + func TestParseFlagsWithArgs_CustomAuditValues(t *testing.T) { fs := flag.NewFlagSet("test-custom", flag.ContinueOnError) args := []string{ diff --git a/cmd/main_chart_notes_test.go b/cmd/main_chart_notes_test.go index f0994f9cd..c0439eeab 100644 --- a/cmd/main_chart_notes_test.go +++ b/cmd/main_chart_notes_test.go @@ -42,17 +42,21 @@ func renderAuditNotes(t *testing.T, values ...string) string { return rendered.Data["notes"] } +// chartAuditPath is the audit path of the chart's own KubeProvider in the rendered release: its +// default route, .. +const chartAuditPath = "/audit-webhook/gitops-reverser.local" + // TestChartAuditNotesUseConfiguredRouteMode checks the mTLS generator across service types and route modes. func TestChartAuditNotesUseConfiguredRouteMode(t *testing.T) { tests := map[string]struct { values []string url string }{ - "default nodeport": {url: "https://127.0.0.1:30444/audit-webhook/default"}, + "default nodeport": {url: "https://127.0.0.1:30444" + chartAuditPath}, "clusterip": {values: []string{"auditService.type=ClusterIP", "auditService.clusterIP=10.96.0.44"}, - url: "https://10.96.0.44:9444/audit-webhook/default"}, + url: "https://10.96.0.44:9444" + chartAuditPath}, "loadbalancer": {values: []string{"auditService.type=LoadBalancer"}, - url: "\"https://:9444/audit-webhook/default\""}, + url: "\"https://:9444" + chartAuditPath + "\""}, "shared stream": {values: []string{"attribution.auditRouteAnnotationKey=example.com/route"}, url: "https://127.0.0.1:30444/audit-webhook"}, } @@ -64,7 +68,7 @@ func TestChartAuditNotesUseConfiguredRouteMode(t *testing.T) { require.Contains(t, notes, "tls-server-name: ${AUDIT_TLS_SERVER_NAME}") require.Contains(t, notes, "umask 077\ncat > audit-webhook.kubeconfig") if name == "shared stream" { - require.NotContains(t, notes, "/audit-webhook/default") + require.NotContains(t, notes, chartAuditPath) require.Contains(t, notes, "example.com/route") } }) @@ -74,7 +78,7 @@ func TestChartAuditNotesUseConfiguredRouteMode(t *testing.T) { // TestChartAuditNotesWithoutTLS checks that development examples match the plain HTTP listener. func TestChartAuditNotesWithoutTLS(t *testing.T) { for _, shared := range []bool{false, true} { - name, path := "named default", "/audit-webhook/default" + name, path := "named route", chartAuditPath values := []string{"servers.audit.tls.enabled=false"} if shared { name, path = "shared stream", "/audit-webhook" diff --git a/cmd/mutation-capture-lab/main.go b/cmd/mutation-capture-lab/main.go index 5d142b671..cf18fcbb0 100644 --- a/cmd/mutation-capture-lab/main.go +++ b/cmd/mutation-capture-lab/main.go @@ -122,7 +122,7 @@ func buildServers( auditMux := http.NewServeMux() auditMux.Handle("/audit-webhook", audit) // Audit routes are NAMED in the e2e cluster: its bootstrap kubeconfig posts to - // /audit-webhook/ (the "default" ClusterProvider), because the bare + // /audit-webhook/ (the chart's local KubeProvider), because the bare // path is the product's shared annotation-routed endpoint. The lab records what // arrives regardless of which source it was addressed to, so it takes the whole // subtree — an exact-path-only mux 404s every event the cluster sends. diff --git a/config/clusterprovider-default.yaml b/config/clusterprovider-default.yaml deleted file mode 100644 index f1fc774ba..000000000 --- a/config/clusterprovider-default.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# The "default" ClusterProvider for the SUT / config-dir install, mirroring what the Helm chart -# renders when clusterProvider.createDefault is true. A GitTarget's clusterProviderRef defaults to -# {name: default}, and the reconcile-time hard gate REQUIRES the referenced ClusterProvider to -# exist, so local mirroring needs this object — the operator never creates it. The empty selector -# admits every namespace (matching the chart default); tighten in a real install. -apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: default -spec: - # no kubeConfig => the operator's own in-cluster config (optional for any provider name) - accessFrom: - selector: {} - # Nothing outside the admitted namespaces, as the chart renders it. - allowCrossNamespace: - sources: false - appliers: false diff --git a/config/crd/bases/configbutler.ai_argocdtriggers.yaml b/config/crd/bases/configbutler.ai_argocdtriggers.yaml index 6f23190d5..c24c339c3 100644 --- a/config/crd/bases/configbutler.ai_argocdtriggers.yaml +++ b/config/crd/bases/configbutler.ai_argocdtriggers.yaml @@ -31,7 +31,7 @@ spec: name: Commit priority: 1 type: string - - jsonPath: .spec.clusterProviderRef.name + - jsonPath: .spec.kubeProviderRef.name name: Cluster priority: 1 type: string @@ -102,14 +102,9 @@ spec: - message: spec.applicationRef.name must not be empty rule: self.name != '' clusterProviderRef: - default: - name: default description: |- - ClusterProviderRef names the ClusterProvider whose cluster holds the Application, such as a - management cluster running Argo CD for the source cluster. A provider without - spec.kubeConfig is the operator's own cluster. The provider must admit this trigger's - namespace (spec.accessFrom). Its identity needs get and update on the Application. Omitted, - it is the ClusterProvider named "default", as for a GitTarget. Immutable. + RemovedClusterProviderRef is the replaced spelling of kubeProviderRef. It is never read; + setting it is refused. properties: name: description: Name of the referent. @@ -117,15 +112,37 @@ spec: required: - name type: object + kubeProviderRef: + description: |- + KubeProviderRef names the KubeProvider whose cluster holds the Application, such as a + management cluster running Argo CD for the source cluster. Namespace omitted means this + trigger's own namespace. The provider must admit this trigger's namespace: its own, or one its + spec.accessFrom admits. Its identity needs get and update on the Application. Immutable. + properties: + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string + required: + - name + type: object x-kubernetes-validations: - - message: spec.clusterProviderRef.name must not be empty + - message: spec.kubeProviderRef.name must not be empty rule: self.name != '' - - message: spec.clusterProviderRef is immutable; delete and recreate + - message: spec.kubeProviderRef is immutable; delete and recreate the ArgoCDTrigger to hold an Application elsewhere rule: self == oldSelf required: - applicationRef + - kubeProviderRef type: object + x-kubernetes-validations: + - message: spec.clusterProviderRef was replaced by spec.kubeProviderRef, + which names a namespaced KubeProvider + rule: '!has(self.clusterProviderRef)' status: description: status defines the observed state of ArgoCDTrigger properties: @@ -135,7 +152,7 @@ spec: syncs a single source, has automated sync off, may be updated, and no other ArgoCDTrigger holds it) and the kstatus Ready/Reconciling/Stalled trio. A trigger acts only while an eligible GitTarget references it: one in its namespace that names it, is admitted by its - ClusterProviders, and writes the repository and branch the Application syncs. A trigger no + KubeProviders, and writes the repository and branch the Application syncs. A trigger no GitTarget names reads nothing in the Application's cluster and is Configured=Unknown, Ready=True, both NotReferenced; one named only by GitTargets that are not eligible is Configured NotBound or a refusal. Of the ArgoCDTriggers in line for one Application in one cluster, the one diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index a2293ea5e..5a3bfddfb 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -51,7 +51,7 @@ spec: carried by the rule KIND, so it has no per-rule scope choice and no source-namespace selection. It is cluster-scoped and requires cluster-admin permissions. Its gitTargetRef names a GitTarget - (namespace required), whose namespace must be admitted by that target's ClusterProvider. To + (namespace required), whose namespace must be admitted by that target's KubeProvider. To mirror NAMESPACED resources use a WatchRule in the tenant namespace and set spec.rules[].sourceNamespace, whose "*" reaches every namespace the source credential can read. properties: diff --git a/config/crd/bases/configbutler.ai_fluxtriggers.yaml b/config/crd/bases/configbutler.ai_fluxtriggers.yaml index da10746bf..bb29fb44e 100644 --- a/config/crd/bases/configbutler.ai_fluxtriggers.yaml +++ b/config/crd/bases/configbutler.ai_fluxtriggers.yaml @@ -34,7 +34,7 @@ spec: name: Commit priority: 1 type: string - - jsonPath: .spec.clusterProviderRef.name + - jsonPath: .spec.kubeProviderRef.name name: Cluster priority: 1 type: string @@ -79,15 +79,9 @@ spec: description: spec defines the Kustomization the trigger holds properties: clusterProviderRef: - default: - name: default description: |- - ClusterProviderRef names the ClusterProvider whose cluster holds the Kustomization, such as a - management cluster running Flux for the source cluster. A provider without spec.kubeConfig is - the operator's own cluster. The provider must admit this trigger's namespace - (spec.accessFrom). Its identity needs get and patch on the Kustomization and its - GitRepository. Omitted, - it is the ClusterProvider named "default", as for a GitTarget. Immutable. + RemovedClusterProviderRef is the replaced spelling of kubeProviderRef. It is never read; + setting it is refused. properties: name: description: Name of the referent. @@ -95,10 +89,27 @@ spec: required: - name type: object + kubeProviderRef: + description: |- + KubeProviderRef names the KubeProvider whose cluster holds the Kustomization, such as a + management cluster running Flux for the source cluster. Namespace omitted means this + trigger's own namespace. The provider must admit this trigger's namespace: its own, or one its + spec.accessFrom admits. Its identity needs get and patch on the Kustomization and its GitRepository. Immutable. + properties: + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string + required: + - name + type: object x-kubernetes-validations: - - message: spec.clusterProviderRef.name must not be empty + - message: spec.kubeProviderRef.name must not be empty rule: self.name != '' - - message: spec.clusterProviderRef is immutable; delete and recreate + - message: spec.kubeProviderRef is immutable; delete and recreate the FluxTrigger to hold a Kustomization elsewhere rule: self == oldSelf kustomizationRef: @@ -124,8 +135,13 @@ spec: - message: spec.kustomizationRef.name must not be empty rule: self.name != '' required: + - kubeProviderRef - kustomizationRef type: object + x-kubernetes-validations: + - message: spec.clusterProviderRef was replaced by spec.kubeProviderRef, + which names a namespaced KubeProvider + rule: '!has(self.clusterProviderRef)' status: description: status defines the observed state of FluxTrigger properties: @@ -137,7 +153,7 @@ spec: resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; Releasing: being handed back; NotReferenced; NotConfigured), and the kstatus Ready/Reconciling/Stalled trio. A trigger acts only while an eligible GitTarget references it: - one in its namespace that names it, is admitted by its ClusterProviders, and writes the + one in its namespace that names it, is admitted by its KubeProviders, and writes the repository and branch the GitRepository fetches. A trigger no GitTarget names reads nothing in the Kustomization's cluster and is Configured=Unknown, Ready=True, both NotReferenced; one named only by GitTargets that are not eligible is Configured NotBound or a refusal. Of the diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index 64fc05746..fe6058932 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -137,14 +137,9 @@ spec: refused. type: string clusterProviderRef: - default: - name: default description: |- - ClusterProviderRef names the SOURCE cluster this GitTarget mirrors FROM, by referencing a - cluster-scoped ClusterProvider by name. That ClusterProvider owns the cluster's connectivity - credential, namespace-access authorization, and author-attribution mode. The default provider - name is "default" and must exist; it may be in-cluster or remote. - Immutable: a folder's source cluster is part of what the folder means; delete and recreate. + RemovedClusterProviderRef is the replaced spelling of kubeProviderRef. It is never read; + setting it is refused. properties: name: description: Name of the referent. @@ -152,9 +147,6 @@ spec: required: - name type: object - x-kubernetes-validations: - - message: spec.clusterProviderRef.name must not be empty - rule: self.name != '' commit: description: |- Commit configures how this target's writes are batched into commits, and how those commits @@ -347,6 +339,30 @@ spec: required: - name type: object + kubeProviderRef: + description: |- + KubeProviderRef names the KubeProvider this GitTarget mirrors FROM: the source cluster, and the + credential it is read with. Namespace omitted means this GitTarget's own namespace. A + KubeProvider in another namespace is used only while its spec.accessFrom admits this + GitTarget's namespace. Immutable: a folder's source cluster is part of what the folder means; + delete and recreate the GitTarget to change it. + properties: + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: spec.kubeProviderRef.name must not be empty + rule: self.name != '' + - message: spec.kubeProviderRef is immutable; delete and recreate + the GitTarget to change the cluster it mirrors + rule: self == oldSelf onRefusal: default: Ignore description: |- @@ -539,6 +555,7 @@ spec: type: boolean required: - gitBranchRef + - kubeProviderRef - path type: object x-kubernetes-validations: @@ -551,9 +568,9 @@ spec: - message: spec.gitProviderRef, spec.branch and spec.parentBranch moved to a GitBranch; set spec.gitBranchRef to it rule: '!has(self.gitProviderRef) && !has(self.branch) && !has(self.parentBranch)' - - message: spec.clusterProviderRef is immutable; delete and recreate the - GitTarget to change the cluster it mirrors - rule: self.clusterProviderRef == oldSelf.clusterProviderRef + - message: spec.clusterProviderRef was replaced by spec.kubeProviderRef, + which names a namespaced KubeProvider + rule: '!has(self.clusterProviderRef)' - message: 'spec.onRefusal: Reapply needs spec.applyTriggerRef, the trigger asked to reapply' rule: '!has(self.onRefusal) || self.onRefusal != ''Reapply'' || has(self.applyTriggerRef)' diff --git a/config/crd/bases/configbutler.ai_clusterproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml similarity index 59% rename from config/crd/bases/configbutler.ai_clusterproviders.yaml rename to config/crd/bases/configbutler.ai_kubeproviders.yaml index 676dd1272..f11018ab1 100644 --- a/config/crd/bases/configbutler.ai_clusterproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -4,15 +4,15 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.22.0 - name: clusterproviders.configbutler.ai + name: kubeproviders.configbutler.ai spec: group: configbutler.ai names: - kind: ClusterProvider - listKind: ClusterProviderList - plural: clusterproviders - singular: clusterprovider - scope: Cluster + kind: KubeProvider + listKind: KubeProviderList + plural: kubeproviders + singular: kubeprovider + scope: Namespaced versions: - additionalPrinterColumns: - jsonPath: .status.conditions[?(@.type=="Ready")].status @@ -24,6 +24,10 @@ spec: - jsonPath: .status.conditions[?(@.type=="AuditFactsReceived")].status name: FactsReceived type: string + - jsonPath: .status.auditRoute + name: Route + priority: 1 + type: string - jsonPath: .status.conditions[?(@.type=="Validated")].status name: Validated priority: 1 @@ -39,19 +43,11 @@ spec: schema: openAPIV3Schema: description: |- - ClusterProvider is the cluster-scoped, read-side peer of GitProvider: it names a SOURCE cluster a - GitTarget mirrors FROM, and owns that cluster's connectivity credential (spec.kubeConfig), - namespace-access authorization (spec.accessFrom), and per-cluster status. Its NAME is the - cluster's identity for the watch data plane, and the DEFAULT for its audit route: attribution - facts are partitioned by spec.attribution.auditRoute, which falls back to this name. Several - providers may name one cluster by declaring one route, which is what an API server with a single - audit webhook backend requires. No name is special: "default" is merely what an omitted - GitTarget.spec.clusterProviderRef points at, and it may just as well name a remote cluster, since - in-cluster-ness follows from spec.kubeConfig (omitted = in-cluster) rather than from the name. - - It is cluster-scoped and requires platform-admin permissions to create. A GitTarget may reference - it only from a namespace spec.accessFrom admits — deny-by-default, enforced at admission - and again before any watch starts. + KubeProvider names a source cluster: the cluster a GitTarget mirrors from, or an apply trigger + reaches its applier in. It holds the credential that reaches it (a kubeconfig Secret in its own + namespace, or the operator's own identity where the installation allows it), which control-cluster + namespaces may use it (its own, and those spec.accessFrom admits), and the audit route its + attribution facts are partitioned under. properties: apiVersion: description: |- @@ -71,14 +67,13 @@ spec: metadata: type: object spec: - description: spec defines the desired state of ClusterProvider. + description: spec defines the desired state of KubeProvider. properties: accessFrom: description: |- - AccessFrom is the deny-by-default policy for which CONTROL-CLUSTER namespaces may reference - this provider from a GitTarget. Empty (or omitted) means no namespace may reference it. Its - selector matches labels on Namespaces in the control cluster (the cluster the operator's own - CRs live in), never on the source cluster this provider names. + AccessFrom admits other control-cluster namespaces to reference this provider. Its own + namespace is always admitted; omitted, no other namespace is. Its selector matches labels on + Namespaces in the control cluster, never on the source cluster this provider names. properties: names: description: |- @@ -140,11 +135,6 @@ spec: type: object x-kubernetes-map-type: atomic type: object - allowAnySourceNamespace: - description: |- - RemovedAllowAnySourceNamespace is the removed spelling of allowCrossNamespace.sources. It is - never read; setting it to true is refused. - type: boolean allowCrossNamespace: description: |- AllowCrossNamespace grants what a namespace this provider admits may reach outside itself, @@ -177,31 +167,27 @@ spec: description: |- AuditRoute is the route this cluster's audit events arrive on. The sender is the API server's webhook backend (https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/#webhook-backend), - and this is the segment its configured URL ends in: /audit-webhook/. When several - logical clusters share one backend it is instead the value of the audit-event annotation named - by --author-attribution-audit-route-annotation-key. + and this is the segment its configured URL ends in: /audit-webhook/. When + several logical clusters share one backend it is instead the value of the audit-event + annotation named by --author-attribution-audit-route-annotation-key. - It partitions the attribution facts, so two ClusterProviders carrying the same route read one + It partitions the attribution facts, so two KubeProviders carrying the same route read one cluster's facts, and two carrying different routes can never cross-credit an author. - Empty means metadata.name. Set it when several ClusterProviders name one cluster, since an API - server has one webhook backend and so posts under one route: every other provider for that - cluster must be pointed at the same route or it resolves no authors at all. Set it also when - the events are labelled for something other than this object, such as a kcp logical cluster. + Empty means ., which no provider in another namespace can resolve to. Set it + when several KubeProviders in this namespace name one cluster, since an API server has one + webhook backend and so posts under one route. A route is held by one namespace: providers in + another namespace that carry it report Stalled=True with reason AuditRouteConflict and are not + used. The namespace a route starts with (.) holds it when a provider there carries + it; otherwise the namespace of the oldest provider carrying it does. maxLength: 253 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string type: object - burst: - description: RemovedBurst is the old spelling of client.burst. It - is never read; setting it is refused. - format: int32 - type: integer client: description: |- Client overrides the operator's outgoing kube-client throttles for this cluster. Omitted, - the built-in defaults apply (20 QPS, burst 30). Ignored when kubeConfig is omitted (the - in-cluster client is not per-provider). + the built-in defaults apply (20 QPS, burst 30). Refused beside inCluster. properties: burst: description: |- @@ -218,122 +204,61 @@ spec: minimum: 1 type: integer type: object - kubeConfig: + inCluster: description: |- - KubeConfig names the SOURCE CLUSTER this provider represents and the credentials to reach it - (Flux's meta.KubeConfigReference, embedded verbatim). OMITTED means the operator's own - in-cluster cluster, for any provider name. IMMUTABLE. - - The referenced Secret is resolved from the operator's namespace, so a cluster's credential - never has to live on that cluster. Only secretRef is honored; configMapRef is rejected. When - secretRef.key is empty the resolver reads "value" then "value.yaml" (Flux's order). Unsafe - kubeconfigs (exec auth, insecure-skip-tls-verify) are rejected with Validated=False unless the - operator opts in via flags. + InCluster makes this provider use the operator's own identity in the cluster it runs in. It is + honored only for a KubeProvider the installation lists in --in-cluster-kube-providers; any + other reports Stalled=True with reason InClusterNotAuthorized and is never used. Exactly one + of secretRef and inCluster is set. Immutable. + type: object + secretRef: + description: |- + SecretRef names a Secret in this KubeProvider's namespace holding a kubeconfig for the source + cluster. When key is empty the kubeconfig is read from "value", then "value.yaml" (Flux's + order). Unsafe kubeconfigs (exec auth, insecure-skip-tls-verify, credentials named by file + path) are refused with Validated=False unless the operator opts in through its flags. + Exactly one of secretRef and inCluster is set. Immutable. properties: - configMapRef: - description: |- - ConfigMapRef holds an optional name of a ConfigMap that contains - the following keys: - - - `provider`: the provider to use. One of `aws`, `azure`, `gcp`, or - `generic`. Required. - - `cluster`: the fully qualified resource name of the Kubernetes - cluster in the cloud provider API. Not used by the `generic` - provider. Required when one of `address` or `ca.crt` is not set. - - `address`: the address of the Kubernetes API server. Required - for `generic`. For the other providers, if not specified, the - first address in the cluster resource will be used, and if - specified, it must match one of the addresses in the cluster - resource. - If audiences is not set, will be used as the audience for the - `generic` provider. - - `ca.crt`: the optional PEM-encoded CA certificate for the - Kubernetes API server. If not set, the controller will use the - CA certificate from the cluster resource. - - `audiences`: the optional audiences as a list of - line-break-separated strings for the Kubernetes ServiceAccount - token. Defaults to the `address` for the `generic` provider, or - to specific values for the other providers depending on the - provider. - - `serviceAccountName`: the optional name of the Kubernetes - ServiceAccount in the same namespace that should be used - for authentication. If not specified, the controller - ServiceAccount will be used. - - Mutually exclusive with SecretRef. - properties: - name: - description: Name of the referent. - type: string - required: - - name - type: object - secretRef: - description: |- - SecretRef holds an optional name of a secret that contains a key with - the kubeconfig file as the value. If no key is set, the key will default - to 'value'. Mutually exclusive with ConfigMapRef. - It is recommended that the kubeconfig is self-contained, and the secret - is regularly updated if credentials such as a cloud-access-token expire. - Cloud specific `cmd-path` auth helpers will not function without adding - binaries and credentials to the Pod that is responsible for reconciling - Kubernetes resources. Supported only for the generic provider. - properties: - key: - description: Key in the Secret, when not specified an implementation-specific - default key is used. - type: string - name: - description: Name of the Secret. - type: string - required: - - name - type: object + key: + description: Key in the Secret, when not specified an implementation-specific + default key is used. + type: string + name: + description: Name of the Secret. + type: string + required: + - name type: object x-kubernetes-validations: - - message: exactly one of spec.kubeConfig.configMapRef or spec.kubeConfig.secretRef - must be specified - rule: has(self.configMapRef) || has(self.secretRef) - - message: exactly one of spec.kubeConfig.configMapRef or spec.kubeConfig.secretRef - must be specified - rule: '!has(self.configMapRef) || !has(self.secretRef)' - qps: - description: RemovedQPS is the old spelling of client.qps. It is never - read; setting it is refused. - format: int32 - type: integer + - message: spec.secretRef.name must not be empty + rule: size(self.name) > 0 type: object x-kubernetes-validations: - - message: spec.kubeConfig is immutable; delete and recreate the ClusterProvider - to point a name at a different cluster - rule: has(self.kubeConfig) == has(oldSelf.kubeConfig) && (!has(self.kubeConfig) - || self.kubeConfig == oldSelf.kubeConfig) - - message: spec.kubeConfig.configMapRef (workload-identity auth) is not - yet supported; use secretRef - rule: '!has(self.kubeConfig) || !has(self.kubeConfig.configMapRef)' - - message: spec.kubeConfig.secretRef.name must not be empty - rule: '!has(self.kubeConfig) || !has(self.kubeConfig.secretRef) || size(self.kubeConfig.secretRef.name) - > 0' - - message: spec.qps and spec.burst moved to spec.client.qps and spec.client.burst - rule: '!has(self.qps) && !has(self.burst)' - - message: allowAnySourceNamespace was removed; set allowCrossNamespace.sources - rule: '!has(self.allowAnySourceNamespace) || !self.allowAnySourceNamespace' + - message: set exactly one of spec.secretRef and spec.inCluster + rule: has(self.secretRef) != has(self.inCluster) + - message: 'spec.client is refused beside spec.inCluster: the operator''s + own client is not throttled per provider' + rule: '!has(self.inCluster) || !has(self.client)' + - message: spec.secretRef and spec.inCluster are immutable; delete and + recreate the KubeProvider to point it at a different cluster + rule: has(self.inCluster) == has(oldSelf.inCluster) && (!has(self.secretRef) + || self.secretRef == oldSelf.secretRef) status: - description: status defines the observed state of ClusterProvider. + description: status defines the observed state of KubeProvider. properties: auditRoute: description: |- AuditRoute is the route this provider's attribution facts are partitioned under, as resolved: - spec.attribution.auditRoute, or metadata.name when that is empty. It is the route the + spec.attribution.auditRoute, or . when that is empty. It is the route the AuditFactsReceived condition describes, so when it changes that condition starts over — proof that one route delivered says nothing about another. type: string conditions: description: |- - Conditions report the provider's readiness: Validated (kubeconfig inputs are safe and + Conditions report the provider's readiness: Validated (the credential is safe and resolvable, asserted without a network dial) plus the aggregated Ready and the kstatus - Reconciling/Stalled pair. Runtime reachability/discovery health and a last-audit-event - timestamp are deferred until authenticated remote ingest wires them from the watch engine. + Reconciling/Stalled pair. Stalled=True with reason InClusterNotAuthorized or + AuditRouteConflict means the provider is not used at all. AuditFactsReceived reports whether an attribution fact has ever been accepted and recorded on this provider's audit route. It is True (Received) from the first one on and never goes back: diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index a469bf49f..346f14ad1 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -224,7 +224,7 @@ spec: "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound is the credential's own RBAC and nothing else. Naming any namespace other than this rule's - own, "*" included, requires the GitTarget's ClusterProvider to admit the target's namespace + own, "*" included, requires the GitTarget's KubeProvider to admit the target's namespace AND to set spec.allowCrossNamespace.sources; while that is false, "*" is refused outright. This changes only which namespace is WATCHED, never where objects are written: Git placement diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index a5e53d721..d049ceb95 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,11 +2,11 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - bases/configbutler.ai_argocdtriggers.yaml - - bases/configbutler.ai_clusterproviders.yaml - bases/configbutler.ai_clusterwatchrules.yaml - bases/configbutler.ai_commitrequests.yaml - bases/configbutler.ai_fluxtriggers.yaml - bases/configbutler.ai_gitbranches.yaml - bases/configbutler.ai_gitproviders.yaml - bases/configbutler.ai_gittargets.yaml + - bases/configbutler.ai_kubeproviders.yaml - bases/configbutler.ai_watchrules.yaml diff --git a/config/deployment.yaml b/config/deployment.yaml index c17b48193..4cb63548c 100644 --- a/config/deployment.yaml +++ b/config/deployment.yaml @@ -54,6 +54,9 @@ spec: # The push receiver, so the e2e suite can deliver a Git host's push webhook through the # gitops-reverser-receiver Service (receiver-service.yaml) instead of waiting for a refresh. - --receiver-bind-address=:9292 + # The KubeProvider in kubeprovider-local.yaml uses the operator's own identity, which + # is honored only for a provider listed here. The namespace is this kustomization's. + - --in-cluster-kube-providers=gitops-reverser/local # Dev/e2e only: the in-cluster Gitea SSH host key is not pinned, so permit SSH # when no known_hosts source exists. Never set this in production. - --insecure-allow-missing-known-hosts diff --git a/config/kubeprovider-local.yaml b/config/kubeprovider-local.yaml new file mode 100644 index 000000000..17992190e --- /dev/null +++ b/config/kubeprovider-local.yaml @@ -0,0 +1,19 @@ +# The KubeProvider for the SUT / config-dir install, mirroring what the Helm chart renders when +# kubeProvider.create is true: "local" in the operator's namespace, using the operator's own +# identity. Every GitTarget names its KubeProvider (spec.kubeProviderRef), and the reconcile-time +# hard gate REQUIRES it to exist, so local mirroring needs this object — the operator never creates +# it. spec.inCluster is honored only because deployment.yaml lists gitops-reverser/local in +# --in-cluster-kube-providers. The empty selector admits every other namespace (matching the chart +# default); tighten in a real install. +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: + name: local +spec: + inCluster: {} + accessFrom: + selector: {} + # Nothing outside the admitted namespaces, as the chart renders it. + allowCrossNamespace: + sources: false + appliers: false diff --git a/config/kustomization.yaml b/config/kustomization.yaml index a4c7da4b4..0eeba6aef 100644 --- a/config/kustomization.yaml +++ b/config/kustomization.yaml @@ -10,7 +10,7 @@ resources: - deployment.yaml - certs - webhook -- clusterprovider-default.yaml +- kubeprovider-local.yaml images: - name: gitops-reverser newName: gitops-reverser diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 862c48821..15bffbd50 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -47,6 +47,7 @@ rules: - configbutler.ai resources: - argocdtriggers + - kubeproviders verbs: - get - list @@ -55,13 +56,13 @@ rules: - configbutler.ai resources: - argocdtriggers/status - - clusterproviders/status - clusterwatchrules/status - commitrequests/status - fluxtriggers/status - gitbranches/status - gitproviders/status - gittargets/status + - kubeproviders/status - watchrules/status verbs: - get @@ -70,10 +71,13 @@ rules: - apiGroups: - configbutler.ai resources: - - clusterproviders - - fluxtriggers - - gitbranches + - clusterwatchrules + - gitproviders + - gittargets + - watchrules verbs: + - create + - delete - get - list - patch @@ -82,27 +86,23 @@ rules: - apiGroups: - configbutler.ai resources: - - clusterwatchrules - - gitproviders - - gittargets - - watchrules + - commitrequests verbs: - - create - delete - get - list - patch - - update - watch - apiGroups: - configbutler.ai resources: - - commitrequests + - fluxtriggers + - gitbranches verbs: - - delete - get - list - patch + - update - watch - apiGroups: - configbutler.ai diff --git a/config/samples/README.md b/config/samples/README.md index 25e662447..48c6fcaa0 100644 --- a/config/samples/README.md +++ b/config/samples/README.md @@ -7,8 +7,8 @@ These samples are quick starting points for common GitOps Reverser setups. repository. - `quickstart-gittarget.yaml`: Minimal `GitTarget` using a non-root `spec.path` and SOPS encryption auto-generation. - `quickstart-watchrule.yaml`: Minimal `WatchRule` for ConfigMaps. -- `clusterprovider.yaml`: The conventional `default` provider (in-cluster) and a remote one, showing - `accessFrom` and `allowCrossNamespace`. +- `kubeprovider.yaml`: A `KubeProvider` for a remote cluster, with its kubeconfig Secret in its own + namespace, showing `accessFrom` and `allowCrossNamespace`. - `clusterwatchrule.yaml`: Minimal `ClusterWatchRule` for cluster-scoped resources. - `fluxtrigger.yaml`, `argocdtrigger.yaml`: An apply trigger holding the Flux `Kustomization` or the Argo CD `Application` that applies `example-target`'s folder, for its `spec.applyTriggerRef`. @@ -17,4 +17,6 @@ These samples are quick starting points for common GitOps Reverser setups. `kubectl create -f`: `kubectl apply` refuses a generated name. Every sample references `example-target` / `example-branch` / `example-provider`, the same names the chart's -`quickstart` values use, so the set is internally consistent and can be applied together. +`quickstart` values use, so the set is internally consistent and can be applied together. The +`GitTarget` and the triggers mirror through `local` in `gitops-reverser`, the `KubeProvider` the chart +renders in its release namespace. diff --git a/config/samples/argocdtrigger.yaml b/config/samples/argocdtrigger.yaml index 54ca28cf1..6c9a34c41 100644 --- a/config/samples/argocdtrigger.yaml +++ b/config/samples/argocdtrigger.yaml @@ -7,10 +7,11 @@ metadata: name: example-trigger namespace: default spec: - # The cluster the Application is in; "default" is the operator's own. - clusterProviderRef: - name: default - # In another namespace than the trigger's, so the ClusterProvider must set + # The cluster the Application is in: here the chart's `local` KubeProvider, the operator's own. + kubeProviderRef: + name: local + namespace: gitops-reverser + # In another namespace than the trigger's, so the KubeProvider must set # allowCrossNamespace.appliers: true, or the trigger reports ApplierNamespaceNotAllowed. applicationRef: namespace: argocd diff --git a/config/samples/clusterprovider.yaml b/config/samples/clusterprovider.yaml deleted file mode 100644 index 57d90cc07..000000000 --- a/config/samples/clusterprovider.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# The conventionally named "default" provider, here in-cluster. Omitting kubeConfig means "the -# cluster the operator runs in" — an option open to ANY provider name, not a property of this one. -# GitTarget.spec.clusterProviderRef defaults to {name: default}, so a single-cluster install needs -# no ClusterProvider of its own beyond this one (the chart renders it when -# clusterProvider.createDefault is true; the operator never creates one). accessFrom is -# deny-by-default: only the listed/selected namespaces may bind it. -apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: default -spec: - # no kubeConfig => in-cluster. Set it here instead to make "default" mirror a remote cluster. - accessFrom: - names: - - team-a ---- -# A remote source cluster. Its name is the cluster's identity everywhere: the -# /audit-webhook/prod-eu-1 audit route and the attribution fact-index key. kubeConfig is immutable -# and its Secret is resolved from the operator's namespace (never from the source cluster). -apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: prod-eu-1 -spec: - kubeConfig: - secretRef: - name: prod-eu-1-kubeconfig - accessFrom: - selector: - matchLabels: - tier: trusted - # What those namespaces may reach outside themselves, each granted on purpose. sources: a - # WatchRule may choose which SOURCE namespaces to mirror; while false (the default) it watches - # only its own namespace, and `sourceNamespace: "*"` is refused outright. appliers: a trigger may - # suspend and apply a Kustomization or Application in another namespace. Both delegate to every - # admitted namespace; neither proves that a namespace owns what it reaches. - allowCrossNamespace: - sources: true - appliers: false diff --git a/config/samples/fluxtrigger.yaml b/config/samples/fluxtrigger.yaml index aba6be797..da330c3d9 100644 --- a/config/samples/fluxtrigger.yaml +++ b/config/samples/fluxtrigger.yaml @@ -7,10 +7,11 @@ metadata: name: example-trigger namespace: default spec: - # The cluster the Kustomization is in; "default" is the operator's own. - clusterProviderRef: - name: default - # In another namespace than the trigger's, so the ClusterProvider must set + # The cluster the Kustomization is in: here the chart's `local` KubeProvider, the operator's own. + kubeProviderRef: + name: local + namespace: gitops-reverser + # In another namespace than the trigger's, so the KubeProvider must set # allowCrossNamespace.appliers: true, or the trigger reports ApplierNamespaceNotAllowed. kustomizationRef: namespace: flux-system diff --git a/config/samples/kubeprovider.yaml b/config/samples/kubeprovider.yaml new file mode 100644 index 000000000..8ab6e55dd --- /dev/null +++ b/config/samples/kubeprovider.yaml @@ -0,0 +1,33 @@ +# A remote source cluster, as a namespaced KubeProvider. Its kubeconfig Secret lives in this +# provider's own namespace, never on the source cluster. secretRef is immutable: delete and +# recreate the provider to point it at another cluster. key is optional; empty reads "value", +# then "value.yaml". +# +# Its audit route defaults to . (default.prod-eu-1), so the source cluster's API +# server posts audit events to /audit-webhook/default.prod-eu-1. +# +# For the operator's own cluster, set `inCluster: {}` instead of secretRef. That is honored only for +# a provider the installation lists in --in-cluster-kube-providers; the chart renders one, `local` +# in its release namespace, and lists it. +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: + name: prod-eu-1 + namespace: default +spec: + secretRef: + name: prod-eu-1-kubeconfig + # Consumers in this namespace are always admitted. accessFrom admits the others: here, every + # namespace labeled tier=trusted in the control cluster. + accessFrom: + selector: + matchLabels: + tier: trusted + # What those namespaces may reach outside themselves, each granted on purpose. sources: a + # WatchRule may choose which SOURCE namespaces to mirror; while false (the default) it watches + # only its own namespace, and `sourceNamespace: "*"` is refused outright. appliers: a trigger may + # suspend and apply a Kustomization or Application in another namespace. Both delegate to every + # admitted namespace; neither proves that a namespace owns what it reaches. + allowCrossNamespace: + sources: true + appliers: false diff --git a/config/samples/quickstart-gittarget.yaml b/config/samples/quickstart-gittarget.yaml index 15a59d3f5..3c4bd9770 100644 --- a/config/samples/quickstart-gittarget.yaml +++ b/config/samples/quickstart-gittarget.yaml @@ -8,6 +8,12 @@ spec: # which its GitProvider's accessFrom has to admit. gitBranchRef: name: example-branch + # The KubeProvider this folder mirrors from: the chart's `local` provider in its release + # namespace, which admits every namespace by default. Omit `namespace:` for one in this + # GitTarget's own namespace. Immutable. + kubeProviderRef: + name: local + namespace: gitops-reverser # Required relative repository path. Use "." only to deliberately write at repo root. path: live-cluster # How this folder's writes become commits. A commit window closes after diff --git a/docs/INDEX.md b/docs/INDEX.md index 9887b2e9a..c93225f2d 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -141,7 +141,7 @@ so you never have to read a proposal to find out it already shipped. | [`open-asks-priority.md`](design/open-asks-priority.md) | **the work queue.** Merges three overlapping backlogs — the downstream consumer asks, the API-surface block left unbuilt by the status and configuration-model review, and the config-surface proposal (B1–B6) — into one ordered queue under four stated tests, and says where we deliberately do **not** do what was asked. The standing caveat narrowed once the layout model reversed: a Tier 2 entry belongs to postponed [#294](https://github.com/ConfigButler/gitops-reverser/issues/294) only if it breaks a `GitTarget` field, and everything else is independently schedulable. Makes one design call against what was asked: **delete Option C sibling inference** rather than ship an off-switch for it, because it let a human's edit to the repository change operator behavior with nothing in status recording the move. That deletion has shipped, and "what the deletion taught" records what building it found. **F9 is Tier 1**: the only item whose answer is unknown rather than whose work is unscheduled, and it gates planning the enum work | | [`proving-the-metric-surface.md`](design/proving-the-metric-surface.md) | **design, partly built.** What identity an operator needs to locate a failure, and what an e2e check has to do to be worth trusting. Written after a metric value that was declared, switched on, surfaced as a condition reason and counted turned out to have no producer at all, and the check added to catch that class then skipped whenever the counter read zero — so deleting every recording call left the suite green. Two rules follow: an assertion must not take its trigger from the metric under test, and no enum value may be reachable only through an injected fake. Carries a cardinality policy that separates bounded configuration identity from per-request identity, a table of which instrument families should own `source_cluster` directly rather than by join, and the PromQL reasoning for `increase()` versus `max_over_time` (neither applies mechanically: one cannot see an event before the first sample, the other cannot reconstruct totals across a reset). Steps 1–4 are built; step 5, diagnostics export, the `e2e_run` scrape label and a pinned-seed regression are not | | [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design, half built.** The three questions the inference deletion left, decided and then built in two goes. PR #291 shipped the deletion and none of the eight items queued behind it; the residue was filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) — **which shipped in 0.42.1 via [#319](https://github.com/ConfigButler/gitops-reverser/pull/319) and is what reversed the layout model** — and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296), whose largest half then shipped in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326): `status.layout` became `status.placement`, and the ambiguous render root came with it as a refusal rather than a third counter outcome. The `declared` metric value has since split into `by_type`/`default` too, which was the one row with an external consumer because it changes a label a dashboard may select on. What still stands and is still unbuilt is the rest of the legibility set: the canonical path as a template constant, and `{kindLower}`, tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339) now that #296 is closed; the Event on the GitTarget was dropped. Its Question 2, a CRD default for `placement.default`, is **re-opened rather than superseded** and now lives as an open question in [`layout/model.md`](layout/model.md); the argument against it is legibility, since #319 removed the correctness objection | -| [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **partly built**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). One breaking wave on `GitTarget`, of which steps 2 to 7 have **shipped**: `spec.suspend`, `status.placement` and the reconcile-request annotation in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), and B4's `commitWindow`/`commit.message` move plus the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330). Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that became a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers, and `status.lastHandledReconcileAt` was dropped in the build for a stated reason. Step 8, the riders, is closed: the `CommitRequest` lifecycle hole shipped in [#400](https://github.com/ConfigButler/gitops-reverser/pull/400), the `default` `ClusterProvider` message in [#258](https://github.com/ConfigButler/gitops-reverser/pull/258), and an asserted `CommitRequest.spec.author` is **declined** because authorship is established, never asserted ([#339](https://github.com/ConfigButler/gitops-reverser/issues/339)); #294 is closed. The reference collapse and the `scope` removal shipped in [#333](https://github.com/ConfigButler/gitops-reverser/pull/333); the `TooManyStreams` cap and the Event on a changed resolution were dropped. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown** | +| [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **partly built**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). One breaking wave on `GitTarget`, of which steps 2 to 7 have **shipped**: `spec.suspend`, `status.placement` and the reconcile-request annotation in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), and B4's `commitWindow`/`commit.message` move plus the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330). Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that became a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers, and `status.lastHandledReconcileAt` was dropped in the build for a stated reason. Step 8, the riders, is closed: the `CommitRequest` lifecycle hole shipped in [#400](https://github.com/ConfigButler/gitops-reverser/pull/400), the missing source-cluster provider message in [#258](https://github.com/ConfigButler/gitops-reverser/pull/258), and an asserted `CommitRequest.spec.author` is **declined** because authorship is established, never asserted ([#339](https://github.com/ConfigButler/gitops-reverser/issues/339)); #294 is closed. The reference collapse and the `scope` removal shipped in [#333](https://github.com/ConfigButler/gitops-reverser/pull/333); the `TooManyStreams` cap and the Event on a changed resolution were dropped. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown** | | [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A resource collection — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per collection, so adding one WatchRule stops re-initializing every unrelated collection into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT collection holds the result its own initialization produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected collection may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. | | [`collection-terminology-rename.md`](design/collection-terminology-rename.md) | **step 1, shipped.** Replaced cells with resource collections and removed per-rule `operations` from both rule kinds. Rules observe every object event; GitTarget prune mode controls removal. Records the rename census (760 Go identifiers, 265 documentation occurrences), the operation-filter inventory, API/Helm migration, compatibility boundaries, and test cases. | | [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **step 2, implemented.** After the collection rename and operation-filter removal, native per-rule `objectSelector` (#146). Kubernetes is authoritative: `Always` compares selected identities with all managed Git identities in the type/namespace scope, without comparing Git labels. Full support includes supported Kustomize layouts from the first release, plus filtered-removal attribution and ordered producer replacement. | @@ -171,12 +171,12 @@ itself non-binding. Read them as history that the code still points at. | Doc | Open question | |---|---| | [`push-notification-and-reconcile-trigger.md`](design/push-notification-and-reconcile-trigger.md) | **built, and superseded** by [`apply-trigger.md`](design/apply-trigger.md): the record of how the apply trigger was decided. Kept here because its Part 1, how the branch worker reads the remote (the pre-push fetch, the push advertisement, base trust), is cited from the Git code by section number | -| [`vocabulary-cleanup.md`](design/vocabulary-cleanup.md) | **built.** The breaking release that made the API obey [`definitions.md`](definitions.md): condition reasons that restated their type are `Succeeded`, `GitRepoConfigNotFound` is `GitProviderNotFound`, printer columns are named for what they read and the dependency projections are gone, a bare commit hash is `commit` in every status field, and `ClusterProvider`'s throttles are `spec.client`. Kept here because `definitions.md` cites it as the record of which audit findings the existing rulebooks already settled: the `insecure` flag split, bare-noun booleans, `--redis-addr`, and the lowercase `sops` enum | +| [`vocabulary-cleanup.md`](design/vocabulary-cleanup.md) | **built.** The breaking release that made the API obey [`definitions.md`](definitions.md): condition reasons that restated their type are `Succeeded`, `GitRepoConfigNotFound` is `GitProviderNotFound`, printer columns are named for what they read and the dependency projections are gone, a bare commit hash is `commit` in every status field, and the source-cluster provider's throttles are `spec.client`. Kept here because `definitions.md` cites it as the record of which audit findings the existing rulebooks already settled: the `insecure` flag split, bare-noun booleans, `--redis-addr`, and the lowercase `sops` enum | | [`created-root-namespace.md`](design/created-root-namespace.md) | **built.** One question with five answers: what namespace a `kustomization.yaml` the operator CREATES should carry. Decided **B, never write one** — `spec.serializeNamespace: false` means the artifact does not encode its deployment namespace, and adding a root must not quietly change that contract; the namespace comes from the documents when the field is unset or `true`, and from the installer (Flux `targetNamespace`, Argo `destination.namespace`) when it is `false`. Records the three facts an earlier draft got wrong (a namespace-less root is ordinary, both installers supply one, and what refused it was our own fidelity gate rather than kustomize), and carries the scoped fidelity rule that follows: the namespace is ignored in the render comparison ONLY when the governing root sets none, so a root that declares `namespace: shop` still rejects a live `billing` object. Shipped in #328, which is also why [`kustomization_bootstrap.go`](../internal/git/kustomization_bootstrap.go) cites the page by path. Also the sibling call: under `useKustomize` a placement no `resources:` list would name is refused rather than committed unrendered | | [`watch-manager-ownership.md`](design/watch-manager-ownership.md) | A rule edit used to be applied inline by the controller worker that observed it, and it re-planned EVERY GitTarget rather than the one the rule names: 1256 plan reconciles across 28 targets in one e2e run, peaking at 78 in a second, behind two network calls, on a shared worker pool. The watch manager had no owner, so eleven mutexes stood in for one. Now controllers post a trigger naming a GitTarget and return, one loop owns the plan and paces itself, and repeated triggers for one target collapse into a single pass. The debounce is framed around how the config is actually edited: a GitTarget and its rules are one piece of configuration applied together, so a per-target ROLLING SILENCE window of 2s (max wait ~10s) turns a five-object `kubectl apply` into one pass, the same mechanism `DefaultCommitWindow` already uses one layer down on the write path. That reverses an earlier revision's "never debounce the first declaration": declaring a GitTarget the instant it lands means declaring it with no rules yet, which manufactures a transient EMPTY plan on every cold start, and an empty plan is what vacuously cleared a write divergence in the fidelity gate. States the contract as "one settled configuration adjustment, not one function invocation": the window is a heuristic and never a correctness boundary (Kubernetes has no apply-complete event), a per-target DIRTY SEQUENCE means a change arriving mid-pass is never lost, and the pass reads a coherent rule-store snapshot rather than the rule that triggered it. Carries the deletion inventory, because the point is that the system got smaller: four trigger mechanisms collapsed to one (`signalCatalogRefresh` and `catalogRefreshCh` are gone), `refreshRunningTargetWatches` and its running-set filter are gone (that filter is why a target whose first declare never completed was never picked up again), and six mutexes went for stated reasons, with `RenderFidelityGate.mu` and the two event-channel locks kept and justified. The four steps shipped: the local-cluster discovery call is bounded (a real defect — the legacy non-context `ServerGroupsAndResources()` ran with no deadline at all), the owner loop carries the debounce, dirty sequence, per-target deadline and 2s/5s/10s/30s/1m backoff, the plan now carries NO lock while the projection is a published snapshot, and catalog invalidation is scoped by diffing each target's rendered plan across the re-projection. "What shipped" records where the implementation departed from the page, including the one bug only e2e caught: streams were parented to the PASS context, which a deadline cancels the moment the pass returns, so every stream died the instant its plan was applied — and it reads like health, because the plan logs `start:1`, every later pass reports `keep:1` and never restarts it, and nothing logs an error while readiness sits at `Replaying` and every WatchRule sits `Ready=False`. A stream's parent is the manager's lifetime; the pass deadline bounds the pass. A second e2e catch is a BEHAVIORAL consequence worth knowing: toggling a rule off and on inside the settle window is no longer a replay — it used to tear the stream down and re-establish it because each apply replanned synchronously, and it is now one pass over a plan that never changed. Correct (a net-zero change is no change; widening `prune.mode` remains the supported force) but a real difference in what an operator gesture does. Both specs that broke were also gating on the wrong thing: asking a GitTarget "are all your streams running" about a change to ONE rule, which a target that is already mirroring answers True to before that rule has been planned, and which a different controller publishes than the one that compiled the rule — so the rule's OWN StreamsRunning is the gate, and `waitForWatchRuleStreamsRunning` existed unused for exactly this. Departures: reports became a published snapshot rather than a second channel; ISOLATION came from taking the I/O off the loop rather than from the deadline (a pass never dials, the shared refresh runs on its own goroutine, and the deadline is the backstop it should have been — a first cut that kept two network calls on the loop had one unreachable cluster holding every healthy target, which is the same availability failure relocated); DELETION names an incarnation resolved when it is queued, because both production callers react to a NotFound and carry no UID, so a UID-less delete matched everything and could tear down the successor of a same-name recreate; and persistent failure surfaces as `WatchPlanFailing`, where pending means "no pass has ever landed", not "dirty right now". Step 3's type-to-target index did not earn its staleness. Still open: whether the settle window ever needs to be configurable | | [`docs-linting.md`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.docs-lint-scope) lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the `MD013` limit, and whether `AGENTS.md` and the chart READMEs are in scope | | [`azure-devops-multi-ack.md`](design/azure-devops-multi-ack.md) | go-git v6 was the answer: why Azure DevOps rejects our fetches, and what to do instead of PR [#292](https://github.com/ConfigButler/gitops-reverser/pull/292)'s bundled `git` binary. The capability filter fails in two independent halves: advertising `multi_ack` is a four-line change, but v5 then cannot parse the multi-ACK **response**, which only a fetch with `have` lines provokes. That is why **Flux ships ADO support on v5 with no git binary — it never fetches**, only `CloneContext`, so it never enters the path v5 cannot serve; our persistent-clone-plus-incremental-fetch design is the opposite, which makes the trim alone insufficient for us. **go-git v6 already implements `multi_ack`** (PR #1204, in every v6 tag; upstream then deleted their ADO example saying it "works out of the box"), and its churn in the packages we import runs 96 → 39 → **1** → **9** removals per alpha, so it is one settled breaking wave rather than a moving target; the migration is four known API removals over two rewritten files, `transport.AuthMethod` being the invasive one. Prices PR #292 as measured rather than argued: the image goes **217 MB → 940 MB**, of which 723 MB is a `cp -rL` that dereferences 165 hardlinks to one binary (a one-character fix), arm64 is unaffected and native, but **Trivy reports zero findings on both images** while the new one carries git 2.54.0, OpenSSH 10.3p1 and OpenSSL 3.5.7 as loose files no package database describes — so the CRITICAL gate is blind to a third of the runtime. Also catches an unflagged non-ADO regression (`Depth: 1` dropped, so every provider full-fetches) and 10% patch coverage on an untestable path. The unlock is that **canonical `git upload-pack` advertises `multi_ack`** (verified), so the Gitea already in the e2e lab plus a 400-injecting proxy is a faithful ADO simulator — no tenant needed, and the only way any option becomes CI-testable. Four options priced, and Option A (v6) is the one shipped. Carries a measured **capability matrix** over our three network calls with two diagrams, which narrows the blast radius to **one call, `repo.Fetch`**: `receive-pack` never advertises `multi_ack` (measured), so **the atomic push is out of scope for every option** — its safety rests on the same-session advertisement plus the server-side `Old`/`New` compare-and-swap in `packp.Command`, neither of which touches `upload-pack`, and we already push from a shallow store today. v6 keeps that pattern 1:1 (`Handshake` → `GetRemoteRefs`/`Push`, same `[]*packp.Command`), which is an argument *for* migrating. Records what the migration actually cost, including the four v6 behaviour changes it surfaced — two of them settings v6 reads from the environment and fails closed on, invisible to unit tests | -| [`source-scope-simplification.md`](design/source-scope-simplification.md) | except the additive `SelfSubjectAccessReview` pass it explicitly leaves for later. Declines Flux-style impersonation, deletes `GitTarget.spec.allowedSourceNamespaces` and its selector machinery (**4,569 lines**, and the only cross-cluster read in the authorization path), renames two `ClusterProvider` fields, and redefines `sourceNamespace: "*"` as one cluster-wide list and watch. The argument is an API reading, not a security one: the chain from a Git folder back to the object that fills it never leaves one namespace, so ordinary RBAC on `watchrules` already answers it. **Keeps `allowedNamespaces`** (renamed `accessFrom`), reversing an earlier draft — source RBAC bounds what a credential may READ, never which tenant may WIELD it. Prices what is lost: source-side label selectors, which have no replacement. Archaeology in [`facts/kubernetes-impersonation-and-flux-identity.md`](facts/kubernetes-impersonation-and-flux-identity.md) | +| [`source-scope-simplification.md`](design/source-scope-simplification.md) | except the additive `SelfSubjectAccessReview` pass it explicitly leaves for later. Declines Flux-style impersonation, deletes `GitTarget.spec.allowedSourceNamespaces` and its selector machinery (**4,569 lines**, and the only cross-cluster read in the authorization path), renames two source-cluster provider fields, and redefines `sourceNamespace: "*"` as one cluster-wide list and watch. The argument is an API reading, not a security one: the chain from a Git folder back to the object that fills it never leaves one namespace, so ordinary RBAC on `watchrules` already answers it. **Keeps `allowedNamespaces`** (renamed `accessFrom`), reversing an earlier draft — source RBAC bounds what a credential may READ, never which tenant may WIELD it. Prices what is lost: source-side label selectors, which have no replacement. Archaeology in [`facts/kubernetes-impersonation-and-flux-identity.md`](facts/kubernetes-impersonation-and-flux-identity.md) | ## The layout topic — [`layout/`](layout/README.md) diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 13b156d2c..7ad06219f 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,83 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. +## `KubeProvider` replaces `ClusterProvider`; every reference names one with `spec.kubeProviderRef` + +**Breaking.** The source cluster is a namespaced `KubeProvider`. The cluster-scoped +`ClusterProvider` kind is gone, and nothing converts one: a CRD cannot change its scope. `GitTarget`, +`FluxTrigger` and `ArgoCDTrigger` name their provider with the required, immutable +`spec.kubeProviderRef`, whose omitted namespace means the referrer's own. There is no provider named +`default` that an omitted reference falls back to. `spec.clusterProviderRef` stays in the schema only +so that setting it is refused at admission, with a message naming `spec.kubeProviderRef`. + +```yaml +kind: KubeProvider +metadata: {name: prod-eu-1, namespace: platform} +spec: + secretRef: + name: prod-eu-1-kubeconfig # a Secret in this namespace; key defaults to value, then value.yaml + accessFrom: + names: [team-a] # other namespaces; platform itself is always admitted +--- +kind: GitTarget +metadata: {name: apps, namespace: team-a} +spec: + gitBranchRef: {name: platform-main, namespace: platform} + kubeProviderRef: {name: prod-eu-1, namespace: platform} + path: apps +``` + +Migrate in this order: + +1. Record what you have, before upgrading the CRDs: + + ```bash + kubectl get clusterproviders -o yaml > clusterproviders.yaml + kubectl get gittargets,fluxtriggers,argocdtriggers -A -o jsonpath='{range .items[*]}{.kind}{"\t"}{.metadata.namespace}/{.metadata.name}{"\t"}{.spec.clusterProviderRef.name}{"\n"}{end}' + ``` + +2. Upgrade. The chart renders a `KubeProvider` named `local` in the release namespace, using the + operator's own identity (see below). Its values are `kubeProvider.create`, `kubeProvider.name`, + `kubeProvider.secretRef`, `kubeProvider.accessFrom` and `kubeProvider.allowCrossNamespace`; the + `clusterProvider.*` values are gone. +3. Create a `KubeProvider` for each other `ClusterProvider`, in the namespace that should own the + credential. **The kubeconfig Secret is read from the provider's own namespace**, not from the + operator's, so move or copy it there. `spec.kubeConfig.secretRef` is `spec.secretRef`. +4. Delete and recreate each `GitTarget`, `FluxTrigger` and `ArgoCDTrigger` with + `spec.kubeProviderRef`. The reference is immutable and the old field is refused, so an edit cannot + do it. A `GitTarget`'s folder in Git is untouched: the new object takes it over on its first + snapshot. A `FluxTrigger` hands its `Kustomization` back to Flux when it is deleted, and the new + one takes hold of it again. +5. Delete the old CRD, which Helm leaves installed: `kubectl delete crd clusterproviders.configbutler.ai`. + +**The operator's own identity is an explicit arm, and the installation must allow it.** A provider +that omitted `kubeConfig` used the operator's identity. A `KubeProvider` sets exactly one of +`spec.secretRef` and `spec.inCluster: {}`, and `spec.inCluster` is honored only for a provider listed +by exact `namespace/name` in `--in-cluster-kube-providers`. The chart lists the provider it renders, +and `controllerManager.inClusterKubeProviders` adds others. Any other provider that sets +`spec.inCluster` reports `Validated=False` and `Stalled=True` (`InClusterNotAuthorized`) and is never +used. `spec.client` is refused beside `spec.inCluster`, where it was ignored. + +**A provider admits its own namespace.** An empty `accessFrom` admitted nobody on a +`ClusterProvider`. On a `KubeProvider` it admits only the provider's own namespace, and `accessFrom` +admits the others. `allowCrossNamespace.sources` and `.appliers` keep their meaning. + +**Audit routes default to `.`, so the audit webhook URL changes.** The chart's +provider posts under `/audit-webhook/.local`, `/audit-webhook/gitops-reverser.local` +for a release in `gitops-reverser`. Point each API server's audit webhook configuration at its +provider's new route, or set `spec.attribution.auditRoute` on the provider to the route it posts under +today. A route belongs to one namespace: the namespace its first segment names, when a provider there +carries it, and otherwise the namespace of the oldest provider carrying it. A provider in another +namespace that carries it reports `Stalled=True` (`AuditRouteConflict`), and nothing mirrors or +applies through it, so one tenant cannot read another namespace's attribution facts. Providers in one +namespace may share a route, which several connections to one API server need. + +The reasons and conditions are renamed with the kind: `ClusterProviderNotFound` is +`KubeProviderNotFound`, and a `GitTarget`'s `ClusterProviderReady` condition is `KubeProviderReady`, +with reason `KubeProviderNotReady`. The resource-condition metrics report the kind as +`KubeProvider`, and the `source_cluster` label carries `/` instead of the provider's +name, so two providers with one name in two namespaces are two series. + ## `GitBranch` holds a repository branch; a `GitTarget` names one with `spec.gitBranchRef` **Breaking.** A repository branch is its own object, `GitBranch`, in the namespace of the diff --git a/docs/api-first-publication.md b/docs/api-first-publication.md index d8d3c6c8b..7ebd6c42f 100644 --- a/docs/api-first-publication.md +++ b/docs/api-first-publication.md @@ -41,7 +41,7 @@ destination paths remain target-specific. ```mermaid flowchart LR - CP["ClusterProvider
source connection and authorization"] --> API["Kubernetes API"] + CP["KubeProvider
source connection and authorization"] --> API["Kubernetes API"] WR["WatchRule / ClusterWatchRule
resource selection"] --> WATCH["Watch manager"] API -->|"persisted object events"| WATCH AUDIT["Optional audit facts
actor attribution"] -.-> WATCH @@ -61,7 +61,7 @@ flowchart LR | Structure | Responsibility | | --- | --- | -| `ClusterProvider` | Selects the source cluster and its access boundary | +| `KubeProvider` | Selects the source cluster and its access boundary | | `WatchRule`, `ClusterWatchRule` | Select resources and route them to a target | | `GitTarget` | Binds source state to a Git path and branch; sets commit shaping | | `GitProvider` | Supplies repository access and commit identity settings | diff --git a/docs/architecture.md b/docs/architecture.md index 182666a82..3334878fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,28 +15,34 @@ with the source, the source wins; deeper design records live under [docs/design/ inventory rather than the mechanics, which package holds which responsibility and which components observe a Kubernetes API server, see [components.md](components.md). -Source and destination connections deliberately have different scopes. A namespaced `GitProvider` is a -team's Git write boundary: its credential, branch policy, and targets usually belong together. -`ClusterProvider` is cluster-scoped because it represents one shared **logical source identity** whose -client, discovery surface, watch state, and attribution partition must remain consistent across -namespaces. `accessFrom` then explicitly controls which control-cluster namespaces may reference -that shared source; it does not grant source-cluster RBAC or select source namespaces. The source identity -is the `ClusterProvider` name alone, with no API-server identity probe: two providers configured for the same -server deliberately remain separate source partitions. - -The asymmetry is not an oversight. A source cluster is an inherent singleton; a Git destination is -not, and a namespaced `GitProvider` keeps a repository's write credential in the namespace of -whoever owns that repository, which is what makes a team able to start mirroring without a platform -admin. Each branch of that repository is a `GitBranch` in the credential's namespace, and **one -`GitBranch` holds a repository branch**, because one branch worker writes the branch, with one -credential: the oldest `GitBranch` whose credential opens a push session on the remote holds it, and -any other reports `BranchConflict`. A repository written from several namespaces needs no copy of -the credential in each: their `GitTarget`s name the shared `GitBranch` with -`spec.gitBranchRef.namespace`, admitted by its `GitProvider`'s `spec.accessFrom` (the -`ClusterProvider` pattern, applied to the destination). Its own namespace is always admitted. -Converting `GitProvider` to cluster scope is not an -option: a CRD's `spec.scope` is immutable once the definition is `Established`, so the change would -mean deleting the definition (and every `GitProvider` in the cluster with it) and recreating both. +Both connection kinds are namespaced, for one reason: a connection carries a credential, and the +credential belongs in the namespace of whoever owns it. A namespaced `GitProvider` is a team's Git +write boundary: its credential, branch policy, and targets usually belong together. A namespaced +`KubeProvider` is one **logical source identity**: one client, discovery surface, watch state, and +attribution partition, shared by every target that names it. Its kubeconfig Secret lives in its own +namespace, so a tenant's connection keeps its credential with the tenant. A provider admits +consumers in its own namespace, and `accessFrom` admits other control-cluster namespaces; neither +grants source-cluster RBAC or selects source namespaces. The source identity is the provider's +namespace and name, with no API-server identity probe: two providers configured for the same server +remain separate source partitions, and so do two providers with one name in two namespaces. + +The operator's own identity is the one credential no namespace owns, so it is not an ordinary +`secretRef`. A `KubeProvider` asks for it with `spec.inCluster: {}`, and the installation decides: +only a provider listed by exact `namespace/name` in `--in-cluster-kube-providers` is honored. Any +other reports `Stalled=True` (`InClusterNotAuthorized`) and is never used, so no omitted, malformed, +or forbidden credential falls through to the operator's identity. + +Keeping a repository's write credential in the namespace of whoever owns that repository is what +makes a team able to start mirroring without a platform admin. Each branch of that repository is a +`GitBranch` in the credential's namespace, and **one `GitBranch` holds a repository branch**, +because one branch worker writes the branch, with one credential: the oldest `GitBranch` whose +credential opens a push session on the remote holds it, and any other reports `BranchConflict`. A +repository written from several namespaces needs no copy of the credential in each: their +`GitTarget`s name the shared `GitBranch` with +`spec.gitBranchRef.namespace`, admitted by its `GitProvider`'s `spec.accessFrom`, the same pattern a +`KubeProvider` uses. Its own namespace is always admitted. Neither kind can change scope later: a +CRD's `spec.scope` is immutable once the definition is `Established`, so the change would mean +deleting the definition (and every object of the kind with it) and recreating both. *** @@ -49,7 +55,7 @@ API. State is ingested by **watch**; these paths never treat Git as authority. W with a newer remote commit, the operator fetches the new remote state, resets its local clone, and replays its retained writes from the API. -**Watch is the only object-state source.** Each `GitTarget` names one `ClusterProvider` and opens one +**Watch is the only object-state source.** Each `GitTarget` names one `KubeProvider` and opens one Kubernetes watch per claimed `(GVR, scope)` against that source, with `sendInitialEvents=true`. For a namespaced type, a scope is one concrete source namespace; cluster-scoped types use the cluster-wide scope. Every Git write derives from persisted state the watch observed. Audit never defines *what* @@ -158,7 +164,7 @@ Redis as the shared store across replicas. **Audit is an optional attribution lookup.** When attribution is enabled, kube-apiserver posts audit events to `/audit-webhook/` (or a configured annotation-routed shared endpoint). The route -is `ClusterProvider.spec.attribution.auditRoute`, which defaults to the provider's own name. +is `KubeProvider.spec.attribution.auditRoute`, which defaults to `.`. The operator appends a minimal fact to that route's per-type fact stream; every process watching the type follows that stream into a bounded, TTL'd in-process index, and a watch event joins against the index within a bounded grace window. A missing, late, or absent fact never blocks state capture; it @@ -217,7 +223,7 @@ optional and only add *who* did something: |---|---|---|---| | **Discovery** (CRD/APIService) | *what types exist* | yes | the API surface: served GVRs, scope, preferred version, subresources; rules resolve against it | | **Watch** (per claimed `(GVR, scope)`) | *what changed* | yes | the object body, ordered per type; the only object-state source, with deletes reconciled via `sendInitialEvents` replay + mark-and-sweep | -| **Audit webhook** (`/audit-webhook/`) | *who changed mirrored state* | no | a post-persist attribution fact, partitioned by source provider and joined to the watch event by resourceVersion ([Optional Attribution](#optional-attribution)) | +| **Audit webhook** (`/audit-webhook/`) | *who changed mirrored state* | no | a post-persist attribution fact, partitioned by source provider and joined to the watch event by resourceVersion ([Optional Attribution](#optional-attribution)) | | **Validating admission webhook** (`/validate-operator-types`) | *who issued a command* | no | the submitter of a `CommitRequest`, captured at admission and keyed 1:1 by UID ([CommitRequest Finalize](#commitrequest-finalize)) | With both optional sources off, the product still mirrors state correctly, and every commit is authored @@ -266,11 +272,12 @@ capture audit and admission with **no** resulting watch event. ## Configuration model -You configure GitOps Reverser through eight CRDs (group `configbutler.ai`, version `v1alpha3`). +You configure GitOps Reverser through nine CRDs (group `configbutler.ai`, version `v1alpha3`). `WatchRule` and `ClusterWatchRule` choose which Kubernetes resources enter the pipeline. `CommitRequest` can ask for the current window to be saved. `GitTarget` joins one source cluster to one -Git destination. `ClusterProvider` supplies the source connection and authorization boundary; -`GitProvider` supplies the repository, credentials, commit settings, and push policy. `FluxTrigger` +Git destination. `KubeProvider` supplies the source connection and authorization boundary; +`GitProvider` supplies the repository, credentials, commit settings, and push policy, and a +`GitBranch` holds one branch of that repository for every `GitTarget` that writes it. `FluxTrigger` and `ArgoCDTrigger` hold the Flux `Kustomization` or Argo CD `Application` that applies a folder back, so GitOps Reverser decides when it applies. @@ -281,21 +288,21 @@ graph LR CWR[ClusterWatchRule] -->|gitTargetRef| GT CR[CommitRequest] -->|gitTargetRef| GT GT -->|applyTriggerRef| TR["FluxTrigger /\nArgoCDTrigger"] - GT -->|clusterProviderRef| CP[ClusterProvider] + GT -->|kubeProviderRef| CP[KubeProvider] GT -->|gitBranchRef| GB[GitBranch] GB -->|gitProviderRef| GP[GitProvider] - KCFG[("Remote kubeconfig Secret\noperator namespace")] + KCFG[("Remote kubeconfig Secret\nprovider's namespace")] WM["Watch manager\nper-provider source context"] end - subgraph SOURCE["Source cluster selected by ClusterProvider"] + subgraph SOURCE["Source cluster selected by KubeProvider"] API["Kubernetes API\ndiscovery · watches"] APPLIER["Applier\nFlux Kustomization\nor Argo CD Application"] end HOST[("Git host")] - CP -. "optional kubeconfig reference" .-> KCFG + CP -. "secretRef (or inCluster)" .-> KCFG CP -->|"source identity + config"| WM WM -->|"source client"| API TR -. "kustomizationRef / applicationRef" .-> APPLIER @@ -309,16 +316,17 @@ graph LR style CR fill:#f3e5f5,stroke:#8e24aa ``` -The diagram follows one `GitTarget`. A provider with no `spec.kubeConfig` selects the operator's own -cluster as its source; any provider name, including `default`, can instead select a remote cluster through -a kubeconfig Secret in the operator namespace. Each referenced `ClusterProvider` gets an independent -source context: client, discovery catalog, followability registry, and reachability state. Its -`GitTarget`s open their source watches through that context. The contexts are keyed -by provider name and are released when no `GitTarget` references them. - -The applier is not one of the eight kinds. It belongs to Flux or Argo CD, lives in the cluster the -trigger's `spec.clusterProviderRef` names (the source cluster, or a management cluster), and is -reached through that `ClusterProvider`'s identity. The trigger is ours: it holds the applier, and +The diagram follows one `GitTarget`. A provider with `spec.secretRef` selects a remote cluster through a +kubeconfig Secret in the provider's own namespace; one with `spec.inCluster` selects the operator's own +cluster, when the installation lists it. Each referenced `KubeProvider` gets an independent source +context: client, discovery catalog, followability registry, and reachability state. Its `GitTarget`s +open their source watches through that context. The contexts are keyed by the provider's +`namespace/name` and are released when no `GitTarget` references them, so a provider deleted and +recreated under the same name is a new connection. + +The applier is not one of the nine kinds. It belongs to Flux or Argo CD, lives in the cluster the +trigger's `spec.kubeProviderRef` names (the source cluster, or a management cluster), and is +reached through that `KubeProvider`'s identity. The trigger is ours: it holds the applier, and records the apply operation it started. | CRD | Scope | One line role | @@ -326,8 +334,9 @@ records the apply operation it started. | `WatchRule` | namespaced | which NAMESPACED resources, in which source namespaces, route to a GitTarget | | `ClusterWatchRule` | cluster | which CLUSTER SCOPED resources route to a GitTarget | | `CommitRequest` | namespaced | a one shot "save the open window now" signal | -| `GitTarget` | namespaced | one materialization from a source provider to `(provider, branch, path)` | -| `ClusterProvider` | cluster | one source-cluster connection plus namespace access policy | +| `GitTarget` | namespaced | one materialization from a `KubeProvider` to `(GitBranch, path)` | +| `KubeProvider` | namespaced | one source-cluster connection plus namespace access policy | +| `GitBranch` | namespaced | one repository branch, held for every `GitTarget` that writes it | | `GitProvider` | namespaced | a Git repo + credentials + commit/signing config | | `FluxTrigger` | namespaced | the Flux `Kustomization` GitOps Reverser asks to apply, and the apply in flight | | `ArgoCDTrigger` | namespaced | the Argo CD `Application` GitOps Reverser asks to sync, and the sync in flight | @@ -367,9 +376,9 @@ event serves only to name who scaled the parent. The two namespace controls intentionally live in different planes: -- `ClusterProvider.spec.accessFrom` authorizes **control-cluster** namespaces to reference the - provider. It is a tenant/export boundary; it neither selects nor grants access to source namespaces. -- `ClusterProvider.spec.allowCrossNamespace.sources` delegates the CHOICE of source namespace to the +- `KubeProvider.spec.accessFrom` authorizes **control-cluster** namespaces other than its own to + reference the provider. It is a tenant/export boundary; it neither selects nor grants access to source namespaces. +- `KubeProvider.spec.allowCrossNamespace.sources` delegates the CHOICE of source namespace to the `GitTarget`s that provider admits. It grants nothing: what may actually be read from the source cluster is bounded by that credential's own Kubernetes RBAC, which is the only bound there is. The operator reads no `Namespace` objects in a source cluster for authorization. @@ -408,12 +417,14 @@ How attribution and finalization interact is described under - **Source**: [api/v1alpha3/gittarget_types.go](../api/v1alpha3/gittarget_types.go) - **Controller**: [internal/controller/gittarget_controller.go](../internal/controller/gittarget_controller.go) -One materialization from a source provider to a Git destination: `(cluster provider, GitBranch, path)`. +One materialization from a source provider to a Git destination: `(KubeProvider, GitBranch, path)`. Key fields: - `spec.gitBranchRef`: required; a `GitBranch` by name, in the `GitTarget`'s own namespace unless `namespace` names another one, whose `GitProvider`'s `spec.accessFrom` must then admit it. -- `spec.clusterProviderRef`: a cluster-scoped source `ClusterProvider`; it defaults to `{name: default}`. +- `spec.kubeProviderRef`: required; the source `KubeProvider`, in the `GitTarget`'s own namespace + unless `namespace` names another one, whose `spec.accessFrom` must then admit it. There is no + default provider: a missing one reports `Validated=False` (`KubeProviderNotFound`). - `spec.path`: immutable, required path under the repo (`MinLength=1`; `.` means repo root and must be chosen explicitly). - `spec.encryption`: optional SOPS/age encryption settings for sensitive resources. @@ -432,7 +443,7 @@ may batch and phrase their commits differently. A branch worker serves a reposit its `GitBranch`'s `GitProvider`, and resolves the window per open window, since a window is bound to exactly one target. -`gitBranchRef`, `clusterProviderRef`, and `path` are immutable so a target cannot silently orphan an +`gitBranchRef`, `kubeProviderRef`, and `path` are immutable so a target cannot silently orphan an old materialization or change its source cluster. Among GitTargets on one `GitBranch`, from any namespace, the controller rejects overlapping paths. `spec.gitProviderRef`, `spec.branch` and `spec.parentBranch` left the `GitTarget` for the `GitBranch`; the schema keeps them only so that @@ -451,7 +462,7 @@ Status has a kstatus-compatible summary layer plus domain conditions: - `Validated` and `EncryptionConfigured` explain control-plane health. - `StreamsRunning` explains the source side: every tracked type is past initial replay and routing live events. -- `ClusterProviderReady` and `SourceClusterReachable` distinguish valid source configuration from a +- `KubeProviderReady` and `SourceClusterReachable` distinguish valid source configuration from a source API the data plane can currently reach. - `GitPathAccepted` explains the target side: the selected Git path is safe for the operator to materialize. @@ -478,7 +489,7 @@ Represents a Git repository and the credentials/configuration used to write it. - `spec.url`: immutable repository URL. - `spec.secretRef`: optional Secret in the same namespace for HTTP/SSH authentication. - `spec.accessFrom`: which other namespaces' `GitTarget`s may write through this provider's - `GitBranch`es, by name or label selector, as on a `ClusterProvider`. Its own namespace is always + `GitBranch`es, by name or label selector, as on a `KubeProvider`. Its own namespace is always admitted; omitted admits no other. A `GitTarget` admitted from another namespace writes with this provider's credential and identity. Checked on every reconcile, so a tightened policy revokes. - `spec.knownHostsRef`: optional SSH known hosts source. @@ -520,26 +531,46 @@ The controller proves the provider's credential by opening a receive-pack sessio steady interval while it succeeds), keeps the branch claim, and publishes the worker's observation. See [Git write architecture](#git-write-architecture) for the branch claim. -### ClusterProvider - -`ClusterProvider` is the read-side peer of `GitProvider`. A `GitTarget` references it by the immutable -`spec.clusterProviderRef`; omission defaults to the conventional name `default`. The name is only a -defaulting convention: a provider without `spec.kubeConfig` uses the operator's in-cluster client, while -any name (including `default`) may instead carry a remote kubeconfig resolved from the operator namespace. - -Its cluster scope is intentional. Several namespaces may mirror through one provider, but that provider's -source identity must not vary by target because it keys source clients, discovery, watches, and -attribution. The identity is the provider name rather than a deduplicated physical-cluster identity, so -two providers pointing at the same API server still have separate contexts and authorization boundaries. -`spec.accessFrom` is therefore a deny-by-default control-cluster policy, enforced on every -reconcile before watches start, so tightening it also stops an already-existing `GitTarget`, which an -admission-time check could not do. It guards which tenant may cause the operator to export a shared source; -it does not expand that source credential's Kubernetes permissions. The `ClusterProvider` validates its -connection inputs, while a `GitTarget` projects provider readiness and live source reachability. - -Remote credentials are re-read on the catalog-refresh cadence, not on every watch reconnect. A rotated -Secret or a `qps`/`burst` change rebuilds that provider's clients and restarts only the watches using that -provider; a definitively invalid or missing credential fails closed and stops those watches. An unreachable +### KubeProvider + +- **Source**: [api/v1alpha3/kubeprovider_types.go](../api/v1alpha3/kubeprovider_types.go) +- **Controller**: [internal/controller/kubeprovider_controller.go](../internal/controller/kubeprovider_controller.go) + +`KubeProvider` is the read-side peer of `GitProvider`. A `GitTarget`, `FluxTrigger` or `ArgoCDTrigger` +references it by the required, immutable `spec.kubeProviderRef`; an omitted `namespace` means the +referrer's own, and nothing falls back to a default provider. Its credential is exactly one of two +immutable arms (delete and recreate the provider to repoint it): + +- `spec.secretRef`: a kubeconfig Secret in the provider's own namespace (key `value`, then + `value.yaml`, unless `key` names one). The kubeconfig safety checks refuse credentials named by + file path, and exec auth and insecure TLS unless the operator opts in through its flags. +- `spec.inCluster: {}`: the operator's own identity, honored only for a provider listed in + `--in-cluster-kube-providers`; any other is `Stalled=True` (`InClusterNotAuthorized`) and never + used. `spec.client` is refused beside it, because the operator's own client is not throttled per + provider. + +Several namespaces may mirror through one provider, but its source identity must not vary by target +because it keys source clients, discovery, watches, and attribution. The identity is the provider's +`namespace/name` rather than a deduplicated physical-cluster identity, so two providers pointing at the +same API server still have separate contexts and authorization boundaries. A provider admits +consumers in its own namespace; `spec.accessFrom` admits others, and an omitted one admits no other +namespace. The check runs on every reconcile before watches start (`NamespaceNotAuthorized`), so +tightening it also stops an already-existing `GitTarget`, which an admission-time check could not +do. It guards which tenant may cause the operator to export a source; it does not expand that source +credential's Kubernetes permissions. + +Its audit route, `spec.attribution.auditRoute`, defaults to `.` and is held by one +namespace: the namespace the route's first dot-segment names when a provider there carries it, +otherwise the namespace of the oldest carrier. Providers in the holding namespace may share a route +(several connections to one API server); one in another namespace reports `Stalled=True` +(`AuditRouteConflict`) and is not used. The `KubeProvider` validates its connection inputs, while a +`GitTarget` projects provider readiness and live source reachability. + +Credentials are re-resolved on the catalog-refresh cadence, not on every watch reconnect, `inCluster` +ones included. A rotated Secret, a `spec.client` change, or a provider deleted and recreated under the +same name rebuilds that provider's clients and restarts only the watches using that provider; a +definitively invalid or missing credential, or an `inCluster` provider the installation stops +listing, fails closed and stops those watches. An unreachable remote similarly makes only its dependent targets unready; it does not block discovery or watches for other source contexts. @@ -554,8 +585,8 @@ change takes. ```mermaid flowchart LR subgraph CONTROL["Control plane: operator cluster"] - CP[ClusterProvider] - KCFG[("Remote kubeconfig Secret\noperator namespace")] + CP[KubeProvider] + KCFG[("Remote kubeconfig Secret\nprovider's namespace")] OWN["GitTarget watch set\nclaimed ∩ followable (GVR, scope)"] RELEVANCE["Relevance filter\nsanitize · followability · no-op diff"] RESOLVE["Resolver\nbounded grace window"] @@ -565,10 +596,10 @@ flowchart LR AINDEX[("In-process fact index\nbounded, TTL, keyed for join")] end - subgraph SOURCE["Source cluster selected by ClusterProvider\n(the control cluster when kubeConfig is omitted)"] + subgraph SOURCE["Source cluster selected by KubeProvider\n(the control cluster for spec.inCluster)"] WATCH["WATCH + sendInitialEvents replay
(per claimed GVR + scope)"] DISC["Discovery: CRDs / APIServices"] - AUDIT["/audit-webhook/<provider> (optional)"] + AUDIT["/audit-webhook/<route> (optional)"] end subgraph GIT["Git writes: internal/git"] @@ -577,7 +608,7 @@ flowchart LR PUSH["Atomic push"] end - CP -. "optional Secret reference" .-> KCFG + CP -. "secretRef" .-> KCFG CP -->|"source client"| DISC CP -->|"source client"| WATCH DISC --> OWN @@ -621,7 +652,7 @@ Following the ConfigMap edit: the remote moved). Separately, the audit path (only when attribution is enabled): kube-apiserver POSTs audit events to -`/audit-webhook/`; [AuditHandler](../internal/webhook/audit_handler.go) extracts the minimal +`/audit-webhook/`; [AuditHandler](../internal/webhook/audit_handler.go) extracts the minimal attribution facts and appends them to that route's per-type fact stream. A follower on the watch side reads the streams for the types it watches into a bounded, TTL'd in-process index. That index is read only by the resolver in step 3; it never creates or repairs object state. @@ -879,7 +910,7 @@ per-mutation change log. Attribution runs only when `--author-attribution=true`. A normal source posts audit `EventList` payloads to `/audit-webhook/`, where the route is -`ClusterProvider.spec.attribution.auditRoute` and defaults to the provider's own name. The bare +`KubeProvider.spec.attribution.auditRoute` and defaults to `.`. The bare `/audit-webhook` endpoint is enabled only with `--author-attribution-audit-route-annotation-key`, for a trusted control plane that puts an audit route in each event. There is no supplementary body endpoint or body joiner, because watch (not audit) carries the object body. The handler applies an intrinsic accept @@ -908,7 +939,7 @@ requests initial state with either configuration. | Endpoint | Role | |---|---| -| `/audit-webhook/` | One audit route's stream; some ClusterProvider must carry that route | +| `/audit-webhook/` | One audit route's stream; some `KubeProvider` must carry that route | | `/audit-webhook` | Shared stream only when annotation routing is configured; each event names its route | The handler accepts a client certificate signed by the audit CA, but it does not yet bind that certificate @@ -1248,7 +1279,7 @@ because Git already holds current state. This section is the authoritative contr The watch manager is a controller runtime `Runnable` (`NeedLeaderElection`). It owns **type level** discovery, the per-GitTarget watch sets, and the watched type tables for GitTargets. Its object-state -intake is the watches themselves. Every active `ClusterProvider` has an independent source context; its +intake is the watches themselves. Every active `KubeProvider` has an independent source context; its catalog, registry, dynamic client, and reachability can fail or recover without borrowing facts from another source. Discovery trigger informers (CRDs / APIServices) run only in the control plane and accelerate its catalog refresh; remote source catalogs use the periodic refresh. @@ -1672,7 +1703,7 @@ by the validating webhook, not derived from the audit attribution index. Controllers watch their dependencies so dependents reconcile quickly after spec changes: -- `GitTargetReconciler` watches `GitProvider`, `ClusterProvider`, `Namespace`, `WatchRule`, and +- `GitTargetReconciler` watches `GitProvider`, `KubeProvider`, `Namespace`, `WatchRule`, and `ClusterWatchRule`. Provider changes re-check connection and authorization inputs; control-cluster Namespace label changes re-check provider-reference access. Rule changes re-resolve selected collections. It deliberately does **not** watch encryption Secrets, so their recovery is picked up @@ -1680,21 +1711,23 @@ Controllers watch their dependencies so dependents reconcile quickly after spec every Secret value in the control-plane cache. It watches the apply triggers its `GitTarget`s name, for `ApplyTriggerReady`. - `ApplyTriggerReconciler` (one per trigger kind) watches its triggers, the `GitTarget`s naming - them, `GitProvider`s, `ClusterProvider`s and `Namespace` labels. It does not watch appliers: it + them, `GitProvider`s, `KubeProvider`s and `Namespace` labels. It does not watch appliers: it reads the Flux `Kustomization` or Argo CD `Application` on its tick to set `Configured`, so a cluster without the Flux or Argo CD CRDs needs no informer for them. - `WatchRuleReconciler` / `ClusterWatchRuleReconciler` watch `GitTarget`, `GitProvider` and - `ClusterProvider` (a `ClusterWatchRule` also `Namespace` labels), populate the RuleStore, and + `KubeProvider` (a `ClusterWatchRule` also `Namespace` labels), populate the RuleStore, and trigger the rule-change reconcile. - `GitProviderReconciler` validates reachability and manages the signing key lifecycle. -- `ClusterProviderReconciler` validates source-connection inputs and projects its readiness to dependent - targets. It also removes the retired fact-purge finalizer from pre-release objects during an upgrade. +- `KubeProviderReconciler` validates source-connection inputs, decides whether the provider may be used + at all (the installation's `inCluster` consent, and which namespace holds its audit route), and + projects its readiness to dependent targets. A provider created, deleted or moved to another route + wakes the others carrying that route, so an `AuditRouteConflict` clears without waiting for a requeue. - `CommitRequestReconciler` runs with `MaxConcurrentReconciles=1` and attributes/attaches as above; its optional `AuthorLookup` is the command-author cache populated by the `/validate-operator-types` webhook (wired whenever the admission webhook is enabled, independent of `--author-attribution`, and nil otherwise, so the request claims no actor). -Dependency watches use narrow predicates to avoid status-only heartbeat churn: a `ClusterProvider` also +Dependency watches use narrow predicates to avoid status-only heartbeat churn: a `KubeProvider` also admits a `Ready` transition, and a `Namespace` admits only label changes. `GitProvider`, `GitTarget`, and `CommitRequest` carry immutability constraints where a spec change would orphan a materialized subtree or invalidate an in-flight finalize. @@ -1727,7 +1760,7 @@ flowchart TD Hq -->|no| J[Configured-author: no fact streams or index; audit webhook skipped] I --> K[Setup + register Watch Manager] J --> K - K --> L[Register GitProvider + ClusterProvider + GitTarget + FluxTrigger + ArgoCDTrigger
+ CommitRequest controllers; admission webhooks] + K --> L[Register GitProvider + KubeProvider + GitTarget + FluxTrigger + ArgoCDTrigger
+ CommitRequest controllers; admission webhooks] L --> M[Add cert watchers + health checks] M --> N[mgr.Start] ``` @@ -1823,14 +1856,14 @@ request and only a foreign one by a `ForeignCommit`. See evidence named the author and who it named; `_resolution_wait_seconds{tier,event_kind}` says what the grace wait cost, split by write and removal. -**Discovery** (`api_catalog_*`) carries a `source_cluster` label and is recorded for every source -cluster, so a degraded `APIService` on a remote cluster is visible. **Secret encryption** is one -counter, `_secret_encryptions_total{outcome}`. +**Discovery** (`api_catalog_*`) carries a `source_cluster` label (the provider's +`/`) and is recorded for every source cluster, so a degraded `APIService` on a +remote cluster is visible. **Secret encryption** is one counter, `_secret_encryptions_total{outcome}`. **Configuration state** is the one family that is not about flow. `gitopsreverser_resource_condition{kind,resource_namespace,resource_name,type,status,reason}` publishes the kstatus trio of every object a human declared (`GitTarget`, `WatchRule`, -`ClusterWatchRule`, `GitProvider`, `ClusterProvider`, `FluxTrigger`, `ArgoCDTrigger`) as one series +`ClusterWatchRule`, `GitProvider`, `KubeProvider`, `FluxTrigger`, `ArgoCDTrigger`) as one series per possible status, of which exactly one is `1`. It is recorded at `reconcileStatus.commit()`, the single status choke point every controller but the `CommitRequest` one uses, and the object's entry is dropped when the object goes: a condition series that outlives its object reports @@ -1877,14 +1910,14 @@ Current limitations: sync or `flux resume`, which the operator suspends again when it starts. - **Only Flux and Argo CD can be appliers yet,** and an Argo CD `Application` only with automated sync off: one that syncs on its own is refused, not driven. The applier may live in any cluster a - `ClusterProvider` reaches (the trigger's `spec.clusterProviderRef`), such as a management cluster. + `KubeProvider` reaches (the trigger's `spec.kubeProviderRef`), such as a management cluster. - **An apply request cannot reserve time.** An edit made while the requested apply runs, to a field the applier sets, is reverted on the cluster. Its value stays in Git, because the applier's write over it is not mirrored, and the next apply restores it; until then the cluster runs the older value. See [the apply window](design/apply-trigger.md#the-apply-window). -- **Audit ingress uses a shared-CA trust boundary.** Named `/audit-webhook/` routes and the +- **Audit ingress uses a shared-CA trust boundary.** Named `/audit-webhook/` routes and the annotation-routed shared endpoint both require a client certificate signed by the audit CA, but that - certificate is not bound to one provider. This is an accepted privileged-control-plane assumption, not + certificate is not bound to one audit route. This is an accepted privileged-control-plane assumption, not tenant isolation; see [SECURITY.md](../SECURITY.md#shared-audit-ingress-trust-model). - **`deletecollection`** is reconciled by the watch (each item arrives as its own `DELETED`, or the mark-and-sweep reconciles them on replay). diff --git a/docs/attribution-setup-guide.md b/docs/attribution-setup-guide.md index ba782dcf8..a111ed0d6 100644 --- a/docs/attribution-setup-guide.md +++ b/docs/attribution-setup-guide.md @@ -29,9 +29,9 @@ and watches cold-replay. For a disposable single-pod setup, `attribution.transpo no Redis but loses facts whenever the reverser restarts. It also leaves Redis-backed features such as `CommitRequest` author capture inactive unless you configure Redis separately. -The chart's default `ClusterProvider` admits targets from every namespace. To restrict it, set -`clusterProvider.default.accessFrom.names` and `selector: null`; Helm otherwise retains the -permissive `selector: {}`. See [RBAC](rbac.md) for the controller's read permissions. +The chart's `KubeProvider`, `local` in the release namespace, admits targets from every namespace. +To restrict it, set `kubeProvider.accessFrom.names` and `selector: null`; Helm otherwise retains the +permissive `selector: {}`. Its own namespace is always admitted. See [RBAC](rbac.md) for the controller's read permissions. The chart creates the audit Service and cert-manager certificates. Wait for the release to be ready, then read its Secret names and kubeconfig generator: @@ -52,17 +52,19 @@ private key: encrypt it before committing it to Git. Set its `server` to an address reachable from each control-plane host, with a **named route**: ```yaml -server: https://REACHABLE_ADDRESS:9444/audit-webhook/default +server: https://REACHABLE_ADDRESS:9444/audit-webhook/gitops-reverser.local tls-server-name: gitops-reverser-audit.gitops-reverser.svc ``` -Use `/audit-webhook/default` for a normal installation. The chart creates the `default` -`ClusterProvider`; older Helm notes may omit the `/default` suffix. -For another provider, use its `spec.attribution.auditRoute`, which defaults to its name. +Use `/audit-webhook/.local` for a normal installation: the chart creates the +`local` `KubeProvider` in its release namespace, and a provider's route defaults to +`.`. For another provider, use its `spec.attribution.auditRoute`, or +`.` when it sets none. `kubectl get kubeprovider -A -o wide` prints each +provider's resolved route in its `Route` column. Only installations explicitly using `attribution.auditRouteAnnotationKey` should keep the bare -`/audit-webhook` endpoint. That mode selects a route from each event's annotation; appending -`/default` would instead select the named `default` route for the entire request. +`/audit-webhook` endpoint. That mode selects a route from each event's annotation; appending a +route would instead select that one route for the entire request. A ClusterIP works only when the host can route to the service CIDR. Otherwise use a reachable NodePort or load balancer. Do not assume cluster DNS or a loopback NodePort works from the host. @@ -124,7 +126,7 @@ Check receiver readiness and the route: ```bash kubectl -n gitops-reverser rollout status deployment/gitops-reverser -kubectl get clusterprovider default +kubectl -n gitops-reverser get kubeprovider local ``` `FACTSRECEIVED=True` means the provider has received an attribution fact at least once. It is historical diff --git a/docs/audit-setup/cluster/audit/webhook-config.yaml b/docs/audit-setup/cluster/audit/webhook-config.yaml index 251ee46d8..aeecbed65 100644 --- a/docs/audit-setup/cluster/audit/webhook-config.yaml +++ b/docs/audit-setup/cluster/audit/webhook-config.yaml @@ -7,7 +7,7 @@ clusters: - name: audit-webhook cluster: # Replace with a Service IP reachable from every control-plane host. - server: https://10.43.200.200:9444/audit-webhook/default + server: https://10.43.200.200:9444/audit-webhook/gitops-reverser.local tls-server-name: gitops-reverser-audit.gitops-reverser.svc certificate-authority-data: REPLACE_WITH_BASE64_AUDIT_ROOT_CA contexts: diff --git a/docs/azure-devops-getting-started.md b/docs/azure-devops-getting-started.md index f72f7a2e8..d1e926be2 100644 --- a/docs/azure-devops-getting-started.md +++ b/docs/azure-devops-getting-started.md @@ -65,6 +65,9 @@ metadata: spec: gitBranchRef: name: ado-branch + kubeProviderRef: + name: local + namespace: gitops-reverser path: clusters/my-cluster --- apiVersion: configbutler.ai/v1alpha3 diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index 42bda8c7c..c516459c0 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -56,7 +56,7 @@ starts and once when a `GitTarget` first names the trigger ([one sync](#one-sync | `targetRevision` is the `GitTarget`'s branch (`main` or `refs/heads/main`) | The tip GitOps Reverser checks is the tip of that branch | `SourceMismatch` for `HEAD`, an empty `targetRevision`, a tag or a commit | | The source renders the folder GitOps Reverser writes: `directory.recurse: true` for a plain directory, or a kustomization at the path | GitOps Reverser writes `////.yaml` | A non-recursive directory sees no files, and the sync applies nothing | | Resource tracking by `annotation`, Argo CD's default | GitOps Reverser strips `argocd.argoproj.io/tracking-id`; it cannot strip `app.kubernetes.io/instance`, a label people set on purpose | With `label` or `annotation+label`, the tracking label reaches Git on every object | -| `get` and `update` on the `Application`, for the identity of the `ClusterProvider` that reaches it | A sync is an update of the `Application` | `Forbidden` | +| `get` and `update` on the `Application`, for the identity of the `KubeProvider` that reaches it | A sync is an update of the `Application` | `Forbidden` | | Nothing but GitOps Reverser syncs the `Application` while GitOps Reverser runs | See [what not to do](#what-not-to-do) | Not checked: an unchecked sync can revert live edits | ## A complete example @@ -95,18 +95,22 @@ spec: ``` The GitOps Reverser side. The `GitProvider` points at the same repository (any URL form of it) and -serves a push receiver; an `ArgoCDTrigger` holds the `Application`, in the cluster the `default` -`ClusterProvider` names, and the `GitTarget` names the trigger. The `Application` is in `argocd` -and the trigger in `team-a`, so the provider lets `team-a`'s triggers reach another namespace -(`allowCrossNamespace.appliers`); with the Helm chart, set -`clusterProvider.default.allowCrossNamespace.appliers: true` instead of declaring it: +serves a push receiver; an `ArgoCDTrigger` holds the `Application`, in the cluster the chart's +`local` `KubeProvider` names (the operator's own, through `spec.inCluster`), and the `GitTarget` +names the trigger. The `Application` is in `argocd` and the trigger in `team-a`, so the provider +lets `team-a`'s triggers reach another namespace (`allowCrossNamespace.appliers`); with the Helm +chart, set `kubeProvider.allowCrossNamespace.appliers: true` instead of declaring it. A +`KubeProvider` with `inCluster` that you declare yourself is honored only once it is listed in +`--in-cluster-kube-providers`, which the chart does for its own: ```yaml apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider +kind: KubeProvider metadata: - name: default + name: local + namespace: gitops-reverser spec: + inCluster: {} accessFrom: names: [team-a] allowCrossNamespace: @@ -153,6 +157,9 @@ metadata: spec: gitBranchRef: name: platform-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout applyTriggerRef: kind: ArgoCDTrigger @@ -165,8 +172,9 @@ metadata: name: checkout namespace: team-a spec: - clusterProviderRef: - name: default + kubeProviderRef: + name: local + namespace: gitops-reverser applicationRef: namespace: argocd name: checkout @@ -212,21 +220,22 @@ metadata: name: checkout namespace: team-a # the GitTargets naming it are in this namespace spec: - clusterProviderRef: # the cluster the Application lives in; default: "default" - name: management + kubeProviderRef: # the cluster the Application lives in; required + name: management # namespace omitted: the trigger's own applicationRef: namespace: argocd # default: the trigger's namespace name: checkout ``` -`clusterProviderRef` names the cluster that holds the `Application`: the operator's own cluster for -`default`, or a management cluster for a hub Argo CD that deploys to other clusters. The -`GitTarget`'s own `spec.clusterProviderRef` stays the cluster it mirrors. An `Application` in -another namespace than the trigger's, such as `argocd`, needs `allowCrossNamespace.appliers: true` on -that `ClusterProvider`; without it the trigger reports `ApplierNamespaceNotAllowed`. That is a -delegation, not owner consent: every namespace the provider admits may then sync any `Application` -in reach of its identity whose source is the repository its `GitProvider` names, and nothing proves -it owns the `Application` or may write to the repository. See +`kubeProviderRef` names the cluster that holds the `Application`: the operator's own cluster for an +`inCluster` provider such as the chart's `local`, or a management cluster for a hub Argo CD that +deploys to other clusters. The provider must admit the trigger's namespace: its own, or one its +`accessFrom` admits. The `GitTarget`'s own `spec.kubeProviderRef` stays the cluster it mirrors. An +`Application` in another namespace than the trigger's, such as `argocd`, needs +`allowCrossNamespace.appliers: true` on that `KubeProvider`; without it the trigger reports +`ApplierNamespaceNotAllowed`. That is a delegation, not owner consent: every namespace the provider +admits may then sync any `Application` in reach of its identity whose source is the repository its +`GitProvider` names, and nothing proves it owns the `Application` or may write to the repository. See [what a namespace may reach outside itself](configuration.md#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace). Both references are immutable: delete and recreate the trigger to hold another `Application`. The trigger writes nothing to the `Application` until it syncs it, so deleting it, or no longer naming @@ -256,7 +265,7 @@ stateDiagram-v2 ``` A trigger acts only while an eligible `GitTarget` references it: one in the trigger's namespace that -names it, is admitted by its `ClusterProvider`s, and writes the repository and branch the +names it, is admitted by its `KubeProvider`s, and writes the repository and branch the `Application` syncs. A trigger nothing eligible references starts no sync and is not in line for the `Application`; one no `GitTarget` names also reads nothing in the `Application`'s cluster. An `Application` has nothing to hold, so there is nothing to hand back either. @@ -273,9 +282,10 @@ The trigger reports `Configured`, and its `Ready` includes it: | `UnsupportedSource` | The `Application` has several sources, a source hydrator, or no source | stalled | | `AutomatedSyncEnabled` | Automated sync is on | stalled | | `Forbidden` | The applier cluster's identity may not update the `Application` | stalled | -| `NamespaceNotAuthorized` | The `clusterProviderRef` names a provider whose `accessFrom` does not admit the trigger's namespace | stalled | -| `ClusterProviderNotFound` | The `clusterProviderRef` names a provider that does not exist | progressing | -| `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the trigger's, and the `ClusterProvider` does not set `allowCrossNamespace.appliers` | stalled | +| `NamespaceNotAuthorized` | The `kubeProviderRef` names a provider in another namespace whose `accessFrom` does not admit the trigger's namespace | stalled | +| `KubeProviderNotFound` | The `kubeProviderRef` names a provider that does not exist | progressing | +| `AuditRouteConflict` | The provider carries an audit route another namespace holds, so it is not used | stalled | +| `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the trigger's, and the `KubeProvider` does not set `allowCrossNamespace.appliers` | stalled | | `HeldByAnotherTrigger` | Another `ArgoCDTrigger` in line for the same `Application` in the same cluster, one an eligible `GitTarget` references, was created first and holds it; this one starts no sync, and takes over once that one is deleted or no longer referenced | stalled: delete one of the two | A sync that has not completed within 5 minutes also makes the trigger `Ready=False` with @@ -288,8 +298,8 @@ trigger has seen that the `GitTarget` names it, and otherwise the trigger's own ## Permissions -GitOps Reverser looks the `Application` up in the cluster of the trigger's `clusterProviderRef`, -with the identity of that `ClusterProvider`. That identity needs: +GitOps Reverser looks the `Application` up in the cluster of the trigger's `kubeProviderRef`, +with the identity of that `KubeProvider`. That identity needs: | API group | Resource | Verbs | | --- | --- | --- | @@ -299,7 +309,7 @@ with the identity of that `ClusterProvider`. That identity needs: A sync is started by an `update` of the `Application`, the way Argo CD's own automated sync starts one. For the operator's own cluster, install the chart with `applierAccess.argocd: true`, or apply [`config/rbac/argocd-applier-role.yaml`](../config/rbac/argocd-applier-role.yaml) and its binding. -For a remote cluster, grant the same to the identity in the `ClusterProvider`'s kubeconfig. A `Role` +For a remote cluster, grant the same to the identity in the `KubeProvider`'s kubeconfig. A `Role` in the `Application`'s namespace is enough. Argo CD's own RBAC does not apply: GitOps Reverser writes the `Application` through the Kubernetes API, as `kubectl` would, not through `argocd-server`. @@ -464,8 +474,8 @@ it. | Trigger `Configured=False`, `AutomatedSyncEnabled` | `spec.syncPolicy.automated` is present without `enabled: false` | Set `automated.enabled: false` (keep `prune`), or remove the block | | Trigger `Configured=False`, `ApplierNotFound` | No `Application` with that namespace and name in the trigger's cluster, or Argo CD is not installed there | Create it, or recreate the trigger with the right `applicationRef`; an omitted namespace is the trigger's | | `ApplyTriggerReady=False`, `SourceMismatch`, or trigger `UnsupportedSource` | The message names the broken [`Application` rule](#the-application) | Fix the `Application`; a sync that was waiting is asked for afterward | -| Trigger `Configured=False`, `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the trigger, such as `argocd` | Set `allowCrossNamespace.appliers: true` on the trigger's `ClusterProvider` (Helm: `clusterProvider.default.allowCrossNamespace.appliers`), or move the trigger and `GitTarget` to the `Application`'s namespace | -| Trigger `Configured=Unknown`, `NotBound` | A `GitTarget` names the trigger, but cannot be judged yet: its `GitProvider` or `ClusterProvider` does not exist | Create the missing object; the message names it | +| Trigger `Configured=False`, `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the trigger, such as `argocd` | Set `allowCrossNamespace.appliers: true` on the trigger's `KubeProvider` (Helm: `kubeProvider.allowCrossNamespace.appliers`), or move the trigger and `GitTarget` to the `Application`'s namespace | +| Trigger `Configured=Unknown`, `NotBound` | A `GitTarget` names the trigger, but cannot be judged yet: its `GitProvider` or `KubeProvider` does not exist | Create the missing object; the message names it | | Trigger `Configured=False`, `Forbidden` | The identity lacks one of the [permissions](#permissions) | Grant it, or set `applierAccess.argocd: true` | | A Warning `ApplyRequestFailed` Event | A request could not be delivered, such as a sync somebody else started that did not end within 5 minutes | GitOps Reverser keeps asking after its backoff, at most every five minutes | | A Warning `ApplyFailed` Event | The sync ended `Failed` or `Error`; the message carries Argo CD's | Fix what the message names; GitOps Reverser asks again after its backoff | diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index d6e780602..44e8a77b4 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -69,18 +69,22 @@ spec: ``` The GitOps Reverser side. The `GitProvider` points at the same repository (any URL form of it) and -serves a push receiver; a `FluxTrigger` holds the `Kustomization`, in the cluster the `default` -`ClusterProvider` names, and the `GitTarget` names the trigger. The `Kustomization` is in -`flux-system` and the trigger in `team-a`, so the provider lets `team-a`'s triggers reach another -namespace (`allowCrossNamespace.appliers`); with the Helm chart, set -`clusterProvider.default.allowCrossNamespace.appliers: true` instead of declaring it: +serves a push receiver; a `FluxTrigger` holds the `Kustomization`, in the cluster the chart's +`local` `KubeProvider` names (the operator's own, through `spec.inCluster`), and the `GitTarget` +names the trigger. The `Kustomization` is in `flux-system` and the trigger in `team-a`, so the +provider lets `team-a`'s triggers reach another namespace (`allowCrossNamespace.appliers`); with the +Helm chart, set `kubeProvider.allowCrossNamespace.appliers: true` instead of declaring it. A +`KubeProvider` with `inCluster` that you declare yourself is honored only once it is listed in +`--in-cluster-kube-providers`, which the chart does for its own: ```yaml apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider +kind: KubeProvider metadata: - name: default + name: local + namespace: gitops-reverser spec: + inCluster: {} accessFrom: names: [team-a] allowCrossNamespace: @@ -127,6 +131,9 @@ metadata: spec: gitBranchRef: name: platform-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout applyTriggerRef: kind: FluxTrigger @@ -139,8 +146,9 @@ metadata: name: checkout namespace: team-a spec: - clusterProviderRef: - name: default + kubeProviderRef: + name: local + namespace: gitops-reverser kustomizationRef: namespace: flux-system name: checkout @@ -194,10 +202,10 @@ Appliers on one branch that share a source apply one at a time. with its apply window open, for as long as it runs. - `spec.dependsOn`: a dependency whose apply failed is suspended with its generation unapplied, so its dependents wait until GitOps Reverser's retry of it succeeds. -- It lives in the trigger's namespace unless the trigger's `ClusterProvider` sets +- It lives in the trigger's namespace unless the trigger's `KubeProvider` sets `allowCrossNamespace.appliers: true`; a trigger naming one elsewhere without it reports `ApplierNamespaceNotAllowed`. A `Kustomization` in `flux-system` therefore needs the grant. It is - a delegation, not owner consent: every namespace the provider's `accessFrom` admits may then + a delegation, not owner consent: every namespace the provider admits may then suspend and apply any `Kustomization` in reach of the provider's identity whose source fetches the repository its `GitProvider` names. Nothing proves that the namespace owns the `Kustomization`, or may write to that repository: the binding compares URLs. See @@ -223,21 +231,23 @@ metadata: name: checkout namespace: team-a # the GitTargets naming it are in this namespace spec: - clusterProviderRef: # the cluster Flux runs in; default: "default" - name: default + kubeProviderRef: # the cluster Flux runs in; required + name: local + namespace: gitops-reverser # default: the trigger's namespace kustomizationRef: namespace: flux-system # default: the trigger's namespace name: checkout ``` -`clusterProviderRef` names the cluster that holds the `Kustomization`: the operator's own cluster -for `default`, or a management cluster where Flux runs. Both references are immutable: delete and -recreate the trigger to hold another `Kustomization`. +`kubeProviderRef` names the cluster that holds the `Kustomization`: the operator's own cluster for +an `inCluster` provider such as the chart's `local`, or a management cluster where Flux runs. The +provider must admit the trigger's namespace: its own, or one its `accessFrom` admits. Both +references are immutable: delete and recreate the trigger to hold another `Kustomization`. ### How the trigger holds the `Kustomization` A trigger acts only while an eligible `GitTarget` references it: one in the trigger's namespace that -names it in `spec.applyTriggerRef`, is admitted by its `ClusterProvider`s, and writes the repository +names it in `spec.applyTriggerRef`, is admitted by its `KubeProvider`s, and writes the repository and branch the `Kustomization`'s `GitRepository` fetches. A trigger no `GitTarget` names holds nothing, reads nothing in the `Kustomization`'s cluster, and is `Ready`. One that only ineligible `GitTarget`s name holds nothing either, and its `Configured` says why. @@ -293,8 +303,8 @@ stateDiagram-v2 Until that is done it stays the holder, so no other trigger acts on the `Kustomization`. One whose `spec.suspend` another manager also applies stays suspended, and the Event names that manager. With another `FluxTrigger` in line, the `Kustomization` stays suspended for that one. A - `ClusterProvider` that is gone does not stop the hand-back: it goes on through whatever still - reaches the cluster, which for a provider without `kubeConfig` is the operator's own. When nothing + `KubeProvider` that is gone does not stop the hand-back: it goes on through whatever still + reaches the cluster, which for an `inCluster` provider is the operator's own. When nothing does, or the hand-back has not finished 10 minutes after `Held` turned `Releasing`, the trigger lets go with a `ReleaseFailed` Event saying where the `Kustomization` was left. A reference that returns before the release is done holds the `Kustomization` again; one that returns after adopts @@ -319,9 +329,10 @@ The trigger reports `Configured` and `Held`, and its `Ready` includes both: | `Configured`: `UnsupportedSource` | The source is not a `GitRepository`, or breaks one of the [`GitRepository` rules](#the-gitrepository) | stalled | | `Configured`: `SuspendOwnedElsewhere` | Another field manager applies `spec.suspend` | stalled: remove the field from the manifest it applies | | `Configured`: `Forbidden` | The applier cluster's identity may not patch the `Kustomization` or its `GitRepository` | stalled | -| `Configured`: `NamespaceNotAuthorized` | The `clusterProviderRef` names a provider whose `accessFrom` does not admit the trigger's namespace | stalled | -| `Configured`: `ClusterProviderNotFound` | The `clusterProviderRef` names a provider that does not exist | progressing | -| `Configured`: `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the trigger's, and the `ClusterProvider` does not set `allowCrossNamespace.appliers` | stalled | +| `Configured`: `NamespaceNotAuthorized` | The `kubeProviderRef` names a provider in another namespace whose `accessFrom` does not admit the trigger's namespace | stalled | +| `Configured`: `KubeProviderNotFound` | The `kubeProviderRef` names a provider that does not exist | progressing | +| `Configured`: `AuditRouteConflict` | The provider carries an audit route another namespace holds, so it is not used | stalled | +| `Configured`: `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the trigger's, and the `KubeProvider` does not set `allowCrossNamespace.appliers` | stalled | | `Configured`: `HeldByAnotherTrigger` | Another `FluxTrigger` holds this `Kustomization` | stalled: delete one of the two | | `Held`: `Suspended` | Suspended under GitOps Reverser, and the last suspend is handled. Nothing applies | ready | | `Held`: `Applying` | An apply GitOps Reverser started is in flight: `status.operation` shows it | ready | @@ -343,8 +354,8 @@ trigger has seen that the `GitTarget` names it, and otherwise the trigger's own ## Permissions -GitOps Reverser looks the `Kustomization` up in the cluster of the trigger's `clusterProviderRef`, -with the identity of that `ClusterProvider`. That identity needs: +GitOps Reverser looks the `Kustomization` up in the cluster of the trigger's `kubeProviderRef`, +with the identity of that `KubeProvider`. That identity needs: | API group | Resource | Verbs | | --- | --- | --- | @@ -356,7 +367,7 @@ with the identity of that `ClusterProvider`. That identity needs: For the operator's own cluster, install the chart with `applierAccess.flux: true`, or apply [`config/rbac/flux-applier-role.yaml`](../config/rbac/flux-applier-role.yaml) and its binding. For a -remote cluster, grant the same to the identity in the `ClusterProvider`'s kubeconfig. The chart's +remote cluster, grant the same to the identity in the `KubeProvider`'s kubeconfig. The chart's grant is cluster-wide for convenience; GitOps Reverser only patches the `Kustomization` and `GitRepository` a trigger names. @@ -471,8 +482,8 @@ flux get ks checkout -n flux-system | Trigger `Configured=False`, `ApplierNotFound` | No `Kustomization` with that namespace and name in the trigger's cluster, or Flux is not installed there | Create it, or recreate the trigger with the right `kustomizationRef`; an omitted namespace is the trigger's | | Trigger `Configured=False`, `SuspendOwnedElsewhere` | The manifest that applies the `Kustomization` sets `spec.suspend` | Remove the field from that manifest | | `ApplyTriggerReady=False`, `SourceMismatch`, or trigger `UnsupportedSource` | The message names the broken [`GitRepository` rule](#the-gitrepository) | Fix the `GitRepository`; the trigger then adopts the `Kustomization`, and the apply that was waiting is asked for | -| Trigger `Configured=False`, `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the trigger, such as `flux-system` | Set `allowCrossNamespace.appliers: true` on the trigger's `ClusterProvider` (Helm: `clusterProvider.default.allowCrossNamespace.appliers`), or move the trigger and `GitTarget` to the `Kustomization`'s namespace | -| Trigger `Configured=Unknown`, `NotBound` | A `GitTarget` names the trigger, but cannot be judged yet: its `GitProvider` or `ClusterProvider` does not exist | Create the missing object; the message names it | +| Trigger `Configured=False`, `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the trigger, such as `flux-system` | Set `allowCrossNamespace.appliers: true` on the trigger's `KubeProvider` (Helm: `kubeProvider.allowCrossNamespace.appliers`), or move the trigger and `GitTarget` to the `Kustomization`'s namespace | +| Trigger `Configured=Unknown`, `NotBound` | A `GitTarget` names the trigger, but cannot be judged yet: its `GitProvider` or `KubeProvider` does not exist | Create the missing object; the message names it | | Trigger `Ready=True`, `NotReferenced`, and the `Kustomization` is not suspended | No `GitTarget` names the trigger, so it holds nothing | Name it in the `GitTarget`'s `spec.applyTriggerRef` | | Trigger `Configured=False`, `Forbidden` | The identity lacks one of the [permissions](#permissions) | Grant it, or set `applierAccess.flux: true` | | A Warning `ApplyRequestFailed` Event | An apply could not be asked for; the message says why | GitOps Reverser keeps asking after its backoff, at most every five minutes | @@ -480,7 +491,7 @@ flux get ks checkout -n flux-system | A Warning `ApplyFailed` Event | Flux ran the apply and reported it failed | Read the `Kustomization`'s status; GitOps Reverser retries on its backoff | | A Warning `ApplyUnresolved` Event, trigger `OperationUnresolved` | The apply has not completed in 5 minutes | It is suspended and still followed; look at the `Kustomization`'s status and health checks | | A Warning `OperationUnfollowed` Event | An apply was left in flight with nothing following it | It was suspended and completed as failed. A recurring one is a bug worth reporting with the operator's logs | -| A Warning `ReleaseFailed` Event | A trigger being deleted, or no longer referenced, could not hand its `Kustomization` back: a write it may not make, a `ClusterProvider` gone with no way left to its cluster, or 10 minutes passed | The message says where it was left; `flux resume` it if it should apply on its own | +| A Warning `ReleaseFailed` Event | A trigger being deleted, or no longer referenced, could not hand its `Kustomization` back: a write it may not make, a `KubeProvider` gone with no way left to its cluster, or 10 minutes passed | The message says where it was left; `flux resume` it if it should apply on its own | | A dependent `Kustomization` waits | Its dependency's last apply failed | Fix the dependency's failure; GitOps Reverser's retry unblocks it | | A Git-side push takes minutes to reach the cluster | No receiver, so the refresh finds it | Configure [the push webhook](bi-directional.md#telling-gitops-reverser-that-somebody-else-pushed) | | A refused edit is not reverted | The target is suspended, `onRefusal` is not `Reapply`, or the refusal is not eligible | See [reverting a refused edit](configuration.md#reverting-a-refused-edit-speconrefusal) | @@ -513,9 +524,8 @@ applied the tip is asked nothing. | The [complete example](#a-complete-example) decodes strictly, reports `Configured=True`, binds the `GitTarget`, and an apply fetches and resumes the `Kustomization`; the manifest omits `spec.suspend` | [`flux_guide_test.go`](../internal/applier/flux_guide_test.go) | | Every fact of kustomize-controller v1.9.6 the lifecycle relies on, in a fake that applies them | [`fluxsim`](../internal/applier/fluxsim/fluxsim_test.go) | | An operation is not finished when the revision is attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../internal/applier/flux_lifecycle_test.go), [`flux_consumers_test.go`](../internal/applier/flux_consumers_test.go) | -| Adoption, an external resume suspended again with an Event each time, `SuspendOwnedElsewhere`, the hand-back on deletion (a `ClusterProvider` gone included), and the source's other consumers | [`fluxtrigger_hold_test.go`](../internal/controller/fluxtrigger_hold_test.go) | +| Adoption, an external resume suspended again with an Event each time, `SuspendOwnedElsewhere`, the hand-back on deletion (a `KubeProvider` gone included), and the source's other consumers | [`fluxtrigger_hold_test.go`](../internal/controller/fluxtrigger_hold_test.go) | | A trigger nothing names reads nothing and is `Ready`; the first eligible `GitTarget` adopts and the last releases, suspending an apply in flight first; a reference returning mid-release holds again; the binding over several `GitTarget`s; an older trigger not in line keeps none out, and a retiring holder stays first until its release is done; `ApplierNamespaceNotAllowed` | [`applytrigger_rule_test.go`](../internal/controller/applytrigger_rule_test.go) | -| `allowAnySourceNamespace: true` is refused on create and on any spec change, a stored one included; `false` and its removal are accepted | [`removed_allow_any_source_namespace_test.go`](../internal/controller/removed_allow_any_source_namespace_test.go) | | Every operation checks the target again (incarnation, branch, admission, consent) and the source binding before it resumes, records itself first, and never asks for something weaker; a stop mid-apply suspends the `Kustomization` | [`dispatcher_test.go`](../internal/applier/dispatcher_test.go) | | A failed attempt is decided again; each `Kustomization` keeps its own backoff; a shared one is asked once; appliers sharing a source apply one at a time; nothing is sent while no flush can be made; an undo of an edit in the apply window is mirrored, and the applier's own write is not | [`apply_attempt_test.go`](../internal/git/apply_attempt_test.go), [`apply_groups_test.go`](../internal/git/apply_groups_test.go), [`apply_trigger_test.go`](../internal/git/apply_trigger_test.go), [`apply_window_test.go`](../internal/git/apply_window_test.go) | | A newly named `Kustomization` converges once; the startup converge asks nothing of one at the tip; an API create is adopted even at the tip; nothing is asked before the initial snapshots are published, or for an empty tip | [`apply_catchup_test.go`](../internal/git/apply_catchup_test.go), [`apply_trigger_test.go`](../internal/git/apply_trigger_test.go) | diff --git a/docs/bi-directional.md b/docs/bi-directional.md index 8a119facc..2b628a974 100644 --- a/docs/bi-directional.md +++ b/docs/bi-directional.md @@ -31,7 +31,7 @@ flowchart TD ``` Everything bi-directional is opt-in and off by default: the apply triggers, the push receiver, -`onRefusal: Reapply`, the chart's `applierAccess` grants, and the `ClusterProvider`'s +`onRefusal: Reapply`, the chart's `applierAccess` grants, and the `KubeProvider`'s `allowCrossNamespace.appliers`, which a trigger needs to hold an applier outside its own namespace, such as one in `flux-system` or `argocd`. A `GitTarget` without `spec.applyTriggerRef` never reads, patches or syncs anything of Flux's or Argo CD's, and neither does a trigger no `GitTarget` names, so @@ -54,7 +54,7 @@ things set that up: 1. **An apply trigger holds the applier.** A `FluxTrigger` holds a Flux `Kustomization`, an `ArgoCDTrigger` an Argo CD `Application`, and the `GitTarget` names the trigger in `spec.applyTriggerRef`. A trigger acts only while an eligible `GitTarget` names it: one in its - namespace, admitted by its `ClusterProvider`s, that writes the repository and branch the applier + namespace, admitted by its `KubeProvider`s, that writes the repository and branch the applier fetches. 2. **The applier's own triggers are off.** The `FluxTrigger` keeps its `Kustomization` suspended; the `Application` runs with automated sync off. @@ -270,7 +270,7 @@ nothing else. - **Just before it asks, GitOps Reverser checks the decision again**: the commit is still the branch tip, each `GitTarget` still names this trigger on this branch, is still admitted by its - `ClusterProvider`s, and, for a `Refusal`, still consents; and the trigger still holds the applier. + `KubeProvider`s, and, for a `Refusal`, still consents; and the trigger still holds the applier. When any of that changed, it asks nothing and decides again, after a new flush. It never asks for something weaker than what it was told. - **The apply's own writes are not mirrored.** Inside the apply window, a write whose newest field @@ -433,10 +433,10 @@ returns to `Ready=True` only after a resync covering the refused scope. The elig wait: a push reaches the cluster only through a manual sync or `flux resume`. A graceful stop suspends a `Kustomization` it had resumed; after a crash, the apply that was running finishes, and the next process suspends it. Monitor the operator the way you monitor the applier. -- **A trigger may hold any applier its `ClusterProvider` reaches.** With +- **A trigger may hold any applier its `KubeProvider` reaches.** With `allowCrossNamespace.appliers`, every namespace the provider admits may hold a `Kustomization` or `Application` in another namespace whose source is the repository its `GitProvider` names. That - is a delegation by the platform admin: nothing proves the namespace owns the applier, or may + is a delegation by the provider's owner: nothing proves the namespace owns the applier, or may write to the repository, since the binding compares URLs. - **Pending writes live in worker memory.** They do not survive a restart; recovery is a resync of the cluster as it is then. diff --git a/docs/components.md b/docs/components.md index 2e6e81397..d67a49e06 100644 --- a/docs/components.md +++ b/docs/components.md @@ -12,7 +12,7 @@ command-line binaries that are never deployed with it. What makes the operator f programs is that the one process spans two clusters' worth of concerns: - the **config plane**, the cluster the operator runs in, where its own custom resources live; -- one or more **source clusters**, each named by a `ClusterProvider`, whose objects are mirrored +- one or more **source clusters**, each named by a `KubeProvider`, whose objects are mirrored into Git. The shortest description of the split of responsibility: rules and discovery decide *what* to watch, @@ -23,7 +23,7 @@ the target watches supply *state*, the branch worker owns *Git*, and audit only ```mermaid flowchart TB subgraph CFG["Config plane (operator's own cluster)"] - CRD["api/v1alpha3
GitTarget · WatchRule · ClusterWatchRule
GitProvider · ClusterProvider · CommitRequest
FluxTrigger · ArgoCDTrigger"] + CRD["api/v1alpha3
GitTarget · WatchRule · ClusterWatchRule
GitProvider · GitBranch · KubeProvider · CommitRequest
FluxTrigger · ArgoCDTrigger"] CTRL["internal/controller
reconcilers"] ADM["internal/webhook
admission handlers"] AUTHZ["internal/authz
namespace admission"] @@ -76,10 +76,10 @@ The operator's own API surface and the reconcilers behind it. | Component | Path | Role | |---|---|---| -| CRD types | [`api/v1alpha3/`](../api/v1alpha3/) | the eight kinds users apply | +| CRD types | [`api/v1alpha3/`](../api/v1alpha3/) | the nine kinds users apply | | Reconcilers | [`internal/controller/`](../internal/controller/) | one per kind, plus shared condition and status helpers | | Admission handlers | [`internal/webhook/`](../internal/webhook/) | the always-allow observer and `validate-operator-types`, which captures a `CommitRequest` submitter | -| Namespace admission | [`internal/authz/`](../internal/authz/) | the `ClusterProvider.spec.accessFrom` decision and the `spec.allowCrossNamespace` gates, in their own package because several call sites need the same answer | +| Namespace admission | [`internal/authz/`](../internal/authz/) | the `KubeProvider` and `GitProvider` namespace-admission decisions (own namespace, then `spec.accessFrom`) and the `spec.allowCrossNamespace` gates, in their own package because several call sites need the same answer | | Rule store | [`internal/rulestore/`](../internal/rulestore/) | compiled `WatchRule` and `ClusterWatchRule` cache the data plane reads | ### Type and scope resolution @@ -259,7 +259,7 @@ Three gates can stop a type before it reaches a stream: come from trusted discovery. A degraded group version keeps its last-known record and is `Retained`, never treated as withdrawn. 2. **The claim.** Some rule attached to the GitTarget must select it, and the GitTarget's namespace - must be admitted by the `ClusterProvider`. + must be admitted by the `KubeProvider`. 3. **The acceptance gate.** The Git folder must be one the operator can own. A refusal is recorded as `GitPathAccepted=False` and nothing is committed. Writes are not the only thing that runs it: the periodic refresh reads the folder too, so content pushed by somebody else is reported diff --git a/docs/config-flag-conventions.md b/docs/config-flag-conventions.md index 92363f83a..356ac100f 100644 --- a/docs/config-flag-conventions.md +++ b/docs/config-flag-conventions.md @@ -90,7 +90,7 @@ All flags for one component share a prefix and don't drift. If the toggle is **The prefix is a flat-namespace device, so nested configuration drops it.** Chart values and CRD fields are already scoped by their block, and repeating the prefix inside one stutters: `attribution.ttl` in `values.yaml` maps to `--author-attribution-ttl`, and -`ClusterProvider.spec.attribution` needs no `author` because a source-cluster object has no other +`KubeProvider.spec.attribution` needs no `author` because a source-cluster object has no other kind of attribution. Flags have no such scope, which is why they keep the qualifier: this product has two attribution concepts, author attribution and [render attribution](design/support-boundary/render-attribution.md), and only the flat namespace has diff --git a/docs/configuration.md b/docs/configuration.md index f677375af..c27da7cb7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,8 +6,9 @@ in the [quickstart](quickstart.md). 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 +- `GitBranch` holds one branch of that repository, for every target that writes it +- `KubeProvider` defines the Kubernetes source cluster a target mirrors from +- `GitTarget` defines which source cluster to mirror, and which folder on a `GitBranch` 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 @@ -30,22 +31,25 @@ attribution later only when you need named Kubernetes users or service accounts The usual flow is: 1. Create a `GitProvider` for repository access and commit behavior. -2. Create a `GitTarget` that points at the Git provider, source cluster, branch, and repository path. -3. Create one or more `WatchRule` or `ClusterWatchRule` objects that point at that target. -4. Create a `CommitRequest` only when you want a save: your writes in one commit, on the request's +2. Create a `GitBranch` naming that provider and the branch to write. +3. Create a `GitTarget` that points at the `GitBranch`, the `KubeProvider` to mirror from, and a + folder in the repository. +4. Create one or more `WatchRule` or `ClusterWatchRule` objects that point at that target. +5. Create a `CommitRequest` only when you want a save: your writes in one commit, on the request's timers rather than the target's, and optionally with your message. -5. Only when Flux or Argo CD also applies that same folder back to the cluster: hold its +6. Only when Flux or Argo CD also applies that same folder back to the cluster: hold its `Kustomization` or `Application` with an apply trigger, name the trigger on the `GitTarget`, and give the `GitProvider` a push receiver, so GitOps Reverser decides when Git is applied. See [who applies this folder](#who-applies-this-folder-specapplytriggerref) and [bi-directional GitOps](bi-directional.md). Flux and Argo CD applying other folders need nothing. -That means one repository connection can back multiple targets, and one target can be fed by -multiple watch rules. +That means one repository connection can back multiple branches, one branch multiple targets, and +one target can be fed by multiple watch rules. -> **`ClusterProvider` is not one of those steps.** Installing created the `default` one, and that is -> the source cluster a `GitTarget` mirrors from when it omits `spec.clusterProviderRef`. Write your -> own only to mirror a second, remote cluster. +> **A `KubeProvider` usually exists already.** The chart creates `local` in its release namespace, +> for the operator's own cluster, and a `GitTarget` names it with +> `spec.kubeProviderRef: {name: local, namespace: gitops-reverser}`. Write your own to mirror a +> remote cluster, or to keep a connection and its credential in a team's namespace. @@ -81,17 +85,20 @@ Every field is immutable: to change one, delete the `GitBranch` and create a new | `branch` | **required** | Branch to write. Must be in the provider's `allowedBranches`. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | | `parentBranch` | the remote's default branch | Branch a `branch` the remote does not have yet is created from. Only read, so it need not be allowed. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch) | -### `ClusterProvider` (cluster-scoped): the source cluster +### `KubeProvider` (namespaced): the source cluster + +Set exactly one of `secretRef` and `inCluster`. Both are immutable: delete and recreate to repoint. | Field | Default | What it does | |---|---|---| -| `kubeConfig` | the operator's own cluster | Points at a remote source cluster instead of the one the operator runs in | -| `accessFrom` | **no namespace** | Deny-by-default list of control-cluster namespaces that may reference this provider. Omitted, none may; an empty `selector: {}` admits every namespace | +| `secretRef` | none; set `inCluster` instead | Secret in this provider's namespace holding a kubeconfig for the source cluster. `key` defaults to `value`, then `value.yaml`. See [`KubeProvider`](#kubeprovider) | +| `inCluster` | none; set `secretRef` instead | `{}` uses the operator's own identity. Honored only for a provider listed in `--in-cluster-kube-providers`. See [the operator's own identity](#the-operators-own-identity-specincluster) | +| `accessFrom` | **no other namespace** | Other control-cluster namespaces whose objects may use this provider, by name or label selector. Its own namespace is always admitted; an empty `selector: {}` admits every namespace | | `allowCrossNamespace.sources` | `false` | Whether rules may watch namespaces other than their own, `*` included. See [cross-namespace grants](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace) | | `allowCrossNamespace.appliers` | `false` | Whether triggers may name a `Kustomization` or `Application` in another namespace than their own | -| `attribution.auditRoute` | the provider's own name | Audit route this provider joins. See [audit route](#audit-route-and-auditfactsreceived) | -| `client.qps` | `20` | Client rate limit for this source cluster. Ignored when `kubeConfig` is omitted | -| `client.burst` | `30` | Client burst for this source cluster. Ignored when `kubeConfig` is omitted | +| `client.qps` | `20` | Client rate limit for this source cluster. Refused beside `inCluster` | +| `client.burst` | `30` | Client burst for this source cluster. Refused beside `inCluster` | +| `attribution.auditRoute` | `.` | Audit route this provider joins. A route is held by one namespace. See [audit route](#audit-route-and-auditfactsreceived) | ### `GitTarget` (namespaced): which branch and folder @@ -101,7 +108,7 @@ The destination fields are immutable: to move a target, delete it and create a n |---|---|---| | `gitBranchRef` | **required** | The `GitBranch` this target writes: its own namespace, or `namespace` names one whose `GitProvider`'s `accessFrom` admits it | | `path` | **required** | Folder within the repository. `.` targets the root; empty is rejected | -| `clusterProviderRef` | `{"name":"default"}` | Source cluster to mirror from. The default names a `ClusterProvider` called `default` | +| `kubeProviderRef` | **required** | The `KubeProvider` to mirror from: its own namespace, or `namespace` names one whose `accessFrom` admits it | | `commit.window.idleTimeout` | `5s` | Close a commit window after this much silence. `0s` commits every write on its own. See [the commit window](#the-commit-window-speccommitwindow) | | `commit.window.maxDuration` | `1m` | Close a commit window this long after it opened, however much keeps arriving | | `commit.message` | the built-in templates | How commits are phrased. See [commit messages](commit-messages.md) | @@ -144,7 +151,7 @@ The references are immutable: to hold another `Kustomization`, delete the trigge | Field | Default | What it does | |---|---|---| -| `clusterProviderRef` | `{"name":"default"}` | The `ClusterProvider` whose cluster holds the `Kustomization`. Its `accessFrom` must admit the trigger's namespace | +| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Kustomization`: the trigger's own namespace, or `namespace` names one whose `accessFrom` admits it | | `kustomizationRef` | **required** | The `Kustomization` that applies the folders of the `GitTarget`s naming this trigger. See [who applies this folder](#who-applies-this-folder-specapplytriggerref) | ### `ArgoCDTrigger` (namespaced): the Argo CD `Application` GitOps Reverser asks to sync @@ -153,7 +160,7 @@ The references are immutable: to hold another `Application`, delete the trigger | Field | Default | What it does | |---|---|---| -| `clusterProviderRef` | `{"name":"default"}` | The `ClusterProvider` whose cluster holds the `Application`. Its `accessFrom` must admit the trigger's namespace | +| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Application`: the trigger's own namespace, or `namespace` names one whose `accessFrom` admits it | | `applicationRef` | **required** | The `Application`, with automated sync off, that syncs the folders of the `GitTarget`s naming this trigger. See [who applies this folder](#who-applies-this-folder-specapplytriggerref) | ### `CommitRequest` (namespaced): save now @@ -170,21 +177,24 @@ The references are immutable: to hold another `Application`, delete the trigger -## Why the two provider types have different scopes +## Why both provider types are namespaced | 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. | +| `KubeProvider` | Namespace | One connection to a Kubernetes source cluster and the credential it is read with. The credential is a Secret, so the provider sits beside it in its owner's namespace, and its owner decides who else may use it. | + +Each admits the objects in its own namespace, and its `spec.accessFrom` admits other namespaces. A +team can keep its own connection to a remote cluster in its own namespace, and a platform team can +publish one connection that several namespaces use. -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. +Neither has a default. The operator cannot infer a safe repository, branch, or write credential, nor +which cluster a folder means, so every `GitTarget` names its `GitBranch` and its `KubeProvider`. +Nothing falls back to the provider the chart renders. -`ClusterProvider.spec.accessFrom` decides which control-cluster namespaces may contain `GitTarget`s -that reference the provider. It does not select namespaces in the source cluster and grants nothing -there. +`KubeProvider.spec.accessFrom` decides which control-cluster namespaces may contain `GitTarget`s +and triggers that reference the provider. It does not select namespaces in the source cluster and +grants nothing there. ## `GitProvider` @@ -454,29 +464,28 @@ Without a receiver, GitOps Reverser still learns of a foreign push, from its nex from the [periodic refresh](#keeping-an-idle-target-fresh---git-refresh-interval). The receiver makes it seconds instead of minutes. -## `ClusterProvider` +## `KubeProvider` -`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. +`KubeProvider` names the Kubernetes cluster a `GitTarget` mirrors **from**, and the cluster an apply +trigger reaches its applier in. It is the read-side peer of `GitProvider`: a target has one source +cluster and one Git destination. It is namespaced, and it sets exactly one credential: -`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. +- `spec.secretRef`: a Secret in the provider's own namespace holding a kubeconfig for the source + cluster. `key` is optional; empty reads `value`, then `value.yaml` (Flux's order). +- `spec.inCluster: {}`: the operator's own identity in the cluster it runs in, honored only where + the installation allows it. See [the operator's own identity](#the-operators-own-identity-specincluster). -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. +For a remote source cluster, create the provider beside its kubeconfig Secret: ```yaml apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider +kind: KubeProvider metadata: name: prod-eu-1 + namespace: platform-connections spec: - kubeConfig: - secretRef: - name: default-source-kubeconfig + secretRef: + name: prod-eu-1-kubeconfig accessFrom: names: [team-a] selector: @@ -484,14 +493,63 @@ spec: gitops.configbutler.ai/source-access: "true" ``` -`accessFrom` is evaluated against namespaces in the **control cluster**, where -`GitTarget`s 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. +The Secret is read in the provider's namespace, so a team's connection keeps its credential with the +team, and the credential never has to live on the source cluster. The operator checks the kubeconfig +before it uses it, without dialing the cluster, and reports the verdict as `Validated`: + +| Reason | What the kubeconfig does | +|---|---| +| `KubeConfigSecretNotFound`, `KubeConfigKeyNotFound` | nothing yet: the Secret, or the key in it, does not exist | +| `KubeConfigInvalid` | does not parse as a kubeconfig | +| `KubeConfigExecNotAllowed` | runs an exec auth plugin, a binary in the operator's Pod. Allowed with `--insecure-kubeconfig-exec` | +| `KubeConfigInsecureTLSNotAllowed` | sets `insecure-skip-tls-verify`. Allowed with `--insecure-kubeconfig-tls` | +| `KubeConfigFileReferenceNotAllowed` | names a token, certificate or key by file path, which the operator would read from its own Pod. Always refused: embed the `*-data` form instead | + +A `KubeProvider` admits the `GitTarget`s and triggers in its own namespace. `accessFrom` admits +other namespaces, and is evaluated against namespaces in the **control cluster**, where those +objects live. In this example, a `GitTarget` in `platform-connections`, 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 other namespace. A selector is only as strong as the right to +set the labels it matches: anyone who can label a namespace can admit it, so select on labels only +the platform can set, or use `names`. The check runs when the referrer is +reconciled, not at admission: a `GitTarget` or trigger the provider does not admit is accepted by +the API server and reports `NamespaceNotAuthorized`, and it recovers on its own once the provider +admits it. 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. +### The operator's own identity (`spec.inCluster`) + +```yaml +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: + name: local + namespace: gitops-reverser +spec: + inCluster: {} +``` + +`inCluster` is honored only for a `KubeProvider` listed by exact `namespace/name` in +`--in-cluster-kube-providers`, a comma-separated list with no wildcards, empty by default. With the +chart, add entries to `controllerManager.inClusterKubeProviders`; the provider the chart renders is +listed on its own when it uses `inCluster`. A provider that sets `inCluster` and is not listed +reports `Validated=False` and `Stalled=True` with reason `InClusterNotAuthorized`, and nothing uses +it: a `GitTarget` naming it reports `KubeProviderReady=False`, and the watch side refuses to connect +through it as well. + +The list exists because the operator's own identity reads far more than any one team should, and a +namespaced kind can be created by anyone with write access to a namespace. Without the list, writing +`inCluster: {}` would be enough to borrow that identity. The list puts the decision with whoever +installs the operator. Entries are exact names, because whoever can create a `KubeProvider` with a +listed name wields the identity through it. No path leads from an omitted, malformed, or refused +`secretRef` to the operator's identity: that identity is a credential of its own that a provider has +to ask for by name. + +`spec.client` is refused beside `inCluster`: the operator's own client is shared by everything that +uses it, so it is not throttled per provider. + ### What a namespace may reach outside itself (`spec.allowCrossNamespace`) ```yaml @@ -513,48 +571,57 @@ Both default to `false`, and each is a power of its own: `Configured=False` (`ApplierNamespaceNotAllowed`). The trigger's own namespace is always allowed; in a remote cluster that is the namespace of the same name. -**It is a delegation, not owner consent.** You trust every namespace `accessFrom` admits with -everything in reach of the provider's identity. Neither flag proves that a namespace owns the -objects it reaches, or the repository its `GitTarget` writes. Setting either on an **in-cluster** -provider is a much sharper decision than on a remote one: there the config plane *is* the watched -cluster, so it deliberately bypasses live namespace RBAC and lets the owner of an admitted object -act on another namespace through the operator's own credential. That is legitimate to grant on -purpose, which is why each is explicit and defaults to false. - -`allowAnySourceNamespace`, which `sources` replaces, is no longer read. Setting it to `true` is -refused; see [UPGRADING](UPGRADING.md). - -`spec.kubeConfig` and `GitTarget.spec.clusterProviderRef` are immutable: changing either would silently -make an existing materialization mean a different source cluster. Rotate credential *contents* in the -referenced Secret instead. `client.qps` and `client.burst` optionally tune a remote provider's client; the -`ClusterProvider` conditions validate its configuration, while the consuming `GitTarget` reports the -live source reachability and stream state. +**It is a delegation, not owner consent.** You trust every namespace the provider admits, its own +and those `accessFrom` admits, with everything in reach of the provider's identity. Neither flag +proves that a namespace owns the objects it reaches, or the repository its `GitTarget` writes. +Setting either on an `inCluster` provider is a much sharper decision than on a remote one: there the +config plane *is* the watched cluster, so it deliberately bypasses live namespace RBAC and lets the +owner of an admitted object act on another namespace through the operator's own credential. That is +legitimate to grant on purpose, which is why each is explicit and defaults to false. + +`spec.secretRef` and `spec.inCluster` are immutable, switching from one to the other included, and +so is `GitTarget.spec.kubeProviderRef`: changing either would silently make an existing +materialization mean a different source cluster. To point a provider at another cluster, delete it +and create a new one. Rotate credential *contents* in the referenced Secret instead. `client.qps` +and `client.burst` optionally tune a `secretRef` provider's client; the `KubeProvider` 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 `` -segment the apiserver's audit webhook URL ends in, `/audit-webhook/`. 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. +`spec.attribution.auditRoute` is the route this cluster's attribution facts arrive on: the +`` segment the apiserver's audit webhook URL ends in, `/audit-webhook/`. It defaults +to `.`, so the chart's provider listens on `/audit-webhook/gitops-reverser.local`. +The route is what partitions the facts: 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: +`KubeProvider` naming the same cluster, in the same namespace, must be pointed at that route: ```yaml spec: attribution: - auditRoute: default + auditRoute: platform-connections.prod-eu-1 ``` +**A route is held by one namespace.** Providers in the holding namespace may share it, as above; +a provider in another namespace that carries it reports `Stalled=True` with reason +`AuditRouteConflict` and is not used, and the GitTargets and triggers naming it are refused with the +same reason. Its `Validated` stays what its credential earned. The namespace a route starts with, +the segment before its first dot, holds it when a provider there carries it, so a default route is +always its own provider's. Otherwise the namespace of the oldest provider carrying it holds it +(by `creationTimestamp`, then UID). This is a status condition the reconcile sets, not an admission +refusal, and it clears on its own once the holder lets the route go. Without it, a provider in one +namespace could read the attribution facts of a cluster another namespace connected. + The `AuditFactsReceived` condition reports whether that route has ever delivered, with a default -`FACTSRECEIVED` printer column: +`FACTSRECEIVED` printer column, and `-o wide` adds the resolved `ROUTE`: ```console -$ kubectl get clusterprovider -NAME READY REASON FACTSRECEIVED AGE -default True Succeeded True 31m -srcns-delegating True Succeeded Unknown 4m +$ kubectl get kubeprovider -A +NAMESPACE NAME READY REASON FACTSRECEIVED AGE +gitops-reverser local True Succeeded True 31m +platform-connections prod-eu-1 True Succeeded Unknown 4m ``` - `True` / `Received`: a fact has arrived, and the message carries when the first one did. @@ -566,11 +633,11 @@ srcns-delegating True Succeeded Unknown 4m |---|---|---| | `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 | +| `RouteUnused` | audit is arriving and no fact has ever been published for this route | this provider's route (`status.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. +`KubeProvider` 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 @@ -586,45 +653,45 @@ The condition is **not part of `Ready`**: a provider reading a route nobody post 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 +### Creating and managing a `KubeProvider` -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. +The operator **never creates a `KubeProvider`**, and never re-creates one you delete. A `GitTarget` +whose provider does not exist reports `Validated=False` with reason `KubeProviderNotFound`, and +recovers on its own once the provider appears, so manifests can be applied in any order. 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](../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. +- **Commit it yourself.** The objects above are ordinary YAML. Put them in the repository that + manages this install. This is the recommended path once you are past a first trial. + [`config/kubeprovider-local.yaml`](../config/kubeprovider-local.yaml) is the plain-manifest form of + the provider the chart renders. +- **Let the chart render it.** With `kubeProvider.create` (on by default), the chart creates and owns + a `KubeProvider` named `kubeProvider.name` (`local`) in the release namespace, using `inCluster` + unless `kubeProvider.secretRef.name` is set, with its `accessFrom` and `allowCrossNamespace`. See + [charts/gitops-reverser/README.md](../charts/gitops-reverser/README.md). Turn + `kubeProvider.create` 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. +operator brings the object back. The chart's optional `quickstart` starter resources mirror through +that provider, so they require `kubeProvider.create`. -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: +Every `GitTarget` names its provider. Omit `namespace` for one in the `GitTarget`'s own namespace: ```yaml spec: - clusterProviderRef: + kubeProviderRef: name: prod-eu-1 + namespace: platform-connections ``` -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. +The provider's namespace and name are the source-cluster identity used for watches and in the +`source_cluster` metric label (`platform-connections/prod-eu-1`). A provider deleted and recreated +under the same name is a new connection. Changing a target's source cluster changes what its folder +means, so `kubeProviderRef` is immutable. ## `GitBranch` @@ -733,8 +800,7 @@ The important fields are: - `spec.gitBranchRef`: which `GitBranch` this target writes, and so which repository branch and credential -- `spec.clusterProviderRef`: which `ClusterProvider` supplies resources; omit it to reference the - user-created `default` provider +- `spec.kubeProviderRef`: which `KubeProvider` supplies resources; required - `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 @@ -777,8 +843,10 @@ metadata: spec: gitBranchRef: name: example-branch - # Omit clusterProviderRef to reference the user-created ClusterProvider named "default". - # clusterProviderRef: {name: prod-eu-1} selects a different source provider. + # The chart's provider. {name: prod-eu-1} names one in this GitTarget's own namespace. + kubeProviderRef: + name: local + namespace: gitops-reverser path: live-cluster ``` @@ -802,17 +870,22 @@ One policy governs both hops. A `GitBranch` that does not exist is `Validated=Fa `Ready=False` with reason `GitBranchNotReady` until it does. Either clears on its own once the `GitBranch` appears or proves itself, so manifests can be applied in any order. -`spec.gitProviderRef`, `spec.branch` and `spec.parentBranch` are no longer `GitTarget` fields: -setting one is refused at admission with a message naming `spec.gitBranchRef`. +`spec.gitProviderRef`, `spec.branch` and `spec.parentBranch` belong to the `GitBranch`: setting one +on a `GitTarget` is refused at admission with a message naming `spec.gitBranchRef`. Two `GitTarget`s on one `GitBranch` must not write the same or nested paths: the later-created one -reports `Validated=False` with reason `TargetConflict`. - -`spec.clusterProviderRef` references a cluster-scoped `ClusterProvider`. It defaults to -`{name: default}` when omitted. That is intentionally different from `gitBranchRef`: a source cluster -is a shared physical identity, while a Git destination and its credential normally belong to the -target's namespace. The default name can represent either an in-cluster or remote source according -to the `ClusterProvider` the user created. +reports `Validated=False` with reason `TargetConflict`. Only a `GitTarget` that its providers admit +holds a path this way. One that its `GitBranch`'s `GitProvider` or its `KubeProvider` does not admit +writes nothing and holds no path, so creating it first cannot block a folder on someone else's +branch. + +`spec.kubeProviderRef` references a `KubeProvider` by `name`, in the `GitTarget`'s own namespace +unless `namespace` names another, and is required and immutable. A provider in another namespace +must admit this `GitTarget`'s namespace in its `spec.accessFrom`, or the `GitTarget` is refused with +`Validated=False` (`NamespaceNotAuthorized`). A provider that does not exist is `Validated=False` +(`KubeProviderNotFound`), one whose audit route another namespace holds is refused with +`AuditRouteConflict`, and one that is not `Ready` keeps the target at `KubeProviderReady=False` +(`KubeProviderNotReady`). See [`KubeProvider`](#kubeprovider). The most useful status fields are: @@ -851,10 +924,10 @@ summary reason, while the domain conditions name the axis to fix: | Condition | What to check | |---|---| -| `Validated=False` | The referenced `GitBranch`, path conflicts, `ClusterProvider`, and namespace authorization. | +| `Validated=False` | The referenced `GitBranch`, path conflicts, `KubeProvider`, and namespace authorization. | | `EncryptionConfigured=False` | The age/SOPS Secret and whether key generation is allowed. | | `GitBranchReady=False` | The `GitBranch`: whether it holds its branch, and its `GitProvider`'s URL, credential Secret, host keys, and push permissions. | -| `ClusterProviderReady=False` or `SourceClusterReachable=False` | Source-cluster credentials and access. | +| `KubeProviderReady=False` or `SourceClusterReachable=False` | Source-cluster credentials and access. | | `GitPathAccepted=False` | Repository files under `spec.path`; the message includes the issue kind, path, and actor when known. | | `RenderMatchesLive=False` | Fields that no longer match the rendered source, often transformer-owned values. | | `ApplyTriggerReady=False` | The trigger named in `spec.applyTriggerRef`, and its applier: its source, its automated sync (an `Application`), and the permission to write it. Commits still flow; apply requests may not. | @@ -964,6 +1037,7 @@ spec: kind: GitTarget spec: gitBranchRef: {name: gitops-preview} + kubeProviderRef: {name: local, namespace: gitops-reverser} path: apps/checkout ``` @@ -1260,7 +1334,7 @@ lifecycle of an apply, every condition reason and the permissions, are the [apply trigger design](design/apply-trigger.md). **A trigger acts only while an eligible `GitTarget` references it**: one in its namespace that names -it, is admitted by its `ClusterProvider`s, and writes the repository and branch the applier fetches. +it, is admitted by its `KubeProvider`s, and writes the repository and branch the applier fetches. Until one does, the trigger reads and writes nothing in the applier's cluster, and reports `Configured=Unknown` and `Ready=True`, both `NotReferenced`, so the two may be created in either order. A `FluxTrigger` that held its `Kustomization` hands it back once no eligible `GitTarget` @@ -1273,8 +1347,9 @@ metadata: name: checkout namespace: team-a spec: - clusterProviderRef: - name: default # the cluster Flux runs in + kubeProviderRef: # the cluster Flux runs in + name: local + namespace: gitops-reverser kustomizationRef: namespace: flux-system # default: the trigger's namespace name: checkout @@ -1285,7 +1360,7 @@ metadata: name: checkout namespace: team-a spec: - # gitBranchRef, path ... + # gitBranchRef, kubeProviderRef, path ... applyTriggerRef: kind: FluxTrigger # or ArgoCDTrigger name: checkout @@ -1293,7 +1368,7 @@ spec: | Field | Default | What it does | | --- | --- | --- | -| `FluxTrigger.spec.clusterProviderRef`, `ArgoCDTrigger.spec.clusterProviderRef` | `{name: default}` | The `ClusterProvider` whose cluster holds the applier, defaulted as a `GitTarget`'s is. Immutable | +| `FluxTrigger.spec.kubeProviderRef`, `ArgoCDTrigger.spec.kubeProviderRef` | required | The `KubeProvider` whose cluster holds the applier, in the trigger's namespace unless `namespace` names another. Immutable | | `FluxTrigger.spec.kustomizationRef` | required | A Flux `Kustomization` whose `sourceRef` is a `GitRepository` that follows a branch. Immutable | | `ArgoCDTrigger.spec.applicationRef` | required | An Argo CD `Application` on a single source whose `targetRevision` names a branch, with automated sync off. Immutable | | `GitTarget.spec.applyTriggerRef` | none | The trigger, by `kind` and `name`, in the `GitTarget`'s namespace | @@ -1306,11 +1381,11 @@ trigger: a second trigger of the same kind in line for the same applier in the s `HeldByAnotherTrigger` and does nothing. A trigger no eligible `GitTarget` references is not in line, so it keeps no other out. -**Where the applier is looked up.** In the cluster the trigger's `spec.clusterProviderRef` names, -with the identity that [`ClusterProvider`](#clusterprovider) uses. Name a provider for a management -cluster when Flux or Argo CD runs there; a provider without `spec.kubeConfig` is the operator's own -cluster. That provider's `spec.accessFrom` must admit the trigger's namespace, as for mirroring, or -the trigger is `Configured=False` with `NamespaceNotAuthorized` (or `ClusterProviderNotFound`). An +**Where the applier is looked up.** In the cluster the trigger's `spec.kubeProviderRef` names, +with the identity that [`KubeProvider`](#kubeprovider) uses. Name a provider for a management +cluster when Flux or Argo CD runs there; an `inCluster` provider is the operator's own cluster. That +provider must admit the trigger's namespace, as for mirroring, or the trigger is `Configured=False` +with `NamespaceNotAuthorized` (or `KubeProviderNotFound`). An applier in another namespace than the trigger's, such as `flux-system` in the example above, also needs the provider's [`allowCrossNamespace.appliers`](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace), or the trigger is `Configured=False` with `ApplierNamespaceNotAllowed`. For the operator's own @@ -1324,7 +1399,7 @@ metadata: name: prod-eu-1-apps namespace: team-a spec: - clusterProviderRef: + kubeProviderRef: name: management # Flux runs here, and applies to prod-eu-1 kustomizationRef: namespace: flux-system @@ -1424,7 +1499,7 @@ yet, `False` once each is refused for different reasons), or as the refusal they `SourceMismatch` or `NamespaceNotAuthorized`, every `GitTarget` and its reason in the message. The other `False` reasons are `ApplierNotFound`, `UnsupportedSource`, `AutomatedSyncEnabled` (Argo CD), `SuspendOwnedElsewhere` (Flux), `Forbidden`, `NamespaceNotAuthorized` (the trigger's own namespace), -`ClusterProviderNotFound`, `ApplierNamespaceNotAllowed` and `HeldByAnotherTrigger`; each guide's +`KubeProviderNotFound`, `AuditRouteConflict`, `ApplierNamespaceNotAllowed` and `HeldByAnotherTrigger`; each guide's table says what they mean for its applier and how to fix them ([Flux](bi-directional-flux.md#the-fluxtrigger), [Argo CD](bi-directional-argocd.md#the-argocdtrigger)). The `GitTarget` reports `ApplyTriggerReady`, and its `Ready` aggregates it: `True` when the trigger @@ -2001,7 +2076,7 @@ overwriting. A second `WatchRule` bringing another source namespace to such a ta `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 +`WatchRule` pointing at it. It is unrelated to the `KubeProvider` 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 @@ -2163,8 +2238,9 @@ so one `WatchRule` can follow different resource types in different namespaces. 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 `allowCrossNamespace.sources: true`. +1. the `GitTarget`'s namespace is admitted by its `KubeProvider`: the provider's own, or one its + `accessFrom` admits; and +2. that `KubeProvider` sets `allowCrossNamespace.sources: true`. There is no third condition on the `GitTarget`. What a target may read from its source cluster is bounded by that cluster's RBAC for the provider's credential: a request these two conditions permit @@ -2208,7 +2284,7 @@ object's own namespace, so the rule above writes secrets under `repo-config/…` 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 +see another tenant's cluster-scoped objects, give each tenant its own `KubeProvider` and credential, so that credential's RBAC is the boundary. Each entry in `spec.rules` is a logical OR. A resource matching any rule is watched. A rule selects @@ -2345,7 +2421,7 @@ namespaced resources across namespaces, use a `WatchRule` with [`rules[].sourceNamespace`](#watching-a-different-source-namespace). 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`. +`GitTarget`, and that namespace must be admitted by the target's `KubeProvider`. Example: @@ -2567,7 +2643,7 @@ never deleted. Object state comes from Kubernetes **watch**, not from audit. Audit is an optional attribution lookup: kube-apiserver posts audit events to a named path, `/audit-webhook/`, where the route is -`ClusterProvider.spec.attribution.auditRoute` and defaults to the provider's own name. The +`KubeProvider.spec.attribution.auditRoute` and defaults to `.`. 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, @@ -2575,7 +2651,7 @@ TTL'd in-memory index, and attaches the commit author to each watch event by mat bounded grace window. Redis also stores per-watch resume cursors, so short reconnects can resume a normal watch from the last processed resourceVersion when the apiserver can still serve that history. -Named ingress is authenticated to the shared audit CA. It is **not** gated on a `ClusterProvider` +Named ingress is authenticated to the shared audit CA. It is **not** gated on a `KubeProvider` carrying that route, and deliberately so: a route is a partition name, not a claim about an object, and refusing unknown routes dropped audit batches in flight while a provider was being created or recreated, which the API server does not retry after a 404. A fact filed under a route nobody reads @@ -2587,7 +2663,7 @@ needs that boundary should keep sources isolated until provider-bound ingress au ### 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. +`/audit-webhook/gitops-reverser.local`. 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: @@ -2598,8 +2674,8 @@ attribution: 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. +partitions. A `KubeProvider` joins a route by setting `spec.attribution.auditRoute` to the same +value; it defaults to `.`. **The bare endpoint never guesses a route.** Rejection happens at two levels: @@ -2608,7 +2684,7 @@ value; it defaults to the provider's own name. | A request reaches `/audit-webhook` while `auditRouteAnnotationKey` is unset | The whole request is rejected with **400**. The bare endpoint is not enabled, so a producer posting to it is misconfigured. This is the one route-shaped rejection there is, and it is counted as `gitopsreverser_audit_eventlist_duration_seconds_count{outcome="bare_endpoint_disabled"}`. | | An event carries no annotation | That **event** is rejected: it produces no attribution fact and is never credited to a fallback route. The request still returns 200, so correctly-annotated events in the same batch are kept. | -An annotation naming a route no `ClusterProvider` has declared is **not** rejected. The route is a +An annotation naming a route no `KubeProvider` 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. diff --git a/docs/definitions.md b/docs/definitions.md index 353f5c4ec..c19e04070 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -40,13 +40,13 @@ Almost every naming mistake in this project's history came from one ambiguity: t without a side, or "namespace" without a direction. Fix the side first and the rest of the vocabulary follows. -**Control cluster.** The cluster the operator runs in, and where its own eight kinds live. When a +**Control cluster.** The cluster the operator runs in, and where its own nine kinds live. When a document says "a namespace" with no qualifier, it means a control-cluster namespace. -**Source cluster.** The cluster whose objects are mirrored into Git. A `ClusterProvider` names one. -It is the same cluster as the control cluster on a single-cluster install, which is why the two -words have to stay distinct even when they point at one machine: `ClusterProvider.spec.kubeConfig` -omitted means "the control cluster, acting as a source". +**Source cluster.** The cluster whose objects are mirrored into Git. A `KubeProvider` names one, by +its namespace and name. It is the same cluster as the control cluster on a single-cluster install, +which is why the two words have to stay distinct even when they point at one machine: +`KubeProvider.spec.inCluster` means "the control cluster, acting as a source". **Mirror.** The projection of source-cluster objects into a Git folder. It is a noun for the result and a verb for the act. The operator mirrors; it does not "sync", "export" or "back up", and those @@ -58,10 +58,11 @@ folder has exactly one `GitTarget`, and one `GitTarget` has exactly one folder. The two directions, which share the word "source" and must not share anything else: - **Access** is the control-cluster direction: which control-cluster namespaces may reference a - `ClusterProvider` from a `GitTarget`. `spec.accessFrom` (the spelling is Flux's, on purpose). + provider (`KubeProvider` or `GitProvider`) from a `GitTarget` or trigger. A provider's own + namespace always may; `spec.accessFrom` admits the others (the spelling is Flux's, on purpose). - **Source namespace** is the source-cluster direction: which source-cluster namespace a rule reads objects from. `WatchRule.spec.rules[].sourceNamespace`, gated by - `ClusterProvider.spec.allowCrossNamespace.sources`. + `KubeProvider.spec.allowCrossNamespace.sources`. A sentence about namespace policy names one of those two. Never "namespace access" unqualified. @@ -212,7 +213,7 @@ condition. The trigger is never called the applier: the applier is the object it "Argo trigger", which names a trigger of Argo Events. **Eligible.** A `GitTarget` is eligible for the trigger it names when it is in the trigger's -namespace, not being deleted, admitted by its `ClusterProvider`s, and writes the repository and +namespace, not being deleted, admitted by its `KubeProvider`s, and writes the repository and branch the applier fetches. A trigger acts only while an eligible `GitTarget` references it; one nothing references is `NotReferenced`. Eligibility is judged from those inputs alone, never from the `GitTarget`'s `Ready`. "Bound" names a trigger with at least one eligible `GitTarget`. @@ -295,7 +296,10 @@ Inside author attribution: transport (Redis Streams, or an in-process ring) and are held in a bounded, TTL'd index. **Audit route.** The partition facts are keyed by, so two clusters never cross-credit an author. It -is the `` segment in `/audit-webhook/`, and defaults to the `ClusterProvider`'s own name. +is the `` segment in `/audit-webhook/`, and defaults to the `KubeProvider`'s +`.`. A route is **held** by one namespace: the one its first dot-segment names when +a provider there carries it, otherwise the namespace of the oldest provider carrying it. A provider +elsewhere that carries it is not used (`AuditRouteConflict`), so no namespace reads another's facts. **Grace window.** The bounded wait for a matching fact before a watch event ships credited to the committer instead. @@ -417,7 +421,7 @@ same type. A status mirror of a spec enum with no enum marker is a defect. **Which conditions get a column.** Every condition a controller sets gets one, at `priority=1` unless an operator needs it in the default four, with one exclusion: a condition that merely **projects another object's readiness** gets no column at all. `Ready`'s reason already names the dependency that -is not ready (`GitProviderNotReady`, `ClusterProviderNotReady`), and the detail an operator then needs +is not ready (`GitProviderNotReady`, `KubeProviderNotReady`), and the detail an operator then needs lives on the object being projected, which is where they have to go anyway. A column for it repeats the default output one place to the right and then sends the reader somewhere else. @@ -437,10 +441,10 @@ length or about which end of the name is cut. source. `AuditFactsReceived` may print as `FactsReceived`, but not as `Facts`, which is a different word and does not lead back to the condition to describe. -`GitProviderReady` may not print as `GitReady`, and `ClusterProviderReady` may not print as -`ClusterReady`, which is the worked case for why the test is about subjects rather than length. +`GitProviderReady` may not print as `GitReady`, and `KubeProviderReady` may not print as +`KubeReady`, which is the worked case for why the test is about subjects rather than length. `GitReady` would sit beside `GitPathAccepted`, where `Git` means the repository, so it reads as a -claim about Git rather than about the `GitProvider` object. `ClusterReady` would sit beside +claim about Git rather than about the `GitProvider` object. `KubeReady` would sit beside `SourceReachable`, which is about the cluster itself, so it invites the reader to wonder how a cluster being ready differs from it being reachable. `Provider` is the token that says "the config object, not the thing it names", and it is the one token neither name can lose. Both are moot now that a diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index c6be77b8d..189eec44e 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -7,8 +7,8 @@ resource types, so saving never commits the save button. **Run the [quickstart](../quickstart.md) first.** It leaves behind everything this example builds on: the `gitops-reverser-quickstart-demo` namespace, the `git-creds` Secret, a `GitProvider` named -`example-provider` whose `allowedBranches` is `"*"`, and the `default` `ClusterProvider`. If you -configured those yourself instead, substitute your own names below. +`example-provider` whose `allowedBranches` is `"*"`, and the chart's `local` `KubeProvider` in +`gitops-reverser`. If you configured those yourself instead, substitute your own names below. The example writes to its own branch, `commit-window-demo`, which no reconciler deploys. The quickstart's own starter `GitTarget` keeps watching ConfigMaps in this namespace and writing them to @@ -38,6 +38,9 @@ metadata: spec: gitBranchRef: name: example-provider-commit-window-demo + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/demo commit: window: diff --git a/docs/design/apply-trigger.md b/docs/design/apply-trigger.md index 494d1de46..b8681d301 100644 --- a/docs/design/apply-trigger.md +++ b/docs/design/apply-trigger.md @@ -829,10 +829,10 @@ adopted by one apply. | The suspended lifecycle against the source facts: an apply is not finished when attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../../internal/applier/flux_lifecycle_test.go), [`flux_consumers_test.go`](../../internal/applier/flux_consumers_test.go), [`fluxsim`](../../internal/applier/fluxsim/fluxsim_test.go) | | Adoption, `SuspendOwnedElsewhere`, an external resume suspended again with an Event every time, the hand-back on deletion (a `ClusterProvider` gone included), and the source's other consumers | [`fluxtrigger_hold_test.go`](../../internal/controller/fluxtrigger_hold_test.go) | | The rule: a trigger nothing names reads nothing and is `Ready`; the first eligible reference adopts and the last releases, suspending an apply in flight first; a reference returning mid-release holds again; a re-created `GitTarget` releases and re-adopts; the binding over several `GitTarget`s; an older trigger not in line keeps none out, and a retiring holder stays first until its release is done; a holder left unconfigured says the `Kustomization` is suspended; `ApplierNamespaceNotAllowed` for both kinds | [`applytrigger_rule_test.go`](../../internal/controller/applytrigger_rule_test.go) | -| `allowAnySourceNamespace: true` is refused on create and on any spec change, a stored one included; `false` and its removal are accepted; a `WatchRule` that relied on it is refused naming the new field | [`removed_allow_any_source_namespace_test.go`](../../internal/controller/removed_allow_any_source_namespace_test.go), [`watchrule_source_namespace_test.go`](../../internal/controller/watchrule_source_namespace_test.go) | +| `allowAnySourceNamespace: true` is refused on create and on any spec change, a stored one included; `false` and its removal are accepted; a `WatchRule` that relied on it is refused naming the new field | `removed_allow_any_source_namespace_test.go` (removed with `ClusterProvider`), [`watchrule_source_namespace_test.go`](../../internal/controller/watchrule_source_namespace_test.go) | | A restarted worker follows an operation left in flight with its window open, restores `repairPending`, and follows an apply started elsewhere; one recorded but never resumed ends `Failed` | [`apply_recovery_test.go`](../../internal/git/apply_recovery_test.go), [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go) | | Appliers sharing a source have one operation at a time and take turns, across workers too; a shared source applies only the consumer asked | [`apply_groups_test.go`](../../internal/git/apply_groups_test.go), [`flux_consumers_test.go`](../../internal/applier/flux_consumers_test.go) | -| An applier in another cluster is reached through its own `ClusterProvider`, behind that provider's `accessFrom` | [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go), [`clusterprovider_admission_test.go`](../../internal/authz/clusterprovider_admission_test.go), [`applytrigger_controller_test.go`](../../internal/controller/applytrigger_controller_test.go) | +| An applier in another cluster is reached through its own `ClusterProvider`, behind that provider's `accessFrom` | [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go), [`kubeprovider_admission_test.go`](../../internal/authz/kubeprovider_admission_test.go), [`applytrigger_controller_test.go`](../../internal/controller/applytrigger_controller_test.go) | | The applier's late write over a newer edit is withheld, and nothing that could be the edit is: a tie, and an undo that leaves an older applier entry, are mirrored; the apply itself is not mirrored, a delayed echo of the attempt before included; an applier entry older than the send is an edit; an applier write with no window open is late | [`apply_window_test.go`](../../internal/git/apply_window_test.go), [`last_field_managers_test.go`](../../internal/watch/last_field_managers_test.go) | | A newly named applier is caught up | [`apply_trigger_test.go`](../../internal/git/apply_trigger_test.go) | | The receiver's path names no provider, and every miss answers the same | [`receiver_test.go`](../../internal/webhook/receiver_test.go) | diff --git a/docs/design/gitbranch-and-kubeprovider-plan.md b/docs/design/gitbranch-and-kubeprovider-plan.md index c544e289c..ca08df982 100644 --- a/docs/design/gitbranch-and-kubeprovider-plan.md +++ b/docs/design/gitbranch-and-kubeprovider-plan.md @@ -1,9 +1,9 @@ # GitBranch, KubeProvider, and a phased API break -Status: **plan**, 2026-10-21. Nothing here is built. The current contract stays in -[architecture.md](../architecture.md) and [configuration.md](../configuration.md) until each phase -ships. `ideal-shared-stuff.md` is the analysis behind this plan; this page -records what we decided from it and in what order we build it. +Status: **plan**, 2026-10-21. Phase 1 shipped in #429 and phase 2 in #430; phase 3 is not built. +The current contract is in [architecture.md](../architecture.md) and +[configuration.md](../configuration.md). This page records what we decided and in what order we +build it. The plan breaks the API on purpose, more than once. There are no production users to carry across, so each phase replaces a shape outright instead of keeping a deprecated twin beside it. A removed @@ -155,8 +155,9 @@ way this API is installed. A missing or unauthorized reference is a status condi | `GitBranch` | `ParentBranchNotFound`, `RecoveringParentBranch` | Moved from `GitTarget`, with their rules unchanged | | `GitBranch` | `BranchConflict` | Another `GitBranch` holds the key (new) | -This departs from the strict-admission section of `ideal-shared-stuff.md`, deliberately. -`BranchConflict` is a new reason; the others exist today and change only which kind reports them. +Strict admission, which refuses a reference to an object that does not exist yet, was considered +and rejected for that reason. `BranchConflict` is a new reason; the others exist today and change +only which kind reports them. ### Lifecycle @@ -254,8 +255,13 @@ with no alias left behind. The provider named `default` that an omitted `clusterProviderRef` selects today goes away. - **Audit routes cannot be borrowed across namespaces.** An omitted `auditRoute` defaults to `.`, which cannot collide because a namespace name has no dots. An explicit - route may be shared by providers in one namespace (several connections to one API server) and is - refused at admission when a provider in another namespace already carries it. + route may be shared by providers in one namespace (several connections to one API server). It is + held by one namespace: the one its first segment names, when a provider there carries it, and + otherwise the namespace of the oldest provider carrying it. A provider in another namespace that + carries it reports `Stalled=True` (`AuditRouteConflict`) and is not used. This is a status + condition rather than an admission refusal, for the reasons `where-validation-lives.md` gives for + every cross-object check: the webhook is optional and fail-open, and it cannot see a provider that + changes its route later. The `allowCrossNamespace` grants keep today's meaning. Narrower per-tenant grants are a later design. @@ -270,7 +276,8 @@ design. attribution facts, also after one is deleted and recreated. - A remote-source `GitTarget` with a local Flux applier (gitops-api ask #34) still exercises the apply window end to end. -- An explicit audit route already used in another namespace is refused at admission. +- A provider carrying an explicit audit route another namespace holds reports + `AuditRouteConflict`, and nothing mirrors or applies through it. ## Phase 3: one receiver for many repositories @@ -333,12 +340,8 @@ Phase 1: - Is the receive-pack proof enough on the Git hosts the e2e suite covers, or does one of them need a documented exception? -Phase 2: - -- The flag name and chart value for the `inCluster` list, per - [config-flag-conventions.md](../config-flag-conventions.md). -- Whether the chart's local provider keeps the name `default` (less churn in examples) or becomes - `local` (no hint of a fallback). +Phase 2 (settled): the `inCluster` list is `--in-cluster-kube-providers` (chart value +`controllerManager.inClusterKubeProviders`), and the chart's provider is named `local`. Phase 3: diff --git a/docs/facts/subresources.md b/docs/facts/subresources.md index 8285d1031..079eb357f 100644 --- a/docs/facts/subresources.md +++ b/docs/facts/subresources.md @@ -209,6 +209,9 @@ metadata: spec: gitBranchRef: name: platform-config-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: clusters/dev status: observedGeneration: 4 diff --git a/docs/finished/multi-cluster-author-attribution.md b/docs/finished/multi-cluster-author-attribution.md index df609d668..212496e50 100644 --- a/docs/finished/multi-cluster-author-attribution.md +++ b/docs/finished/multi-cluster-author-attribution.md @@ -10,7 +10,7 @@ > [`../design/multi-source-audit-ingress-hardening.md`](../design/multi-source-audit-ingress-hardening.md). > Purge-on-delete was answered separately and rejected, and its record has since been removed. The > reasoning that still binds lives on `LegacyClusterProviderFinalizer` in -> [`clusterprovider_controller.go`](../../internal/controller/clusterprovider_controller.go): no +> [`kubeprovider_controller.go`](../../internal/controller/kubeprovider_controller.go): no > finalizer, because `helm uninstall` would strand the object in `Terminating`. ## The problem @@ -76,7 +76,7 @@ object. **None of that shipped.** What shipped: local-vs-remote follows from **`spec.kubeConfig` alone** — omitted means the operator's own cluster, for *any* name -([`clusterprovider_types.go:174-178`](../../api/v1alpha3/clusterprovider_types.go#L174-L178)). A +(`clusterprovider_types.go`, since replaced by [`kubeprovider_types.go`](../../api/v1alpha3/kubeprovider_types.go)). A provider named `default` may carry a `kubeConfig` and mirror a remote cluster; a provider named anything else may omit it and be in-cluster. `default` is only the value that `GitTarget.spec.clusterProviderRef` defaults to @@ -172,7 +172,7 @@ no recent mutations must not read as unready. | Admission attribution | Cut entirely (above) — needs a source-side agent, not a webhook. | | Mutable `kubeConfig` / endpoint repointing | v1 promised it; a mutable *endpoint* silently retargets and misattributes. `kubeConfig` is immutable; Secret **contents** rotation stays transparent. | | Workload identity (`configMapRef`), ServiceAccount impersonation | Present in the embedded Flux type; rejected by CEL for now. | -| Fact purge on delete | Answered separately and **rejected**. No finalizer: `helm uninstall` would strand the object in `Terminating`. See `LegacyClusterProviderFinalizer` in [`clusterprovider_controller.go`](../../internal/controller/clusterprovider_controller.go). | +| Fact purge on delete | Answered separately and **rejected**. No finalizer: `helm uninstall` would strand the object in `Terminating`. See the `KubeProviderReconciler` doc comment in [`kubeprovider_controller.go`](../../internal/controller/kubeprovider_controller.go). | | Managed control planes (EKS/GKE/AKS) | Needs the source-side agent. | | `ReferenceGrant`-style per-namespace consent | `allowedNamespaces` is one object for the admin; revisit if self-service consent is wanted. | diff --git a/docs/interpreting-metrics.md b/docs/interpreting-metrics.md index 006665f00..d8ed5c5b4 100644 --- a/docs/interpreting-metrics.md +++ b/docs/interpreting-metrics.md @@ -82,7 +82,7 @@ without a kubeconfig. | `resource_condition` | gauge | `kind`, `resource_namespace`, `resource_name`, `type`, `status`, `reason` | - `kind` is `GitTarget`, `WatchRule`, `ClusterWatchRule`, `GitProvider`, `GitBranch`, - `ClusterProvider`, `FluxTrigger` or `ArgoCDTrigger`: the objects a human declares. + `KubeProvider`, `FluxTrigger` or `ArgoCDTrigger`: the objects a human declares. - `type` is the kstatus trio: `Ready` (the summary), `Reconciling` (working on it) and `Stalled` (given up, and nothing will retry without a human). - `status` is `True`, `False` or `Unknown`. @@ -221,6 +221,10 @@ boundary, the commit, the push. Background: | `placement_kustomization_entries_total` | counter | `outcome`, `gittarget_namespace`, `gittarget_name` | `added` / `no_change` / `failed`. | | `git_resync_failures_total` | counter | `gittarget_namespace`, `gittarget_name` | Rule-change resyncs whose apply failed **after** enqueue. | +`source_cluster` is the `KubeProvider` the `GitTarget` mirrors from, as `/` +(`gitops-reverser/local` for the chart's provider), so two providers with one name in two namespaces +are two values. + ### Reading `git_fetches_total` Nothing else here answers "is my mirror pulling the branch on every write?". `git_pushes_total` @@ -831,7 +835,7 @@ else pushed. See [who applies this folder](configuration.md#who-applies-this-fol | Metric | Type | Labels | Meaning | | --- | --- | --- | --- | -| `apply_requests_total` | counter | `applier_kind`, `reason`, `outcome` | One per attempt to one applier. `applier_kind` is `kustomization` (a Flux `Kustomization`) or `application` (an Argo CD `Application`); `reason` is `ForeignCommit` / `Refusal` / `Repair` / `CatchUp`, for either kind (`CatchUp` adopts an object the API created in the folder, or converges once when the worker starts and once when a `GitTarget` first names the trigger); `outcome` is `sent` (the applier was asked and the ask landed; it says nothing about how the apply went. What needs no apply, a converge of the commit the applier last applied or a branch the remote does not carry, the branch worker never sends, so it is not counted), `failed` (could not ask, including an applier that applies another repository or branch, an `Application` with automated sync on, or one whose running sync did not end within 5 minutes; the branch worker keeps the reason pending and decides the retry after a flush, so a failure that persists keeps counting at its backoff) or `dropped` (no `GitTarget` may still ask for that reason: each was deleted or recreated, moved to another branch or applier, is no longer admitted by its `ClusterProvider`, or withdrew its consent to a `Refusal`) or `stale` (asked nothing because the branch tip moved before the ask landed; the worker decides again at once, at the new tip, and frequent stale attempts mean the branch keeps moving within an attempt's lifetime) | +| `apply_requests_total` | counter | `applier_kind`, `reason`, `outcome` | One per attempt to one applier. `applier_kind` is `kustomization` (a Flux `Kustomization`) or `application` (an Argo CD `Application`); `reason` is `ForeignCommit` / `Refusal` / `Repair` / `CatchUp`, for either kind (`CatchUp` adopts an object the API created in the folder, or converges once when the worker starts and once when a `GitTarget` first names the trigger); `outcome` is `sent` (the applier was asked and the ask landed; it says nothing about how the apply went. What needs no apply, a converge of the commit the applier last applied or a branch the remote does not carry, the branch worker never sends, so it is not counted), `failed` (could not ask, including an applier that applies another repository or branch, an `Application` with automated sync on, or one whose running sync did not end within 5 minutes; the branch worker keeps the reason pending and decides the retry after a flush, so a failure that persists keeps counting at its backoff) or `dropped` (no `GitTarget` may still ask for that reason: each was deleted or recreated, moved to another branch or applier, is no longer admitted by its `KubeProvider`, or withdrew its consent to a `Refusal`) or `stale` (asked nothing because the branch tip moved before the ask landed; the worker decides again at once, at the new tip, and frequent stale attempts mean the branch keeps moving within an attempt's lifetime) | | `apply_revision_mismatches_total` | counter | `applier_kind` | One per apply operation that succeeded on another revision than the commit GitOps Reverser checked. A `Kustomization` applies whatever its `GitRepository` artifact holds when it reconciles; the artifact is read again just before the resume, so this is somebody fetching the source in between (a person, or the source's own interval). The apply window still withholds a late write over a newer edit; a steady rate means something else keeps fetching the source | | `apply_window_collisions_total` | counter | `provider_namespace`, `provider_name`, `branch`, `outcome` | One per write by the applier, while an apply it was asked for runs, to an object the API side edited after the flush before that request: the apply arriving late over a newer edit. `outcome` is `withheld` (proven to be the applier's write, by its field manager alone, with an entry as new as the edit or newer: `kustomize-controller`, or `argocd-controller` / `argocd-application-controller`; so not mirrored: Git keeps the API's value, and a `Repair` of the applier follows, which restores it on the cluster) or `ambiguous` (the applier and another field manager wrote in the same second, so it was mirrored as ever; if it was the apply, the revert is in Git as the applier's commit). An applier entry older than the edit is not counted: it is the API's undo of an edit, mirrored | | `apply_echoes_total` | counter | `provider_namespace`, `provider_name`, `branch`, `outcome` | One per write by the applier's own field managers alone, judged by the apply window: an apply GitOps Reverser asked for landing, object by object. `outcome` is `not_mirrored` (the applier wrote the object last, at or after the earliest send of the apply windows still open, and nobody edited it in the window: the apply itself, whose content is the commit Git already holds, so it is not mirrored) or `late` (the applier's managers alone wrote the object last with no apply window open: an echo arriving more than 10 seconds after its operation completed, or an apply somebody else started; it is mirrored as an edit). `not_mirrored` rises with every apply and needs no action. A steady `late` rate means applies keep landing outside the windows, and such a write can revert a newer Git-side change to the object: look for a person or a tool applying the folder by hand (`flux reconcile`, a manual sync), or for watch events reaching the worker long after the apply | @@ -910,7 +914,7 @@ touched, so an apiserver posting audit to a path this operator does not serve lo apiserver posting nothing. `bare_endpoint_disabled` is named for what it covers, and it is **not** "a route no -`ClusterProvider` claims". A named `/audit-webhook/` is accepted as-is, deliberately: a route +`KubeProvider` claims". A named `/audit-webhook/` is accepted as-is, deliberately: a route is a partition name, not a claim about an object, and refusing unknown ones dropped audit batches in flight while a provider was being recreated. The only route-shaped rejection is the bare `/audit-webhook` endpoint when `--audit-route-annotation-key` is unset. diff --git a/docs/layout/new-file-placement-rules.md b/docs/layout/new-file-placement-rules.md index caa232fb5..ccdeb23bc 100644 --- a/docs/layout/new-file-placement-rules.md +++ b/docs/layout/new-file-placement-rules.md @@ -99,6 +99,9 @@ kind: GitTarget spec: gitBranchRef: name: platform-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: clusters/prod placement: byType: diff --git a/docs/quickstart.md b/docs/quickstart.md index d9b29e93f..2d44b8f0c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -5,7 +5,8 @@ throw it away afterwards is to delete the repo. It creates a starter `GitProvide `GitTarget`, and `WatchRule` in the `gitops-reverser-quickstart-demo` namespace, watching ConfigMaps there and writing them to `/live-cluster` on `main`. It runs in `configured-author` mode (no Redis) by default. The -chart also renders the cluster-scoped `default` `ClusterProvider` the starter target resolves against. +chart also renders the `local` `KubeProvider` in the `gitops-reverser` namespace, the operator's own +cluster, which the starter target names as its source. ![Config basics diagram showing the relationship between GitProvider, GitTarget, and WatchRule](images/config-basics.excalidraw.svg) @@ -128,7 +129,7 @@ kubectl get gittarget example-target -n gitops-reverser-quickstart-demo kubectl describe gittarget example-target -n gitops-reverser-quickstart-demo ``` -A `Reason` that names a missing or unauthorized dependency (`ClusterProviderNotFound`, +A `Reason` that names a missing or unauthorized dependency (`KubeProviderNotFound`, `NamespaceNotAuthorized`, `InvalidConfig`) is a misconfiguration to fix. One that names discovery or streams still settling is initialization, and resolves on its own. `describe` prints every condition with its message, which is where the specific cause is written. @@ -146,18 +147,18 @@ kubectl logs -n gitops-reverser deploy/gitops-reverser kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-demo ``` -Two `GitTarget` conditions stop the data plane and are worth recognizing: `ClusterProviderNotFound` -(the `default` `ClusterProvider` is missing) and `NamespaceNotAuthorized` (its `accessFrom` -selector does not cover the demo namespace). +Two `GitTarget` conditions stop the data plane and are worth recognizing: `KubeProviderNotFound` +(the `local` `KubeProvider` in `gitops-reverser` is missing) and `NamespaceNotAuthorized` (its +`accessFrom` selector does not cover the demo namespace). ## Clean up To tear the demo down: `helm uninstall gitops-reverser -n gitops-reverser` and `kubectl delete namespace gitops-reverser-quickstart-demo`. -> **Note:** the `default` `ClusterProvider` is cluster-scoped and chart-owned, so uninstalling takes -> it with it, holding *every* other `GitTarget` in the cluster unready. Mind that if you run the demo -> alongside a real deployment. +> **Note:** the `local` `KubeProvider` is chart-owned, so uninstalling takes it with it, holding +> unready *every* other `GitTarget` that names it. Mind that if you run the demo alongside a real +> deployment. ## Want named users on your commits? diff --git a/docs/rbac.md b/docs/rbac.md index 238c3b915..d553ccef0 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -75,7 +75,7 @@ it, so the list must cover every type your rules name. ### `namespaces` on a REMOTE source cluster The operator needs **no `Namespace` access at all** in a source cluster. The manager role's -`namespaces` `get`/`list`/`watch` covers the operator's own cluster, where `ClusterProvider.accessFrom` +`namespaces` `get`/`list`/`watch` covers the operator's own cluster, where `KubeProvider.accessFrom` selectors match control-cluster labels. This used to be different: `GitTarget.spec.allowedSourceNamespaces` could carry a label selector @@ -89,8 +89,8 @@ cluster-wide `list`/`watch` of its types, and gets a clean 403 if the credential It holds **no Secret informer**: Secrets are excluded from the manager's cache ([`cmd/main.go`](../cmd/main.go), `Cache.DisableFor`), so every read is a direct `get` of a -Secret a `GitProvider` or `GitTarget` names. Nothing in the operator calls `list` or `watch` -on Secrets. It creates and updates only the Secrets it owns (the generated signing key). +Secret a `GitProvider`, `KubeProvider` or `GitTarget` names. Nothing in the operator calls `list` +or `watch` on Secrets. It creates and updates only the Secrets it owns (the generated signing key). That is why the manager role asks for `get`, `create`, `update` and nothing more. @@ -110,7 +110,7 @@ Be precise about what is left, because RBAC is additive and the manager role is | Verb on `secrets` | Still granted? | Why | |---|---|---| | `list`, `watch` | **No** | The operator never enumerates Secrets. Removed from the manager role, and `selected` adds no wildcard to put them back. | -| `get` | Yes, cluster-scoped | It reads the git-creds and age-key Secrets a `GitProvider`/`GitTarget` names — and those may live in any namespace. | +| `get` | Yes, cluster-scoped | It reads the git-creds, kubeconfig and age-key Secrets a `GitProvider`, `KubeProvider` or `GitTarget` names, each in that object's own namespace — and those objects may live in any namespace. | | `create`, `update` | Yes, cluster-scoped | It generates the signing key and, with `generateWhenMissing`, the age key. | So a `selected` install can still **read a Secret whose name and namespace it already @@ -118,8 +118,8 @@ knows**. It cannot discover one. That is a real reduction — enumeration is wha mirroring operator into a credential-harvesting one — but it is not zero Secret access, and this page will not pretend otherwise. -Scoping `get`/`create`/`update` down to the namespaces a `GitProvider` actually references -needs a namespaced `Role` the chart does not yet render. Until then the escape hatch is +Scoping `get`/`create`/`update` down to the namespaces that hold a `GitProvider`, `KubeProvider` +or `GitTarget` needs a namespaced `Role` the chart does not yet render. Until then the escape hatch is `rbac.create: false` plus your own role set. See [`future/least-privilege-remaining-work.md`](future/least-privilege-remaining-work.md). diff --git a/docs/security-model.md b/docs/security-model.md index 66dc2f622..66249216f 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -34,8 +34,9 @@ To mirror live state into Git the controller must: convenient, and it means the reverser can enumerate **every Secret in the cluster**. `mode: selected` grants only the types you name. See [`rbac.md`](rbac.md). - **Read referenced Secrets.** The git-creds Secret, and the SOPS/age key Secret when encryption - is configured, both named by a `GitProvider`/`GitTarget`. This is a `get` by name — never a - `list` or a `watch`. + is configured, both named by a `GitProvider`/`GitTarget`, and a remote cluster's kubeconfig + Secret, named by a `KubeProvider` in its own namespace. So the controller reads Secrets in every + namespace that holds one of those objects. This is a `get` by name, never a `list` or a `watch`. - **Receive audit events.** The kube-apiserver audit webhook posts events to the audit ingress. Those carry object metadata and, for some resources, request/response bodies. - **Drive an applier, only with an apply trigger.** Holding a Flux `Kustomization` takes `get` and @@ -54,22 +55,22 @@ RBAC above is the hard maximum; *within* it, scope is carried by the rule **kind - A **`WatchRule`** selects namespaced resources. Each `spec.rules[]` item watches the rule's own namespace by default. Naming any other source namespace — including `sourceNamespace: "*"` — - requires `ClusterProvider.spec.allowCrossNamespace.sources`, a platform-admin delegation that is - false by default. That flag is the only namespace policy in this direction: what may actually be + requires `KubeProvider.spec.allowCrossNamespace.sources`, a delegation by the provider's owner that + is false by default. That flag is the only namespace policy in this direction: what may actually be read is bounded by the provider credential's own Kubernetes RBAC, and a `"*"` reaches every namespace that credential can read, as one cluster-wide watch. - On an **in-cluster** provider that delegation deliberately bypasses live namespace RBAC: the owner + On an **`inCluster`** provider that delegation deliberately bypasses live namespace RBAC: the owner of an admitted `GitTarget` can then mirror another namespace's objects — read through the operator's own cluster-wide credential — into a Git destination they control. That is legitimate to grant on purpose, and it is why the flag exists and defaults to false. - A **`ClusterWatchRule`** selects cluster-scoped resources only. Cluster-scoped objects have no namespace, so no namespace policy bounds it: it is intentionally cluster-global. Isolating - cluster-scoped objects between tenants therefore takes a separate `ClusterProvider` and credential + cluster-scoped objects between tenants therefore takes a separate `KubeProvider` and credential per tenant, so that credential's RBAC is the boundary. The audit question is one per kind: read a `WatchRule`'s items and the delegation flag on its -target's `ClusterProvider`, or recognise a `ClusterWatchRule` as cluster-global. In both cases the +target's `KubeProvider`, or recognize a `ClusterWatchRule` as cluster-global. In both cases the provider credential's RBAC in the source cluster is the outer bound, and it is the only one. ## The controller does not hold Secret values @@ -99,7 +100,8 @@ picked up by the next direct read when work happens, and at the latest by the 5- This is why the manager role asks for `secrets: get, create, update` and nothing more — the runtime genuinely does not need `list` or `watch`. What it does **not** do is scope those verbs to -a namespace: a `GitProvider` may reference a Secret anywhere, so the grant is cluster-scoped. A +a namespace: a `GitProvider` or `KubeProvider` reads its Secret in its own namespace, and either may +live in any namespace, so the grant is cluster-scoped. A `selected` install therefore cannot *discover* a Secret, but can still read one whose name and namespace it already knows. Narrowing that further is [remaining work](future/least-privilege-remaining-work.md). @@ -111,10 +113,12 @@ namespace it already knows. Narrowing that further is | Git credentials Secret | Grants push access to your repository. | | SOPS/age key material | Decrypts (and the public key encrypts) Secret data written to Git. | | Redis/Valkey queue | Buffers decoded audit events in transit; not an audit archive. | -| Audit ingress (`/audit-webhook/default` for the default provider) | Accepts audit traffic; protected by mutual TLS via cert-manager. | +| Audit ingress (`/audit-webhook/`, such as `/audit-webhook/gitops-reverser.local` for the chart's provider) | Accepts audit traffic; protected by mutual TLS via cert-manager. A route is held by one namespace: a `KubeProvider` elsewhere that carries it is `Stalled` (`AuditRouteConflict`) and reads none of its facts. | | Push receiver (`--receiver-bind-address`, off by default) | Plain HTTP for Git hosts' push webhooks. Each `GitProvider` route is a path derived from its token, and a delivery must carry a valid signature or token for it. A delivery is only a hint that makes the branch worker read the remote, never a fact about the branch. | | Generated Secret material | Signing keys and generated age keys live in cluster Secrets. | -| `ClusterProvider.spec.allowCrossNamespace.appliers` | Lets every namespace `accessFrom` admits suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`, through the provider's identity. A delegation, not owner consent: it does not prove the namespace owns the applier or its repository. A trigger only acts while an eligible `GitTarget` names it, and the binding check compares the `GitProvider` URL, not write access. | +| `accessFrom` label selectors on `GitProvider` and `KubeProvider` | A selector admits every control-cluster namespace whose labels match, so it is only as strong as the right to set those labels. Whoever can label a namespace can admit it, including a tenant allowed to edit its own namespace. Select on labels only the platform can set, or list namespaces by `names`. | +| `KubeProvider.spec.inCluster` and `--in-cluster-kube-providers` | `inCluster` wields the operator's own identity in the cluster it runs in. It is honored only for a `KubeProvider` the flag lists by exact `namespace/name` (no wildcards, empty by default); any other is `Stalled` (`InClusterNotAuthorized`) and never used. Anyone who can create a `KubeProvider` under a listed name wields that identity through it, so list only names in namespaces you control. | +| `KubeProvider.spec.allowCrossNamespace.appliers` | Lets every namespace the provider admits suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`, through the provider's identity. A delegation, not owner consent: it does not prove the namespace owns the applier or its repository. A trigger only acts while an eligible `GitTarget` names it, and the binding check compares the `GitProvider` URL, not write access. | ## Secret data the controller writes to Git diff --git a/docs/spec/attribution.md b/docs/spec/attribution.md index a886697f6..08268c590 100644 --- a/docs/spec/attribution.md +++ b/docs/spec/attribution.md @@ -226,32 +226,46 @@ gain most. Facts are partitioned by `factScope{route, groupResource}`. Both halves are load-bearing. -**The route** is where a cluster's audit events arrive, and it is **declared** rather than inferred -from an object's name: +**The route** is where a cluster's audit events arrive, and it can be **declared** rather than +derived from the provider's identity: ```yaml apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider +kind: KubeProvider metadata: name: tenant-acme-delegating # the name humans read and GitTargets reference + namespace: platform-connections spec: + secretRef: + name: prod-eu-1-kubeconfig attribution: - auditRoute: prod-eu-1 # the route this cluster's audit events arrive on + auditRoute: platform-connections.prod-eu-1 # the route this cluster's audit events arrive on ``` -`spec.attribution.auditRoute` is the `` segment the API server's webhook URL ends in -(`/audit-webhook/`), or (when several logical clusters share one backend) the value of the +`spec.attribution.auditRoute` is the `` segment the API server's webhook URL ends in +(`/audit-webhook/`), or (when several logical clusters share one backend) the value of the audit-event annotation named by `--author-attribution-audit-route-annotation-key`. It defaults to -`metadata.name` through [`ClusterProvider.AuditRoute()`](../../api/v1alpha3/clusterprovider_types.go), -so no caller sees the empty case and every single-provider install keeps working unchanged. - -It exists because an API server posts audit to exactly **one** route. Several `ClusterProvider`s may -name one physical cluster, and without a declared route only one of those names is ever fed while -every other one authors `unknown (attribution unresolved)`. Carrying the same route lets them share -one cluster's facts; carrying different routes means a fact from cluster A can never name the author -of an object watched on cluster B, which is also what keeps a cloned or restored cluster separate -from its origin. It is **mutable**, unlike `spec.kubeConfig`: repointing it changes which partition -is read, not which cluster is mirrored, so correcting a typo does not require deleting the object. +`.` through [`KubeProvider.AuditRoute()`](../../api/v1alpha3/kubeprovider_types.go), +so no caller sees the empty case. A namespace name has no dots, so a default route always starts +with the provider's own namespace, and no provider in another namespace resolves to it by default. + +It exists because an API server posts audit to exactly **one** route. Several `KubeProvider`s may +name one physical cluster, and without a declared route only one of them is ever fed while every +other one authors `unknown (attribution unresolved)`. Carrying the same route lets them share one +cluster's facts; carrying different routes means a fact from cluster A can never name the author of +an object watched on cluster B, which is also what keeps a cloned or restored cluster separate from +its origin. It is **mutable**, unlike `spec.secretRef`: repointing it changes which partition is +read, not which cluster is mirrored, so correcting a typo does not require deleting the object. + +**A route is held by one namespace**, so a provider cannot read facts that belong to a tenant it was +never admitted to. The namespace the route's first dot-segment names holds it when a provider there +carries it; otherwise the namespace of the oldest carrier does (`creationTimestamp`, then UID). +Providers in the holding namespace may share the route: that is the case above, several +connections to one API server. A provider in another namespace that carries it reports +`Stalled=True` with reason `AuditRouteConflict` and is not used at all: `GitTarget`s and triggers +through it are refused with the same reason. This is a status condition the controller recomputes on +every reconcile, not an admission refusal, so the provider that loses a route is admitted and then +reports why it is idle; `Validated` keeps what its credential earned. Ingestion makes **no Kubernetes API call**. The route is a partition name rather than a claim about an object that must exist, so an audit batch that arrives while a provider is being created is @@ -600,11 +614,11 @@ maintained. This document deliberately does not repeat them: it carried its own drifted from the code, which is the argument against a second inventory rather than for a better one. Metrics are not the whole surface. One misconfiguration is a **state**, not a rate, and it is -reported as a condition on the object that carries it: `AuditFactsReceived` on `ClusterProvider`, +reported as a condition on the object that carries it: `AuditFactsReceived` on `KubeProvider`, with a default `FACTSRECEIVED` printer column. -A `ClusterProvider`'s readiness is watchable from Prometheus as -`gitopsreverser_resource_condition{kind="ClusterProvider", type="Ready", status="False"} == 1` +A `KubeProvider`'s readiness is watchable from Prometheus as +`gitopsreverser_resource_condition{kind="KubeProvider", type="Ready", status="False"} == 1` ([interpreting-metrics.md](../interpreting-metrics.md#configuration-state)). `AuditFactsReceived` is deliberately not on that metric: it is a latch rather than a level, it is absent entirely when the operator runs without author attribution, and it is not part of `Ready` for the reason given below. @@ -622,7 +636,7 @@ severity: a failing transport makes every route look silent, and with nothing de route can be the one at fault. Only the last is a statement about this provider's own configuration. The status stays `Unknown` for all three because the first two are faults in the pipeline rather than in the object carrying them; a per-object `False` would turn one outage into a -verdict repeated on every `ClusterProvider` that has not yet latched. Pre-latch `Unknown` is +verdict repeated on every `KubeProvider` that has not yet latched. Pre-latch `Unknown` is therefore inconclusive rather than an accusation: only `RouteUnused` points at the object's own configuration, and even it names the audit policy as a second candidate. @@ -632,7 +646,7 @@ nothing attributable. The reason stays true for both, and its message names the second thing to check once the route has been ruled out. The condition is a **one-way latch**, and that is what makes it readable without a threshold. A -route that has never carried a fact is a route nobody posts to: a `ClusterProvider` that did not +route that has never carried a fact is a route nobody posts to: a `KubeProvider` that did not declare `spec.attribution.auditRoute` on a cluster whose apiserver posts under a different one, and therefore every commit through it authored `unknown (attribution unresolved)`. A route that carried facts yesterday and none today is a quiet cluster. No timer separates those, so the condition does @@ -720,5 +734,5 @@ The behavior above is pinned in three places, and the corpus is the one worth kn - **E2E** in `test/e2e/commit_author_attribution_e2e_test.go`, `test/e2e/audit_route_attribution_e2e_test.go` and `test/e2e/deletecollection_intent_e2e_test.go` prove the OIDC claim chain end to end, that a - declared `auditRoute` resolves where the object's name would not, and that a finalizer-guarded + declared `auditRoute` resolves where the default route would not, and that a finalizer-guarded object is removed and attributed at intent time while a *different* identity clears the finalizer. diff --git a/docs/spec/e2e-bi-directional-corner.md b/docs/spec/e2e-bi-directional-corner.md index db54bac0d..fc4387add 100644 --- a/docs/spec/e2e-bi-directional-corner.md +++ b/docs/spec/e2e-bi-directional-corner.md @@ -420,7 +420,8 @@ The `Application` lives in the `argocd` namespace, targets `spec.destination.namespace = `, `spec.source.path = `, `targetRevision: main`, `directory.recurse: true`. The trigger lives in the test namespace, so each describe first grants `allowCrossNamespace.appliers` on the -`default` `ClusterProvider`, as a user must for an `Application` in `argocd`. The +install's `local` `KubeProvider` in the operator's namespace, as a user must for an +`Application` in `argocd`. The `GitProvider` has a `gitea` receiver, registered as a Gitea webhook next to Argo CD's own. diff --git a/docs/spec/status-conditions-guide.md b/docs/spec/status-conditions-guide.md index b6ccee02a..a36330470 100644 --- a/docs/spec/status-conditions-guide.md +++ b/docs/spec/status-conditions-guide.md @@ -214,7 +214,7 @@ const ( TypeEncryptionConfigured = "EncryptionConfigured" TypeSourceClusterReachable = "SourceClusterReachable" TypeGitBranchReady = "GitBranchReady" - TypeClusterProviderReady = "ClusterProviderReady" + TypeKubeProviderReady = "KubeProviderReady" TypeStreamsRunning = "StreamsRunning" TypeGitPathAccepted = "GitPathAccepted" TypeRenderMatchesLive = "RenderMatchesLive" @@ -313,7 +313,7 @@ Canonical reads: the trigger must not wait for the `GitTarget` that will name it - holding its applier: `Configured=True`, for a `FluxTrigger` `Held=True` (`Suspended` or `Applying`), and `Ready=True` -- applier or `ClusterProvider` missing (`ApplierNotFound`, `ClusterProviderNotFound`): +- applier or `KubeProvider` missing (`ApplierNotFound`, `KubeProviderNotFound`): `Ready=False`, `Reconciling=True`, `Stalled=False`, because Flux and Argo CD objects are often created after the trigger. Every other `Configured=False` reason is `Stalled=True` - an operation past its time (`status.operation.phase: Unresolved`): `Ready=False`, diff --git a/docs/spec/where-validation-lives.md b/docs/spec/where-validation-lives.md index c1ac1fb3b..dc1da7b97 100644 --- a/docs/spec/where-validation-lives.md +++ b/docs/spec/where-validation-lives.md @@ -10,8 +10,8 @@ Validate at the **first** rung that can express the rule: 1. **OpenAPI schema** — types, enums, required, min/max, immutability where the shape allows. 2. **CEL `XValidation`** — anything expressible from the object *itself*, including - transitions against `oldSelf`. Example: `spec.kubeConfig` immutability on `ClusterProvider` - ([`clusterprovider_types.go:25`](../../api/v1alpha3/clusterprovider_types.go#L25)). + transitions against `oldSelf`. Example: `spec.secretRef` and `spec.inCluster` immutability on + `KubeProvider` ([`kubeprovider_types.go:28`](../../api/v1alpha3/kubeprovider_types.go#L28)). 3. **The reconciler** — everything **cross-object**, because CEL cannot read another object. Refusal surfaces as `Validated=False` with a reason, and **returns before** the object reaches the data plane. @@ -37,12 +37,15 @@ Three things, in order of how often they get argued backwards: property, and it is worth less than the install cost of a webhook: cert wiring in the chart, a failure mode that can block tenant writes, and an extra moving part in every install path. -The worked example is `ClusterProvider.spec.accessFrom`. An earlier design proposed +The worked example is `KubeProvider.spec.accessFrom`. An earlier design proposed enforcing it "in two places", one of them a webhook. What shipped enforces it in **one**, on every reconcile, before `DeclareForGitTarget` -([`gittarget_controller.go:473`](../../internal/controller/gittarget_controller.go#L473), -[`gittarget_source_cluster.go:37`](../../internal/controller/gittarget_source_cluster.go#L37)), -and that is why the quickstart is a single command again. +([`gittarget_controller.go:479`](../../internal/controller/gittarget_controller.go#L479), +[`gittarget_source_cluster.go:36`](../../internal/controller/gittarget_source_cluster.go#L36)), +and that is why the quickstart is a single command again. The `KubeProvider` audit route follows +the same rule: which namespace holds a route depends on every other provider carrying it, so a +provider that loses it reports `Stalled=True` (`AuditRouteConflict`) on reconcile rather than +being refused at admission. ## The one webhook we ship, and why it is not an exception to the rule diff --git a/docs/style-guide.md b/docs/style-guide.md index 75c667ff3..566180d44 100644 --- a/docs/style-guide.md +++ b/docs/style-guide.md @@ -15,16 +15,17 @@ Applies to every tracked `.md` file: product docs, design records, chart READMEs ## Voice **Write to an operator who is competent but new to this product.** They know Kubernetes. They do -not know why a `ClusterProvider` is cluster-scoped. Explain the second thing, never the first. +not know why a `KubeProvider` needs the installation's consent to use `inCluster`. Explain the +second thing, never the first. **State what the software does, in present tense, active voice.** Not what it aims to do, is designed to do, or will do. A doc describes behavior that exists; a design record under [`docs/design/`](design/) is where intent belongs, and it says so explicitly. **Give the reason when the reason is not obvious.** This is the house signature and the main thing -that makes these docs worth reading. `configuration.md` does not just say `ClusterProvider` is -cluster-scoped; it has a section called "Why the two provider types have different scopes". Keep -doing that. A rule with no rationale gets argued with later, or silently reverted. +that makes these docs worth reading. `configuration.md` does not just say both provider types are +namespaced; it has a section called "Why both provider types are namespaced". Keep doing that. A +rule with no rationale gets argued with later, or silently reverted. **Do not sell.** No `powerful`, `seamless`, `robust`, `simply`, `effortlessly`. The docs currently contain 15 uses of `powerful` and 7 of `robust`; none of them carry information. If a capability @@ -69,10 +70,10 @@ after: the author becomes the real Kubernetes actor (user, service account, or **Definition or expansion → colon.** ```text -before: Two conditions stop the data plane and are worth recognizing — ClusterProviderNotFound +before: Two conditions stop the data plane and are worth recognizing — KubeProviderNotFound and NamespaceNotAuthorized after: Two conditions stop the data plane and are worth recognizing: - ClusterProviderNotFound and NamespaceNotAuthorized + KubeProviderNotFound and NamespaceNotAuthorized ``` **Two independent clauses → a period, or a semicolon when they are genuinely one thought.** diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml index f5e919db3..7a782219c 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -79,17 +79,27 @@ kinds: be allowed. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch) - - kind: ClusterProvider - title: "`ClusterProvider` (cluster-scoped): the source cluster" + - kind: KubeProvider + title: "`KubeProvider` (namespaced): the source cluster" + intro: "Set exactly one of `secretRef` and `inCluster`. Both are immutable: delete and recreate to repoint." fields: - - path: kubeConfig - default: the operator's own cluster - doc: Points at a remote source cluster instead of the one the operator runs in + - path: secretRef + default: none; set `inCluster` instead + doc: >- + Secret in this provider's namespace holding a kubeconfig for the source cluster. `key` + defaults to `value`, then `value.yaml`. See [`KubeProvider`](#kubeprovider) + - path: inCluster + default: none; set `secretRef` instead + doc: >- + `{}` uses the operator's own identity. Honored only for a provider listed in + `--in-cluster-kube-providers`. See + [the operator's own identity](#the-operators-own-identity-specincluster) - path: accessFrom - default: "**no namespace**" + default: "**no other namespace**" doc: >- - Deny-by-default list of control-cluster namespaces that may reference this provider. - Omitted, none may; an empty `selector: {}` admits every namespace + Other control-cluster namespaces whose objects may use this provider, by name or label + selector. Its own namespace is always admitted; an empty `selector: {}` admits every + namespace - path: allowCrossNamespace.sources default: "`false`" doc: >- @@ -100,23 +110,17 @@ kinds: doc: >- Whether triggers may name a `Kustomization` or `Application` in another namespace than their own - - path: attribution.auditRoute - default: the provider's own name - doc: >- - Audit route this provider joins. See - [audit route](#audit-route-and-auditfactsreceived) - path: client.qps default: "`20`" - doc: Client rate limit for this source cluster. Ignored when `kubeConfig` is omitted + doc: Client rate limit for this source cluster. Refused beside `inCluster` - path: client.burst default: "`30`" - doc: Client burst for this source cluster. Ignored when `kubeConfig` is omitted - - path: qps - skip: the old spelling of `client.qps`, kept in the schema only so admission refuses it - - path: burst - skip: the old spelling of `client.burst`, kept in the schema only so admission refuses it - - path: allowAnySourceNamespace - skip: removed for `allowCrossNamespace.sources`, kept in the schema only so admission refuses `true` + doc: Client burst for this source cluster. Refused beside `inCluster` + - path: attribution.auditRoute + default: "`.`" + doc: >- + Audit route this provider joins. A route is held by one namespace. See + [audit route](#audit-route-and-auditfactsreceived) - kind: GitTarget title: "`GitTarget` (namespaced): which branch and folder" @@ -134,8 +138,12 @@ kinds: skip: moved to `GitBranch`, kept in the schema only so admission refuses it, naming `gitBranchRef` - path: path doc: Folder within the repository. `.` targets the root; empty is rejected + - path: kubeProviderRef + doc: >- + The `KubeProvider` to mirror from: its own namespace, or `namespace` names one whose + `accessFrom` admits it - path: clusterProviderRef - doc: "Source cluster to mirror from. The default names a `ClusterProvider` called `default`" + skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: commit.window skip: a block; its two timers are listed below - path: commit.window.idleTimeout @@ -243,10 +251,12 @@ kinds: title: "`FluxTrigger` (namespaced): the Flux `Kustomization` GitOps Reverser asks to apply" intro: "The references are immutable: to hold another `Kustomization`, delete the trigger and create a new one." fields: - - path: clusterProviderRef + - path: kubeProviderRef doc: >- - The `ClusterProvider` whose cluster holds the `Kustomization`. Its `accessFrom` must admit - the trigger's namespace + The `KubeProvider` whose cluster holds the `Kustomization`: the trigger's own namespace, or + `namespace` names one whose `accessFrom` admits it + - path: clusterProviderRef + skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: kustomizationRef doc: >- The `Kustomization` that applies the folders of the `GitTarget`s naming this trigger. See @@ -256,10 +266,12 @@ kinds: title: "`ArgoCDTrigger` (namespaced): the Argo CD `Application` GitOps Reverser asks to sync" intro: "The references are immutable: to hold another `Application`, delete the trigger and create a new one." fields: - - path: clusterProviderRef + - path: kubeProviderRef doc: >- - The `ClusterProvider` whose cluster holds the `Application`. Its `accessFrom` must admit - the trigger's namespace + The `KubeProvider` whose cluster holds the `Application`: the trigger's own namespace, or + `namespace` names one whose `accessFrom` admits it + - path: clusterProviderRef + skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: applicationRef doc: >- The `Application`, with automated sync off, that syncs the folders of the `GitTarget`s diff --git a/hack/e2e/install-plain-manifests.sh b/hack/e2e/install-plain-manifests.sh index 6cd6e24c3..f268bd613 100755 --- a/hack/e2e/install-plain-manifests.sh +++ b/hack/e2e/install-plain-manifests.sh @@ -31,7 +31,7 @@ tmpdir="$(mktemp -d)" trap 'rm -rf "${tmpdir}"' EXIT # CRDs ship as their own file and must exist (and be Established) BEFORE the bundle is applied: -# dist/install.yaml contains the reserved `default` ClusterProvider, a custom resource kubectl +# dist/install.yaml contains the `local` KubeProvider, a custom resource kubectl # cannot map if its CRD is created in the same apply. This mirrors the documented two-step user # flow (kubectl apply -f crds.yaml, then -f install.yaml), so the e2e exercises it for real. "${KUBECTL}" --context "${CTX}" apply -f dist/crds.yaml diff --git a/hack/generate-audit-webhook-kubeconfig.sh b/hack/generate-audit-webhook-kubeconfig.sh index ff69944f9..c7db57646 100755 --- a/hack/generate-audit-webhook-kubeconfig.sh +++ b/hack/generate-audit-webhook-kubeconfig.sh @@ -7,10 +7,10 @@ set -euo pipefail CTX="${CTX:-k3d-gitops-reverser-test-e2e}" NAMESPACE="${NAMESPACE:-gitops-reverser}" AUDIT_CLUSTER_ID="${AUDIT_CLUSTER_ID:-kind-e2e}" -# Audit routes are NAMED (/audit-webhook/); "default" is the provider a -# GitTarget references when it omits spec.clusterProviderRef. The bare /audit-webhook is the -# shared, annotation-routed endpoint and 400s unless the cluster annotation key is configured. -AUDIT_WEBHOOK_SERVER_URL="${AUDIT_WEBHOOK_SERVER_URL:-https://127.0.0.1:30444/audit-webhook/default}" +# Audit routes are NAMED (/audit-webhook/). The install's KubeProvider is "local" in +# the operator's namespace, so its route defaults to .local. The bare /audit-webhook is +# the shared, annotation-routed endpoint and 400s unless the cluster annotation key is configured. +AUDIT_WEBHOOK_SERVER_URL="${AUDIT_WEBHOOK_SERVER_URL:-https://127.0.0.1:30444/audit-webhook/${NAMESPACE}.local}" AUDIT_TLS_SERVER_NAME="${AUDIT_TLS_SERVER_NAME:-gitops-reverser-audit.${NAMESPACE}.svc}" WAIT_RETRIES="${WAIT_RETRIES:-60}" WAIT_SECONDS="${WAIT_SECONDS:-2}" diff --git a/internal/applier/applier.go b/internal/applier/applier.go index bd0e0f1df..e30705bbd 100644 --- a/internal/applier/applier.go +++ b/internal/applier/applier.go @@ -160,7 +160,7 @@ const ( // ReasonAutomatedSyncEnabled is an Argo CD Application with automated sync on, which syncs on // its own and so is never asked to. ReasonAutomatedSyncEnabled = "AutomatedSyncEnabled" - // ReasonForbidden is a ClusterProvider identity that may not read or write the applier. + // ReasonForbidden is a KubeProvider identity that may not read or write the applier. ReasonForbidden = "Forbidden" // ReasonCheckFailed is a check that could not complete: the applier's cluster did not answer. // Verdict.Err carries the cause; it says nothing about the applier itself. @@ -252,7 +252,7 @@ func BindingMismatch(trigger v1alpha3.ApplyTrigger, want Binding) string { return bindingMismatch(trigger, url, branch, want) } -// AccessCheck reports whether the ClusterProvider identity may perform attrs. +// AccessCheck reports whether the KubeProvider identity may perform attrs. type AccessCheck func(ctx context.Context, attrs authorizationv1.ResourceAttributes) (bool, error) // permanentError marks a request no retry can make succeed: the applier is configured in a way a @@ -324,7 +324,7 @@ func readFailure( case apierrors.IsForbidden(err): return Verdict{ Reason: ReasonForbidden, - Message: fmt.Sprintf("the ClusterProvider identity may not get %s %s: %v", kind, ref, err), + Message: fmt.Sprintf("the KubeProvider identity may not get %s %s: %v", kind, ref, err), } default: return Verdict{ @@ -369,7 +369,7 @@ func accessVerdict( if !allowed { return &Verdict{ Reason: ReasonForbidden, - Message: fmt.Sprintf("the ClusterProvider identity may not %s %s %s (%s)", + Message: fmt.Sprintf("the KubeProvider identity may not %s %s %s (%s)", verb, kind, ref, gvr.GroupResource()), } } diff --git a/internal/applier/dispatcher.go b/internal/applier/dispatcher.go index 9b40f2d6c..dcda4a227 100644 --- a/internal/applier/dispatcher.go +++ b/internal/applier/dispatcher.go @@ -60,8 +60,8 @@ const shortCommitLength = 8 // errNotRunning fails an attempt made before the dispatcher started or after it stopped. var errNotRunning = errors.New("the apply request dispatcher is not running") -// Clients resolves the dynamic client of the cluster an applier lives in, by ClusterProvider -// name (a trigger's spec.clusterProviderRef). In production it is +// Clients resolves the dynamic client of the cluster an applier lives in, by KubeProvider +// name (a trigger's spec.kubeProviderRef). In production it is // (*watch.Manager).ApplierDynamicClient. type Clients func(ctx context.Context, clusterID string) (dynamic.Interface, error) @@ -102,7 +102,7 @@ const pollTimeout = 10 * time.Second // What it does check is what can change between the worker's decision and the write. It reads each // GitTarget again and asks only for those that are still the incarnation the worker decided for, // still name a trigger that holds this applier on this branch, are still admitted by their -// ClusterProviders, and, for a Refusal, still consent. When no target still holds the request's +// KubeProviders, and, for a Refusal, still consent. When no target still holds the request's // reason it asks nothing at all, rather than ask for something weaker: a weaker request is the // worker's to decide. Then the applier's client (Flux or Argo CD, by the applier's kind) checks the // source binding before it writes. @@ -772,8 +772,8 @@ func (d *Dispatcher) recheck(ctx context.Context, req git.ApplyRequest) ([]eligi // recheckTarget reads one GitTarget again and returns it when it may still ask the applier for its // reason, or nil when it may not. // -// A ClusterProvider's client is shared by every GitTarget that uses the cluster, so a client being -// available is no permission to use it: a GitTarget its ClusterProviders do not admit (its own, and +// A KubeProvider's client is shared by every GitTarget that uses the cluster, so a client being +// available is no permission to use it: a GitTarget its KubeProviders do not admit (its own, and // its trigger's) would otherwise drive the applier on the strength of another GitTarget's admission. func (d *Dispatcher) recheckTarget( ctx context.Context, req git.ApplyRequest, candidate git.ApplyTarget, @@ -800,10 +800,10 @@ func (d *Dispatcher) recheckTarget( } decision, err := authz.ApplierAdmitted(ctx, d.reader, target, trigger) if err != nil { - return nil, fmt.Errorf("check GitTarget %s against its ClusterProviders: %w", candidate.Ref, err) + return nil, fmt.Errorf("check GitTarget %s against its KubeProviders: %w", candidate.Ref, err) } if !decision.Allowed { - log.Info("GitTarget is not admitted by its ClusterProviders; not asking its applier", + log.Info("GitTarget is not admitted by its KubeProviders; not asking its applier", "reason", decision.Reason, "message", decision.Message) return nil, nil //nolint:nilnil // as above } diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index 016fd042b..1e9c92e7b 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -41,6 +41,21 @@ const ( targetNS = "team-a" ) +// kubeProviderNS is where the tests' KubeProviders live: a namespace of their own, so whether one +// admits targetNS is its spec.accessFrom's decision rather than the own-namespace rule's. +const kubeProviderNS = "clusters" + +// localCluster is the KubeProvider a test GitTarget mirrors through when it names none. +const localCluster = "local" + +// clusterID is the source-cluster id of the test KubeProvider called name: its namespace/name. +func clusterID(name string) string { return kubeProviderNS + "/" + name } + +// kubeProviderRef references the test KubeProvider called name, spelling its namespace out. +func kubeProviderRef(name string) fluxmeta.NamespacedObjectReference { + return fluxmeta.NamespacedObjectReference{Namespace: kubeProviderNS, Name: name} +} + var ( testBranchKey = git.BranchKeyFor(testRepoURL, testBranch) testProvider = types.NamespacedName{Namespace: targetNS, Name: "platform"} @@ -48,14 +63,16 @@ var ( ) // Test-only annotations on a GitTarget, which newDispatcherFixture reads to create the trigger the -// GitTarget names: the applier it holds ("namespace/name") and the cluster it is in. +// GitTarget names: the applier it holds ("namespace/name") and the cluster it is in (a source-cluster +// id, "namespace/name" of its KubeProvider). const ( testKustomizationAnnotation = "test.gitops-reverser/kustomization" testApplicationAnnotation = "test.gitops-reverser/application" testTriggerClusterAnnotation = "test.gitops-reverser/trigger-cluster" ) -// gitTarget builds a GitTarget on testBranchKey, mirroring cluster (the default when empty). A +// gitTarget builds a GitTarget on testBranchKey, mirroring the KubeProvider cluster (localCluster +// when empty). A // kustomizationRef makes it name the FluxTrigger of that Kustomization's name, which holds it in the // same cluster; nil names no trigger. func gitTarget(name string, kustomizationRef *fluxmeta.NamespacedObjectReference, cluster string) *v1alpha3.GitTarget { @@ -66,9 +83,10 @@ func gitTarget(name string, kustomizationRef *fluxmeta.NamespacedObjectReference Path: name, }, } - if cluster != "" { - target.Spec.ClusterProviderRef = &fluxmeta.LocalObjectReference{Name: cluster} + if cluster == "" { + cluster = localCluster } + target.Spec.KubeProviderRef = kubeProviderRef(cluster) if kustomizationRef != nil { namesKustomization(target, kustomizationRef.Namespace, kustomizationRef.Name) target.Annotations[testTriggerClusterAnnotation] = target.SourceCluster() @@ -102,15 +120,16 @@ func triggersFor(objects []client.Object) []client.Object { seen[key] = true cluster := target.Annotations[testTriggerClusterAnnotation] if cluster == "" { - cluster = v1alpha3.DefaultClusterProviderName + cluster = clusterID(localCluster) } meta := metav1.ObjectMeta{Namespace: target.Namespace, Name: target.Spec.ApplyTriggerRef.Name} - provider := &fluxmeta.LocalObjectReference{Name: cluster} + providerNS, providerName, _ := strings.Cut(cluster, "/") + provider := fluxmeta.NamespacedObjectReference{Namespace: providerNS, Name: providerName} if app, ok := target.Annotations[testApplicationAnnotation]; ok { namespace, name, _ := strings.Cut(app, "/") triggers = append(triggers, &v1alpha3.ArgoCDTrigger{ObjectMeta: meta, Spec: v1alpha3.ArgoCDTriggerSpec{ - ClusterProviderRef: provider, - ApplicationRef: fluxmeta.NamespacedObjectReference{Namespace: namespace, Name: name}, + KubeProviderRef: provider, + ApplicationRef: fluxmeta.NamespacedObjectReference{Namespace: namespace, Name: name}, }}) continue } @@ -118,8 +137,8 @@ func triggersFor(objects []client.Object) []client.Object { namespace, name, _ := strings.Cut(ks, "/") // Its reconciler holds the Kustomization (Ask.Held), unless a test says otherwise. triggers = append(triggers, &v1alpha3.FluxTrigger{ObjectMeta: meta, Spec: v1alpha3.FluxTriggerSpec{ - ClusterProviderRef: provider, - KustomizationRef: fluxmeta.NamespacedObjectReference{Namespace: namespace, Name: name}, + KubeProviderRef: provider, + KustomizationRef: fluxmeta.NamespacedObjectReference{Namespace: namespace, Name: name}, }, Status: v1alpha3.FluxTriggerStatus{Conditions: []metav1.Condition{{ Type: TriggerConditionHeld, Status: metav1.ConditionTrue, Reason: "Suspended", LastTransitionTime: metav1.Now(), @@ -135,20 +154,22 @@ func reapplying(target *v1alpha3.GitTarget) *v1alpha3.GitTarget { return target } -// clusterProvider admits the GitTargets in the named namespaces. -func clusterProvider(name string, admits ...string) *v1alpha3.ClusterProvider { - return &v1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Spec: v1alpha3.ClusterProviderSpec{ +// kubeProvider is the KubeProvider name in kubeProviderNS, admitting the GitTargets in the named +// namespaces. +func kubeProvider(name string, admits ...string) *v1alpha3.KubeProvider { + return &v1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: kubeProviderNS, Name: name}, + Spec: v1alpha3.KubeProviderSpec{ + InCluster: &v1alpha3.KubeProviderInCluster{}, AccessFrom: &v1alpha3.NamespaceMatcher{Names: admits}, // The test appliers live in flux-system and argocd, not in the triggers' namespace. - AllowCrossNamespace: &v1alpha3.ClusterProviderCrossNamespace{Appliers: true}, + AllowCrossNamespace: &v1alpha3.KubeProviderCrossNamespace{Appliers: true}, }, } } // controlPlane is what every dispatcher test reads besides its GitTargets: testProvider, the -// GitProvider of testBranchKey, and the ClusterProviders the tests name, admitting targetNS. +// GitProvider of testBranchKey, and the KubeProviders the tests name, admitting targetNS. func controlPlane() []client.Object { return []client.Object{ &v1alpha3.GitProvider{ @@ -161,8 +182,8 @@ func controlPlane() []client.Object { GitProviderRef: fluxmeta.LocalObjectReference{Name: testProvider.Name}, Branch: testBranch, }, }, - clusterProvider(v1alpha3.DefaultClusterProviderName, targetNS), - clusterProvider("prod-eu-1", targetNS), + kubeProvider(localCluster, targetNS), + kubeProvider("prod-eu-1", targetNS), } } @@ -176,17 +197,17 @@ func ref(name string) itypes.ResourceReference { return itypes.NewResourceRefere // uid is the uid gitTarget gives the GitTarget of that name. func uid(name string) types.UID { return types.UID("uid-" + name) } -// kustomizationApplier is a Kustomization applier in a cluster, held by the FluxTrigger in targetNS -// of the Kustomization's name. +// kustomizationApplier is a Kustomization applier in the cluster of the test KubeProvider called +// cluster, held by the FluxTrigger in targetNS of the Kustomization's name. func kustomizationApplier(cluster, namespace, name string) git.ApplierRef { return git.ApplierRef{ Trigger: git.TriggerRef{Kind: v1alpha3.ApplyTriggerKindFlux, Namespace: targetNS, Name: name}, - Cluster: cluster, Kind: v1alpha3.ApplierKindKustomization, Namespace: namespace, Name: name, + Cluster: clusterID(cluster), Kind: v1alpha3.ApplierKindKustomization, Namespace: namespace, Name: name, } } -// checkout is the applier most tests ask: flux-system/checkout in the default cluster. -var checkout = kustomizationApplier(v1alpha3.DefaultClusterProviderName, fluxSystemNS, "checkout") +// checkout is the applier most tests ask: flux-system/checkout in the local cluster. +var checkout = kustomizationApplier(localCluster, fluxSystemNS, "checkout") // request is one attempt for an applier to apply testCommit, for the named targets, all asking for // reason. @@ -383,7 +404,7 @@ func TestDispatcher_AsksOnceForEveryTarget(t *testing.T) { assert.Equal(t, git.ApplyResult{ID: 1, Outcome: git.ApplyRequested}, res) assert.Equal(t, fetchedAndResumed, f.patches(), "one fetch and one resume for the applier") - assert.Equal(t, []string{v1alpha3.DefaultClusterProviderName}, f.source.clusters) + assert.Equal(t, []string{clusterID(localCluster)}, f.source.clusters) assert.ElementsMatch(t, []string{ "Normal ApplyRequested asked Kustomization flux-system/checkout to apply 4f2c1ab9 (Refusal)", "Normal ApplyRequested asked Kustomization flux-system/checkout to apply 4f2c1ab9 (CatchUp)", @@ -409,14 +430,14 @@ func TestDispatcher_LooksTheApplierUpInItsCluster(t *testing.T) { ) assert.Equal(t, git.ApplyRequested, f.attempt(request(own, v1alpha3.ApplyReasonForeignCommit, "c")).Outcome) - assert.Equal(t, []string{"prod-eu-1"}, f.source.clusters) + assert.Equal(t, []string{clusterID("prod-eu-1")}, f.source.clusters) assert.Equal(t, []string{"gitrepositories " + targetNS, "kustomizations " + targetNS + " (suspend=false)"}, f.patches()) } -// appliedFrom puts the applier of a GitTarget's trigger in the cluster another ClusterProvider names. +// appliedFrom puts the applier of a GitTarget's trigger in the cluster of another test KubeProvider. func appliedFrom(target *v1alpha3.GitTarget, cluster string) *v1alpha3.GitTarget { - target.Annotations[testTriggerClusterAnnotation] = cluster + target.Annotations[testTriggerClusterAnnotation] = clusterID(cluster) return target } @@ -428,13 +449,13 @@ func TestDispatcher_AsksAnApplierInItsOwnCluster(t *testing.T) { t, []client.Object{ appliedFrom(gitTarget("a", checkoutRef(), "prod-eu-1"), "hub"), - clusterProvider("hub", targetNS), + kubeProvider("hub", targetNS), }, behindSource()..., ) assert.Equal(t, git.ApplyRequested, f.attempt(request(hub, v1alpha3.ApplyReasonForeignCommit, "a")).Outcome) - assert.Equal(t, []string{"hub"}, f.source.clusters) + assert.Equal(t, []string{clusterID("hub")}, f.source.clusters) // The same Kustomization asked as if it lived in the source cluster is not this target's applier. f.source.clusters = nil @@ -443,7 +464,7 @@ func TestDispatcher_AsksAnApplierInItsOwnCluster(t *testing.T) { assert.Empty(t, f.source.clusters) } -// TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing: the applier's ClusterProvider gates who +// TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing: the applier's KubeProvider gates who // may wield its credential exactly as a source's does, so a GitTarget its accessFrom does not admit // asks nothing, though its own source admits it. func TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing(t *testing.T) { @@ -451,7 +472,7 @@ func TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing(t *testing.T) { f := newDispatcherFixture(t, []client.Object{ appliedFrom(gitTarget("a", checkoutRef(), "prod-eu-1"), "hub"), - clusterProvider("hub", "some-other-namespace"), + kubeProvider("hub", "some-other-namespace"), }, behindSource()..., ) @@ -516,15 +537,15 @@ func TestDispatcher_ASourceMismatchIsRejectedAndNeverStamped(t *testing.T) { assert.Empty(t, f.patches()) } -// TestDispatcher_ATargetItsClusterProviderDeniesAsksNothing is the first review's finding 1: the +// TestDispatcher_ATargetItsKubeProviderDeniesAsksNothing is the first review's finding 1: the // source client is shared by every GitTarget that declared the cluster, so a GitTarget its -// ClusterProvider does not admit drove the applier on another GitTarget's admission. -func TestDispatcher_ATargetItsClusterProviderDeniesAsksNothing(t *testing.T) { +// KubeProvider does not admit drove the applier on another GitTarget's admission. +func TestDispatcher_ATargetItsKubeProviderDeniesAsksNothing(t *testing.T) { restricted := kustomizationApplier("restricted", fluxSystemNS, "checkout") f := newDispatcherFixture(t, []client.Object{ gitTarget("denied", checkoutRef(), "restricted"), - clusterProvider("restricted", "some-other-namespace"), + kubeProvider("restricted", "some-other-namespace"), }, behindSource()..., ) @@ -965,7 +986,7 @@ func argoTarget() *v1alpha3.GitTarget { // argoPlatform is the applier argoTarget's trigger holds. var argoPlatform = git.ApplierRef{ Trigger: git.TriggerRef{Kind: v1alpha3.ApplyTriggerKindArgoCD, Namespace: targetNS, Name: "platform"}, - Cluster: v1alpha3.DefaultClusterProviderName, Kind: v1alpha3.ApplierKindApplication, + Cluster: clusterID(localCluster), Kind: v1alpha3.ApplierKindApplication, Namespace: argoNS, Name: "platform", } @@ -1030,7 +1051,7 @@ func TestDispatcher_AnApplicationWithAutomatedSyncIsRejectedAndNeverSynced(t *te // GitTarget names, so a request for a Kustomization of the same name asks nothing for it. func TestDispatcher_AnApplicationIsNotTheKustomizationOfItsName(t *testing.T) { f := newDispatcherFixture(t, []client.Object{argoTarget()}, manualApp()) - sameName := kustomizationApplier(v1alpha3.DefaultClusterProviderName, argoNS, "platform") + sameName := kustomizationApplier(localCluster, argoNS, "platform") assert.Equal(t, git.ApplyDropped, f.attempt(request(sameName, v1alpha3.ApplyReasonForeignCommit, "a")).Outcome) assert.Empty(t, f.updates()) diff --git a/internal/applier/flux_guide_test.go b/internal/applier/flux_guide_test.go index 7978a111e..04e9e3349 100644 --- a/internal/applier/flux_guide_test.go +++ b/internal/applier/flux_guide_test.go @@ -167,21 +167,21 @@ func decodeStrict(t *testing.T, objects map[string][]*unstructured.Unstructured, require.NoError(t, decoder.Decode(into), "the guide's %s must decode with no unknown field", kind) } -// exampleReachesItsApplier checks that the guide's example ClusterProvider admits the trigger's +// exampleReachesItsApplier checks that the guide's example KubeProvider admits the trigger's // namespace and lets it reach its applier, through the admission the trigger reconciler runs. func exampleReachesItsApplier( t *testing.T, objects map[string][]*unstructured.Unstructured, trigger v1alpha3.ApplyTrigger, ) { t.Helper() - var provider v1alpha3.ClusterProvider - decodeStrict(t, objects, "ClusterProvider", &provider) - assert.Equal(t, trigger.ClusterProvider(), provider.Name, "the example declares the trigger's ClusterProvider") + var provider v1alpha3.KubeProvider + decodeStrict(t, objects, "KubeProvider", &provider) + assert.Equal(t, trigger.KubeProviderKey(), provider.Key(), "the example declares the trigger's KubeProvider") scheme := runtime.NewScheme() require.NoError(t, clientgoscheme.AddToScheme(scheme)) require.NoError(t, v1alpha3.AddToScheme(scheme)) control := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&provider).Build() decision, err := authz.ApplyTriggerAdmitted(context.Background(), control, trigger) require.NoError(t, err) - assert.True(t, decision.Allowed, "the example's ClusterProvider lets the trigger reach its applier: %s: %s", + assert.True(t, decision.Allowed, "the example's KubeProvider lets the trigger reach its applier: %s: %s", decision.Reason, decision.Message) } diff --git a/internal/applier/holder.go b/internal/applier/holder.go index ced395597..7a05b1d47 100644 --- a/internal/applier/holder.go +++ b/internal/applier/holder.go @@ -26,15 +26,15 @@ import ( // keeps no younger one out, and one that is retiring stays the holder until its release is done. // ReasonHeldByAnotherTrigger is Configured=False on a trigger whose applier another trigger of its -// kind holds: one in line that names the same object in the same ClusterProvider's cluster, and has +// kind holds: one in line that names the same object in the same KubeProvider's cluster, and has // an apply in flight, holds the Kustomization already, or was created first. The trigger takes the // applier over once it no longer comes first. const ReasonHeldByAnotherTrigger = "HeldByAnotherTrigger" // sameApplier reports whether two triggers name one applier: the same kind of object, in the same -// ClusterProvider's cluster, with the same namespace and name. +// KubeProvider's cluster, with the same namespace and name. func sameApplier(a, b v1alpha3.ApplyTrigger) bool { - return a.TriggerKind() == b.TriggerKind() && a.ClusterProvider() == b.ClusterProvider() && + return a.TriggerKind() == b.TriggerKind() && a.KubeProviderKey() == b.KubeProviderKey() && a.Applier() == b.Applier() } @@ -196,8 +196,8 @@ func ListApplyTriggers( // HeldByAnotherMessage is the Configured message of a trigger whose applier holder holds. func HeldByAnotherMessage(trigger, holder v1alpha3.ApplyTrigger) string { - held := fmt.Sprintf("%s %s in the cluster of ClusterProvider %q is held by %s %s/%s", trigger.ApplierKind(), - trigger.Applier(), trigger.ClusterProvider(), holder.TriggerKind(), holder.GetNamespace(), holder.GetName()) + held := fmt.Sprintf("%s %s in the cluster of KubeProvider %s is held by %s %s/%s", trigger.ApplierKind(), + trigger.Applier(), trigger.KubeProviderKey(), holder.TriggerKind(), holder.GetNamespace(), holder.GetName()) switch { case holdsAnApply(holder) && !holdsAnApply(trigger): return held + ", whose apply is in flight; this trigger takes it over once that apply ends" diff --git a/internal/applier/holder_test.go b/internal/applier/holder_test.go index 730dbeddd..f66d83ca1 100644 --- a/internal/applier/holder_test.go +++ b/internal/applier/holder_test.go @@ -47,7 +47,7 @@ func rival(t *testing.T, f *dispatcherFixture, kind, name string, older bool) { } // referenceFrom puts a GitTarget in trigger's namespace that names it, and admits that namespace -// on the ClusterProviders, so the trigger is in line for its applier. +// on the KubeProviders, so the trigger is in line for its applier. func referenceFrom(t *testing.T, c client.Client, trigger v1alpha3.ApplyTrigger) { t.Helper() ctx := context.Background() @@ -55,10 +55,10 @@ func referenceFrom(t *testing.T, c client.Client, trigger v1alpha3.ApplyTrigger) target.Namespace = trigger.GetNamespace() target.Spec.ApplyTriggerRef = &v1alpha3.ApplyTriggerReference{Kind: trigger.TriggerKind(), Name: trigger.GetName()} require.NoError(t, c.Create(ctx, target)) - for _, name := range []string{v1alpha3.DefaultClusterProviderName, "prod-eu-1", trigger.ClusterProvider()} { - provider := &v1alpha3.ClusterProvider{} - if err := c.Get(ctx, client.ObjectKey{Name: name}, provider); err != nil { - require.NoError(t, c.Create(ctx, clusterProvider(name, targetNS, trigger.GetNamespace()))) + for _, name := range []string{localCluster, "prod-eu-1", trigger.KubeProviderKey().Name} { + provider := &v1alpha3.KubeProvider{} + if err := c.Get(ctx, client.ObjectKey{Namespace: kubeProviderNS, Name: name}, provider); err != nil { + require.NoError(t, c.Create(ctx, kubeProvider(name, targetNS, trigger.GetNamespace()))) continue } provider.Spec.AccessFrom.Names = append(provider.Spec.AccessFrom.Names, trigger.GetNamespace()) @@ -120,22 +120,22 @@ func TestHolder_FirstInLine(t *testing.T) { return &v1alpha3.FluxTrigger{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, CreationTimestamp: created}, Spec: v1alpha3.FluxTriggerSpec{ - ClusterProviderRef: &fluxmeta.LocalObjectReference{Name: cluster}, - KustomizationRef: fluxmeta.NamespacedObjectReference{Namespace: "flux-system", Name: ks}, + KubeProviderRef: kubeProviderRef(cluster), + KustomizationRef: fluxmeta.NamespacedObjectReference{Namespace: "flux-system", Name: ks}, }, } } - self := trigger("team-c", "mine", "default", "checkout", at(10)) - deleting := trigger("team-z", "old", "default", "checkout", at(5)) + self := trigger("team-c", "mine", localCluster, "checkout", at(10)) + deleting := trigger("team-z", "old", localCluster, "checkout", at(5)) deleting.Finalizers = []string{v1alpha3.FluxTriggerFinalizer} deleting.DeletionTimestamp = &metav1.Time{Time: time.Unix(100, 0)} objects := []client.Object{ self, trigger("team-a", "elsewhere", "hub", "checkout", at(1)), - trigger("team-a", "other", "default", "infra", at(1)), - trigger("team-b", "same-second", "default", "checkout", at(10)), + trigger("team-a", "other", localCluster, "infra", at(1)), + trigger("team-b", "same-second", localCluster, "checkout", at(10)), } - unreferenced := trigger("team-a", "stray", "default", "checkout", at(1)) + unreferenced := trigger("team-a", "stray", localCluster, "checkout", at(1)) f := newDispatcherFixture(t, nil) for _, obj := range append(objects, unreferenced) { require.NoError(t, f.control.Create(context.Background(), obj)) diff --git a/internal/applier/references.go b/internal/applier/references.go index bbe50abb5..040eda3d8 100644 --- a/internal/applier/references.go +++ b/internal/applier/references.go @@ -18,7 +18,7 @@ import ( ) // A trigger acts only while an eligible GitTarget references it: one that names it, is admitted by -// its ClusterProviders, and writes the repository and branch the applier fetches. A trigger nothing +// its KubeProviders, and writes the repository and branch the applier fetches. A trigger nothing // eligible names serves nobody, so it reads and writes nothing in the applier's cluster, and is not // in line for its applier. Eligibility is judged from those inputs alone, never from the // GitTarget's Ready, which reads the trigger's own Ready back (ApplyTriggerReady) and would make @@ -38,7 +38,7 @@ type ReferenceState int const ( // ReferenceEligible names the trigger, is admitted, and writes what the applier fetches. ReferenceEligible ReferenceState = iota - // ReferenceUndecided cannot be judged yet: its GitProvider or a ClusterProvider is missing, or + // ReferenceUndecided cannot be judged yet: its GitProvider or a KubeProvider is missing, or // the applier's source has not been read. ReferenceUndecided // ReferenceRefused is not admitted, or writes another repository or branch. @@ -167,10 +167,10 @@ func judgeReference( decision, err := authz.ApplierAdmitted(ctx, reader, target, trigger) switch { case err != nil: - return ref, fmt.Errorf("check GitTarget %s against its ClusterProviders: %w", ref.Target, err) + return ref, fmt.Errorf("check GitTarget %s against its KubeProviders: %w", ref.Target, err) case decision.Allowed: - case decision.Reason == authz.ReasonClusterProviderNotFound: - // A provider may be on its way, as the trigger's own ClusterProviderNotFound may. + case decision.Reason == authz.ReasonKubeProviderNotFound: + // A provider may be on its way, as the trigger's own KubeProviderNotFound may. ref.State, ref.Message = ReferenceUndecided, decision.Message return ref, nil default: diff --git a/internal/applier/status_writer_test.go b/internal/applier/status_writer_test.go index f90e61db2..a26c9e5cc 100644 --- a/internal/applier/status_writer_test.go +++ b/internal/applier/status_writer_test.go @@ -36,8 +36,8 @@ func statusWriterFixture(t *testing.T, funcs interceptor.Funcs) (*StatusWriter, trigger := &v1alpha3.FluxTrigger{ ObjectMeta: metav1.ObjectMeta{Namespace: targetNS, Name: "apps"}, Spec: v1alpha3.FluxTriggerSpec{ - ClusterProviderRef: &fluxmeta.LocalObjectReference{Name: v1alpha3.DefaultClusterProviderName}, - KustomizationRef: fluxmeta.NamespacedObjectReference{Name: "apps"}, + KubeProviderRef: kubeProviderRef(localCluster), + KustomizationRef: fluxmeta.NamespacedObjectReference{Name: "apps"}, }, Status: v1alpha3.FluxTriggerStatus{Conditions: []metav1.Condition{{ Type: "Configured", Status: metav1.ConditionTrue, Reason: "Succeeded", diff --git a/internal/attribution/route_health.go b/internal/attribution/route_health.go index 89e19e72e..1326fc860 100644 --- a/internal/attribution/route_health.go +++ b/internal/attribution/route_health.go @@ -34,10 +34,10 @@ const firstFactEventsBuffer = 64 // read side — whether attribution has ever resolved and how many events have gone unresolved // since. // -// It makes one specific misconfiguration visible: a ClusterProvider whose +// It makes one specific misconfiguration visible: a KubeProvider whose // spec.attribution.auditRoute names a route no API server posts under reads a partition nothing // writes, so every commit through it is authored "attribution unresolved" with no error, no failed -// reconcile, and perfect mirroring. The published-fact half is what the ClusterProvider's +// reconcile, and perfect mirroring. The published-fact half is what the KubeProvider's // AuditFactsReceived condition latches on; the resolve half is the log warning. // // The zero value is ready to use. Every method is safe on a nil receiver, so a caller wired for @@ -57,8 +57,8 @@ type RouteHealth struct { delivered bool transportFailing bool - // firstFactEvents carries a ClusterProvider-shaped GenericEvent naming the ROUTE (not an - // object) the first time a fact lands on it, so the ClusterProvider controller re-reconciles + // firstFactEvents carries a KubeProvider-shaped GenericEvent naming the ROUTE (not an + // object) the first time a fact lands on it, so the KubeProvider controller re-reconciles // the providers reading that route within seconds instead of on their periodic requeue. firstFactEvents chan event.GenericEvent @@ -124,9 +124,9 @@ func notifyFirstFact(ch chan event.GenericEvent, route string) { if ch == nil { return } - // The object is a CARRIER for the route name, not a real ClusterProvider: several providers + // The object is a CARRIER for the route name, not a real KubeProvider: several providers // may declare one route, so the controller maps the route to the providers reading it. - evt := event.GenericEvent{Object: &configv1alpha3.ClusterProvider{ + evt := event.GenericEvent{Object: &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: route}, }} select { @@ -187,7 +187,7 @@ func (h *RouteHealth) TransportFailing() bool { // // It answers only for THIS process: the registry is in-process state and a restart empties it, // including under the memory transport. That is why the condition it feeds is latched in the -// object's persisted status rather than recomputed from here — see the ClusterProvider controller. +// object's persisted status rather than recomputed from here — see the KubeProvider controller. func (h *RouteHealth) FirstFactAt(route string) (time.Time, bool) { if h == nil { return time.Time{}, false @@ -199,7 +199,7 @@ func (h *RouteHealth) FirstFactAt(route string) (time.Time, bool) { } // FirstFactEvents returns the channel a controller wires via source.Channel so a route's first -// fact enqueues the ClusterProviders that read it. It is lazily created, so a zero-value registry +// fact enqueues the KubeProviders that read it. It is lazily created, so a zero-value registry // and a wired one behave the same. func (h *RouteHealth) FirstFactEvents() <-chan event.GenericEvent { if h == nil { diff --git a/internal/audit/outcome/outcome.go b/internal/audit/outcome/outcome.go index d27930876..726a02d76 100644 --- a/internal/audit/outcome/outcome.go +++ b/internal/audit/outcome/outcome.go @@ -81,7 +81,7 @@ const ( // NonNumericRV — an RV that is not a uint64 (aggregated apiservers). NonNumericRV Outcome = "non_numeric_rv" // MissingClusterAnnotation — an event on the shared (annotation-routed) /audit-webhook endpoint - // that carries no source-cluster annotation, so it names no ClusterProvider. Never credited to a + // that carries no source-cluster annotation, so it names no KubeProvider. Never credited to a // fallback; a rising rate means a producer is not stamping the annotation. MissingClusterAnnotation Outcome = "missing_cluster_annotation" // NoAttributionFact — the event passed the accept gate and produced no attribution fact, so diff --git a/internal/authz/gitbranch_admission_test.go b/internal/authz/gitbranch_admission_test.go index b576e2358..56ab10a1c 100644 --- a/internal/authz/gitbranch_admission_test.go +++ b/internal/authz/gitbranch_admission_test.go @@ -37,7 +37,7 @@ func sharedGitBranch(provider string) *configv1alpha3.GitBranch { // targetWriting is a GitTarget in testNS writing through the GitBranch namespace/name; an empty // namespace means its own. func targetWriting(namespace, name string) *configv1alpha3.GitTarget { - target := targetIn("") + target := targetIn("prod-eu-1") target.Spec.GitBranchRef = meta.NamespacedObjectReference{Name: name, Namespace: namespace} return target } @@ -47,9 +47,8 @@ func targetWriting(namespace, name string) *configv1alpha3.GitTarget { // spec.accessFrom admits the GitTarget's namespace. A GitBranch in the GitTarget's own namespace // needs no policy. func TestGitTargetAdmitted_GitBranchsProviderAccessFrom(t *testing.T) { - // The ClusterProvider gate passes in every case, so only the GitProvider's decides. - source := providerNamed(configv1alpha3.DefaultClusterProviderName, - &configv1alpha3.NamespaceMatcher{Names: []string{testNS}}) + // The KubeProvider gate passes in every case, so only the GitProvider's decides. + source := providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{testNS}}) tests := []struct { name string objects []client.Object diff --git a/internal/authz/clusterprovider_admission.go b/internal/authz/kubeprovider_admission.go similarity index 59% rename from internal/authz/clusterprovider_admission.go rename to internal/authz/kubeprovider_admission.go index 08dcead3a..a7192d43b 100644 --- a/internal/authz/clusterprovider_admission.go +++ b/internal/authz/kubeprovider_admission.go @@ -2,11 +2,9 @@ // Package authz holds the namespace-admission decisions for the providers a GitTarget uses. // -// A ClusterProvider is cluster-scoped and holds a credential that can read a lot of a source -// cluster; its spec.accessFrom is that provider's explicit, deny-by-default admission of -// the GitTarget NAMESPACES permitted to mirror through it. A GitProvider is namespaced and holds a -// write credential; its spec.accessFrom admits the OTHER namespaces whose GitTargets may write -// through it. +// A KubeProvider holds a credential that can read a lot of a source cluster; it admits its own +// namespace, and its spec.accessFrom admits the OTHER control-cluster namespaces permitted to use +// it. A GitProvider holds a write credential, and admits namespaces the same way. // // The decision lives in its own package — outside both internal/controller and internal/watch — // because more than one call site compiles rules or starts a data plane for a GitTarget: the @@ -28,28 +26,32 @@ import ( ) const ( - // ReasonClusterProviderNotFound is the denial reason when a GitTarget's referenced - // ClusterProvider does not exist. This is a HARD GATE: a GitTarget may mirror a source cluster - // ONLY through an existing ClusterProvider, "default" included. The operator never creates one, - // so a target whose provider was never declared is denied rather than mirroring on an implicit - // local identity. - ReasonClusterProviderNotFound = "ClusterProviderNotFound" - - // ReasonNamespaceNotAuthorized is the denial reason when the GitTarget's namespace is not - // admitted by its ClusterProvider's spec.accessFrom — including the case where that - // policy carries a selector the apiserver accepted but that does not convert to a selector. + // ReasonKubeProviderNotFound is the denial reason when a referenced KubeProvider does not + // exist. This is a HARD GATE: a GitTarget may mirror a source cluster ONLY through an existing + // KubeProvider. The operator never creates one, so a target whose provider was never declared + // is denied rather than mirroring on an implicit identity. + ReasonKubeProviderNotFound = "KubeProviderNotFound" + + // ReasonNamespaceNotAuthorized is the denial reason when the consumer's namespace is neither the + // KubeProvider's own nor admitted by its spec.accessFrom — including the case where that policy + // carries a selector the apiserver accepted but that does not convert to a selector. ReasonNamespaceNotAuthorized = "NamespaceNotAuthorized" // ReasonApplierNamespaceNotAllowed is the denial reason when a trigger names an applier in - // another namespace than its own and its ClusterProvider does not set + // another namespace than its own and its KubeProvider does not set // spec.allowCrossNamespace.appliers. ReasonApplierNamespaceNotAllowed = "ApplierNamespaceNotAllowed" + + // ReasonAuditRouteConflict is the denial reason when a KubeProvider's audit route is held by a + // namespace other than its own (v1alpha3.AuditRouteHolder). Such a provider is not used at all: + // mirroring through it would read the attribution facts of a tenant it was never admitted to. + ReasonAuditRouteConflict = "AuditRouteConflict" ) -// Decision is the outcome of a ClusterProvider namespace-admission check. A denial always carries +// Decision is the outcome of a KubeProvider namespace-admission check. A denial always carries // a Reason and an operator-legible Message; an admission carries neither. type Decision struct { - // Allowed reports whether the GitTarget's namespace may mirror through its ClusterProvider. + // Allowed reports whether the consumer's namespace may use the KubeProvider. Allowed bool // Reason is a CamelCase condition reason, set only when Allowed is false. Reason string @@ -57,7 +59,7 @@ type Decision struct { Message string } -// GitTargetAdmitted reports whether target's namespace is admitted by the ClusterProvider that +// GitTargetAdmitted reports whether target's namespace is admitted by the KubeProvider that // target references, reading both the provider and the target's Namespace through reader, and by // the GitProvider of the GitBranch it writes through (gitBranchAdmitted). // @@ -75,12 +77,10 @@ func GitTargetAdmitted( reader client.Reader, target *configv1alpha3.GitTarget, ) (Decision, error) { - providerName := target.SourceCluster() - decision, _, err := namespaceAdmitted(ctx, reader, target.Namespace, providerName, fmt.Sprintf( - "referenced ClusterProvider %q was not found; a GitTarget may mirror a source cluster "+ - "only through an existing ClusterProvider. The operator never creates one: declare it "+ - "yourself, or let the chart render %q via clusterProvider.createDefault", - providerName, configv1alpha3.DefaultClusterProviderName)) + key := target.KubeProviderKey() + decision, _, err := namespaceAdmitted(ctx, reader, target.Namespace, key, fmt.Sprintf( + "referenced KubeProvider %s was not found; a GitTarget may mirror a source cluster only "+ + "through an existing KubeProvider, which the operator never creates", key)) if err != nil || !decision.Allowed { return decision, err } @@ -139,8 +139,8 @@ func gitBranchAdmitted( return Decision{Allowed: true}, nil } -// ApplyTriggerAdmitted reports whether a trigger's namespace is admitted by the ClusterProvider it -// names (spec.clusterProviderRef), and whether it may reach its applier there. The applier is +// ApplyTriggerAdmitted reports whether a trigger's namespace is admitted by the KubeProvider it +// names (spec.kubeProviderRef), and whether it may reach its applier there. The applier is // reached with that provider's credential, so the provider's accessFrom decides who may wield it, // exactly as for mirroring, and its allowCrossNamespace.appliers whether a namespace may suspend // and apply another namespace's applier. The trigger's own namespace is always allowed; in a remote @@ -150,10 +150,10 @@ func ApplyTriggerAdmitted( reader client.Reader, trigger configv1alpha3.ApplyTrigger, ) (Decision, error) { - providerName := trigger.ClusterProvider() - decision, provider, err := namespaceAdmitted(ctx, reader, trigger.GetNamespace(), providerName, fmt.Sprintf( - "ClusterProvider %q named by %s %s/%s spec.clusterProviderRef was not found", - providerName, trigger.TriggerKind(), trigger.GetNamespace(), trigger.GetName())) + key := trigger.KubeProviderKey() + decision, provider, err := namespaceAdmitted(ctx, reader, trigger.GetNamespace(), key, fmt.Sprintf( + "KubeProvider %s named by %s %s/%s spec.kubeProviderRef was not found", + key, trigger.TriggerKind(), trigger.GetNamespace(), trigger.GetName())) if err != nil || !decision.Allowed { return decision, err } @@ -162,18 +162,18 @@ func ApplyTriggerAdmitted( return Decision{ Reason: ReasonApplierNamespaceNotAllowed, Message: fmt.Sprintf( - "%s %s is in another namespace than the trigger's own %q, and ClusterProvider %q does not "+ + "%s %s is in another namespace than the trigger's own %q, and KubeProvider %s does not "+ "set spec.allowCrossNamespace.appliers", trigger.ApplierKind(), applier, trigger.GetNamespace(), - providerName), + key), }, nil } return decision, nil } // ApplierAdmitted reports whether target may ask the applier its trigger holds to apply: its -// namespace is admitted by its own ClusterProvider (GitTargetAdmitted) and by the trigger's +// namespace is admitted by its own KubeProvider (GitTargetAdmitted) and by the trigger's // (ApplyTriggerAdmitted). A GitTarget names a trigger in its own namespace only, so the second -// answers for the GitTarget's namespace too. A ClusterProvider's client is shared by every object +// answers for the GitTarget's namespace too. A KubeProvider's client is shared by every object // that uses the cluster, so a client being available is no permission to use it. func ApplierAdmitted( ctx context.Context, @@ -185,26 +185,34 @@ func ApplierAdmitted( if err != nil || !decision.Allowed { return decision, err } - if trigger.ClusterProvider() == target.SourceCluster() && trigger.GetNamespace() == target.Namespace { + if trigger.KubeProviderKey() == target.KubeProviderKey() && trigger.GetNamespace() == target.Namespace { return decision, nil } return ApplyTriggerAdmitted(ctx, reader, trigger) } -// namespaceAdmitted is the check both admissions share: the provider exists, and its accessFrom -// admits namespace. notFound is the denial message for a provider that does not exist. It returns -// the provider it read once it exists. +// namespaceAdmitted is the check both admissions share: the provider exists, it admits namespace +// (its own, or through spec.accessFrom), and it holds its audit route. notFound is the denial +// message for a provider that does not exist. It returns the provider it read once it exists. func namespaceAdmitted( ctx context.Context, reader client.Reader, - namespace, providerName, notFound string, -) (Decision, *configv1alpha3.ClusterProvider, error) { - provider := &configv1alpha3.ClusterProvider{} - if err := reader.Get(ctx, k8stypes.NamespacedName{Name: providerName}, provider); err != nil { + namespace string, + key k8stypes.NamespacedName, + notFound string, +) (Decision, *configv1alpha3.KubeProvider, error) { + if key.Name == "" { + return Decision{ + Reason: ReasonKubeProviderNotFound, + Message: "spec.kubeProviderRef names no KubeProvider", + }, nil, nil + } + provider := &configv1alpha3.KubeProvider{} + if err := reader.Get(ctx, key, provider); err != nil { if apierrors.IsNotFound(err) { - return Decision{Reason: ReasonClusterProviderNotFound, Message: notFound}, nil, nil + return Decision{Reason: ReasonKubeProviderNotFound, Message: notFound}, nil, nil } - return Decision{}, nil, fmt.Errorf("read ClusterProvider %q: %w", providerName, err) + return Decision{}, nil, fmt.Errorf("read KubeProvider %s: %w", key, err) } nsLabels, err := namespaceLabels(ctx, reader, namespace) @@ -212,26 +220,62 @@ func namespaceAdmitted( return Decision{}, nil, err } - allowed, selErr := provider.AllowsNamespace(namespace, nsLabels) + allowed, selErr := provider.AdmitsNamespace(namespace, nsLabels) if selErr != nil { return Decision{ - Reason: ReasonNamespaceNotAuthorized, - Message: fmt.Sprintf( - "ClusterProvider %q accessFrom selector is invalid: %v", providerName, selErr), + Reason: ReasonNamespaceNotAuthorized, + Message: fmt.Sprintf("KubeProvider %s accessFrom selector is invalid: %v", key, selErr), }, provider, nil } if !allowed { return Decision{ Reason: ReasonNamespaceNotAuthorized, Message: fmt.Sprintf( - "namespace %q is not permitted to reference ClusterProvider %q (spec.accessFrom)", - namespace, providerName), + "namespace %q is not permitted to reference KubeProvider %s: it is not the provider's own "+ + "namespace, and spec.accessFrom does not admit it", namespace, key), }, provider, nil } + routeDecision, err := AuditRouteHeld(ctx, reader, provider) + if err != nil || !routeDecision.Allowed { + return routeDecision, provider, err + } return Decision{Allowed: true}, provider, nil } +// AuditRouteHeld reports whether provider's namespace holds the audit route provider carries +// (v1alpha3.AuditRouteHolder). A provider whose route another namespace holds is denied with +// ReasonAuditRouteConflict: it is not used at all, by any consumer. +// +// It reads every KubeProvider through reader, which on the manager's client is the informer cache. +// The holder is recomputed on every call rather than kept anywhere, so it can never disagree with +// the providers as they are. +func AuditRouteHeld( + ctx context.Context, + reader client.Reader, + provider *configv1alpha3.KubeProvider, +) (Decision, error) { + var providers configv1alpha3.KubeProviderList + if err := reader.List(ctx, &providers); err != nil { + return Decision{}, fmt.Errorf("list KubeProviders: %w", err) + } + route := provider.AuditRoute() + // The provider itself is always a carrier, even when the cache has not seen it yet. + providers.Items = append(providers.Items, *provider) + holder, held := configv1alpha3.AuditRouteHolder(route, providers.Items) + if !held || holder == provider.Namespace { + return Decision{Allowed: true}, nil + } + return Decision{ + Reason: ReasonAuditRouteConflict, + Message: fmt.Sprintf( + "KubeProvider %s carries audit route %q, which a KubeProvider in namespace %q holds, so it is "+ + "not used: its attribution facts belong to that namespace. Set spec.attribution.auditRoute to "+ + "another route, or omit it for the default %q", + provider.Key(), route, holder, configv1alpha3.DefaultAuditRoute(provider.Key())), + }, nil +} + // namespaceLabels reads a control-cluster Namespace's labels. A Namespace that does not exist has // none: a names entry still admits it, and a selector does not. func namespaceLabels(ctx context.Context, reader client.Reader, namespace string) (map[string]string, error) { diff --git a/internal/authz/clusterprovider_admission_test.go b/internal/authz/kubeprovider_admission_test.go similarity index 68% rename from internal/authz/clusterprovider_admission_test.go rename to internal/authz/kubeprovider_admission_test.go index e41eb40df..71e216daa 100644 --- a/internal/authz/clusterprovider_admission_test.go +++ b/internal/authz/kubeprovider_admission_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "testing" + "time" "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" @@ -20,7 +21,11 @@ import ( configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -const testNS = "team-a" +const ( + testNS = "team-a" + platformNS = "platform" + platformPrev = "platform-old" +) func admissionScheme(t *testing.T) *runtime.Scheme { t.Helper() @@ -30,22 +35,24 @@ func admissionScheme(t *testing.T) *runtime.Scheme { return s } -// targetIn builds a GitTarget in testNS referencing providerName ("" omits -// clusterProviderRef entirely, which resolves to the reserved "default" provider). +// targetIn builds a GitTarget in testNS referencing the KubeProvider providerName in platformNS +// ("" sets no kubeProviderRef at all). func targetIn(providerName string) *configv1alpha3.GitTarget { t := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "mirror", Namespace: testNS}, } if providerName != "" { - t.Spec.ClusterProviderRef = &meta.LocalObjectReference{Name: providerName} + t.Spec.KubeProviderRef = meta.NamespacedObjectReference{Name: providerName, Namespace: platformNS} } return t } -func providerNamed(name string, policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.ClusterProvider { - return &configv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Spec: configv1alpha3.ClusterProviderSpec{AccessFrom: policy}, +// providerNamed builds a KubeProvider in platformNS, so the GitTarget in testNS is admitted only +// through policy. +func providerNamed(name string, policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: platformNS}, + Spec: configv1alpha3.KubeProviderSpec{AccessFrom: policy}, } } @@ -67,31 +74,35 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { objects: []client.Object{namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantAllowed: false, - wantReason: ReasonClusterProviderNotFound, - wantMessage: `referenced ClusterProvider "prod-eu-1" was not found`, + wantReason: ReasonKubeProviderNotFound, + wantMessage: "referenced KubeProvider platform/prod-eu-1 was not found", }, { - // A GitTarget with no clusterProviderRef resolves to "default"; that name is not a - // bypass — an undeclared "default" is denied exactly like any other missing provider. - name: "omitted clusterProviderRef resolves to default and is still gated", + // No reference names nothing; there is no provider an omitted reference falls back to. + name: "an unset kubeProviderRef is denied, never defaulted", objects: []client.Object{namespaceLabeled(nil)}, target: targetIn(""), wantAllowed: false, - wantReason: ReasonClusterProviderNotFound, - wantMessage: `referenced ClusterProvider "default" was not found`, + wantReason: ReasonKubeProviderNotFound, + wantMessage: "spec.kubeProviderRef names no KubeProvider", }, { - name: "omitted clusterProviderRef is admitted by an admitting default provider", + // A provider admits its own namespace without any accessFrom, like a GitProvider. + name: "a provider in the target's own namespace is admitted without accessFrom", objects: []client.Object{ - providerNamed(configv1alpha3.DefaultClusterProviderName, - &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), + &configv1alpha3.KubeProvider{ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}}, namespaceLabeled(nil), }, - target: targetIn(""), + target: &configv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "mirror", Namespace: testNS}, + Spec: configv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, + }, + }, wantAllowed: true, }, { - name: "nil accessFrom denies by default", + name: "nil accessFrom denies another namespace by default", objects: []client.Object{ providerNamed("prod-eu-1", nil), namespaceLabeled(nil), @@ -99,7 +110,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { target: targetIn("prod-eu-1"), wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, - wantMessage: `namespace "team-a" is not permitted to reference ClusterProvider "prod-eu-1"`, + wantMessage: `namespace "team-a" is not permitted to reference KubeProvider platform/prod-eu-1`, }, { name: "empty policy struct denies by default", @@ -253,12 +264,12 @@ func TestGitTargetAdmitted_ReadErrorsSurfaceAsError(t *testing.T) { wantMsg string }{ { - name: "ClusterProvider read failure", + name: "KubeProvider read failure", failOn: func(_ client.ObjectKey, obj client.Object) bool { - _, ok := obj.(*configv1alpha3.ClusterProvider) + _, ok := obj.(*configv1alpha3.KubeProvider) return ok }, - wantMsg: `read ClusterProvider "prod-eu-1"`, + wantMsg: "read KubeProvider platform/prod-eu-1", }, { name: "Namespace read failure", @@ -301,16 +312,18 @@ func TestGitTargetAdmitted_ReadErrorsSurfaceAsError(t *testing.T) { } } -// TestApplierAdmitted: an applier is reached with its trigger's ClusterProvider's credential, so -// that provider's accessFrom must admit the namespace too, and the source's denial comes first. +// TestApplierAdmitted: an applier is reached with its trigger's KubeProvider's credential, so +// that provider must admit the namespace too, and the source's denial comes first. func TestApplierAdmitted(t *testing.T) { - admits := func(name string, namespaces ...string) *configv1alpha3.ClusterProvider { + admits := func(name string, namespaces ...string) *configv1alpha3.KubeProvider { return providerNamed(name, &configv1alpha3.NamespaceMatcher{Names: namespaces}) } triggerOn := func(cluster string) configv1alpha3.ApplyTrigger { return &configv1alpha3.FluxTrigger{ ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, - Spec: configv1alpha3.FluxTriggerSpec{ClusterProviderRef: &meta.LocalObjectReference{Name: cluster}}, + Spec: configv1alpha3.FluxTriggerSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: cluster, Namespace: platformNS}, + }, } } tests := []struct { @@ -338,21 +351,21 @@ func TestApplierAdmitted(t *testing.T) { objects: []client.Object{admits("prod-eu-1", testNS), admits("hub", "elsewhere")}, trigger: triggerOn("hub"), wantReason: ReasonNamespaceNotAuthorized, - wantMessage: `ClusterProvider "hub"`, + wantMessage: "KubeProvider platform/hub", }, { name: "another provider that does not exist", objects: []client.Object{admits("prod-eu-1", testNS)}, trigger: triggerOn("hub"), - wantReason: ReasonClusterProviderNotFound, - wantMessage: "FluxTrigger " + testNS + "/apps spec.clusterProviderRef", + wantReason: ReasonKubeProviderNotFound, + wantMessage: "FluxTrigger " + testNS + "/apps spec.kubeProviderRef", }, { name: "the source's denial comes first", objects: []client.Object{admits("prod-eu-1", "elsewhere"), admits("hub", testNS)}, trigger: triggerOn("hub"), wantReason: ReasonNamespaceNotAuthorized, - wantMessage: `ClusterProvider "prod-eu-1"`, + wantMessage: "KubeProvider platform/prod-eu-1", }, } for _, tc := range tests { @@ -366,3 +379,61 @@ func TestApplierAdmitted(t *testing.T) { }) } } + +// TestAuditRouteHeld pins that a provider whose route another namespace holds is not used by any +// consumer, and that the holder and its own namespace's siblings are. +func TestAuditRouteHeld(t *testing.T) { + older := metav1.NewTime(metav1.Now().Add(-time.Hour)) + withRoute := func(namespace, name, route string, created metav1.Time) *configv1alpha3.KubeProvider { + p := &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, CreationTimestamp: created}, + Spec: configv1alpha3.KubeProviderSpec{ + AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{testNS}}, + }, + } + if route != "" { + p.Spec.Attribution = &configv1alpha3.KubeProviderAttribution{AuditRoute: route} + } + return p + } + holder := withRoute(platformPrev, "prod", "shared", older) + sibling := withRoute(platformPrev, "prod-2", "shared", metav1.Now()) + borrower := withRoute(platformNS, "prod-eu-1", "shared", metav1.Now()) + + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)). + WithObjects(holder, sibling, borrower, namespaceLabeled(nil)).Build() + + for _, p := range []*configv1alpha3.KubeProvider{holder, sibling} { + decision, err := AuditRouteHeld(context.Background(), reader, p) + require.NoError(t, err) + assert.True(t, decision.Allowed, "%s shares a route its own namespace holds", p.Key()) + } + + decision, err := AuditRouteHeld(context.Background(), reader, borrower) + require.NoError(t, err) + assert.False(t, decision.Allowed) + assert.Equal(t, ReasonAuditRouteConflict, decision.Reason) + assert.Contains(t, decision.Message, `namespace "platform-old"`) + assert.Contains(t, decision.Message, `"platform.prod-eu-1"`, "the message names the default to fall back to") + + // The consumer of the borrowing provider is refused with the same reason, before any watch. + admitted, err := GitTargetAdmitted(context.Background(), reader, targetIn("prod-eu-1")) + require.NoError(t, err) + assert.False(t, admitted.Allowed) + assert.Equal(t, ReasonAuditRouteConflict, admitted.Reason) +} + +// TestAuditRouteHeld_ListErrorSurfacesAsError: a failed list must requeue, never refuse. +func TestAuditRouteHeld_ListErrorSurfacesAsError(t *testing.T) { + boom := errors.New("apiserver unavailable") + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error { + return boom + }, + }).Build() + decision, err := AuditRouteHeld(context.Background(), reader, providerNamed("prod-eu-1", nil)) + require.ErrorIs(t, err, boom) + assert.False(t, decision.Allowed) + assert.Empty(t, decision.Reason) +} diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index c7b4e532e..661d4fc11 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -9,7 +9,6 @@ import ( "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" - k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" @@ -23,7 +22,7 @@ import ( // ANOTHER cluster: that read could be Forbidden, or still syncing, or permanently unevaluatable, so // the verdict had to be three-valued and two of the reasons described an inability to decide rather // than a decision. Nothing here reads another cluster any more. Every input is on the WatchRule, the -// GitTarget and the ClusterProvider, all in the control plane, so the gate is a comparison and its +// GitTarget and the KubeProvider, all in the control plane, so the gate is a comparison and its // answer is always allowed or denied. const ( // ReasonLegacySourceNamespace is the True reason when EVERY item watches the rule's OWN @@ -36,7 +35,7 @@ const ( ReasonSourceNamespaceAllowed = "SourceNamespaceAllowed" // ReasonSourceNamespaceNotAllowed is the TERMINAL False reason for a refusal: the referenced - // ClusterProvider does not exist, does not admit this GitTarget's namespace, or does not set + // KubeProvider does not exist, does not admit this GitTarget's namespace, or does not set // spec.allowCrossNamespace.sources. It is a decision, never an inability to decide. ReasonSourceNamespaceNotAllowed = "SourceNamespaceNotAllowed" ) @@ -106,7 +105,7 @@ func (s ResolvedSourceScope) Fingerprint() string { // ResolveWatchRuleSourceScope is the WatchRule source-namespace gate: may each of this rule's items // watch the source namespace it asks for? // -// It is CROSS-OBJECT authorization — WatchRule → GitTarget → ClusterProvider — so it is not +// It is CROSS-OBJECT authorization — WatchRule → GitTarget → KubeProvider — so it is not // expressible in CEL and is deliberately a reconciler check rather than a webhook // (docs/spec/where-validation-lives.md). Like GitTargetAdmitted it runs on every reconcile, so a // policy TIGHTENED after a rule was accepted revokes it. @@ -117,7 +116,7 @@ func (s ResolvedSourceScope) Fingerprint() string { // This is the legacy case and it must stay free: gating it would break every existing WatchRule // on upgrade. // 2. Any OTHER namespace — a name, or "*" for every namespace the credential can read — -// additionally requires the GitTarget's namespace to be admitted by its ClusterProvider, and +// additionally requires the GitTarget's namespace to be admitted by its KubeProvider, and // that provider to set spec.allowCrossNamespace.sources. // // There is no third rule. GitTarget.spec.allowedSourceNamespaces used to supply one, and what it @@ -125,7 +124,7 @@ func (s ResolvedSourceScope) Fingerprint() string { // fails 403 at the apiserver if the credential cannot read that namespace, which is a better // answer than a policy field that restates the credential in the one place that cannot revoke it. // -// A non-NotFound ClusterProvider read error is returned as err so the caller requeues instead of +// A non-NotFound KubeProvider read error is returned as err so the caller requeues instead of // tearing down a running stream on a transient apiserver failure. func ResolveWatchRuleSourceScope( ctx context.Context, @@ -146,7 +145,7 @@ func ResolveWatchRuleSourceScope( } // itemGate carries the per-rule inputs so each item's decision reads as one step. The -// ClusterProvider verdict is memoised: every overriding item asks the same question of the same +// KubeProvider verdict is memoised: every overriding item asks the same question of the same // provider, and re-reading it per item would multiply apiserver reads by the rule's item count for // an answer that cannot differ within one compile. type itemGate struct { @@ -193,7 +192,7 @@ func (g *itemGate) decide( // named-namespace collection on the same type, never a replacement for one. base.Namespaces = []string{""} base.Message = fmt.Sprintf( - "%s: ClusterProvider %q sets spec.allowCrossNamespace.sources, so this item watches every "+ + "%s: KubeProvider %s sets spec.allowCrossNamespace.sources, so this item watches every "+ "namespace its source credential can read, as one cluster-wide stream", g.describeItem(index, item), g.target.SourceCluster()) return base, nil @@ -202,7 +201,7 @@ func (g *itemGate) decide( candidate := item.EffectiveSourceNamespace(g.rule.Namespace) base.Namespaces = []string{candidate} base.Message = fmt.Sprintf( - "%s: ClusterProvider %q sets spec.allowCrossNamespace.sources, so this item may watch source "+ + "%s: KubeProvider %s sets spec.allowCrossNamespace.sources, so this item may watch source "+ "namespace %q; whether it can is decided by that credential's RBAC", g.describeItem(index, item), g.target.SourceCluster(), candidate) return base, nil @@ -241,22 +240,22 @@ func (g *itemGate) overrideDelegated( // evaluateDelegation returns a refusal template when the provider side of the gate denies, or nil // when it permits. The message is item-agnostic; decide prefixes the item that asked. func (g *itemGate) evaluateDelegation(ctx context.Context) (*SourceNamespaceDecision, error) { - providerName := g.target.SourceCluster() + providerName := g.target.KubeProviderKey() - var provider configv1alpha3.ClusterProvider - if err := g.reader.Get(ctx, k8stypes.NamespacedName{Name: providerName}, &provider); err != nil { + var provider configv1alpha3.KubeProvider + if err := g.reader.Get(ctx, providerName, &provider); err != nil { if apierrors.IsNotFound(err) { return &SourceNamespaceDecision{ Reason: ReasonSourceNamespaceNotAllowed, Message: fmt.Sprintf( - "referenced ClusterProvider %q was not found, so it delegates nothing; a "+ + "referenced KubeProvider %s was not found, so it delegates nothing; a "+ "WatchRule may watch a namespace other than its own only through an "+ "existing provider that sets spec.allowCrossNamespace.sources", providerName), }, nil } // Transient: requeue rather than tear down a running stream. - return nil, fmt.Errorf("read ClusterProvider %q: %w", providerName, err) + return nil, fmt.Errorf("read KubeProvider %s: %w", providerName, err) } // The GitTarget itself must be admitted by that provider before it can delegate anything. @@ -268,7 +267,7 @@ func (g *itemGate) evaluateDelegation(ctx context.Context) (*SourceNamespaceDeci return &SourceNamespaceDecision{ Reason: ReasonSourceNamespaceNotAllowed, Message: fmt.Sprintf( - "GitTarget %s/%s may not mirror through ClusterProvider %q at all: %s", + "GitTarget %s/%s may not mirror through KubeProvider %s at all: %s", g.target.Namespace, g.target.Name, providerName, admitted.Message), }, nil } @@ -277,7 +276,7 @@ func (g *itemGate) evaluateDelegation(ctx context.Context) (*SourceNamespaceDeci return &SourceNamespaceDecision{ Reason: ReasonSourceNamespaceNotAllowed, Message: fmt.Sprintf( - "ClusterProvider %q does not set spec.allowCrossNamespace.sources; a WatchRule may "+ + "KubeProvider %s does not set spec.allowCrossNamespace.sources; a WatchRule may "+ "watch only its own namespace %q until a platform admin delegates that choice", providerName, g.rule.Namespace), }, nil diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index 2e90ee77c..f25c027ad 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -29,6 +29,7 @@ const ( snTargetName = "acme" snRuleName = "repo-config-rule" snProvider = "workspaces" + snProviderNS = "platform" snWildcard = configv1alpha3.SourceNamespaceWildcard ) @@ -44,9 +45,9 @@ func snTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: snTargetName, Namespace: snTenantNS}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: "acme-git-main"}, - ClusterProviderRef: &meta.LocalObjectReference{Name: snProvider}, - Path: "tenants/acme", + GitBranchRef: meta.NamespacedObjectReference{Name: "acme-git-main"}, + KubeProviderRef: meta.NamespacedObjectReference{Name: snProvider, Namespace: snProviderNS}, + Path: "tenants/acme", }, } } @@ -68,14 +69,14 @@ func snRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { } } -// snClusterProvider admits snTenantNS by name, so the "provider admits the target's namespace" leg +// snKubeProvider admits snTenantNS by name, so the "provider admits the target's namespace" leg // of the gate passes and each case isolates the leg it is actually about. -func snClusterProvider(delegate bool) *configv1alpha3.ClusterProvider { - return &configv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: snProvider}, - Spec: configv1alpha3.ClusterProviderSpec{ +func snKubeProvider(delegate bool) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: snProvider, Namespace: snProviderNS}, + Spec: configv1alpha3.KubeProviderSpec{ AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS}}, - AllowCrossNamespace: &configv1alpha3.ClusterProviderCrossNamespace{Sources: delegate}, + AllowCrossNamespace: &configv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, }, } } @@ -88,7 +89,7 @@ func snReader(t *testing.T, objects ...client.Object) client.Reader { } // The gate is two rules and this is the table for both of them: an item on the rule's own namespace -// is free, and anything else needs the ClusterProvider's delegation. +// is free, and anything else needs the KubeProvider's delegation. // // There is deliberately no row for a GitTarget policy. There is no longer such a field: what it // bounded is bounded by the source credential's own RBAC, which this gate cannot and does not try @@ -157,7 +158,7 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reader := snReader(t, snTarget(), snClusterProvider(tt.delegate)) + reader := snReader(t, snTarget(), snKubeProvider(tt.delegate)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(tt.items...), snTarget()) @@ -178,7 +179,7 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { // A rule mixing a legacy item with an authorized override resolves each independently, and the // aggregate is the one condition the object publishes. func TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently(t *testing.T) { - reader := snReader(t, snTarget(), snClusterProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(true)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS, snWildcard), snTarget()) @@ -196,7 +197,7 @@ func TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently(t *testing.T // namespaces a rule asked for is worse than a loud failure: the operator would have no way to see // that the third was dropped. func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) { - reader := snReader(t, snTarget(), snClusterProvider(false)) + reader := snReader(t, snTarget(), snKubeProvider(false)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS), snTarget()) @@ -213,7 +214,7 @@ func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) // provider-side leg is checked before the flag, so a target outside accessFrom is refused even // through a provider that delegates freely. func TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate(t *testing.T) { - denying := snClusterProvider(true) + denying := snKubeProvider(true) denying.Spec.AccessFrom = &configv1alpha3.NamespaceMatcher{Names: []string{"some-other-tenant"}} reader := snReader(t, snTarget(), denying) @@ -223,12 +224,12 @@ func TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate(t *testin require.NoError(t, err) assert.False(t, resolved.Admitted()) assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) - assert.Contains(t, resolved.Message, "may not mirror through ClusterProvider") + assert.Contains(t, resolved.Message, "may not mirror through KubeProvider") } -// A missing ClusterProvider denies an override rather than erroring: a provider that does not exist +// A missing KubeProvider denies an override rather than erroring: a provider that does not exist // delegates nothing, which is a decision the rule's owner can act on. -func TestResolveWatchRuleSourceScope_MissingClusterProviderDeniesOverride(t *testing.T) { +func TestResolveWatchRuleSourceScope_MissingKubeProviderDeniesOverride(t *testing.T) { reader := snReader(t, snTarget()) resolved, err := authz.ResolveWatchRuleSourceScope( @@ -245,13 +246,13 @@ func TestResolveWatchRuleSourceScope_ProviderReadErrorIsRequeued(t *testing.T) { boom := errors.New("apiserver unavailable") reader := fake.NewClientBuilder(). WithScheme(snScheme(t)). - WithObjects(snTarget(), snClusterProvider(true)). + WithObjects(snTarget(), snKubeProvider(true)). WithInterceptorFuncs(interceptor.Funcs{ Get: func( _ context.Context, _ client.WithWatch, key client.ObjectKey, obj client.Object, _ ...client.GetOption, ) error { - if _, ok := obj.(*configv1alpha3.ClusterProvider); ok && key.Name == snProvider { + if _, ok := obj.(*configv1alpha3.KubeProvider); ok && key.Name == snProvider { return boom } return nil @@ -269,14 +270,14 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { reads := 0 reader := fake.NewClientBuilder(). WithScheme(snScheme(t)). - WithObjects(snTarget(), snClusterProvider(true), + WithObjects(snTarget(), snKubeProvider(true), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}). WithInterceptorFuncs(interceptor.Funcs{ Get: func( ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption, ) error { - if _, ok := obj.(*configv1alpha3.ClusterProvider); ok { + if _, ok := obj.(*configv1alpha3.KubeProvider); ok { reads++ } return c.Get(ctx, key, obj, opts...) @@ -297,7 +298,7 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { // the RESOLVED set moves — including for the wildcard, whose resolved value is the empty string. func TestResolvedSourceScope_Fingerprint(t *testing.T) { ctx := context.Background() - reader := snReader(t, snTarget(), snClusterProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(true)) named, err := authz.ResolveWatchRuleSourceScope(ctx, reader, snRule(snSourceNS), snTarget()) require.NoError(t, err) @@ -315,7 +316,7 @@ func TestResolvedSourceScope_Fingerprint(t *testing.T) { // An empty rule is vacuously authorized. It is reachable through the API only transiently, and a // gate that errored on it would turn an empty list into a stall. func TestResolveWatchRuleSourceScope_NoItems(t *testing.T) { - reader := snReader(t, snTarget(), snClusterProvider(false)) + reader := snReader(t, snTarget(), snKubeProvider(false)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(), snTarget()) @@ -329,7 +330,7 @@ func TestResolveWatchRuleSourceScope_NoItems(t *testing.T) { // position by position. An out-of-range index returns nil rather than panicking: the resolved scope // and the spec are two objects, and a caller reading them apart must not take the process down. func TestResolvedSourceScope_NamespacesForIsBoundsChecked(t *testing.T) { - reader := snReader(t, snTarget(), snClusterProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(true)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS), snTarget()) @@ -344,7 +345,7 @@ func TestResolvedSourceScope_NamespacesForIsBoundsChecked(t *testing.T) { // same namespace twice, and the cluster-wide collection is spelled out rather than shown as the empty // string an operator would read as a missing value. func TestResolveWatchRuleSourceScope_AggregateMessageIsDeduplicatedAndLegible(t *testing.T) { - reader := snReader(t, snTarget(), snClusterProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(true)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(snSourceNS, snSourceNS, snWildcard), snTarget()) diff --git a/internal/controller/applytrigger_controller.go b/internal/controller/applytrigger_controller.go index abcddf411..e0f1174e5 100644 --- a/internal/controller/applytrigger_controller.go +++ b/internal/controller/applytrigger_controller.go @@ -34,7 +34,7 @@ import ( // ApplyTriggerConditionConfigured reports whether a trigger's applier is one GitOps Reverser can // drive: it exists in its cluster, applies a source that follows a branch, may be written, and (an -// Argo CD Application) has automated sync off. The trigger's ClusterProvider must admit its +// Argo CD Application) has automated sync off. The trigger's KubeProvider must admit its // namespace first, and no trigger in line before it may name the same applier // (applier.ReasonHeldByAnotherTrigger). const ApplyTriggerConditionConfigured = "Configured" @@ -140,7 +140,7 @@ type ApplyTriggerReconciler struct { // +kubebuilder:rbac:groups=configbutler.ai,resources=fluxtriggers,verbs=update;patch // +kubebuilder:rbac:groups=configbutler.ai,resources=fluxtriggers/finalizers,verbs=update // +kubebuilder:rbac:groups=configbutler.ai,resources=fluxtriggers/status;argocdtriggers/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=configbutler.ai,resources=clusterproviders,verbs=get;list;watch +// +kubebuilder:rbac:groups=configbutler.ai,resources=kubeproviders,verbs=get;list;watch // +kubebuilder:rbac:groups=configbutler.ai,resources=gittargets;gitproviders,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch @@ -287,7 +287,7 @@ func (c triggerCheck) retiring(trigger *configbutleraiv1alpha3.FluxTrigger) bool func (r *ApplyTriggerReconciler) configuredVerdict( ctx context.Context, trigger configbutleraiv1alpha3.ApplyTrigger, ) triggerCheck { - cluster := trigger.ClusterProvider() + cluster := trigger.KubeProviderKey() ctx, cancel := context.WithTimeout(ctx, applierCheckTimeout) defer cancel() kept := func(message string) triggerCheck { return keptCheck(trigger, message) } @@ -310,7 +310,7 @@ func (r *ApplyTriggerReconciler) configuredVerdict( decision, err := authz.ApplyTriggerAdmitted(ctx, r.Client, trigger) switch { case err != nil: - return kept(fmt.Sprintf("Cannot check ClusterProvider %q: %v", cluster, err)) + return kept(fmt.Sprintf("Cannot check KubeProvider %s: %v", cluster, err)) case !decision.Allowed: return falseCheck(decision.Reason, decision.Message, refs) case refs.Refused(): @@ -325,7 +325,7 @@ func (r *ApplyTriggerReconciler) applierVerdict( ctx context.Context, trigger configbutleraiv1alpha3.ApplyTrigger, targets []*configbutleraiv1alpha3.GitTarget, refs applier.References, ) triggerCheck { - kind, ref, cluster := trigger.ApplierKind(), trigger.Applier(), trigger.ClusterProvider() + kind, ref, cluster := trigger.ApplierKind(), trigger.Applier(), trigger.KubeProviderKey().String() if r.ApplierClients == nil { return triggerCheck{configured: conditionValue{ Status: metav1.ConditionUnknown, @@ -449,7 +449,7 @@ func (r *ApplyTriggerReconciler) triggerReadiness( // applyTriggerReadinessGates contributes Configured, the operation, and a FluxTrigger's Held // to the trio, in that order. -// An applier that is missing, or a ClusterProvider that is, may be on its way (Flux and Argo CD +// An applier that is missing, or a KubeProvider that is, may be on its way (Flux and Argo CD // objects are often created after the trigger); anything else Configured=False needs a human. An // operation that has not completed in time clears on its own once it does. A trigger no GitTarget // names is Ready (NotReferenced): it does all it is asked to, which is nothing, and a Flux health @@ -462,7 +462,7 @@ func applyTriggerReadinessGates( Message: "no GitTarget names this trigger, so it does nothing"}) } waits := configured.Reason == applier.ReasonApplierNotFound || - configured.Reason == authz.ReasonClusterProviderNotFound + configured.Reason == authz.ReasonKubeProviderNotFound rd.stalledIf(configured.Status == metav1.ConditionFalse && !waits, configured.Reason, configured.Message) rd.progressingIf(configured.Status == metav1.ConditionFalse, metav1.ConditionFalse, configured.Reason, configured.Message) @@ -508,11 +508,11 @@ func (r *ApplyTriggerReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitDestinationToTriggers), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). - // A ClusterProvider admits the trigger's namespace, and the namespaces of the GitTargets naming + // A KubeProvider admits the trigger's namespace, and the namespaces of the GitTargets naming // triggers, so a provider created, deleted, or with its spec changed re-runs every check. Watches( - &configbutleraiv1alpha3.ClusterProvider{}, - handler.EnqueueRequestsFromMapFunc(r.clusterProviderToTriggers), + &configbutleraiv1alpha3.KubeProvider{}, + handler.EnqueueRequestsFromMapFunc(r.kubeProviderToTriggers), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). // An accessFrom selector reads namespace labels. @@ -684,10 +684,10 @@ func (r *ApplyTriggerReconciler) triggersInLine(ctx context.Context, obj client. return requests } -// clusterProviderToTriggers maps a ClusterProvider event to every trigger of this kind: a provider +// kubeProviderToTriggers maps a KubeProvider event to every trigger of this kind: a provider // admits the triggers that name it, and the GitTargets that mirror through it, which may name any // trigger in their namespace. -func (r *ApplyTriggerReconciler) clusterProviderToTriggers( +func (r *ApplyTriggerReconciler) kubeProviderToTriggers( ctx context.Context, obj client.Object, ) []ctrlreconcile.Request { triggers, err := r.listTriggers(ctx, "") diff --git a/internal/controller/applytrigger_controller_test.go b/internal/controller/applytrigger_controller_test.go index 2d4d86126..459f1aabb 100644 --- a/internal/controller/applytrigger_controller_test.go +++ b/internal/controller/applytrigger_controller_test.go @@ -97,46 +97,71 @@ func applierSim(t *testing.T, objs ...runtime.Object) *fluxsim.Sim { return fluxsim.New(t, client) } -// admitting is a ClusterProvider whose accessFrom admits namespaces, and which lets a trigger name -// an applier in another namespace (the test appliers live in flux-system and argocd). -func admitting(name string, namespaces ...string) *configbutleraiv1alpha3.ClusterProvider { - return &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ +// The trigger tests reach their appliers through KubeProviders in triggerProviderNamespace, a +// namespace of their own, so a provider admits team-a only through spec.accessFrom: the namespace +// policy is what those tests exercise. triggerLocalProvider is the operator's own cluster there. +const ( + triggerProviderNamespace = "clusters" + triggerLocalProvider = "local" +) + +// triggerProviderRef names the KubeProvider name in triggerProviderNamespace. +func triggerProviderRef(name string) meta.NamespacedObjectReference { + return meta.NamespacedObjectReference{Namespace: triggerProviderNamespace, Name: name} +} + +// triggerProviderKey is the key of the KubeProvider name in triggerProviderNamespace, as +// ApplierClients is asked for it. +func triggerProviderKey(name string) string { + return triggerProviderNamespace + "/" + name +} + +// admitting is a KubeProvider in triggerProviderNamespace whose accessFrom admits namespaces, and +// which lets a trigger name an applier in another namespace (the test appliers live in flux-system +// and argocd). +func admitting(name string, namespaces ...string) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: triggerProviderNamespace}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: namespaces}, - AllowCrossNamespace: &configbutleraiv1alpha3.ClusterProviderCrossNamespace{Appliers: true}, + AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{Appliers: true}, }, } } -// testFluxTrigger is the FluxTrigger team-a/checkout holding flux-system/checkout in cluster. +// testFluxTrigger is the FluxTrigger team-a/checkout holding flux-system/checkout in the cluster of +// the KubeProvider cluster in triggerProviderNamespace. func testFluxTrigger(cluster string) *configbutleraiv1alpha3.FluxTrigger { return &configbutleraiv1alpha3.FluxTrigger{ ObjectMeta: metav1.ObjectMeta{Name: "checkout", Namespace: "team-a", Generation: 1}, Spec: configbutleraiv1alpha3.FluxTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: cluster}, - KustomizationRef: meta.NamespacedObjectReference{Namespace: "flux-system", Name: "checkout"}, + KubeProviderRef: triggerProviderRef(cluster), + KustomizationRef: meta.NamespacedObjectReference{Namespace: "flux-system", Name: "checkout"}, }, } } -// testArgoCDTrigger is the ArgoCDTrigger team-a/checkout holding argocd/checkout in cluster. +// testArgoCDTrigger is the ArgoCDTrigger team-a/checkout holding argocd/checkout in the cluster of +// the KubeProvider cluster in triggerProviderNamespace. func testArgoCDTrigger(cluster string) *configbutleraiv1alpha3.ArgoCDTrigger { return &configbutleraiv1alpha3.ArgoCDTrigger{ ObjectMeta: metav1.ObjectMeta{Name: "checkout", Namespace: "team-a", Generation: 1}, Spec: configbutleraiv1alpha3.ArgoCDTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: cluster}, - ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "checkout"}, + KubeProviderRef: triggerProviderRef(cluster), + ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "checkout"}, }, } } -// referencingTarget is a GitTarget team-a/name writing main of the test repository through the -// GitProvider platform, naming the trigger kind/checkout. +// referencingTarget is a GitTarget team-a/name mirroring through the KubeProvider +// triggerLocalProvider and writing main of the test repository through the GitProvider platform, +// naming the trigger kind/checkout. func referencingTarget(name, kind string) *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "team-a"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: triggerProviderRef(triggerLocalProvider), GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("platform", "main")}, Path: name, ApplyTriggerRef: &configbutleraiv1alpha3.ApplyTriggerReference{Kind: kind, Name: "checkout"}, @@ -165,8 +190,8 @@ func referencedIn(namespace, kind string) []ctrlclient.Object { // unreferenced marks a triggerTestClient that holds no GitTarget naming the triggers. type unreferenced struct{ ctrlclient.Object } -// triggerTestClient is a control cluster holding objects, the ClusterProviders "default" and "hub" -// admitting team-a unless objects carries its own, the GitProvider platform unless objects carries a +// triggerTestClient is a control cluster holding objects, the KubeProviders triggerLocalProvider and +// "hub" admitting team-a unless objects carries its own, the GitProvider platform unless objects carries a // GitProvider, and a GitTarget naming each trigger kind's checkout unless objects carries a // GitTarget or unreferenced{}. func triggerTestClient(t *testing.T, objects ...ctrlclient.Object) ctrlclient.Client { @@ -179,7 +204,7 @@ func triggerTestClient(t *testing.T, objects ...ctrlclient.Object) ctrlclient.Cl kept := objects[:0:0] for _, obj := range objects { switch obj.(type) { - case *configbutleraiv1alpha3.ClusterProvider: + case *configbutleraiv1alpha3.KubeProvider: held[obj.GetName()] = true case *configbutleraiv1alpha3.GitTarget, unreferenced: hasTarget = true @@ -193,7 +218,7 @@ func triggerTestClient(t *testing.T, objects ...ctrlclient.Object) ctrlclient.Cl } } objects = kept - for _, name := range []string{configbutleraiv1alpha3.DefaultClusterProviderName, "hub"} { + for _, name := range []string{triggerLocalProvider, "hub"} { if !held[name] { objects = append(objects, admitting(name, "team-a")) } @@ -298,7 +323,7 @@ func TestApplyTriggerReconcile_FluxVerdicts(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - c := triggerTestClient(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + c := triggerTestClient(t, testFluxTrigger(triggerLocalProvider)) r := triggerReconciler(c, configbutleraiv1alpha3.ApplyTriggerKindFlux, applierFakeSource(t, tc.objects...), nil, tc.allowed) @@ -348,7 +373,7 @@ func TestApplyTriggerReconcile_ArgoCDVerdicts(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - c := triggerTestClient(t, testArgoCDTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + c := triggerTestClient(t, testArgoCDTrigger(triggerLocalProvider)) r := triggerReconciler(c, configbutleraiv1alpha3.ApplyTriggerKindArgoCD, applierFakeSource(t, tc.objects...), nil, tc.allowed) @@ -372,7 +397,7 @@ func TestApplyTriggerReconcile_ArgoCDVerdicts(t *testing.T) { // applier's cluster says nothing about the applier, so it must not flip a True to Unknown and back on // every hiccup, nor drop the source the GitTargets bind against. func TestApplyTriggerReconcile_AnUnreachableClusterKeepsThePublishedVerdict(t *testing.T) { - trigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger := testFluxTrigger(triggerLocalProvider) c := triggerTestClient(t, trigger) r := triggerReconciler(c, configbutleraiv1alpha3.ApplyTriggerKindFlux, nil, errors.New("connection refused"), true) got := reconcileTrigger(t, r) @@ -380,7 +405,7 @@ func TestApplyTriggerReconcile_AnUnreachableClusterKeepsThePublishedVerdict(t *t require.NotNil(t, configured) assert.Equal(t, metav1.ConditionUnknown, configured.Status, "nothing published yet") - published := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + published := testFluxTrigger(triggerLocalProvider) published.Status.Conditions = []metav1.Condition{{ Type: ApplyTriggerConditionConfigured, Status: metav1.ConditionTrue, Reason: ReasonSucceeded, LastTransitionTime: metav1.Now(), @@ -404,7 +429,7 @@ func TestApplyTriggerReconcile_AnUnreachableClusterKeepsThePublishedVerdict(t *t func TestApplyTriggerReconcile_AHangingClusterIsBounded(t *testing.T) { var sawDeadline bool source := applierFakeSource(t, applierFluxObjects()...) - published := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + published := testFluxTrigger(triggerLocalProvider) published.Status.Conditions = []metav1.Condition{{ Type: ApplyTriggerConditionConfigured, Status: metav1.ConditionTrue, Reason: ReasonSucceeded, LastTransitionTime: metav1.Now(), @@ -436,25 +461,31 @@ func TestApplyTriggerReconcile_AHangingClusterIsBounded(t *testing.T) { assert.Equal(t, metav1.ConditionTrue, configured.Status, "a timed-out check keeps the published verdict") } -// TestApplyTriggerReconcile_TheTriggersClusterProviderAdmitsIt: the applier is reached with the -// trigger's ClusterProvider's credential, so that provider's accessFrom gates the check before the +// TestApplyTriggerReconcile_TheTriggersKubeProviderAdmitsIt: the applier is reached with the +// trigger's KubeProvider's credential, so that provider's accessFrom gates the check before the // cluster is ever asked. -func TestApplyTriggerReconcile_TheTriggersClusterProviderAdmitsIt(t *testing.T) { +func TestApplyTriggerReconcile_TheTriggersKubeProviderAdmitsIt(t *testing.T) { cases := []struct { name string cluster string - hub *configbutleraiv1alpha3.ClusterProvider + hub *configbutleraiv1alpha3.KubeProvider wantStatus metav1.ConditionStatus wantReason string wantStalled bool wantClients []string }{ - {name: "a provider that admits the namespace is asked", cluster: "hub", hub: admitting("hub", "team-a"), - wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded, wantClients: []string{"hub"}}, + { + name: "a provider that admits the namespace is asked", + cluster: "hub", + hub: admitting("hub", "team-a"), + wantStatus: metav1.ConditionTrue, + wantReason: ReasonSucceeded, + wantClients: []string{triggerProviderKey("hub")}, + }, {name: "a provider that does not admit it is never asked", cluster: "hub", hub: admitting("hub", "team-b"), wantStatus: metav1.ConditionFalse, wantReason: authz.ReasonNamespaceNotAuthorized, wantStalled: true}, {name: "a provider that does not exist is never asked, and may be on its way", cluster: "absent", - wantStatus: metav1.ConditionFalse, wantReason: authz.ReasonClusterProviderNotFound}, + wantStatus: metav1.ConditionFalse, wantReason: authz.ReasonKubeProviderNotFound}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -487,7 +518,7 @@ func TestApplyTriggerReconcile_TheTriggersClusterProviderAdmitsIt(t *testing.T) // seen from the trigger: an operation past its budget keeps its window open, and the trigger says so // in Ready until the operation completes. It clears on its own, so it is never Stalled. func TestApplyTriggerReconcile_AnUnresolvedOperationIsNotReady(t *testing.T) { - trigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger := testFluxTrigger(triggerLocalProvider) trigger.Status.Operation = &configbutleraiv1alpha3.ApplyOperation{ Phase: configbutleraiv1alpha3.ApplyOperationUnresolved, Reason: configbutleraiv1alpha3.ApplyReasonCatchUp, Commit: "4f2c1ab9d00d", Token: "T1", RequestedAt: metav1.Now(), Message: "still applying", @@ -513,7 +544,7 @@ func TestApplyTriggerReconcile_AnUnresolvedOperationIsNotReady(t *testing.T) { func TestApplyTriggerSpecOrOperationChanged_ReadsTheOperationPhase(t *testing.T) { p := applyTriggerSpecOrOperationChanged() at := func(phase configbutleraiv1alpha3.ApplyOperationPhase, generation int64) ctrlclient.Object { - trigger := testFluxTrigger("default") + trigger := testFluxTrigger(triggerLocalProvider) trigger.Generation = generation if phase != "" { trigger.Status.Operation = &configbutleraiv1alpha3.ApplyOperation{Phase: phase} @@ -531,16 +562,16 @@ func TestApplyTriggerSpecOrOperationChanged_ReadsTheOperationPhase(t *testing.T) }), "a status write that moves nothing Ready reads") } -// TestApplyTriggerReconcile_ClusterProviderAndNamespaceEventsFindTheirTriggers: a provider event +// TestApplyTriggerReconcile_KubeProviderAndNamespaceEventsFindTheirTriggers: a provider event // re-runs every trigger, since it admits the GitTargets naming triggers too, and a namespace label // change the triggers in it. -func TestApplyTriggerReconcile_ClusterProviderAndNamespaceEventsFindTheirTriggers(t *testing.T) { +func TestApplyTriggerReconcile_KubeProviderAndNamespaceEventsFindTheirTriggers(t *testing.T) { other := testFluxTrigger("other") other.Name = "elsewhere" c := triggerTestClient(t, testFluxTrigger("hub"), other) r := &ApplyTriggerReconciler{Client: c, Kind: configbutleraiv1alpha3.ApplyTriggerKindFlux} - assert.Len(t, r.clusterProviderToTriggers(context.Background(), admitting("hub")), 2) + assert.Len(t, r.kubeProviderToTriggers(context.Background(), admitting("hub")), 2) ns := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "team-a"}} assert.Len(t, r.namespaceToTriggers(context.Background(), ns), 2) @@ -550,7 +581,7 @@ func TestApplyTriggerReconcile_ClusterProviderAndNamespaceEventsFindTheirTrigger func TestApplyTriggerReconcile_ArgoCDEventsFindTheirTriggers(t *testing.T) { c := triggerTestClient(t, testArgoCDTrigger("hub"), testFluxTrigger("hub")) r := &ApplyTriggerReconciler{Client: c, Kind: configbutleraiv1alpha3.ApplyTriggerKindArgoCD} - requests := r.clusterProviderToTriggers(context.Background(), admitting("hub")) + requests := r.kubeProviderToTriggers(context.Background(), admitting("hub")) assert.Len(t, requests, 1, "a FluxTrigger of the same name is another kind's") } diff --git a/internal/controller/applytrigger_holder_test.go b/internal/controller/applytrigger_holder_test.go index 8aefd9506..9d295e951 100644 --- a/internal/controller/applytrigger_holder_test.go +++ b/internal/controller/applytrigger_holder_test.go @@ -28,13 +28,13 @@ import ( var inLineKey = k8stypes.NamespacedName{Namespace: "team-b", Name: "checkout"} // duplicateFixture is the hold fixture with two FluxTriggers naming flux-system/checkout in the -// default cluster: team-a/checkout, created first, and team-b/checkout, an hour later. The default -// ClusterProvider admits both namespaces. +// cluster of triggerLocalProvider: team-a/checkout, created first, and team-b/checkout, an hour +// later. That KubeProvider admits both namespaces. func duplicateFixture(t *testing.T) *holdFixture { t.Helper() - first := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + first := testFluxTrigger(triggerLocalProvider) first.CreationTimestamp = metav1.NewTime(time.Unix(1_000_000, 0)) - second := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + second := testFluxTrigger(triggerLocalProvider) second.Namespace = inLineKey.Namespace second.CreationTimestamp = metav1.NewTime(first.CreationTimestamp.Add(time.Hour)) @@ -47,11 +47,10 @@ func duplicateFixture(t *testing.T) *holdFixture { } // inLineClient is a control cluster holding triggers, in team-a and team-b, each referenced by an -// eligible GitTarget of its namespace, with the default ClusterProvider admitting both. +// eligible GitTarget of its namespace, with the KubeProvider triggerLocalProvider admitting both. func inLineClient(t *testing.T, kind string, triggers ...ctrlclient.Object) ctrlclient.Client { t.Helper() - objects := append([]ctrlclient.Object{admitting(configbutleraiv1alpha3.DefaultClusterProviderName, - "team-a", "team-b")}, triggers...) + objects := append([]ctrlclient.Object{admitting(triggerLocalProvider, "team-a", "team-b")}, triggers...) objects = append(objects, referencedIn("team-a", kind)...) objects = append(objects, referencedIn("team-b", kind)...) return triggerTestClient(t, objects...) @@ -101,7 +100,7 @@ func TestFluxTrigger_OneTriggerHoldsAKustomization(t *testing.T) { require.NotNil(t, configured) assert.Equal(t, metav1.ConditionFalse, configured.Status) assert.Equal(t, applier.ReasonHeldByAnotherTrigger, configured.Reason) - assert.Equal(t, "Kustomization flux-system/checkout in the cluster of ClusterProvider \"default\" is held by "+ + assert.Equal(t, "Kustomization flux-system/checkout in the cluster of KubeProvider clusters/local is held by "+ "FluxTrigger team-a/checkout, which was created first; this trigger takes it over once that one is deleted "+ "or no eligible GitTarget references it", configured.Message) ready := findCondition(second.Status.Conditions, ConditionTypeReady) @@ -220,7 +219,7 @@ func TestFluxTrigger_DeletingTheHolderHandsOverToTheNextInLine(t *testing.T) { // trigger that holds its Kustomization comes before any that does not, so the Kustomization changes // hands only once the holder lets go of it. func TestFluxTrigger_AHolderWithAnApplyInFlightKeepsIt(t *testing.T) { - holderTrigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + holderTrigger := testFluxTrigger(triggerLocalProvider) holderTrigger.Namespace = inLineKey.Namespace // team-b: after team-a by name holderTrigger.CreationTimestamp = metav1.NewTime(time.Unix(1_000_000, 0)) holderKey := k8stypes.NamespacedName{Namespace: holderTrigger.Namespace, Name: holderTrigger.Name} @@ -246,7 +245,7 @@ func TestFluxTrigger_AHolderWithAnApplyInFlightKeepsIt(t *testing.T) { metav1.PatchOptions{FieldManager: applier.SuspendFieldManager}) require.NoError(t, err) // The newcomer: same second, team-a. - newcomer := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + newcomer := testFluxTrigger(triggerLocalProvider) newcomer.CreationTimestamp = holderTrigger.CreationTimestamp require.NoError(t, f.r.Create(context.Background(), newcomer)) @@ -285,9 +284,9 @@ func TestFluxTrigger_AHolderWithAnApplyInFlightKeepsIt(t *testing.T) { // TestArgoCDTrigger_OneTriggerHoldsAnApplication: two ArgoCDTriggers on one Application would both // start syncs; the one created later is Configured=False (HeldByAnotherTrigger). func TestArgoCDTrigger_OneTriggerHoldsAnApplication(t *testing.T) { - first := testArgoCDTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + first := testArgoCDTrigger(triggerLocalProvider) first.CreationTimestamp = metav1.NewTime(time.Unix(1_000_000, 0)) - second := testArgoCDTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + second := testArgoCDTrigger(triggerLocalProvider) second.Namespace = inLineKey.Namespace second.CreationTimestamp = first.CreationTimestamp c := inLineClient(t, configbutleraiv1alpha3.ApplyTriggerKindArgoCD, first, second) diff --git a/internal/controller/applytrigger_rule_test.go b/internal/controller/applytrigger_rule_test.go index 92e479b1e..e8cfff88b 100644 --- a/internal/controller/applytrigger_rule_test.go +++ b/internal/controller/applytrigger_rule_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -31,7 +30,7 @@ import ( // pin its three cases (never referenced or released, referenced, retiring), the binding verdict, and // the line of triggers for one applier. -// ruleFixture is the hold fixture over a control cluster holding objects, the ClusterProviders and +// ruleFixture is the hold fixture over a control cluster holding objects, the KubeProviders and // the GitProvider platform, and no GitTarget unless objects carries one. func ruleFixture(t *testing.T, objects ...ctrlclient.Object) *holdFixture { t.Helper() @@ -77,9 +76,9 @@ func heldReason(t *testing.T, trigger *configbutleraiv1alpha3.FluxTrigger) strin func TestApplyTriggerRule_AnUnreferencedTriggerDoesNothingAndIsReady(t *testing.T) { for _, kind := range []string{configbutleraiv1alpha3.ApplyTriggerKindFlux, configbutleraiv1alpha3.ApplyTriggerKindArgoCD} { t.Run(kind, func(t *testing.T) { - var trigger ctrlclient.Object = testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + var trigger ctrlclient.Object = testFluxTrigger(triggerLocalProvider) if kind == configbutleraiv1alpha3.ApplyTriggerKindArgoCD { - trigger = testArgoCDTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger = testArgoCDTrigger(triggerLocalProvider) } r := triggerReconciler(triggerTestClient(t, trigger, unreferenced{}), kind, nil, nil, true) calls := 0 @@ -113,7 +112,7 @@ func TestApplyTriggerRule_AnUnreferencedTriggerDoesNothingAndIsReady(t *testing. // TestApplyTriggerRule_TheFirstEligibleReferenceAdoptsAndTheLastReleases: naming the trigger is the // adoption moment; the GitTarget going hands the Kustomization back, and the trigger is idle again. func TestApplyTriggerRule_TheFirstEligibleReferenceAdoptsAndTheLastReleases(t *testing.T) { - f := ruleFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + f := ruleFixture(t, testFluxTrigger(triggerLocalProvider)) idle := f.settle(t, triggerKey) assert.Equal(t, applier.ReasonNotReferenced, heldReason(t, idle)) assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "Flux applies it on its own") @@ -148,7 +147,7 @@ func TestApplyTriggerRule_TheFirstEligibleReferenceAdoptsAndTheLastReleases(t *t // TestApplyTriggerRule_ARecreatedGitTargetReleasesAndReadopts is the trade-off the rule accepts: // deleting and re-creating the GitTarget hands the Kustomization back, and adopts it again. func TestApplyTriggerRule_ARecreatedGitTargetReleasesAndReadopts(t *testing.T) { - f := ruleFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), fluxTarget()) + f := ruleFixture(t, testFluxTrigger(triggerLocalProvider), fluxTarget()) f.settle(t, triggerKey) f.events() @@ -180,7 +179,7 @@ func TestApplyTriggerRule_TheBindingAggregatesItsGitTargets(t *testing.T) { return target } elsewhere := onBranch("elsewhere", "main") - elsewhere.Spec.ClusterProviderRef = &meta.LocalObjectReference{Name: "closed"} + elsewhere.Spec.KubeProviderRef = triggerProviderRef("closed") cases := []struct { name string objects []ctrlclient.Object @@ -206,16 +205,11 @@ func TestApplyTriggerRule_TheBindingAggregatesItsGitTargets(t *testing.T) { wantReason: applier.ReasonNotBound, wantStalled: true, wantNames: []string{"team-a/elsewhere", "team-a/release"}, objects: []ctrlclient.Object{onBranch("release", "release"), elsewhere, - &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "closed"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}, - }, - }}}, + admitting("closed", "team-b")}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - objects := append([]ctrlclient.Object{testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), + objects := append([]ctrlclient.Object{testFluxTrigger(triggerLocalProvider), gitBranchOn("platform", "team-a", "release"), gitBranchOn("platform", "team-a", "staging")}, tc.objects...) f := ruleFixture(t, objects...) @@ -248,22 +242,22 @@ func TestApplyTriggerRule_AnOlderTriggerNotInLineKeepsNoneOut(t *testing.T) { *objects = append(*objects, gitBranchOn("platform", target.Namespace, "release")) }, "not admitted": func(target *configbutleraiv1alpha3.GitTarget, objects *[]ctrlclient.Object) { - target.Spec.ClusterProviderRef = &meta.LocalObjectReference{Name: "closed"} + target.Spec.KubeProviderRef = triggerProviderRef("closed") *objects = append(*objects, admitting("closed", "team-a")) }, } { t.Run(name, func(t *testing.T) { - stray := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + stray := testFluxTrigger(triggerLocalProvider) stray.Namespace = inLineKey.Namespace stray.CreationTimestamp = metav1.NewTime(time.Unix(1_000_000, 0)) - mine := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + mine := testFluxTrigger(triggerLocalProvider) mine.CreationTimestamp = metav1.NewTime(stray.CreationTimestamp.Add(time.Hour)) objects := []ctrlclient.Object{stray, mine} strayRefs := referencedIn(inLineKey.Namespace, configbutleraiv1alpha3.ApplyTriggerKindFlux) older(strayRefs[1].(*configbutleraiv1alpha3.GitTarget), &objects) objects = append(objects, strayRefs...) objects = append(objects, referencedIn("team-a", configbutleraiv1alpha3.ApplyTriggerKindFlux)...) - objects = append(objects, admitting(configbutleraiv1alpha3.DefaultClusterProviderName, "team-a", "team-b")) + objects = append(objects, admitting(triggerLocalProvider, "team-a", "team-b")) f := ruleFixture(t, objects...) got := f.settle(t, triggerKey) @@ -285,13 +279,13 @@ func TestApplyTriggerRule_AnOlderTriggerNotInLineKeepsNoneOut(t *testing.T) { // it stays first in line, Held=False (Releasing), and the successor does nothing; then the holder // leaves the Kustomization suspended for it, and the successor takes it over. func TestApplyTriggerRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone(t *testing.T) { - holder := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + holder := testFluxTrigger(triggerLocalProvider) holder.CreationTimestamp = metav1.NewTime(time.Unix(1_000_000, 0)) - successor := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + successor := testFluxTrigger(triggerLocalProvider) successor.Namespace = inLineKey.Namespace successor.CreationTimestamp = metav1.NewTime(holder.CreationTimestamp.Add(time.Hour)) objects := []ctrlclient.Object{holder, successor, - admitting(configbutleraiv1alpha3.DefaultClusterProviderName, "team-a", "team-b")} + admitting(triggerLocalProvider, "team-a", "team-b")} objects = append(objects, referencedIn("team-a", configbutleraiv1alpha3.ApplyTriggerKindFlux)...) objects = append(objects, referencedIn("team-b", configbutleraiv1alpha3.ApplyTriggerKindFlux)...) f := ruleFixture(t, objects...) @@ -348,7 +342,7 @@ func resume(t *testing.T, f *holdFixture) { // back, before the release finished: the trigger holds the Kustomization again, as it is, and // hands nothing back. func TestApplyTriggerRule_AReferenceReturningDuringTheReleaseHoldsAgain(t *testing.T) { - f := ruleFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), fluxTarget()) + f := ruleFixture(t, testFluxTrigger(triggerLocalProvider), fluxTarget()) f.settle(t, triggerKey) f.events() resume(t, f) @@ -377,7 +371,7 @@ func TestApplyTriggerRule_AReferenceReturningDuringTheReleaseHoldsAgain(t *testi // apply has the Kustomization resumed, followed by nobody here. The release suspends it, waits until // that is handled, then hands it back, and completes the operation nobody would complete. func TestApplyTriggerRule_ReleasingWithAnApplyInFlightSuspendsFirst(t *testing.T) { - f := ruleFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), fluxTarget()) + f := ruleFixture(t, testFluxTrigger(triggerLocalProvider), fluxTarget()) f.settle(t, triggerKey) f.events() trigger := f.trigger(t) @@ -406,7 +400,7 @@ func TestApplyTriggerRule_ReleasingWithAnApplyInFlightSuspendsFirst(t *testing.T // configuration turns invalid keeps its finalizer and the Kustomization suspended, and its Held // message says so. func TestApplyTriggerRule_AHolderLeftUnconfiguredSaysTheKustomizationIsSuspended(t *testing.T) { - f := ruleFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), fluxTarget()) + f := ruleFixture(t, testFluxTrigger(triggerLocalProvider), fluxTarget()) f.settle(t, triggerKey) f.r.newApplier = triggerReconciler(nil, configbutleraiv1alpha3.ApplyTriggerKindFlux, nil, nil, false).newApplier @@ -441,7 +435,7 @@ func TestApplyTriggerRule_AGitTargetEventRerunsTheTriggersItNames(t *testing.T) assert.Empty(t, argo.gitDestinationToTriggers(context.Background(), platformProvider()), "another kind's") } -// TestApplyTriggerRule_AnApplierInAnotherNamespaceNeedsTheOptIn is B1: a namespace a ClusterProvider +// TestApplyTriggerRule_AnApplierInAnotherNamespaceNeedsTheOptIn is B1: a namespace a KubeProvider // admits may suspend and apply an applier in another namespace only when the provider sets // spec.allowCrossNamespace.appliers; one in the trigger's own namespace needs nothing. Refused, the // trigger never asks the applier's cluster, and holds nothing. @@ -482,16 +476,16 @@ func TestApplyTriggerRule_AnApplierInAnotherNamespaceNeedsTheOptIn(t *testing.T) // applierNamespaceReconciler is a reconciler of kind over the trigger team-a/checkout, whose // applier is in team-a when sameNamespace is set and in flux-system or argocd otherwise, with the -// default ClusterProvider setting allowCrossNamespace.appliers when optIn is set. It counts the +// KubeProvider triggerLocalProvider setting allowCrossNamespace.appliers when optIn is set. It counts the // lookups of the applier's cluster. func applierNamespaceReconciler( t *testing.T, kind string, sameNamespace, optIn bool, ) (*ApplyTriggerReconciler, *int) { t.Helper() - trigger := ctrlclient.Object(testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + trigger := ctrlclient.Object(testFluxTrigger(triggerLocalProvider)) appliers := applierFluxObjects() if kind == configbutleraiv1alpha3.ApplyTriggerKindArgoCD { - trigger = testArgoCDTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger = testArgoCDTrigger(triggerLocalProvider) appliers = []runtime.Object{applierArgoApplication(nil)} } if sameNamespace { @@ -505,7 +499,7 @@ func applierNamespaceReconciler( obj.(*unstructured.Unstructured).SetNamespace("team-a") } } - provider := admitting(configbutleraiv1alpha3.DefaultClusterProviderName, "team-a") + provider := admitting(triggerLocalProvider, "team-a") if !optIn { provider.Spec.AllowCrossNamespace = nil } diff --git a/internal/controller/applytrigger_schema_test.go b/internal/controller/applytrigger_schema_test.go index bfaaf96d9..40ff2545b 100644 --- a/internal/controller/applytrigger_schema_test.go +++ b/internal/controller/applytrigger_schema_test.go @@ -33,8 +33,8 @@ var _ = Describe("Apply trigger schema", func() { return &configbutleraiv1alpha3.FluxTrigger{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, Spec: configbutleraiv1alpha3.FluxTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: "hub"}, - KustomizationRef: meta.NamespacedObjectReference{Namespace: "flux-system", Name: "apps"}, + KubeProviderRef: meta.NamespacedObjectReference{Name: "hub"}, + KustomizationRef: meta.NamespacedObjectReference{Namespace: "flux-system", Name: "apps"}, }, } } @@ -42,22 +42,18 @@ var _ = Describe("Apply trigger schema", func() { return &configbutleraiv1alpha3.ArgoCDTrigger{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, Spec: configbutleraiv1alpha3.ArgoCDTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: "hub"}, - ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "apps"}, + KubeProviderRef: meta.NamespacedObjectReference{Name: "hub"}, + ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "apps"}, }, } } - It("defaults the cluster to the default ClusterProvider, as a GitTarget does, and requires names", func() { - omitted := argo("argo-default-cluster") - omitted.Spec.ClusterProviderRef = nil - Expect(create(omitted)).To(Succeed()) - Expect(omitted.Spec.ClusterProviderRef).To(Equal( - &meta.LocalObjectReference{Name: configbutleraiv1alpha3.DefaultClusterProviderName})) - + It("requires names", func() { + // A reference naming only a namespace names no KubeProvider; there is no default to fall + // back to. noCluster := flux("flux-no-cluster") - noCluster.Spec.ClusterProviderRef.Name = "" - Expect(create(noCluster)).To(MatchError(ContainSubstring("spec.clusterProviderRef.name must not be empty"))) + noCluster.Spec.KubeProviderRef = meta.NamespacedObjectReference{Namespace: "default"} + Expect(create(noCluster)).To(MatchError(ContainSubstring("spec.kubeProviderRef.name must not be empty"))) noApplication := argo("argo-no-app") noApplication.Spec.ApplicationRef.Name = "" @@ -103,8 +99,8 @@ var _ = Describe("Apply trigger schema", func() { Expect(create(fluxTrigger)).To(Succeed()) Expect(update(fluxTrigger, func() { fluxTrigger.Spec.KustomizationRef.Name = "other" })). To(MatchError(ContainSubstring("spec.kustomizationRef is immutable"))) - Expect(update(fluxTrigger, func() { fluxTrigger.Spec.ClusterProviderRef.Name = "other" })). - To(MatchError(ContainSubstring("spec.clusterProviderRef is immutable"))) + Expect(update(fluxTrigger, func() { fluxTrigger.Spec.KubeProviderRef.Name = "other" })). + To(MatchError(ContainSubstring("spec.kubeProviderRef is immutable"))) argoTrigger := argo("argo-immutable") Expect(create(argoTrigger)).To(Succeed()) @@ -117,6 +113,7 @@ var _ = Describe("Apply trigger schema", func() { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("prov", "main")}, Path: "apps/" + name, ApplyTriggerRef: ref, @@ -149,8 +146,8 @@ var _ = Describe("Apply trigger reconcile", func() { trigger := &configbutleraiv1alpha3.ArgoCDTrigger{ ObjectMeta: metav1.ObjectMeta{Name: "reconciled", Namespace: "default"}, Spec: configbutleraiv1alpha3.ArgoCDTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: configbutleraiv1alpha3.DefaultClusterProviderName}, - ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "apps"}, + KubeProviderRef: testKubeProviderRef(), + ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "apps"}, }, } Expect(k8sClient.Create(ctx, trigger)).To(Succeed()) @@ -171,6 +168,7 @@ var _ = Describe("Apply trigger reconcile", func() { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "names-reconciled", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("prov", "main"), }, diff --git a/internal/controller/clusterprovider_controller.go b/internal/controller/clusterprovider_controller.go deleted file mode 100644 index 3ac8946fd..000000000 --- a/internal/controller/clusterprovider_controller.go +++ /dev/null @@ -1,292 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package controller - -import ( - "context" - "fmt" - "sync" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - k8stypes "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/handler" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/source" - - "github.com/go-logr/logr" - - configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" - "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" - "github.com/ConfigButler/gitops-reverser/internal/telemetry" -) - -// LegacyClusterProviderFinalizer is the fact-purge finalizer this controller USED to take. It is -// no longer added; it is only ever REMOVED, so an object created by an older operator can still be -// deleted after an upgrade instead of stranding in Terminating. -// -// DO NOT REINTRODUCE A FINALIZER HERE. It was dropped because purge-on-delete is a promise only a -// living operator can keep, and the two cases that matter break it. `helm uninstall` removes the -// manager and the ClusterProvider together, so nothing detaches the finalizer, the object strands in -// Terminating forever, and the next `helm install --wait` then fails on it — recovering needs a -// manual kubectl patch. That was hit on a live cluster and is why this shipped. An operator that is -// simply down blocks the delete instead, and then loses the purge anyway if the object is -// force-removed, so the finalizer does not even reliably deliver the guarantee it exists for. -// -// The purge it protected is not needed either, and is now unsafe. Facts are keyed by -// (audit route, group/resource, uid, resourceVersion) and expire on their own, so a re-provisioned -// cluster produces different object UIDs and cannot join a stale fact on the exact key. And since -// spec.attribution.auditRoute is what partitions them, SEVERAL ClusterProviders may share one route -// (an API server posts audit under exactly one), so purging on a single provider's deletion would -// drop the facts of every other provider on that route, and of the operator's own cluster, which was -// never torn down. A purge would have to be re-derived against the route, not the object. -const LegacyClusterProviderFinalizer = "configbutler.ai/clusterprovider-fact-purge" - -// ClusterProviderReconciler reconciles a ClusterProvider object. It is the read-side peer of the -// GitProviderReconciler: it validates the cluster's connectivity inputs (spec.kubeConfig) without -// dialing, and owns the per-cluster status the watch engine and GitTargets project from. The -// in-cluster "default" provider has no kubeConfig and is trivially Validated. -type ClusterProviderReconciler struct { - client.Client - - Scheme *runtime.Scheme - - // OperatorNamespace is the namespace a remote provider's kubeConfig Secret is pinned to (the - // operator's own namespace). A cluster-scoped provider has no namespace of its own, so the - // credential for a cluster is always read from here — never from the source cluster. - OperatorNamespace string - - // KubeConfigSafety gates exec-auth and insecure-TLS kubeconfigs (reject-not-strip), matching - // what the watch engine's resolver enforces, so Validated agrees with what a watch would use. - KubeConfigSafety kubeconfig.SafetyPolicy - - // AuditFacts is the per-audit-route fact registry the AuditFactsReceived condition latches on. - // Nil means the operator runs with author attribution disabled, and the condition is then - // absent rather than Unknown. - AuditFacts AuditRouteFacts - - firsts clusterProviderLogFirsts - - // Recorder emits a Kubernetes Event on every persisted Ready transition; nil disables Events. - Recorder record.EventRecorder -} - -// clusterProviderLogFirsts keeps startup progress visible without turning every routine -// revalidation into default-level log noise. -type clusterProviderLogFirsts struct { - validationSuccess sync.Once -} - -// This reconciler reads providers, updates them to shed the retired finalizer, and writes status. -// It never creates or deletes one, so it takes neither verb. -// +kubebuilder:rbac:groups=configbutler.ai,resources=clusterproviders,verbs=get;list;watch;update;patch -// +kubebuilder:rbac:groups=configbutler.ai,resources=clusterproviders/status,verbs=get;update;patch -// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch - -// Reconcile validates a ClusterProvider's inputs and updates its status. -func (r *ClusterProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - log := logf.FromContext(ctx).WithName("ClusterProviderReconciler") - log.V(1).Info("Starting reconciliation", "namespacedName", req.NamespacedName) - - var provider configbutleraiv1alpha3.ClusterProvider - if err := r.Get(ctx, req.NamespacedName, &provider); err != nil { - if client.IgnoreNotFound(err) == nil { - log.Info("ClusterProvider not found, was likely deleted", "name", req.Name) - // The object is gone, so its condition series must stop rather than latch its last - // value: a gauge that keeps reporting Ready=False for a deleted object is a permanent - // false positive that trains people to ignore the panel. - telemetry.ForgetResourceConditions(conditionKindClusterProvider, req.Namespace, req.Name) - return ctrl.Result{}, nil - } - log.Error(err, "unable to fetch ClusterProvider", "name", req.Name) - return ctrl.Result{}, err - } - - // This controller takes NO finalizer. It only sheds the legacy one, so an object created by - // an older operator can still be deleted after an upgrade. Deletion is otherwise ordinary: - // nothing has to happen before a ClusterProvider goes away. - shed, err := r.shedLegacyFinalizer(ctx, log, &provider) - if err != nil { - return ctrl.Result{}, err - } - if shed { - return ctrl.Result{}, nil - } - if !provider.DeletionTimestamp.IsZero() { - return ctrl.Result{}, nil - } - - return r.reconcileClusterProvider(ctx, log, &provider) -} - -// shedLegacyFinalizer removes the retired fact-purge finalizer if the object still carries one, and -// reports whether it did (in which case the update it wrote will re-trigger this reconcile). -// -// This is the upgrade path, and it must run BEFORE the deletion check: an object stuck in -// Terminating from an older operator is only recoverable by removing the finalizer, and a -// controller that returns early on deletionTimestamp would leave it stranded forever. -// -// A failed Update is returned as an error rather than swallowed: a stranded object gets no further -// events of its own, so dropping a transient conflict here would leave it in Terminating until an -// operator restart. The error is what makes the workqueue retry. -func (r *ClusterProviderReconciler) shedLegacyFinalizer( - ctx context.Context, - log logr.Logger, - provider *configbutleraiv1alpha3.ClusterProvider, -) (bool, error) { - if !controllerutil.RemoveFinalizer(provider, LegacyClusterProviderFinalizer) { - return false, nil - } - if err := r.Update(ctx, provider); err != nil { - log.Error(err, "remove retired ClusterProvider finalizer failed; will retry", "name", provider.Name) - return false, fmt.Errorf("remove retired finalizer from ClusterProvider %s: %w", provider.Name, err) - } - log.Info("removed the retired fact-purge finalizer", "name", provider.Name) - return true, nil -} - -// reconcileClusterProvider performs the main validation logic. -func (r *ClusterProviderReconciler) reconcileClusterProvider( - ctx context.Context, - log logr.Logger, - provider *configbutleraiv1alpha3.ClusterProvider, -) (ctrl.Result, error) { - log.V(1).Info("Validating ClusterProvider", - "name", provider.Name, - "inCluster", provider.IsInCluster(), - "generation", provider.Generation) - - st := beginStatus(r.Client, r.Recorder, provider) - - valid, reason, message, err := r.validateProviderKubeConfig(ctx, provider) - if err != nil { - log.Error(err, "failed to read ClusterProvider kubeconfig Secret", "name", provider.Name) - return ctrl.Result{}, err - } - - validated := metav1.ConditionTrue - rd := newReadiness(message, "ClusterProvider is not stalled") - if !valid { - validated = metav1.ConditionFalse - rd.stalled(reason, message) - } - st.set(ClusterProviderConditionValidated, validated, reason, message) - // Written after the readiness trio and deliberately not contributing to it: an audit route - // nobody posts under costs the commit AUTHOR, never the mirror, so it must not make a working - // provider look broken to a kstatus reader. - r.applyAuditFactsCondition(st, provider) - st.applyReadiness(rd) - - // A failed status write is a real failure, not a verdict: propagate it so the provider is - // retried rather than left reporting a stale status for a whole steady interval. - if err := st.commit(ctx); err != nil { - log.Error(err, "failed to update ClusterProvider status", "name", provider.Name) - return ctrl.Result{}, err - } - - if valid { - r.firsts.validationSuccess.Do(func() { - log.Info("First ClusterProvider validation completed successfully", "name", provider.Name) - }) - } - return ctrl.Result{RequeueAfter: st.requeueAfter(RequeueSteadyInterval)}, nil -} - -// validateProviderKubeConfig is the legibility gate for spec.kubeConfig (feeds Validated). It -// reads and parses the kubeconfig Secret FROM THE OPERATOR NAMESPACE and applies the exec/TLS -// safety policy, but deliberately does NOT dial the cluster — reachability is a runtime signal the -// watch engine records on the Reachable condition. It returns ok=false with a typed reason and a -// legible message when an input is wrong; a non-NotFound read error is returned as err so the -// reconcile requeues rather than falsely reporting a bad input. An omitted kubeConfig is an -// in-cluster provider — trivially valid, regardless of its name. -func (r *ClusterProviderReconciler) validateProviderKubeConfig( - ctx context.Context, - provider *configbutleraiv1alpha3.ClusterProvider, -) (bool, string, string, error) { - if provider.IsInCluster() { - return true, ReasonInCluster, "in-cluster provider (no kubeConfig); the operator's own cluster", nil - } - if provider.Spec.KubeConfig.SecretRef == nil { - // A remote provider needs a Secret reference. configMapRef is CEL-rejected, so secretRef is - // the only supported path. - return false, ReasonKubeConfigInvalid, - "spec.kubeConfig.secretRef is required for a remote ClusterProvider", nil - } - ref := provider.Spec.KubeConfig.SecretRef - secretKey := k8stypes.NamespacedName{Namespace: r.OperatorNamespace, Name: ref.Name} - - var secret corev1.Secret - if getErr := r.Get(ctx, secretKey, &secret); getErr != nil { - if apierrors.IsNotFound(getErr) { - return false, kubeconfig.ReasonSecretNotFound, fmt.Sprintf( - "spec.kubeConfig.secretRef names Secret %s, which does not exist in the operator namespace", - secretKey), nil - } - return false, "", "", fmt.Errorf("read kubeconfig Secret %s: %w", secretKey, getErr) - } - - raw, usedKey, present := kubeconfig.ResolveKey(secret.Data, ref.Key) - if !present { - return false, kubeconfig.ReasonKeyNotFound, fmt.Sprintf( - "kubeconfig Secret %s has no kubeconfig under key %q (set spec.kubeConfig.secretRef.key)", - secretKey, describeKubeConfigKey(ref.Key)), nil - } - if rej := kubeconfig.Check(raw, r.KubeConfigSafety); rej != nil { - // A RejectionError is a validation VERDICT, not a reconcile error: surfaced as the - // Validated=False reason/message, reconcile requeues normally (err == nil). - //nolint:nilerr - return false, rej.Reason, fmt.Sprintf("kubeconfig Secret %s key %q: %s", secretKey, usedKey, rej.Message), nil - } - return true, ReasonSucceeded, fmt.Sprintf("kubeconfig Secret %s validated", secretKey), nil -} - -// clusterProviderReconcilePredicate admits spec changes and the start of deletion. A deletion -// transition is needed only for the upgrade path: an older object can still carry the retired -// fact-purge finalizer, and this controller must shed it promptly rather than waiting for periodic -// reconciliation. The controller never adds a finalizer itself. -func clusterProviderReconcilePredicate() predicate.Predicate { - return predicate.Funcs{ - UpdateFunc: func(e event.UpdateEvent) bool { - if e.ObjectOld == nil || e.ObjectNew == nil { - return true - } - oldDeleting := e.ObjectOld.GetDeletionTimestamp() != nil - newDeleting := e.ObjectNew.GetDeletionTimestamp() != nil - return oldDeleting != newDeleting || e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() - }, - } -} - -// SetupWithManager sets up the controller with the Manager. -func (r *ClusterProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { - b := ctrl.NewControllerManagedBy(mgr). - For( - &configbutleraiv1alpha3.ClusterProvider{}, - builder.WithPredicates(clusterProviderReconcilePredicate()), - ). - Named("clusterprovider") - - // React to the FIRST attribution fact on an audit route so AuditFactsReceived latches True - // within seconds instead of on the provider's next periodic requeue, which for a converged - // provider is RequeueSteadyInterval. The event names the route; the map fans it out to every - // provider reading it, the in-cluster "default" included. - if r.AuditFacts != nil { - if events := r.AuditFacts.FirstFactEvents(); events != nil { - b = b.WatchesRawSource(source.Channel( - events, - handler.EnqueueRequestsFromMapFunc(r.auditRouteToClusterProviders), - )) - } - } - - return b.Complete(r) -} diff --git a/internal/controller/clusterprovider_controller_test.go b/internal/controller/clusterprovider_controller_test.go deleted file mode 100644 index 3f7541aa3..000000000 --- a/internal/controller/clusterprovider_controller_test.go +++ /dev/null @@ -1,121 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package controller - -import ( - "context" - "time" - - meta "github.com/fluxcd/pkg/apis/meta" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - - configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" -) - -var _ = Describe("ClusterProvider Controller", func() { - const timeout = 10 * time.Second - const interval = 200 * time.Millisecond - - readyStatus := func(name string) func() metav1.ConditionStatus { - return func() metav1.ConditionStatus { - var got configbutleraiv1alpha3.ClusterProvider - if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: name}, &got); err != nil { - return metav1.ConditionUnknown - } - c := findCondition(got.Status.Conditions, ConditionTypeReady) - if c == nil { - return metav1.ConditionUnknown - } - return c.Status - } - } - - It("validates the in-cluster 'default' provider and goes Ready", func() { - // The "default" ClusterProvider is created once in BeforeSuite (as an install ships it — the - // operator never creates one), so this spec asserts the existing object reconciles to Ready - // rather than creating its own; "default" is cluster-scoped and name-unique. - Eventually(readyStatus("default"), timeout, interval).Should(Equal(metav1.ConditionTrue)) - - var got configbutleraiv1alpha3.ClusterProvider - Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: "default"}, &got)).To(Succeed()) - Expect(findCondition(got.Status.Conditions, ClusterProviderConditionValidated).Status). - To(Equal(metav1.ConditionTrue)) - }) - - It("admits any name that omits kubeConfig — in-cluster is not reserved to 'default'", func() { - // kubeConfig is optional for EVERY provider: omitted means the operator's own cluster, - // whatever the object is called. "default" only names what an omitted clusterProviderRef - // points at; it makes no claim about which cluster that is. - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "local-extra"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"default"}}, - }, - } - Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) - DeferCleanup(func() { _ = k8sClient.Delete(context.Background(), provider) }) - - Eventually(readyStatus("local-extra"), timeout, interval).Should(Equal(metav1.ConditionTrue)) - }) - - It("does not forbid a 'default' provider that sets a kubeConfig", func() { - // The converse of the spec above: a provider named "default" MAY mirror a remote cluster. - // The existing "default" object makes this create collide on the name, so the assertion is - // precisely that the rejection is the name being taken — NOT a schema rule tying the name - // "default" to an absent kubeConfig, which no longer exists. - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "default"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - KubeConfig: &meta.KubeConfigReference{SecretRef: &meta.SecretKeyReference{Name: "kc"}}, - }, - } - err := k8sClient.Create(context.Background(), provider) - Expect(apierrors.IsAlreadyExists(err)).To(BeTrue(), "expected a name collision, got %v", err) - Expect(apierrors.IsInvalid(err)).To(BeFalse(), "the name must not constrain kubeConfig") - }) - - It("validates a remote provider with a valid kubeconfig Secret", func() { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "prod-eu-kc"}, - Data: map[string][]byte{"value": []byte(scValidKubeConfig)}, - } - Expect(k8sClient.Create(context.Background(), secret)).To(Succeed()) - DeferCleanup(func() { _ = k8sClient.Delete(context.Background(), secret) }) - - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - KubeConfig: &meta.KubeConfigReference{SecretRef: &meta.SecretKeyReference{Name: "prod-eu-kc"}}, - }, - } - Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) - DeferCleanup(func() { _ = k8sClient.Delete(context.Background(), provider) }) - - Eventually(readyStatus("prod-eu-1"), timeout, interval).Should(Equal(metav1.ConditionTrue)) - }) - - It("stalls a remote provider whose kubeconfig Secret is missing", func() { - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-us-1"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - KubeConfig: &meta.KubeConfigReference{SecretRef: &meta.SecretKeyReference{Name: "absent-kc"}}, - }, - } - Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) - DeferCleanup(func() { _ = k8sClient.Delete(context.Background(), provider) }) - - Eventually(readyStatus("prod-us-1"), timeout, interval).Should(Equal(metav1.ConditionFalse)) - - var got configbutleraiv1alpha3.ClusterProvider - Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: "prod-us-1"}, &got)).To(Succeed()) - Expect(findCondition(got.Status.Conditions, ClusterProviderConditionValidated).Status). - To(Equal(metav1.ConditionFalse)) - Expect(findCondition(got.Status.Conditions, ConditionTypeStalled).Status). - To(Equal(metav1.ConditionTrue)) - }) -}) diff --git a/internal/controller/clusterprovider_controller_unit_test.go b/internal/controller/clusterprovider_controller_unit_test.go deleted file mode 100644 index 4f2500899..000000000 --- a/internal/controller/clusterprovider_controller_unit_test.go +++ /dev/null @@ -1,367 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package controller - -import ( - "context" - "errors" - "testing" - - meta "github.com/fluxcd/pkg/apis/meta" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - k8stypes "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - - configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" - "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" -) - -const cpOperatorNS = "gitops-reverser-system" - -func clusterProviderWithKubeConfig(name, secretName, key string) *configbutleraiv1alpha3.ClusterProvider { - p := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - } - if secretName != "" { - p.Spec.KubeConfig = &meta.KubeConfigReference{ - SecretRef: &meta.SecretKeyReference{Name: secretName, Key: key}, - } - } - return p -} - -func TestValidateProviderKubeConfig_AllScenarios(t *testing.T) { - tests := []struct { - name string - provider *configbutleraiv1alpha3.ClusterProvider - secretData map[string][]byte - safety kubeconfig.SafetyPolicy - wantOK bool - wantReason string - }{ - { - name: "in-cluster default (no kubeConfig) is valid", - provider: clusterProviderWithKubeConfig("default", "", ""), - wantOK: true, wantReason: ReasonInCluster, - }, - { - name: "missing Secret", - provider: clusterProviderWithKubeConfig("prod-eu-1", "absent", ""), - wantOK: false, wantReason: kubeconfig.ReasonSecretNotFound, - }, - { - name: "missing key", - provider: clusterProviderWithKubeConfig("prod-eu-1", "kc", "value"), - secretData: map[string][]byte{"elsewhere": []byte(scValidKubeConfig)}, - wantOK: false, wantReason: kubeconfig.ReasonKeyNotFound, - }, - { - name: "unparseable", - provider: clusterProviderWithKubeConfig("prod-eu-1", "kc", ""), - secretData: map[string][]byte{"value": []byte("not a kubeconfig")}, - wantOK: false, wantReason: kubeconfig.ReasonInvalid, - }, - { - name: "exec rejected", - provider: clusterProviderWithKubeConfig("prod-eu-1", "kc", ""), - secretData: map[string][]byte{"value": []byte(scExecKubeConfig)}, - wantOK: false, wantReason: kubeconfig.ReasonExecNotAllowed, - }, - { - name: "insecure TLS rejected", - provider: clusterProviderWithKubeConfig("prod-eu-1", "kc", ""), - secretData: map[string][]byte{"value": []byte(scInsecureKubeConfig)}, - wantOK: false, wantReason: kubeconfig.ReasonInsecureTLSNotAllowed, - }, - { - name: "valid via value.yaml fallback", - provider: clusterProviderWithKubeConfig("prod-eu-1", "kc", ""), - secretData: map[string][]byte{"value.yaml": []byte(scValidKubeConfig)}, - wantOK: true, wantReason: ReasonSucceeded, - }, - { - name: "exec allowed when opted in", - provider: clusterProviderWithKubeConfig("prod-eu-1", "kc", ""), - secretData: map[string][]byte{"value": []byte(scExecKubeConfig)}, - safety: kubeconfig.SafetyPolicy{AllowExec: true}, - wantOK: true, wantReason: ReasonSucceeded, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - builder := fake.NewClientBuilder().WithScheme(scScheme(t)) - if tc.secretData != nil { - builder = builder.WithObjects(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Namespace: cpOperatorNS, Name: "kc"}, - Data: tc.secretData, - }) - } - r := &ClusterProviderReconciler{ - Client: builder.Build(), - OperatorNamespace: cpOperatorNS, - KubeConfigSafety: tc.safety, - } - ok, reason, msg, err := r.validateProviderKubeConfig(context.Background(), tc.provider) - require.NoError(t, err) - assert.Equal(t, tc.wantOK, ok) - assert.Equal(t, tc.wantReason, reason) - assert.NotEmpty(t, msg) - }) - } -} - -// TestClusterProviderReconcile_InClusterDefault checks the reserved "default" provider validates -// and goes Ready with no kubeConfig Secret to read. -func TestClusterProviderReconcile_InClusterDefault(t *testing.T) { - provider := clusterProviderWithKubeConfig("default", "", "") - cl := fake.NewClientBuilder(). - WithScheme(scScheme(t)). - WithObjects(provider). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). - Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS} - - _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "default"}}) - require.NoError(t, err) - - var got configbutleraiv1alpha3.ClusterProvider - require.NoError(t, cl.Get(context.Background(), k8stypes.NamespacedName{Name: "default"}, &got)) - assert.Equal( - t, - metav1.ConditionTrue, - findCondition(got.Status.Conditions, ClusterProviderConditionValidated).Status, - ) - assert.Equal(t, metav1.ConditionTrue, findCondition(got.Status.Conditions, ConditionTypeReady).Status) - assert.Equal(t, got.Generation, got.Status.ObservedGeneration) -} - -// TestClusterProviderReconcile_TakesNoFinalizer pins the contract: this controller never makes a -// ClusterProvider undeletable. Nothing has to happen before one goes away — attribution facts are -// keyed by (audit route, group/resource, uid, resourceVersion) and expire on their own, so a -// re-provisioned cluster reusing a provider name mints different object UIDs and cannot join a -// stale fact. The reason a finalizer must never come back is on LegacyClusterProviderFinalizer: -// helm uninstall would strand the object in Terminating. -func TestClusterProviderReconcile_TakesNoFinalizer(t *testing.T) { - provider := clusterProviderWithKubeConfig("default", "", "") - cl := fake.NewClientBuilder(). - WithScheme(scScheme(t)). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). - WithObjects(provider). - Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS} - - _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "default"}}) - require.NoError(t, err) - - var got configbutleraiv1alpha3.ClusterProvider - require.NoError(t, cl.Get(context.Background(), k8stypes.NamespacedName{Name: "default"}, &got)) - assert.Empty(t, got.Finalizers, "a ClusterProvider must never be held by this controller") -} - -// TestClusterProviderReconcile_ShedsLegacyFinalizer is the upgrade path. An object created by an -// older operator carries the retired fact-purge finalizer; after this upgrade nothing would ever -// remove it, so it would strand in Terminating forever and block reinstalling. The controller has -// to actively shed it — including while the object is ALREADY deleting, which is the state a -// stranded object is in. -func TestClusterProviderReconcile_ShedsLegacyFinalizer(t *testing.T) { - tests := []struct { - name string - deleting bool - }{ - {name: "live object carrying the retired finalizer", deleting: false}, - {name: "object already stranded in Terminating", deleting: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider := clusterProviderWithKubeConfig("prod-eu-1", "kc", "") - provider.Finalizers = []string{LegacyClusterProviderFinalizer} - if tt.deleting { - now := metav1.Now() - provider.DeletionTimestamp = &now - } - cl := fake.NewClientBuilder(). - WithScheme(scScheme(t)). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). - WithObjects(provider). - Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS} - - _, err := r.Reconcile( - context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "prod-eu-1"}}, - ) - require.NoError(t, err) - - var got configbutleraiv1alpha3.ClusterProvider - getErr := cl.Get(context.Background(), k8stypes.NamespacedName{Name: "prod-eu-1"}, &got) - if tt.deleting { - // Shedding the last finalizer on a deleting object lets it go immediately. - assert.True(t, apierrors.IsNotFound(getErr), - "a stranded object must be released, not left in Terminating") - return - } - require.NoError(t, getErr) - assert.Empty(t, got.Finalizers, "the retired finalizer must be shed") - }) - } -} - -// TestClusterProviderReconcile_ShedFinalizerUpdateFails pins the retry contract for the upgrade -// path. A stranded object gets no further events of its own, so if the finalizer-shedding Update -// fails the reconcile MUST return the error — that is the only thing that re-queues it. Swallowing -// the failure would leave the object in Terminating until the operator restarts. -func TestClusterProviderReconcile_ShedFinalizerUpdateFails(t *testing.T) { - provider := clusterProviderWithKubeConfig("prod-eu-1", "kc", "") - provider.Finalizers = []string{LegacyClusterProviderFinalizer} - now := metav1.Now() - provider.DeletionTimestamp = &now - - cl := fake.NewClientBuilder(). - WithScheme(scScheme(t)). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). - WithObjects(provider). - WithInterceptorFuncs(interceptor.Funcs{ - Update: func( - _ context.Context, - _ client.WithWatch, - _ client.Object, - _ ...client.UpdateOption, - ) error { - return apierrors.NewConflict( - schema.GroupResource{Group: "configbutler.ai", Resource: "clusterproviders"}, - "prod-eu-1", errors.New("conflict"), - ) - }, - }). - Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS} - - _, err := r.Reconcile( - context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "prod-eu-1"}}, - ) - require.Error(t, err, "a failed finalizer shed must requeue, not silently give up") - assert.Contains(t, err.Error(), "remove retired finalizer") - - // The object is still held, which is exactly why the retry has to happen. - var got configbutleraiv1alpha3.ClusterProvider - require.NoError(t, cl.Get(context.Background(), k8stypes.NamespacedName{Name: "prod-eu-1"}, &got)) - assert.Contains(t, got.Finalizers, LegacyClusterProviderFinalizer) -} - -// TestClusterProviderReconcile_InvalidStatusWriteFails checks that a failed status write on the -// INVALID path is propagated. The verdict itself (a bad kubeconfig) is not an error, but failing to -// record it is: without the error the provider would report a stale status for a whole steady -// interval before anything looked again. -func TestClusterProviderReconcile_InvalidStatusWriteFails(t *testing.T) { - provider := clusterProviderWithKubeConfig("prod-eu-1", "absent-kc", "") - cl := fake.NewClientBuilder(). - WithScheme(scScheme(t)). - WithObjects(provider). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). - WithInterceptorFuncs(interceptor.Funcs{ - SubResourcePatch: func( - _ context.Context, - _ client.Client, - _ string, - _ client.Object, - _ client.Patch, - _ ...client.SubResourcePatchOption, - ) error { - return errors.New("status write boom") - }, - }). - Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS} - - _, err := r.Reconcile( - context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "prod-eu-1"}}, - ) - require.Error(t, err, "a failed status write must requeue rather than report success") - assert.Contains(t, err.Error(), "status write boom") -} - -// TestClusterProviderReconcile_NotFound checks a deleted provider reconciles to a no-op. -func TestClusterProviderReconcile_NotFound(t *testing.T) { - cl := fake.NewClientBuilder().WithScheme(scScheme(t)).Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS} - res, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "gone"}}) - require.NoError(t, err) - assert.Zero(t, res.RequeueAfter) -} - -// TestValidateProviderKubeConfig_NilSecretRef checks a remote provider whose kubeConfig has no -// secretRef is rejected (belt to the CEL configMapRef rejection). -func TestValidateProviderKubeConfig_NilSecretRef(t *testing.T) { - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{KubeConfig: &meta.KubeConfigReference{}}, - } - r := &ClusterProviderReconciler{ - Client: fake.NewClientBuilder().WithScheme(scScheme(t)).Build(), - OperatorNamespace: cpOperatorNS, - } - ok, reason, msg, err := r.validateProviderKubeConfig(context.Background(), provider) - require.NoError(t, err) - assert.False(t, ok) - assert.Equal(t, ReasonKubeConfigInvalid, reason) - assert.NotEmpty(t, msg) -} - -// TestReconcileStatusCommit_DeletedObject checks the shared status writer treats a vanished -// object as done rather than erroring: a reconcile that raced a delete must not fail the workqueue. -func TestReconcileStatusCommit_DeletedObject(t *testing.T) { - cl := fake.NewClientBuilder(). - WithScheme(scScheme(t)). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). - Build() - provider := clusterProviderWithKubeConfig("gone", "", "") - st := beginStatus(cl, nil, provider) - st.set(ConditionTypeReady, metav1.ConditionTrue, ReasonSucceeded, "gone but written") - - // The object was never created, so the patch returns NotFound -> treated as done, no error. - require.NoError(t, st.commit(context.Background())) -} - -// TestClusterProviderReconcile_RemoteInvalidKubeConfig checks a remote provider whose kubeconfig -// is missing goes Validated=False / Stalled, not Ready. -func TestClusterProviderReconcile_RemoteInvalidKubeConfig(t *testing.T) { - provider := clusterProviderWithKubeConfig("prod-eu-1", "absent-kc", "") - cl := fake.NewClientBuilder(). - WithScheme(scScheme(t)). - WithObjects(provider). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). - Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS} - - _, err := r.Reconcile( - context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "prod-eu-1"}}, - ) - require.NoError(t, err) - - var got configbutleraiv1alpha3.ClusterProvider - require.NoError(t, cl.Get(context.Background(), k8stypes.NamespacedName{Name: "prod-eu-1"}, &got)) - assert.Equal( - t, - metav1.ConditionFalse, - findCondition(got.Status.Conditions, ClusterProviderConditionValidated).Status, - ) - assert.Equal( - t, - kubeconfig.ReasonSecretNotFound, - findCondition(got.Status.Conditions, ClusterProviderConditionValidated).Reason, - ) - assert.Equal(t, metav1.ConditionFalse, findCondition(got.Status.Conditions, ConditionTypeReady).Status) - assert.Equal(t, metav1.ConditionTrue, findCondition(got.Status.Conditions, ConditionTypeStalled).Status) -} diff --git a/internal/controller/clusterprovider_predicate_test.go b/internal/controller/clusterprovider_predicate_test.go deleted file mode 100644 index b6fb5e0dd..000000000 --- a/internal/controller/clusterprovider_predicate_test.go +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package controller - -import ( - "testing" - - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/event" - - configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" -) - -// provider builds a ClusterProvider with the given generation, optionally marked for deletion. -func providerAt(generation int64, deleting bool) *configbutleraiv1alpha3.ClusterProvider { - p := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1", Generation: generation}, - } - if deleting { - now := metav1.Now() - p.DeletionTimestamp = &now - p.Finalizers = []string{LegacyClusterProviderFinalizer} - } - return p -} - -// TestClusterProviderReconcilePredicate_AdmitsDeletionAndSpecChanges pins the predicate contract. -// The deletion case lets the controller promptly shed the retired fact-purge finalizer from an -// object created by an older operator. -func TestClusterProviderReconcilePredicate_AdmitsDeletionAndSpecChanges(t *testing.T) { - p := clusterProviderReconcilePredicate() - - tests := []struct { - name string - old *configbutleraiv1alpha3.ClusterProvider - new *configbutleraiv1alpha3.ClusterProvider - want bool - }{ - { - name: "deletion begins is admitted even with no generation bump", - old: providerAt(3, false), - new: providerAt(3, true), - want: true, - }, - { - name: "spec change is admitted", - old: providerAt(3, false), - new: providerAt(4, false), - want: true, - }, - { - name: "status-only update is filtered", - old: providerAt(3, false), - new: providerAt(3, false), - want: false, - }, - { - name: "repeated update while already deleting is filtered", - old: providerAt(3, true), - new: providerAt(3, true), - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := p.Update(event.UpdateEvent{ObjectOld: tt.old, ObjectNew: tt.new}) - assert.Equal(t, tt.want, got) - }) - } -} - -// TestClusterProviderReconcilePredicate_NilObjectsAreAdmitted keeps the predicate fail-open on a -// malformed event: dropping it would silently skip a reconcile, which is worse than one extra pass. -func TestClusterProviderReconcilePredicate_NilObjectsAreAdmitted(t *testing.T) { - p := clusterProviderReconcilePredicate() - assert.True(t, p.Update(event.UpdateEvent{ObjectOld: nil, ObjectNew: providerAt(1, false)})) - assert.True(t, p.Update(event.UpdateEvent{ObjectOld: providerAt(1, false), ObjectNew: nil})) -} diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index 5b8fce100..f478a0ebc 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -106,15 +106,17 @@ const ( cwaTargetName = "prod-mirror" cwaTargetNS = "team-a" cwaProviderName = "prod-eu-1" + // cwaProviderNS holds the KubeProvider, so cwaTargetNS reaches it only through accessFrom. + cwaProviderNS = "platform" ) func cwaGitTarget() *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: cwaTargetName, Namespace: cwaTargetNS}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, - ClusterProviderRef: &meta.LocalObjectReference{Name: cwaProviderName}, - Path: "clusters/prod", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: cwaProviderNS, Name: cwaProviderName}, + Path: "clusters/prod", }, } } @@ -125,10 +127,13 @@ func cwaGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func cwaClusterProvider(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.ClusterProvider { - return &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: cwaProviderName}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{AccessFrom: policy}, +func cwaKubeProvider(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: cwaProviderName, Namespace: cwaProviderNS}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + AccessFrom: policy, + }, } } @@ -238,13 +243,13 @@ func assertTerminalRefusal(t *testing.T, rule *configbutleraiv1alpha3.ClusterWat // --- tests ------------------------------------------------------------------------------------ // TestReconcile_ClusterWatchRuleRefusedWhenTargetNamespaceUnauthorized is the direct-refusal case: -// a ClusterWatchRule may not compile against a GitTarget whose namespace the ClusterProvider never +// a ClusterWatchRule may not compile against a GitTarget whose namespace the KubeProvider never // admitted, even though the rule itself is cluster-scoped and names the target freely. func TestReconcile_ClusterWatchRuleRefusedWhenTargetNamespaceUnauthorized(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaClusterProvider(cwaDenying()), + cwaKubeProvider(cwaDenying()), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cwaTargetNS}}, }, interceptor.Funcs{}) @@ -256,10 +261,10 @@ func TestReconcile_ClusterWatchRuleRefusedWhenTargetNamespaceUnauthorized(t *tes assertTerminalRefusal(t, f.reloadRule(ctx, t)) } -// TestReconcile_ClusterWatchRuleRefusedWhenClusterProviderMissing covers the other half of the +// TestReconcile_ClusterWatchRuleRefusedWhenKubeProviderMissing covers the other half of the // shared gate: an undeclared provider is a hard denial, so an operator cannot sidestep // accessFrom by simply never creating the provider. -func TestReconcile_ClusterWatchRuleRefusedWhenClusterProviderMissing(t *testing.T) { +func TestReconcile_ClusterWatchRuleRefusedWhenKubeProviderMissing(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), @@ -293,7 +298,7 @@ func TestReconcile_RefusalStopsDataPlaneBeforePublishingStatus(t *testing.T) { var f *cwaFixture f = newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaClusterProvider(cwaDenying()), + cwaKubeProvider(cwaDenying()), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cwaTargetNS}}, }, interceptor.Funcs{ SubResourcePatch: func( @@ -335,7 +340,7 @@ func TestReconcile_AdmittedClusterWatchRuleStillCompiles(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaClusterProvider(cwaAdmitting()), + cwaKubeProvider(cwaAdmitting()), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cwaTargetNS}}, }, interceptor.Funcs{}) @@ -357,7 +362,7 @@ func TestReconcile_AdmittedBySelectorOnNamespaceLabels(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaClusterProvider(&configbutleraiv1alpha3.NamespaceMatcher{ + cwaKubeProvider(&configbutleraiv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, }), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ @@ -377,7 +382,7 @@ func TestReconcile_RevocationRemovesCompiledRule(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaClusterProvider(cwaAdmitting()), + cwaKubeProvider(cwaAdmitting()), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cwaTargetNS}}, }, interceptor.Funcs{}) @@ -388,8 +393,9 @@ func TestReconcile_RevocationRemovesCompiledRule(t *testing.T) { replansAfterAdmission := f.wm.replans // Revoke: the namespace leaves accessFrom. - var provider configbutleraiv1alpha3.ClusterProvider - require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Name: cwaProviderName}, &provider)) + var provider configbutleraiv1alpha3.KubeProvider + require.NoError(t, f.client.Get(ctx, + k8stypes.NamespacedName{Namespace: cwaProviderNS, Name: cwaProviderName}, &provider)) provider.Spec.AccessFrom = cwaDenying() require.NoError(t, f.client.Update(ctx, &provider)) @@ -412,14 +418,14 @@ func TestReconcile_AdmissionReadErrorRequeuesAndKeepsCompiledRule(t *testing.T) f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaClusterProvider(cwaAdmitting()), + cwaKubeProvider(cwaAdmitting()), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cwaTargetNS}}, }, interceptor.Funcs{ Get: func( ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption, ) error { - if _, ok := obj.(*configbutleraiv1alpha3.ClusterProvider); ok { + if _, ok := obj.(*configbutleraiv1alpha3.KubeProvider); ok { return boom } return c.Get(ctx, key, obj, opts...) @@ -439,15 +445,23 @@ func TestReconcile_AdmissionReadErrorRequeuesAndKeepsCompiledRule(t *testing.T) // --- mapper tests ----------------------------------------------------------------------------- -// TestClusterProviderToClusterWatchRules proves a provider policy change requeues ClusterWatchRules +// TestKubeProviderToClusterWatchRules proves a provider policy change requeues ClusterWatchRules // and not only GitTargets. Without it a revocation converges only on the ~10m periodic reconcile, // because the GitTarget's resulting status flip is dropped by GenerationChangedPredicate. -func TestClusterProviderToClusterWatchRules(t *testing.T) { +func TestKubeProviderToClusterWatchRules(t *testing.T) { mirrored := cwaGitTarget() elsewhere := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "other-mirror", Namespace: "team-b"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: "prod-us-1"}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: cwaProviderNS, Name: "prod-us-1"}, + }, + } + // The same provider NAME in another namespace is another provider: a namespaced key, not a name, + // is what the map matches on. + sameName := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "same-name-mirror", Namespace: "team-b"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: cwaProviderName}, }, } @@ -460,14 +474,15 @@ func TestClusterProviderToClusterWatchRules(t *testing.T) { } cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects( - mirrored, elsewhere, + mirrored, elsewhere, sameName, ruleFor("rule-affected", cwaTargetName, cwaTargetNS), ruleFor("rule-other-provider", "other-mirror", "team-b"), + ruleFor("rule-same-name-elsewhere", "same-name-mirror", "team-b"), ruleFor("rule-dangling", "no-such-target", cwaTargetNS), ).Build() r := &ClusterWatchRuleReconciler{Client: cl} - reqs := r.clusterProviderToClusterWatchRules(context.Background(), cwaClusterProvider(cwaAdmitting())) + reqs := r.kubeProviderToClusterWatchRules(context.Background(), cwaKubeProvider(cwaAdmitting())) names := []string{} for _, req := range reqs { @@ -517,8 +532,8 @@ func TestClusterWatchRulesTargeting_NoMatchesEnqueuesNothing(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(cwaClusterWatchRule()).Build() r := &ClusterWatchRuleReconciler{Client: cl} - assert.Empty(t, r.clusterProviderToClusterWatchRules(context.Background(), - cwaClusterProvider(cwaAdmitting())), "no GitTargets mirror through this provider") + assert.Empty(t, r.kubeProviderToClusterWatchRules(context.Background(), + cwaKubeProvider(cwaAdmitting())), "no GitTargets mirror through this provider") assert.Empty(t, r.namespaceToClusterWatchRules(context.Background(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "empty-ns"}})) } @@ -529,5 +544,6 @@ func TestRefusalReasonIsStableForOperators(t *testing.T) { assert.Equal(t, "GitTargetNamespaceNotAuthorized", ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized) assert.Equal(t, "NamespaceNotAuthorized", authz.ReasonNamespaceNotAuthorized) - assert.Equal(t, "ClusterProviderNotFound", authz.ReasonClusterProviderNotFound) + assert.Equal(t, "KubeProviderNotFound", authz.ReasonKubeProviderNotFound) + assert.Equal(t, "AuditRouteConflict", authz.ReasonAuditRouteConflict) } diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index 7e3b4f453..b227d9500 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -42,7 +42,7 @@ const ( ClusterWatchRuleReasonCatalogNotReady = "CatalogNotReady" // ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized is the terminal reason when the - // referenced GitTarget's namespace is not admitted by that target's ClusterProvider. It is + // referenced GitTarget's namespace is not admitted by that target's KubeProvider. It is // re-exported from internal/watch, where the shared compile path both bootstrap and this // reconciler call decides it, so the two can never drift. ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized = watch.ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized @@ -63,7 +63,7 @@ type ClusterWatchRuleReconciler struct { // +kubebuilder:rbac:groups=configbutler.ai,resources=clusterwatchrules/status,verbs=get;update;patch // +kubebuilder:rbac:groups=configbutler.ai,resources=gittargets,verbs=get;list;watch // +kubebuilder:rbac:groups=configbutler.ai,resources=gitproviders,verbs=get;list;watch -// +kubebuilder:rbac:groups=configbutler.ai,resources=clusterproviders,verbs=get;list;watch +// +kubebuilder:rbac:groups=configbutler.ai,resources=kubeproviders,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to @@ -216,7 +216,7 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( } // gateClusterWatchRule is the ClusterWatchRule gate and the ONE place this controller compiles a -// cluster rule. It runs the shared compile path, whose refusal is the ClusterProvider namespace +// cluster rule. It runs the shared compile path, whose refusal is the KubeProvider namespace // admission of the referenced GitTarget: a ClusterWatchRule is cluster-scoped and its gitTargetRef // carries a REQUIRED namespace, so it may name a GitTarget in ANY namespace and widen that target's // mirror scope cluster-wide. Compiling such a rule without re-applying the target's own provider @@ -277,7 +277,7 @@ func (r *ClusterWatchRuleReconciler) gateClusterWatchRule( // able to assert the rule is already gone. // // The refusal is terminal (Stalled=True, Reconciling=False) rather than a retry: nothing this -// controller does will change the verdict. Recovery arrives as an event — a ClusterProvider policy +// controller does will change the verdict. Recovery arrives as an event — a KubeProvider policy // change or a Namespace label change — through the mappers registered in SetupWithManager, or as an // edit converting the rule to the cluster-only model. func (r *ClusterWatchRuleReconciler) refuseClusterWatchRule( @@ -368,7 +368,7 @@ func (r *ClusterWatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { // write bumps resourceVersion and fires an Update watch event that EnqueueRequestForObject // turns straight back into a queued request, un-rate-limited. reconcileStatus.commit() // already suppresses no-op writes, so the loop has no fuel; this makes it structural, and - // matches what GitProvider and ClusterProvider already do. + // matches what GitProvider and KubeProvider already do. For(&configbutleraiv1alpha3.ClusterWatchRule{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // This rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has // to see the status-only update that carries a heal — GenerationChangedPredicate would drop @@ -379,16 +379,16 @@ func (r *ClusterWatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitTargetToClusterWatchRules), builder.WithPredicates(gitTargetReadyProjectionChanged()), ). - // React to a ClusterProvider's accessFrom changing. Without this, REVOKING a - // namespace stops the GitTarget (which does watch ClusterProvider) but leaves this rule's + // React to a KubeProvider's accessFrom changing. Without this, REVOKING a + // namespace stops the GitTarget (which does watch KubeProvider) but leaves this rule's // compiled entry resident until the next periodic reconcile, so the admission gate would // converge on a ~10m delay instead of on the event. The GitTarget's own status flip cannot // be relied on to carry it: the edge above re-enqueues only on a move in the MIRRORED // readiness, and a revocation need not move it. Watches( - &configbutleraiv1alpha3.ClusterProvider{}, - handler.EnqueueRequestsFromMapFunc(r.clusterProviderToClusterWatchRules), - builder.WithPredicates(clusterProviderReadyOrSpecChanged()), + &configbutleraiv1alpha3.KubeProvider{}, + handler.EnqueueRequestsFromMapFunc(r.kubeProviderToClusterWatchRules), + builder.WithPredicates(kubeProviderReadyOrSpecChanged()), ). // React to a Namespace's LABELS changing: accessFrom may admit by selector, so a // label change on a GitTarget's namespace grants or revokes every ClusterWatchRule pointing @@ -416,10 +416,10 @@ func (r *ClusterWatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } -// clusterProviderToClusterWatchRules maps a ClusterProvider policy change to every ClusterWatchRule +// kubeProviderToClusterWatchRules maps a KubeProvider policy change to every ClusterWatchRule // whose referenced GitTarget mirrors through that provider, so an admission grant or revocation // converges on the event rather than on the periodic reconcile. -func (r *ClusterWatchRuleReconciler) clusterProviderToClusterWatchRules( +func (r *ClusterWatchRuleReconciler) kubeProviderToClusterWatchRules( ctx context.Context, obj client.Object, ) []ctrlreconcile.Request { @@ -432,7 +432,7 @@ func (r *ClusterWatchRuleReconciler) clusterProviderToClusterWatchRules( affected := make(map[types.NamespacedName]struct{}, len(targets.Items)) for i := range targets.Items { t := &targets.Items[i] - if t.SourceCluster() != obj.GetName() { + if t.KubeProviderKey() != client.ObjectKeyFromObject(obj) { continue } affected[types.NamespacedName{Name: t.Name, Namespace: t.Namespace}] = struct{}{} diff --git a/internal/controller/commitrequest_outcome_metric_test.go b/internal/controller/commitrequest_outcome_metric_test.go index 9a722eb7a..7e8b7de77 100644 --- a/internal/controller/commitrequest_outcome_metric_test.go +++ b/internal/controller/commitrequest_outcome_metric_test.go @@ -356,7 +356,8 @@ func TestCommitRequestMetric_AMissingWorkerKeepsTheTargetLabels(t *testing.T) { target := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "team-a-config", Namespace: "default"}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("team-a-provider", "main")}, + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("team-a-provider", "main")}, }, } fresh := newCommitRequest("save-fresh") diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 1b3168f1d..8ce1b699c 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -95,16 +95,16 @@ const ( // remote repository. ConditionTypePushed = "Pushed" - // ClusterProviderConditionValidated reports whether a ClusterProvider's inputs are safe and - // resolvable: the in-cluster "default" provider is trivially Validated; a remote provider is - // Validated once its kubeconfig Secret is present, keyed, and passes the exec/TLS safety - // policy. It is asserted WITHOUT a network dial — runtime reachability/discovery health are - // deferred until authenticated remote ingest wires them from the watch engine. - ClusterProviderConditionValidated = "Validated" - - // ClusterProviderConditionAuditFactsReceived reports whether an attribution fact has ever been - // published on this provider's audit route (spec.attribution.auditRoute, defaulting to the - // provider's name). It is Unknown from creation until the first fact, with the reason naming + // KubeProviderConditionValidated reports whether a KubeProvider's credential is safe and + // resolvable: an inCluster provider is Validated while the installation lists it in + // --in-cluster-kube-providers; a secretRef provider once its kubeconfig Secret is present, + // keyed, and passes the exec/TLS/file-reference safety policy. It is asserted WITHOUT a network + // dial — runtime reachability is the GitTarget's SourceClusterReachable. + KubeProviderConditionValidated = "Validated" + + // KubeProviderConditionAuditFactsReceived reports whether an attribution fact has ever been + // published on this provider's audit route (spec.attribution.auditRoute, defaulting to + // .). It is Unknown from creation until the first fact, with the reason naming // which silence it is (see the three below), and True (ReasonFactsReceived) from then on. It // NEVER regresses: the persisted status is the latch, so a restart — or the memory transport, // whose in-process state is lost with the process — keeps it True. Later silence is a quiet cluster, not a fault, which is why there is @@ -114,7 +114,7 @@ const ( // perfectly and loses only the commit AUTHOR, so reporting it through Ready would tell every // kstatus reader the object is broken. It is absent entirely when the operator runs with // --author-attribution=false, where no fact was ever expected. - ClusterProviderConditionAuditFactsReceived = "AuditFactsReceived" + KubeProviderConditionAuditFactsReceived = "AuditFactsReceived" // The three AuditFactsReceived=Unknown reasons below separate the silences by SCOPE, because // they send the reader to three different places. None of them repeats "AuditFacts" (the @@ -142,8 +142,8 @@ const ( // and that answer is latched. ReasonFactsReceived = "Received" - // ReasonInCluster is the Validated=True reason for the in-cluster "default" provider. A remote - // provider that validates reports the shared ReasonSucceeded instead. + // ReasonInCluster is the Validated=True reason for an inCluster provider the installation + // allows. A secretRef provider that validates reports the shared ReasonSucceeded instead. ReasonInCluster = "InCluster" // ReasonKubeConfigInvalid is the Validated=False reason for a malformed or unsafe kubeconfig // whose specific cause is carried in the message. diff --git a/internal/controller/dependency_watches_test.go b/internal/controller/dependency_watches_test.go index 780ea0cd5..257fb5233 100644 --- a/internal/controller/dependency_watches_test.go +++ b/internal/controller/dependency_watches_test.go @@ -66,7 +66,8 @@ func gitTarget(name, namespace, providerName string) *configbutleraiv1alpha3.Git return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest(providerName, "main")}, + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest(providerName, "main")}, }, } } diff --git a/internal/controller/duration_fields_admission_test.go b/internal/controller/duration_fields_admission_test.go index cc637e4d6..e7ada17b2 100644 --- a/internal/controller/duration_fields_admission_test.go +++ b/internal/controller/duration_fields_admission_test.go @@ -29,9 +29,10 @@ var _ = Describe("Duration fields", func() { // newTargetWindow builds a GitTarget with the given commit.window block. newTargetWindow := func(name string, window map[string]any) *unstructured.Unstructured { spec := map[string]any{ - "gitBranchRef": map[string]any{"name": "any-provider-main"}, - "path": "clusters/prod", - "commit": map[string]any{"window": window}, + "gitBranchRef": map[string]any{"name": "any-provider-main"}, + "kubeProviderRef": map[string]any{"name": "local"}, + "path": "clusters/prod", + "commit": map[string]any{"window": window}, } return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "configbutler.ai/v1alpha3", @@ -142,9 +143,10 @@ var _ = Describe("Duration fields", func() { "kind": "GitTarget", "metadata": map[string]any{"name": "window-empty-block", "namespace": "default"}, "spec": map[string]any{ - "gitBranchRef": map[string]any{"name": "any-provider-main"}, - "path": "clusters/prod", - "commit": map[string]any{"window": map[string]any{}}, + "gitBranchRef": map[string]any{"name": "any-provider-main"}, + "kubeProviderRef": map[string]any{"name": "local"}, + "path": "clusters/prod", + "commit": map[string]any{"window": map[string]any{}}, }, }} Expect(k8sClient.Create(ctx, empty)).To(Succeed()) @@ -169,9 +171,10 @@ var _ = Describe("Duration fields", func() { "kind": "GitTarget", "metadata": map[string]any{"name": "window-legacy-string", "namespace": "default"}, "spec": map[string]any{ - "gitBranchRef": map[string]any{"name": "any-provider-main"}, - "path": "clusters/prod", - "commit": map[string]any{"window": "5s"}, + "gitBranchRef": map[string]any{"name": "any-provider-main"}, + "kubeProviderRef": map[string]any{"name": "local"}, + "path": "clusters/prod", + "commit": map[string]any{"window": "5s"}, }, }} err := k8sClient.Create(ctx, legacy) diff --git a/internal/controller/fluxtrigger_hold.go b/internal/controller/fluxtrigger_hold.go index 147e5ae0e..8991f96ff 100644 --- a/internal/controller/fluxtrigger_hold.go +++ b/internal/controller/fluxtrigger_hold.go @@ -357,7 +357,7 @@ func (r *ApplyTriggerReconciler) uncachedReader() client.Reader { func (r *ApplyTriggerReconciler) fluxClient( ctx context.Context, trigger *configbutleraiv1alpha3.FluxTrigger, ) (*applier.Flux, error) { - dynamicClient, err := r.ApplierClients(ctx, trigger.ClusterProvider()) + dynamicClient, err := r.ApplierClients(ctx, trigger.KubeProviderKey().String()) if err != nil { return nil, err } @@ -378,7 +378,7 @@ const releaseDeadline = 10 * time.Minute // spec.suspend, unless a trigger in line takes the Kustomization over (releaseStep). Until that is // done the trigger stays the holder, Held=False (Releasing), so no successor acts on the // Kustomization meanwhile. A Kustomization that is gone has nothing to hand back; a cluster no -// client reaches any more since its ClusterProvider went (releaseClient), and a Kustomization the +// client reaches any more since its KubeProvider went (releaseClient), and a Kustomization the // identity may not write, are let go with a Warning Event: none should hold the trigger forever. Nor does anything else past releaseDeadline. A trigger behind another in line for the // Kustomization hands nothing back: the Kustomization is that one's. Letting go removes the // finalizer; a live trigger then holds nothing until an eligible GitTarget references it again, @@ -630,10 +630,10 @@ func (r *ApplyTriggerReconciler) releaseFailed( } // releaseClient is the Flux client a retiring trigger hands its Kustomization back through, or nil -// when there is nothing to reach: no cluster clients are wired, or the ClusterProvider is gone and +// when there is nothing to reach: no cluster clients are wired, or the KubeProvider is gone and // no client reaches its cluster any more, which lets the trigger go with a Warning Event. A -// ClusterProvider that is gone does not end the hand-back by itself: one without kubeConfig names -// the operator's own cluster, which still answers, and a remote one may still be reached through the +// KubeProvider that is gone does not end the hand-back by itself: an inCluster one named the +// operator's own cluster, which may still answer, and a remote one may still be reached through the // client it left. func (r *ApplyTriggerReconciler) releaseClient( ctx context.Context, trigger *configbutleraiv1alpha3.FluxTrigger, @@ -641,8 +641,8 @@ func (r *ApplyTriggerReconciler) releaseClient( if r.ApplierClients == nil { return nil, nil //nolint:nilnil // nothing to reach is no error } - provider := &configbutleraiv1alpha3.ClusterProvider{} - err := r.Get(ctx, client.ObjectKey{Name: trigger.ClusterProvider()}, provider) + provider := &configbutleraiv1alpha3.KubeProvider{} + err := r.Get(ctx, trigger.KubeProviderKey(), provider) gone := apierrors.IsNotFound(err) if err != nil && !gone { return nil, err @@ -651,8 +651,8 @@ func (r *ApplyTriggerReconciler) releaseClient( switch { case err != nil && gone: r.event(trigger, corev1.EventTypeWarning, EventReasonReleaseFailed, - "could not hand Kustomization %s back to Flux: its ClusterProvider %q is gone; it is left suspended, "+ - "and `flux resume` resumes it", trigger.Applier(), trigger.ClusterProvider()) + "could not hand Kustomization %s back to Flux: its KubeProvider %s is gone; it is left suspended, "+ + "and `flux resume` resumes it", trigger.Applier(), trigger.KubeProviderKey()) return nil, nil //nolint:nilnil // nothing to reach, and the Event says so case err != nil: return nil, fmt.Errorf("reach the cluster of Kustomization %s to hand it back: %w", trigger.Applier(), err) @@ -669,7 +669,7 @@ func (r *ApplyTriggerReconciler) event(object client.Object, eventType, reason, // publishConsumers sets the trigger's status.source.otherConsumers: the consumers of its GitRepository // in the applier's cluster, other than its own Kustomization, that no FluxTrigger on the same -// ClusterProvider holds. A listing that fails publishes none. Information only (decision 2): it never +// KubeProvider holds. A listing that fails publishes none. Information only (decision 2): it never // affects Ready. func (r *ApplyTriggerReconciler) publishConsumers(ctx context.Context, trigger *configbutleraiv1alpha3.FluxTrigger) { source := trigger.Status.Source @@ -691,7 +691,7 @@ func (r *ApplyTriggerReconciler) publishConsumers(ctx context.Context, trigger * "gitRepository", source.GitRepository.Namespace+"/"+source.GitRepository.Name, "error", err.Error()) return } - others, err := r.heldKustomizations(ctx, trigger.ClusterProvider()) + others, err := r.heldKustomizations(ctx, trigger.KubeProviderKey()) if err != nil { return } @@ -705,12 +705,12 @@ func (r *ApplyTriggerReconciler) publishConsumers(ctx context.Context, trigger * } } -// heldKustomizations is every Kustomization a FluxTrigger on the ClusterProvider holds: one whose +// heldKustomizations is every Kustomization a FluxTrigger on the KubeProvider holds: one whose // Held condition is True. A trigger that is not Configured (SuspendOwnedElsewhere), or whose // Kustomization has not handled its suspend yet, does not hold it: Flux may still apply a new artifact on its own. func (r *ApplyTriggerReconciler) heldKustomizations( ctx context.Context, - cluster string, + cluster k8stypes.NamespacedName, ) (map[k8stypes.NamespacedName]bool, error) { var triggers configbutleraiv1alpha3.FluxTriggerList if err := r.List(ctx, &triggers); err != nil { @@ -719,7 +719,7 @@ func (r *ApplyTriggerReconciler) heldKustomizations( held := map[k8stypes.NamespacedName]bool{} for i := range triggers.Items { trigger := &triggers.Items[i] - if trigger.DeletionTimestamp.IsZero() && trigger.ClusterProvider() == cluster && heldBefore(trigger) { + if trigger.DeletionTimestamp.IsZero() && trigger.KubeProviderKey() == cluster && heldBefore(trigger) { held[trigger.Applier()] = true } } diff --git a/internal/controller/fluxtrigger_hold_test.go b/internal/controller/fluxtrigger_hold_test.go index 02d9d31f2..136b4d486 100644 --- a/internal/controller/fluxtrigger_hold_test.go +++ b/internal/controller/fluxtrigger_hold_test.go @@ -91,7 +91,7 @@ func suspendedSpec(ks *unstructured.Unstructured) bool { // Reverser's field manager, and is Ready only once that suspend's token is handled, so nothing that // ran before the adoption is still running. func TestFluxTrigger_AdoptsItsKustomization(t *testing.T) { - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider)) got := reconcileTrigger(t, f.r).(*configbutleraiv1alpha3.FluxTrigger) @@ -115,7 +115,7 @@ func TestFluxTrigger_AdoptsItsKustomization(t *testing.T) { // Kustomization is suspended again with a token, every time, with a Normal Event each time, and // recorded as an Unresolved operation that completes once nothing that resume started is running. func TestFluxTrigger_AnExternalResumeIsSuspendedAgain(t *testing.T) { - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider)) reconcileTrigger(t, f.r) f.events() @@ -155,7 +155,7 @@ func TestFluxTrigger_AnExternalResumeIsSuspendedAgain(t *testing.T) { // resumes the Kustomization, so a resumed Kustomization with an operation of the worker's in flight // is that operation, not an external resume; the worker suspends it again itself. func TestFluxTrigger_LeavesABranchWorkersOperationAlone(t *testing.T) { - trigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger := testFluxTrigger(triggerLocalProvider) trigger.Finalizers = []string{configbutleraiv1alpha3.FluxTriggerFinalizer} trigger.Status.Conditions = []metav1.Condition{{ Type: ApplyTriggerConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, @@ -182,7 +182,7 @@ func (f followerStub) Following(_ git.TriggerRef, token string) bool { return f[ // workerOperationTrigger is a held trigger whose worker recorded operation T1 in flight, the // Kustomization left resumed for it. func workerOperationTrigger() *configbutleraiv1alpha3.FluxTrigger { - trigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger := testFluxTrigger(triggerLocalProvider) trigger.Finalizers = []string{configbutleraiv1alpha3.FluxTriggerFinalizer} trigger.Status.Conditions = []metav1.Condition{{ Type: ApplyTriggerConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, @@ -279,7 +279,7 @@ func TestFluxTrigger_ASuspendAppliedElsewhereIsNeverHeld(t *testing.T) { objs := applierFluxObjects() ks := fluxsim.OwnSuspend(t, objs[0].(*unstructured.Unstructured), false, git.FluxFieldManager, metav1.ManagedFieldsOperationApply) - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), ks, objs[1]) + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider), ks, objs[1]) got := reconcileTrigger(t, f.r).(*configbutleraiv1alpha3.FluxTrigger) @@ -294,7 +294,7 @@ func TestFluxTrigger_ASuspendAppliedElsewhereIsNeverHeld(t *testing.T) { // once more when it is resumed, waits until that suspend is handled, and then releases spec.suspend, // so Flux applies on its own again; then the finalizer goes. func TestFluxTrigger_DeletionHandsTheKustomizationBack(t *testing.T) { - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider)) reconcileTrigger(t, f.r) f.events() // A worker's apply is running as the trigger is deleted. @@ -331,12 +331,12 @@ func TestFluxTrigger_DeletionHandsTheKustomizationBack(t *testing.T) { events[0]) } -// TestFluxTrigger_DeletionLetsGoOfWhatIsGone: a Kustomization or a ClusterProvider that is gone has +// TestFluxTrigger_DeletionLetsGoOfWhatIsGone: a Kustomization or a KubeProvider that is gone has // nothing to hand back, and must not hold the trigger. func TestFluxTrigger_DeletionLetsGoOfWhatIsGone(t *testing.T) { for name, cluster := range map[string]string{ - "the Kustomization is gone": configbutleraiv1alpha3.DefaultClusterProviderName, - "the ClusterProvider is gone": "absent", + "the Kustomization is gone": triggerLocalProvider, + "the KubeProvider is gone": "absent", } { t.Run(name, func(t *testing.T) { trigger := testFluxTrigger(cluster) @@ -355,22 +355,22 @@ func TestFluxTrigger_DeletionLetsGoOfWhatIsGone(t *testing.T) { } } -// TestFluxTrigger_AGoneClusterProviderStillHandsBack is review 7's C1: a folder holding both a -// ClusterProvider and a FluxTrigger is removed, and the provider goes first. The hand-back was +// TestFluxTrigger_AGoneKubeProviderStillHandsBack is review 7's C1: a folder holding both a +// KubeProvider and a FluxTrigger is removed, and the provider goes first. The hand-back was // skipped with an Info log line, and the Kustomization stayed suspended under gitops-reverser for -// good, though for a provider without kubeConfig its cluster is still reachable. Now the release +// good, though for an inCluster provider its cluster is still reachable. Now the release // goes on through whatever client still resolves, and only when none does is the trigger let go, // with a Warning ReleaseFailed saying the Kustomization is left suspended. -func TestFluxTrigger_AGoneClusterProviderStillHandsBack(t *testing.T) { +func TestFluxTrigger_AGoneKubeProviderStillHandsBack(t *testing.T) { held := func(t *testing.T) *holdFixture { t.Helper() - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider)) reconcileTrigger(t, f.r) f.events() require.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) - provider := &configbutleraiv1alpha3.ClusterProvider{} + provider := &configbutleraiv1alpha3.KubeProvider{} require.NoError(t, f.r.Get(context.Background(), - ctrlclient.ObjectKey{Name: configbutleraiv1alpha3.DefaultClusterProviderName}, provider)) + ctrlclient.ObjectKey{Namespace: triggerProviderNamespace, Name: triggerLocalProvider}, provider)) require.NoError(t, f.r.Delete(context.Background(), provider)) require.NoError(t, f.r.Delete(context.Background(), f.trigger(t))) return f @@ -397,14 +397,14 @@ func TestFluxTrigger_AGoneClusterProviderStillHandsBack(t *testing.T) { t.Run("its cluster no longer answers, so the trigger lets go loudly", func(t *testing.T) { f := held(t) f.r.ApplierClients = func(context.Context, string) (dynamic.Interface, error) { - return nil, errors.New(`ClusterProvider "default" not found`) + return nil, errors.New(`KubeProvider clusters/local not found`) } gone(t, f) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "left suspended, as the Event says") events := f.events() require.Len(t, events, 1) assert.True(t, strings.HasPrefix(events[0], "Warning ReleaseFailed could not hand Kustomization "+ - "flux-system/checkout back to Flux: its ClusterProvider \"default\" is gone; it is left suspended, "+ + "flux-system/checkout back to Flux: its KubeProvider clusters/local is gone; it is left suspended, "+ "and `flux resume` resumes it"), events[0]) }) } @@ -416,7 +416,7 @@ func TestFluxTrigger_TakesOverASuspendBySomebodyElse(t *testing.T) { objs := applierFluxObjects() ks := fluxsim.OwnSuspend(t, objs[0].(*unstructured.Unstructured), true, "flux", metav1.ManagedFieldsOperationUpdate) - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), ks, objs[1]) + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider), ks, objs[1]) got := reconcileTrigger(t, f.r).(*configbutleraiv1alpha3.FluxTrigger) @@ -428,7 +428,7 @@ func TestFluxTrigger_TakesOverASuspendBySomebodyElse(t *testing.T) { // TestFluxTrigger_AHandBackItMayNotMakeLetsGo: an identity that may not write the Kustomization // cannot hand it back; the trigger goes anyway, with a Warning that says what is left behind. func TestFluxTrigger_AHandBackItMayNotMakeLetsGo(t *testing.T) { - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider)) reconcileTrigger(t, f.r) f.events() f.sim.Client.PrependReactor("patch", "kustomizations", func(k8stesting.Action) (bool, runtime.Object, error) { @@ -470,7 +470,7 @@ func TestFluxTrigger_PublishesTheSourcesOtherConsumers(t *testing.T) { "sourceRef": map[string]any{"kind": "GitRepository", "name": "platform", "namespace": "flux-system"}, }}}, }} - f := newHoldFixture(t, testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), + f := newHoldFixture(t, testFluxTrigger(triggerLocalProvider), append(objs, infra, release)...) source := reconcileTrigger(t, f.r).(*configbutleraiv1alpha3.FluxTrigger).Status.Source @@ -484,7 +484,7 @@ func TestFluxTrigger_PublishesTheSourcesOtherConsumers(t *testing.T) { // The fourth review's finding C: a trigger holds its Kustomization only once it says so // (Held=True). One that is not Configured (SuspendOwnedElsewhere), or still suspending, leaves // a Kustomization Flux applies on its own. - infraTrigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + infraTrigger := testFluxTrigger(triggerLocalProvider) infraTrigger.Name, infraTrigger.Spec.KustomizationRef.Name = "infra", "infra" require.NoError(t, f.r.Create(context.Background(), infraTrigger)) infraOther := []meta.NamespacedObjectKindReference{ @@ -537,7 +537,7 @@ func TestFluxTrigger_CompletesTheLiveExternalOperation(t *testing.T) { metav1.ManagedFieldsOperationApply) ks.SetAnnotations(map[string]string{meta.ReconcileRequestAnnotation: "T2"}) require.NoError(t, unstructured.SetNestedField(ks.Object, "T2", "status", "lastHandledReconcileAt")) - cached := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + cached := testFluxTrigger(triggerLocalProvider) cached.Finalizers = []string{configbutleraiv1alpha3.FluxTriggerFinalizer} f := newHoldFixture(t, cached, ks, objs[1]) live := cached.DeepCopy() @@ -563,7 +563,7 @@ func TestFluxTrigger_ASuspendItMayNotMakeLetsGo(t *testing.T) { objs := applierFluxObjects() ks := fluxsim.OwnSuspend(t, objs[0].(*unstructured.Unstructured), false, applier.SuspendFieldManager, metav1.ManagedFieldsOperationApply) - trigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger := testFluxTrigger(triggerLocalProvider) trigger.Finalizers = []string{configbutleraiv1alpha3.FluxTriggerFinalizer} f := newHoldFixture(t, trigger, ks, objs[1]) f.sim.Client.PrependReactor("patch", "kustomizations", func(k8stesting.Action) (bool, runtime.Object, error) { @@ -597,7 +597,7 @@ func TestFluxTrigger_AHandBackThatNeverEndsLetsGoAtTheDeadline(t *testing.T) { // No source: the simulated kustomize-controller never reconciles it, as one that is down. unstructured.RemoveNestedField(ks.Object, "spec", "sourceRef") ks.SetAnnotations(map[string]string{meta.ReconcileRequestAnnotation: "T2"}) - trigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger := testFluxTrigger(triggerLocalProvider) trigger.Finalizers = []string{configbutleraiv1alpha3.FluxTriggerFinalizer} trigger.DeletionTimestamp = &metav1.Time{Time: time.Now()} // The deadline counts from when Held turned Releasing, not from the deletion. diff --git a/internal/controller/gitbranch_controller_test.go b/internal/controller/gitbranch_controller_test.go index 03c77af90..da7849c3a 100644 --- a/internal/controller/gitbranch_controller_test.go +++ b/internal/controller/gitbranch_controller_test.go @@ -109,7 +109,8 @@ func TestGitBranch_AProvenGitBranchHoldsItsBranch(t *testing.T) { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: "main", Namespace: "platform"}, Path: name, + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: "main", Namespace: "platform"}, Path: name, }, } } @@ -119,7 +120,8 @@ func TestGitBranch_AProvenGitBranchHoldsItsBranch(t *testing.T) { &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "other"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: "main"}, Path: "other", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: "main"}, Path: "other", }, }) diff --git a/internal/controller/gittarget_apply_trigger_test.go b/internal/controller/gittarget_apply_trigger_test.go index e8519a94b..98589fc70 100644 --- a/internal/controller/gittarget_apply_trigger_test.go +++ b/internal/controller/gittarget_apply_trigger_test.go @@ -19,13 +19,15 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/applier" ) -// triggerTarget is the GitTarget team-a/checkout on branch main, naming the FluxTrigger checkout -// when named is set. +// triggerTarget is the GitTarget team-a/checkout mirroring through the KubeProvider +// triggerLocalProvider on branch main, naming the FluxTrigger checkout when named is set. func triggerTarget(named bool) *configbutleraiv1alpha3.GitTarget { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "checkout", Namespace: "team-a"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("platform", "main")}, Path: "apps", + KubeProviderRef: triggerProviderRef(triggerLocalProvider), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("platform", "main")}, + Path: "apps", }, } if named { @@ -52,7 +54,7 @@ func triggerDestination() gitDestination { // readyTrigger is the FluxTrigger checkout, as its reconciler published it: Ready with status, // Stalled when stalled, and applying branch of the test repository. func readyTrigger(status metav1.ConditionStatus, reason string, stalled bool, branch string) ctrlclient.Object { - trigger := testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName) + trigger := testFluxTrigger(triggerLocalProvider) stalledStatus := metav1.ConditionFalse if stalled { stalledStatus = metav1.ConditionTrue @@ -115,7 +117,7 @@ func TestObserveApplyTrigger_Verdicts(t *testing.T) { wantStatus: metav1.ConditionUnknown, wantReason: applier.ReasonNotReferenced, wantMessage: "has not observed that this GitTarget names it yet"}, {name: "a trigger not checked yet", - trigger: testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName), + trigger: testFluxTrigger(triggerLocalProvider), wantStatus: metav1.ConditionUnknown, wantReason: ReasonProgressing}, } for _, tc := range cases { @@ -175,7 +177,7 @@ func TestApplyTriggerToGitTargets_FindsTheTargetsNamingIt(t *testing.T) { r := &GitTargetReconciler{Client: triggerTestClient(t, triggerTarget(true), argo, unnamed)} requests := r.applyTriggerToGitTargets(context.Background(), - testFluxTrigger(configbutleraiv1alpha3.DefaultClusterProviderName)) + testFluxTrigger(triggerLocalProvider)) require.Len(t, requests, 1) assert.Equal(t, "checkout", requests[0].Name) diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 36e42689f..5ee71a434 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/reconcile" "github.com/ConfigButler/gitops-reverser/internal/telemetry" @@ -188,12 +189,11 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, validationErr } if !validated { - // A remote-source GitTarget whose kubeConfig stopped validating (Secret deleted, key gone, - // or contents now unsafe/unparseable) must have its data plane STOPPED, not merely marked - // blocked. Otherwise its watches keep mirroring the remote on the cached credential while - // status claims Validated=False. Forgetting the declaration cancels those watches and - // releases the source-cluster context; a later recovery re-declares and re-snapshots. A - // local GitTarget names no source cluster, so its streams are left untouched. + // A GitTarget that stopped validating (its KubeProvider deleted, no longer admitting this + // namespace, or reading another namespace's audit route) must have its data plane STOPPED, + // not merely marked blocked. Otherwise its watches keep mirroring on the cached credential + // while status claims Validated=False. Forgetting the declaration cancels those watches and + // releases the source-cluster context; a later recovery re-declares and re-snapshots. r.stopSourceClusterMirror(&target) st.set( GitTargetConditionEncryptionConfigured, @@ -244,9 +244,9 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // belongs on the one goroutine allowed to touch the repository. r.requestRemoteRefresh(&target) - // One read of the source ClusterProvider serves everything below it: the audit route captured on - // Declare and the ClusterProviderReady projection. Reading it twice invited the two to disagree. - sourceProvider, sourceProviderErr := r.resolveSourceClusterProvider(ctx, &target) + // One read of the source KubeProvider serves everything below it: the audit route captured on + // Declare and the KubeProviderReady projection. Reading it twice invited the two to disagree. + sourceProvider, sourceProviderErr := r.resolveSourceKubeProvider(ctx, &target) if sourceProviderErr != nil { return ctrl.Result{}, sourceProviderErr } @@ -267,7 +267,7 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( rd := newGitTargetReadiness() convergeAsSuspended(rd, &target) gitTargetReadinessGates(rd, observed, - refs.gitBranch, refs.clusterProvider, refs.sourceCluster, publicationCondition(publication)) + refs.gitBranch, refs.kubeProvider, refs.sourceCluster, publicationCondition(publication)) applyTriggerReadiness(rd, triggerVerdict, hasTrigger) st.applyReadiness(rd) @@ -406,14 +406,15 @@ func (r *GitTargetReconciler) stall( // observeSourceReachable projects the source cluster's runtime reachability. // -// A GitTarget with no source cluster mirrors the cluster the operator runs in, which is reachable -// by definition — so the default (used when no watch manager is wired, e.g. in tests) is -// True/LocalCluster for a local target and Unknown only for a remote one. +// A GitTarget whose KubeProvider sets spec.inCluster mirrors the cluster the operator runs in, which +// is reachable by definition — so the default (used when no watch manager is wired, e.g. in tests) +// is True/LocalCluster for an inCluster provider and Unknown only for a remote one. func (r *GitTargetReconciler) observeSourceReachable( target *configbutleraiv1alpha3.GitTarget, + provider *configbutleraiv1alpha3.KubeProvider, ) conditionValue { reach := watch.SourceClusterReachableStatus{State: "True", Reason: "LocalCluster"} - if !target.IsLocalSource() { + if !provider.IsInCluster() { reach = watch.SourceClusterReachableStatus{State: "Unknown", Reason: "AwaitingDiscovery"} } if r.EventRouter != nil && r.EventRouter.WatchManager != nil { @@ -468,12 +469,13 @@ func (r *GitTargetReconciler) evaluateValidatedGate( return false, fmt.Sprintf("Validated gate failed: %s", GitTargetReasonInvalidConfig), nil } - // The source cluster's connectivity inputs (kubeConfig) are validated on the referenced - // ClusterProvider now, not here — the GitTarget only NAMES its source cluster. The - // ClusterProvider's readiness is projected onto the GitTarget as a separate condition. + // The source cluster's credential is validated on the referenced + // KubeProvider now, not here — the GitTarget only NAMES its source cluster. The + // KubeProvider's readiness is projected onto the GitTarget as a separate condition. - // Namespace authorization: a GitTarget may reference a ClusterProvider only from a namespace - // its spec.accessFrom admits. Enforced HERE and only here, on every reconcile — which + // Namespace authorization: a GitTarget may reference a KubeProvider only from the provider's + // own namespace or one its spec.accessFrom admits, and only while the provider holds its audit + // route. Enforced HERE and only here, on every reconcile — which // also covers a policy tightened after the GitTarget was created. Failing this gate returns // before DeclareForGitTarget below, so an unauthorized target starts no watch and writes no Git. authorized, authReason, authMsg, authErr := r.checkSourceAuthorization(ctx, target) @@ -581,11 +583,10 @@ func (r *GitTargetReconciler) evaluateWorkerWiringGate( // setBlockedDataPlane marks stream readiness as not-yet-evaluated when a control-plane gate // blocked the reconcile before watches could be declared. // stopSourceClusterMirror tears down the data plane of a GitTarget that is no longer Validated, so -// a dead credential — or a source ClusterProvider that was deleted (including the reserved -// "default" for the LOCAL cluster) — can never keep an active mirror running behind a -// Validated=False status. It applies to local AND remote targets: a local target references the -// "default" ClusterProvider, so if that provider is gone the local mirror must stop too, never -// falling back to an implicit in-cluster identity that bypasses the authorization policy. It is +// a dead credential — or a source KubeProvider that was deleted — can never keep an active mirror +// running behind a Validated=False status. It applies to inCluster AND remote providers alike: if +// an inCluster provider is gone the local mirror must stop too, never falling back to an implicit +// in-cluster identity that bypasses the authorization policy. It is // idempotent across requeues while the target stays blocked; a later recovery re-declares the // watches and re-snapshots. func (r *GitTargetReconciler) stopSourceClusterMirror(target *configbutleraiv1alpha3.GitTarget) { @@ -623,7 +624,7 @@ type dataPlaneObservation struct { // projects the three data-plane axes. It mutates only the roll-up fields of status. func (r *GitTargetReconciler) observeDataPlane( target *configbutleraiv1alpha3.GitTarget, - sourceProvider *configbutleraiv1alpha3.ClusterProvider, + sourceProvider *configbutleraiv1alpha3.KubeProvider, forceRecheck bool, log logr.Logger, ) dataPlaneObservation { @@ -767,12 +768,12 @@ func renderAxis(renderFidelity watch.RenderFidelityStatus) conditionValue { } // referenceReadiness is what the three objects a GitTarget REFERENCES say about themselves: -// destination-side (the GitBranch), source-config side (the ClusterProvider), and the runtime +// destination-side (the GitBranch), source-config side (the KubeProvider), and the runtime // reachability of the source cluster. type referenceReadiness struct { - gitBranch conditionValue - clusterProvider conditionValue - sourceCluster conditionValue + gitBranch conditionValue + kubeProvider conditionValue + sourceCluster conditionValue } // publishReferenceReadiness writes those three as conditions of their own and returns them, so the @@ -782,16 +783,16 @@ func (r *GitTargetReconciler) publishReferenceReadiness( st *reconcileStatus, target *configbutleraiv1alpha3.GitTarget, gitBranch conditionValue, - sourceProvider *configbutleraiv1alpha3.ClusterProvider, + sourceProvider *configbutleraiv1alpha3.KubeProvider, ) referenceReadiness { refs := referenceReadiness{ - gitBranch: gitBranch, - clusterProvider: clusterProviderReadiness(sourceProvider), - sourceCluster: r.observeSourceReachable(target), + gitBranch: gitBranch, + kubeProvider: kubeProviderReadiness(sourceProvider), + sourceCluster: r.observeSourceReachable(target, sourceProvider), } st.setValue(GitTargetConditionSourceClusterReachable, refs.sourceCluster) st.setValue(GitTargetConditionGitBranchReady, refs.gitBranch) - st.setValue(GitTargetConditionClusterProviderReady, refs.clusterProvider) + st.setValue(GitTargetConditionKubeProviderReady, refs.kubeProvider) return refs } @@ -805,7 +806,7 @@ func (r *GitTargetReconciler) publishReferenceReadiness( func gitTargetReadinessGates( rd *readiness, observed dataPlaneObservation, - gitBranch, clusterProvider, sourceReach, publication conditionValue, + gitBranch, kubeProvider, sourceReach, publication conditionValue, ) { // Terminal, most specific first. Each of these needs a human: the folder holds content the // operator will not manage, a watch is refused, or what was written no longer matches live. @@ -821,8 +822,8 @@ func gitTargetReadinessGates( // "streams are still replaying", and it is usually the cause of the latter. rd.progressingIf(gitBranch.Status == metav1.ConditionFalse, metav1.ConditionFalse, gitBranch.Reason, gitBranch.Message) - rd.progressingIf(clusterProvider.Status == metav1.ConditionFalse, metav1.ConditionFalse, - clusterProvider.Reason, clusterProvider.Message) + rd.progressingIf(kubeProvider.Status == metav1.ConditionFalse, metav1.ConditionFalse, + kubeProvider.Reason, kubeProvider.Message) rd.progressingIf(sourceReach.Status == metav1.ConditionFalse, metav1.ConditionFalse, sourceReach.Reason, sourceReach.Message) // An unconfirmed source has not been established at all, so Ready is Unknown rather than False. @@ -1058,6 +1059,13 @@ func repoIdentityOf(provider *configbutleraiv1alpha3.GitProvider) git.RepoIdenti return git.RepoIdentity{ProviderUID: provider.UID, URL: provider.Spec.URL} } +// checkForConflicts reports whether an older GitTarget on the same GitBranch already owns a path +// that overlaps target's. Only a GitTarget that is itself admitted can own one: one whose namespace +// its GitBranch's GitProvider or its KubeProvider does not admit writes nothing, so it reserves no +// folder either. Otherwise any namespace that knows a GitBranch's name could create a GitTarget at +// "." first and hold every folder on that branch against the targets allowed to write it. The +// admission is read only for an existing target that would win, so a branch with no overlap costs +// no reads. func (r *GitTargetReconciler) checkForConflicts( ctx context.Context, target *configbutleraiv1alpha3.GitTarget, @@ -1075,7 +1083,20 @@ func (r *GitTargetReconciler) checkForConflicts( if existing.GitBranchKey() != target.GitBranchKey() { continue } - if lost, msg := losesPathConflict(target, existing); lost { + lost, msg := losesPathConflict(target, existing) + if !lost { + continue + } + admitted, err := authz.GitTargetAdmitted(ctx, r.Client, existing) + if err != nil { + return false, "", "", fmt.Errorf( + "check GitTarget %s/%s admission: %w", + existing.Namespace, + existing.Name, + err, + ) + } + if admitted.Allowed { return true, msg, GitTargetReasonTargetConflict, nil } } @@ -1401,7 +1422,7 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { // write bumps resourceVersion and fires an Update watch event that EnqueueRequestForObject // turns straight back into a queued request, un-rate-limited. reconcileStatus.commit() // already suppresses no-op writes, so the loop has no fuel; this makes it structural, and - // matches what GitProvider and ClusterProvider already do. + // matches what GitProvider and KubeProvider already do. // It additionally admits a change to the reconcile-request annotation, which does not bump // metadata.generation and would otherwise be filtered out along with the status writes. For(&configbutleraiv1alpha3.GitTarget{}, builder.WithPredicates(reconcileRequestedOrSpecChanged())). @@ -1426,17 +1447,17 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitDestinationToGitTargets), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). - // React to the referenced (cluster-scoped) ClusterProvider becoming Ready/NotReady so the - // projected ClusterProviderReady condition and the namespace-authorization refusal re-run + // React to the referenced KubeProvider becoming Ready/NotReady so the + // projected KubeProviderReady condition and the namespace-authorization refusal re-run // promptly instead of waiting for the periodic reconcile. A plain GenerationChangedPredicate // would miss a Ready flip (a STATUS-only update), so this fires on spec changes OR a change // in the provider's Ready condition status (plus create/delete). Watches( - &configbutleraiv1alpha3.ClusterProvider{}, - handler.EnqueueRequestsFromMapFunc(r.clusterProviderToGitTargets), - builder.WithPredicates(clusterProviderReadyOrSpecChanged()), + &configbutleraiv1alpha3.KubeProvider{}, + handler.EnqueueRequestsFromMapFunc(r.kubeProviderToGitTargets), + builder.WithPredicates(kubeProviderReadyOrSpecChanged()), ). - // React to a Namespace's LABELS changing: a ClusterProvider's accessFrom selector is + // React to a Namespace's LABELS changing: a KubeProvider's accessFrom selector is // evaluated against namespace labels, so a label change can grant or revoke a GitTarget's // authorization. Re-enqueue the GitTargets in that namespace so the reconcile-time refusal // converges instead of waiting for the periodic reconcile. LabelChangedPredicate ignores the @@ -1593,12 +1614,12 @@ func (r *GitTargetReconciler) gitDestinationToGitTargets( return requests } -// clusterProviderToGitTargets maps a ClusterProvider event to every GitTarget that references it as -// its source, across ALL namespaces (the provider is cluster-scoped). It re-enqueues dependents when the -// provider's Ready flips or its accessFrom policy changes, so the projected -// ClusterProviderReady and the namespace-authorization refusal converge without waiting for the +// kubeProviderToGitTargets maps a KubeProvider event to every GitTarget that references it as +// its source, across ALL namespaces (accessFrom may admit any of them). It re-enqueues dependents +// when the provider's Ready flips or its accessFrom policy changes, so the projected +// KubeProviderReady and the namespace-authorization refusal converge without waiting for the // periodic reconcile. -func (r *GitTargetReconciler) clusterProviderToGitTargets( +func (r *GitTargetReconciler) kubeProviderToGitTargets( ctx context.Context, obj client.Object, ) []ctrlreconcile.Request { @@ -1611,7 +1632,7 @@ func (r *GitTargetReconciler) clusterProviderToGitTargets( var requests []ctrlreconcile.Request for i := range targets.Items { t := &targets.Items[i] - if t.SourceCluster() != obj.GetName() { + if t.KubeProviderKey() != client.ObjectKeyFromObject(obj) { continue } requests = append(requests, ctrlreconcile.Request{ @@ -1680,7 +1701,7 @@ func applyTriggerReadinessChanged() predicate.Predicate { } // namespaceToGitTargets maps a Namespace label change to every GitTarget in that namespace, so the -// reconcile-time ClusterProvider authorization (which may match the namespace's labels via a +// reconcile-time KubeProvider authorization (which may match the namespace's labels via a // selector) re-runs and grants/revokes the target promptly. func (r *GitTargetReconciler) namespaceToGitTargets( ctx context.Context, @@ -1701,31 +1722,31 @@ func (r *GitTargetReconciler) namespaceToGitTargets( return requests } -// clusterProviderReadyStatus returns a ClusterProvider's Ready condition status ("" if absent), so +// kubeProviderReadyStatus returns a KubeProvider's Ready condition status ("" if absent), so // a watch predicate can react to a Ready FLIP that arrives as a status-only update. -func clusterProviderReadyStatus(cp *configbutleraiv1alpha3.ClusterProvider) metav1.ConditionStatus { +func kubeProviderReadyStatus(cp *configbutleraiv1alpha3.KubeProvider) metav1.ConditionStatus { if c := findCondition(cp.Status.Conditions, ConditionTypeReady); c != nil { return c.Status } return "" } -// clusterProviderReadyOrSpecChanged is the ClusterProvider watch predicate for the GitTarget +// kubeProviderReadyOrSpecChanged is the KubeProvider watch predicate for the GitTarget // controller: fire on create/delete, and on an update when the SPEC changed (generation) OR the // Ready condition status changed — the latter a status-only update GenerationChangedPredicate drops. -func clusterProviderReadyOrSpecChanged() predicate.Predicate { +func kubeProviderReadyOrSpecChanged() predicate.Predicate { return predicate.Funcs{ CreateFunc: func(event.CreateEvent) bool { return true }, DeleteFunc: func(event.DeleteEvent) bool { return true }, GenericFunc: func(event.GenericEvent) bool { return false }, UpdateFunc: func(e event.UpdateEvent) bool { - oldCP, ok1 := e.ObjectOld.(*configbutleraiv1alpha3.ClusterProvider) - newCP, ok2 := e.ObjectNew.(*configbutleraiv1alpha3.ClusterProvider) + oldKP, ok1 := e.ObjectOld.(*configbutleraiv1alpha3.KubeProvider) + newKP, ok2 := e.ObjectNew.(*configbutleraiv1alpha3.KubeProvider) if !ok1 || !ok2 { return true } - return oldCP.Generation != newCP.Generation || - clusterProviderReadyStatus(oldCP) != clusterProviderReadyStatus(newCP) + return oldKP.Generation != newKP.Generation || + kubeProviderReadyStatus(oldKP) != kubeProviderReadyStatus(newKP) }, } } diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go index f1b5fb3ed..2d7356bd6 100644 --- a/internal/controller/gittarget_controller_test.go +++ b/internal/controller/gittarget_controller_test.go @@ -35,8 +35,9 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: "missing-branch"}, - Path: "test-folder", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: "missing-branch"}, + Path: "test-folder", }, } Expect(k8sClient.Create(ctx, gitTarget)).Should(Succeed()) @@ -85,6 +86,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-two-axis", "main"), }, @@ -145,6 +147,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-conflict", "main"), }, @@ -179,6 +182,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-conflict", "main"), }, @@ -263,6 +267,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-no-conflict", "main"), }, @@ -278,6 +283,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-no-conflict", "main"), }, @@ -350,6 +356,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-nested", "main"), }, @@ -383,6 +390,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-nested", "main"), }, @@ -463,6 +471,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-generate-enc-secret", "main"), }, @@ -528,6 +537,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-no-generate-enc-secret", "main"), }, @@ -605,6 +615,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-no-enc-recipients", "main"), }, @@ -698,6 +709,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-update-enc-secret", "main"), }, @@ -758,6 +770,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-recreate-enc-secret", "main"), }, @@ -853,6 +866,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: testKubeProviderRef(), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("test-provider-existing-agekey", "main"), }, diff --git a/internal/controller/gittarget_controller_unit_test.go b/internal/controller/gittarget_controller_unit_test.go index dff935693..2eb040a64 100644 --- a/internal/controller/gittarget_controller_unit_test.go +++ b/internal/controller/gittarget_controller_unit_test.go @@ -94,8 +94,9 @@ func TestCheckForConflicts_ListErrorFailsClosed(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "target-a", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("provider-a", "main")}, - Path: "apps", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("provider-a", "main")}, + Path: "apps", }, } diff --git a/internal/controller/gittarget_dependency_status.go b/internal/controller/gittarget_dependency_status.go index 5c46197a4..516389303 100644 --- a/internal/controller/gittarget_dependency_status.go +++ b/internal/controller/gittarget_dependency_status.go @@ -59,7 +59,7 @@ func gitTargetReadyCondition(target configbutleraiv1alpha3.GitTarget) conditionV // (generation) OR the readiness those rules MIRROR changed. // // GenerationChangedPredicate alone is wrong on this edge, for the same reason it cannot carry a -// ClusterProvider change to a GitTarget: a GitTarget that heals publishes its recovery as a +// KubeProvider change to a GitTarget: a GitTarget that heals publishes its recovery as a // status-only update, which that predicate deliberately drops. The rule's mirrored GitTargetReady // then stayed frozen on the failing snapshot until the rule's own periodic requeue — leaving an // object that contradicts itself for minutes, with StreamsRunning already back to True because the diff --git a/internal/controller/gittarget_gitbranch_test.go b/internal/controller/gittarget_gitbranch_test.go index 854d95ace..ef227ac4e 100644 --- a/internal/controller/gittarget_gitbranch_test.go +++ b/internal/controller/gittarget_gitbranch_test.go @@ -48,12 +48,13 @@ func newDestinationFixture(t *testing.T) *destinationFixture { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: "main", Namespace: "platform"}, - Path: "apps", + KubeProviderRef: triggerProviderRef(triggerLocalProvider), + GitBranchRef: meta.NamespacedObjectReference{Name: "main", Namespace: "platform"}, + Path: "apps", }, } c := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(provider, branch, target, admitting(configbutleraiv1alpha3.DefaultClusterProviderName, "team-a")). + WithObjects(provider, branch, target, admitting(triggerLocalProvider, "team-a")). WithStatusSubresource(&configbutleraiv1alpha3.GitTarget{}).Build() workers := git.NewWorkerManager(c, logr.Discard(), git.BranchWorkerLimits{}, types.SensitiveResourcePolicy{}) ctx, cancel := context.WithCancel(context.Background()) @@ -157,7 +158,8 @@ func TestGitBranchWatches_MapToWhatTheyChange(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: "main", Namespace: "platform"}, + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: "main", Namespace: "platform"}, }, } assert.Equal(t, []string{"platform/main"}, requestsToKeys(gitTargetToGitBranch(context.Background(), target))) @@ -200,7 +202,8 @@ func TestGitTargetsOn_FindsTheTargetsOfABranchOrItsProvider(t *testing.T) { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: branch, Namespace: branchNS}, + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: branch, Namespace: branchNS}, }, } } diff --git a/internal/controller/gittarget_immutability_test.go b/internal/controller/gittarget_immutability_test.go index dbe73568e..fd0abaf05 100644 --- a/internal/controller/gittarget_immutability_test.go +++ b/internal/controller/gittarget_immutability_test.go @@ -41,15 +41,16 @@ var _ = Describe("GitTarget Destination Immutability", func() { }, timeout, interval).Should(Succeed()) } - It("rejects changes to gitBranchRef and path but allows a no-op update", func() { + It("rejects changes to gitBranchRef, kubeProviderRef and path but allows a no-op update", func() { ctx := context.Background() key := types.NamespacedName{Name: "immutable-target", Namespace: "default"} gitTarget := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("prov-a", "main")}, - Path: "apps", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("prov-a", "main")}, + Path: "apps", }, } Expect(k8sClient.Create(ctx, gitTarget)).Should(Succeed()) @@ -69,6 +70,8 @@ var _ = Describe("GitTarget Destination Immutability", func() { "spec.gitBranchRef is immutable") expectImmutable(ctx, key, current, func() { current.Spec.GitBranchRef.Namespace = "platform" }, "spec.gitBranchRef is immutable") + expectImmutable(ctx, key, current, func() { current.Spec.KubeProviderRef.Name = "other" }, + "spec.kubeProviderRef is immutable") }) It("rejects any change to a GitBranch's spec, including adding or removing parentBranch", func() { @@ -124,7 +127,8 @@ var _ = Describe("GitTarget Destination Immutability", func() { base := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("prov-a", "main")}, + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("prov-a", "main")}, }, } diff --git a/internal/controller/gittarget_placement_validation_test.go b/internal/controller/gittarget_placement_validation_test.go index e15a09a6d..35aae1052 100644 --- a/internal/controller/gittarget_placement_validation_test.go +++ b/internal/controller/gittarget_placement_validation_test.go @@ -268,8 +268,9 @@ func TestEvaluateValidatedGate_InvalidPlacementPolicy(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "target-a", Namespace: ns}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("provider-a", "main")}, - Path: "apps", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("provider-a", "main")}, + Path: "apps", Placement: &configbutleraiv1alpha3.GitTargetPlacementSpec{ Default: "{bogus}/all.yaml", }, diff --git a/internal/controller/gittarget_shared_branch_test.go b/internal/controller/gittarget_shared_branch_test.go index 2ed65824e..979c98571 100644 --- a/internal/controller/gittarget_shared_branch_test.go +++ b/internal/controller/gittarget_shared_branch_test.go @@ -31,6 +31,7 @@ func sharedProviderTarget( CreationTimestamp: metav1.NewTime(time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC).Add(-age)), }, Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: triggerProviderRef(triggerLocalProvider), GitBranchRef: meta.NamespacedObjectReference{ Name: gitBranchNameForTest("monorepo", "main"), Namespace: providerNS, @@ -45,13 +46,10 @@ func sharedProviderReconciler(t *testing.T, objects ...ctrlclient.Object) *GitTa scheme := runtime.NewScheme() require.NoError(t, clientgoscheme.AddToScheme(scheme)) require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) - // The chart's default ClusterProvider admits every namespace. - objects = append(objects, &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: configbutleraiv1alpha3.DefaultClusterProviderName}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}, - }, - }) + // The KubeProvider every GitTarget here mirrors through admits every namespace. + local := admitting(triggerLocalProvider) + local.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}} + objects = append(objects, local) for _, ns := range []string{"platform", "team-b"} { objects = append(objects, &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "monorepo"}, @@ -80,3 +78,38 @@ func TestCheckForConflicts_ASharedGitBranchIsOneBranchAcrossNamespaces(t *testin assert.Equal(t, GitTargetReasonTargetConflict, reason) assert.Contains(t, msg, "platform/apps") } + +// TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder: a GitTarget whose namespace the +// GitBranch's GitProvider does not admit writes nothing, so it cannot hold a folder against the +// targets that may. Without this, any namespace that knows a GitBranch's name could create a target +// at "." first and block every folder on that branch. Once the provider admits it, the older target +// owns its path as any other does. +func TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder(t *testing.T) { + branch := &configbutleraiv1alpha3.GitBranch{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: gitBranchNameForTest("monorepo", "main")}, + Spec: configbutleraiv1alpha3.GitBranchSpec{ + GitProviderRef: meta.LocalObjectReference{Name: "monorepo"}, Branch: "main", + }, + } + squatter := sharedProviderTarget("intruder", "everything", "platform", ".", 2*time.Hour) + owner := sharedProviderTarget("platform", "apps", "", "apps", time.Hour) + + r := sharedProviderReconciler(t, branch, squatter, owner) + conflict, msg, _, err := r.checkForConflicts(context.Background(), owner) + require.NoError(t, err) + assert.False(t, conflict, "a target its GitProvider does not admit must not hold a folder: %s", msg) + + var provider configbutleraiv1alpha3.GitProvider + require.NoError( + t, + r.Get(context.Background(), ctrlclient.ObjectKey{Namespace: "platform", Name: "monorepo"}, &provider), + ) + provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"intruder"}} + require.NoError(t, r.Update(context.Background(), &provider)) + + conflict, msg, reason, err := r.checkForConflicts(context.Background(), owner) + require.NoError(t, err) + assert.True(t, conflict, "an admitted older target owns its path") + assert.Equal(t, GitTargetReasonTargetConflict, reason) + assert.Contains(t, msg, "intruder/everything") +} diff --git a/internal/controller/gittarget_source_cluster.go b/internal/controller/gittarget_source_cluster.go index f89bf4c59..7ea714bd3 100644 --- a/internal/controller/gittarget_source_cluster.go +++ b/internal/controller/gittarget_source_cluster.go @@ -7,26 +7,25 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - k8stypes "k8s.io/apimachinery/pkg/types" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/authz" ) // GitTargetReasonNamespaceNotAuthorized is the Validated=False reason when a GitTarget's namespace -// is not admitted by its referenced ClusterProvider's spec.accessFrom. It runs on every +// is not admitted by its referenced KubeProvider's spec.accessFrom. It runs on every // reconcile, so a policy tightened AFTER a GitTarget was created stops that target's watches too. const GitTargetReasonNamespaceNotAuthorized = authz.ReasonNamespaceNotAuthorized -// GitTargetReasonClusterProviderNotFound is the Validated=False reason when a GitTarget's -// referenced ClusterProvider does not exist. This is a HARD GATE: a GitTarget may mirror a source -// cluster ONLY through an existing ClusterProvider, "default" included. The operator never creates -// one, so a target whose provider was never declared is held NotReady and its data plane stopped -// rather than mirroring on an implicit local identity. -const GitTargetReasonClusterProviderNotFound = authz.ReasonClusterProviderNotFound +// GitTargetReasonKubeProviderNotFound is the Validated=False reason when a GitTarget's +// referenced KubeProvider does not exist. This is a HARD GATE: a GitTarget may mirror a source +// cluster ONLY through an existing KubeProvider. The operator never creates one, so a target whose +// provider was never declared is held NotReady and its data plane stopped rather than mirroring on +// an implicit identity. +const GitTargetReasonKubeProviderNotFound = authz.ReasonKubeProviderNotFound // checkSourceAuthorization is the GitTarget reconciler's view of the shared source-cluster gate: -// the referenced ClusterProvider must exist, and it must admit the GitTarget's namespace. The +// the referenced KubeProvider must exist, and it must admit the GitTarget's namespace. The // decision itself lives in internal/authz because the ClusterWatchRule reconciler and the watch // manager's bootstrap must reach the SAME verdict — see authz.GitTargetAdmitted. // @@ -50,7 +49,7 @@ func (r *GitTargetReconciler) checkSourceAuthorization( const ( // GitTargetConditionSourceClusterReachable is the RUNTIME reachability of the source - // cluster a GitTarget mirrors from: True (reason LocalCluster) when kubeConfig is omitted, + // cluster a GitTarget mirrors from: True (reason LocalCluster) for an inCluster provider, // Unknown before the data plane's first discovery, False after a real failed attempt. It is // distinct from Validated: a missing/malformed kubeconfig is Validated=False (an input the // controller reads without a network dial); an otherwise-valid kubeconfig whose API server @@ -65,21 +64,21 @@ const ( // does not hold its branch, or reports itself not Ready. GitTargetReasonGitBranchNotReady = "GitBranchNotReady" - // GitTargetConditionClusterProviderReady projects the referenced ClusterProvider's Ready onto + // GitTargetConditionKubeProviderReady projects the referenced KubeProvider's Ready onto // the GitTarget, so one `kubectl get gittarget` shows whether the SOURCE cluster's provider is // healthy — distinct from SourceClusterReachable (the data plane's runtime reach) and // GitBranchReady (the destination). It follows the GitBranchReady contract: only an // EXPLICIT Ready=False downgrades the GitTarget; a provider that has not reported readiness yet // is Unknown and does not. A MISSING provider never reaches this projection at all — that is the - // Validated gate's hard denial (GitTargetReasonClusterProviderNotFound), not a readiness axis. - GitTargetConditionClusterProviderReady = "ClusterProviderReady" - // GitTargetReasonClusterProviderNotReady is the ClusterProviderReady=False/Unknown reason. - GitTargetReasonClusterProviderNotReady = "ClusterProviderNotReady" + // Validated gate's hard denial (GitTargetReasonKubeProviderNotFound), not a readiness axis. + GitTargetConditionKubeProviderReady = "KubeProviderReady" + // GitTargetReasonKubeProviderNotReady is the KubeProviderReady=False/Unknown reason. + GitTargetReasonKubeProviderNotReady = "KubeProviderNotReady" ) -// resolveSourceClusterProvider reads the ClusterProvider this GitTarget mirrors through, ONCE per +// resolveSourceKubeProvider reads the KubeProvider this GitTarget mirrors through, ONCE per // reconcile, for the two consumers that need it: the audit route captured on Declare, and the -// ClusterProviderReady projection. It runs AFTER the Validated gate, which already proved through +// KubeProviderReady projection. It runs AFTER the Validated gate, which already proved through // authz.GitTargetAdmitted that this provider exists and admits this namespace — so a failure here // means the provider vanished (or the apiserver blinked) mid-reconcile, and it is returned as an // error so the reconcile requeues. @@ -90,54 +89,54 @@ const ( // under a partition the audit webhook never writes to. Attribution then resolves nobody — or, if // some other provider is NAMED `shared-route`, resolves that cluster's facts and writes a wrong // author into Git. -func (r *GitTargetReconciler) resolveSourceClusterProvider( +func (r *GitTargetReconciler) resolveSourceKubeProvider( ctx context.Context, target *configbutleraiv1alpha3.GitTarget, -) (*configbutleraiv1alpha3.ClusterProvider, error) { - name := target.SourceCluster() - var cp configbutleraiv1alpha3.ClusterProvider - if err := r.Get(ctx, k8stypes.NamespacedName{Name: name}, &cp); err != nil { - return nil, fmt.Errorf("read source ClusterProvider %q: %w", name, err) +) (*configbutleraiv1alpha3.KubeProvider, error) { + key := target.KubeProviderKey() + var cp configbutleraiv1alpha3.KubeProvider + if err := r.Get(ctx, key, &cp); err != nil { + return nil, fmt.Errorf("read source KubeProvider %s: %w", key, err) } return &cp, nil } -// clusterProviderReadiness projects the referenced ClusterProvider's Ready condition, mirroring -// gitProviderReadiness. It takes the provider resolveSourceClusterProvider already read rather than +// kubeProviderReadiness projects the referenced KubeProvider's Ready condition, mirroring +// gitProviderReadiness. It takes the provider resolveSourceKubeProvider already read rather than // reading its own, so the readiness reported and the audit route declared always describe the same // observation of the same object. // // Only an EXPLICIT Ready=False downgrades the GitTarget; a provider that has not reported readiness // yet is Unknown, which does not. -func clusterProviderReadiness(cp *configbutleraiv1alpha3.ClusterProvider) conditionValue { +func kubeProviderReadiness(cp *configbutleraiv1alpha3.KubeProvider) conditionValue { name := cp.Name notReady := func(status metav1.ConditionStatus, format string, args ...any) conditionValue { return conditionValue{ Status: status, - Reason: GitTargetReasonClusterProviderNotReady, + Reason: GitTargetReasonKubeProviderNotReady, Message: fmt.Sprintf(format, args...), } } c := findCondition(cp.Status.Conditions, ConditionTypeReady) switch { case c == nil: - return notReady(metav1.ConditionUnknown, "referenced ClusterProvider %q has not reported readiness yet", name) + return notReady(metav1.ConditionUnknown, "referenced KubeProvider %q has not reported readiness yet", name) case c.Status == metav1.ConditionTrue: return conditionValue{ Status: metav1.ConditionTrue, Reason: ReasonSucceeded, - Message: fmt.Sprintf("referenced ClusterProvider %q is Ready", name), + Message: fmt.Sprintf("referenced KubeProvider %q is Ready", name), } case c.Status == metav1.ConditionFalse: if c.Message != "" { - return notReady(metav1.ConditionFalse, "referenced ClusterProvider %q is not Ready: %s", name, c.Message) + return notReady(metav1.ConditionFalse, "referenced KubeProvider %q is not Ready: %s", name, c.Message) } - return notReady(metav1.ConditionFalse, "referenced ClusterProvider %q is not Ready", name) + return notReady(metav1.ConditionFalse, "referenced KubeProvider %q is not Ready", name) default: // Ready=Unknown is the provider saying it does not know yet, which is exactly the case the // contract above refuses to downgrade on. Collapsing it into False would hold a GitTarget // down on a provider mid-reconcile. - return notReady(metav1.ConditionUnknown, "referenced ClusterProvider %q readiness is unknown", name) + return notReady(metav1.ConditionUnknown, "referenced KubeProvider %q readiness is unknown", name) } } diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index 529b6ceaf..ccd7028c8 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "testing" + "time" meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" @@ -23,6 +24,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/event" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" "github.com/ConfigButler/gitops-reverser/internal/types" "github.com/ConfigButler/gitops-reverser/internal/watch" ) @@ -47,11 +49,11 @@ func TestNamespaceToGitTargets(t *testing.T) { assert.ElementsMatch(t, []string{"a", "b"}, names) } -func TestClusterProviderReadyOrSpecChanged(t *testing.T) { - p := clusterProviderReadyOrSpecChanged() - cp := func(gen int64, ready metav1.ConditionStatus) *configbutleraiv1alpha3.ClusterProvider { - c := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "default", Generation: gen}, +func TestKubeProviderReadyOrSpecChanged(t *testing.T) { + p := kubeProviderReadyOrSpecChanged() + cp := func(gen int64, ready metav1.ConditionStatus) *configbutleraiv1alpha3.KubeProvider { + c := &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "team-a", Generation: gen}, } if ready != "" { c.Status.Conditions = []metav1.Condition{{Type: ConditionTypeReady, Status: ready}} @@ -116,49 +118,57 @@ func scScheme(t *testing.T) *runtime.Scheme { } func TestCheckSourceAuthorization(t *testing.T) { - provider := func(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.ClusterProvider { - return &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{AccessFrom: policy}, - } + // The provider lives in "platform", so team-a reaches it only through spec.accessFrom. + provider := func(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.KubeProvider { + p := remoteKubeProvider("kc", "") + p.Namespace = "platform" + p.Spec.AccessFrom = policy + return p } ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: map[string]string{"tier": "trusted"}}} target := func(providerName string) *configbutleraiv1alpha3.GitTarget { - return &configbutleraiv1alpha3.GitTarget{ - ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "team-a"}, - Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: providerName}, - }, + gt := &configbutleraiv1alpha3.GitTarget{ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "team-a"}} + if providerName != "" { + gt.Spec.KubeProviderRef = meta.NamespacedObjectReference{Namespace: "platform", Name: providerName} } + return gt } tests := []struct { name string objects []client.Object - gitProviderRef string + providerRef string wantAuthorized bool wantReason string }{ { name: "provider not found -> hard gate (NotReady, no mirroring)", objects: []client.Object{ns}, - gitProviderRef: "absent", + providerRef: "absent", + wantAuthorized: false, + wantReason: GitTargetReasonKubeProviderNotFound, + }, + { + // There is no "default" provider to fall back to: an unset reference names nothing. + name: "no kubeProviderRef -> hard gate, no fallback", + objects: []client.Object{ns}, + providerRef: "", wantAuthorized: false, - wantReason: GitTargetReasonClusterProviderNotFound, + wantReason: GitTargetReasonKubeProviderNotFound, }, { name: "provider allows the namespace by name", objects: []client.Object{ provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), ns, }, - gitProviderRef: "prod-eu-1", wantAuthorized: true, + providerRef: "prod-eu-1", wantAuthorized: true, }, { name: "provider does not allow the namespace -> refused", objects: []client.Object{ provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), ns, }, - gitProviderRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, + providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, { name: "provider allows the namespace by label selector", @@ -167,7 +177,7 @@ func TestCheckSourceAuthorization(t *testing.T) { Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), ns, }, - gitProviderRef: "prod-eu-1", wantAuthorized: true, + providerRef: "prod-eu-1", wantAuthorized: true, }, { // A selector the API accepted but that cannot compile must FAIL CLOSED. Treating an @@ -182,7 +192,7 @@ func TestCheckSourceAuthorization(t *testing.T) { }, }), ns, }, - gitProviderRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, + providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, { // A missing Namespace object is not an error: the policy is still evaluated, just with @@ -191,7 +201,7 @@ func TestCheckSourceAuthorization(t *testing.T) { objects: []client.Object{ provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), }, - gitProviderRef: "prod-eu-1", wantAuthorized: true, + providerRef: "prod-eu-1", wantAuthorized: true, }, { name: "namespace object absent -> selector-only policy denies (no labels to match)", @@ -200,14 +210,14 @@ func TestCheckSourceAuthorization(t *testing.T) { Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), }, - gitProviderRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, + providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(tc.objects...).Build() r := &GitTargetReconciler{Client: cl} - ok, reason, msg, err := r.checkSourceAuthorization(context.Background(), target(tc.gitProviderRef)) + ok, reason, msg, err := r.checkSourceAuthorization(context.Background(), target(tc.providerRef)) require.NoError(t, err) assert.Equal(t, tc.wantAuthorized, ok) if !tc.wantAuthorized { @@ -218,6 +228,47 @@ func TestCheckSourceAuthorization(t *testing.T) { } } +// TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute covers the two admission rules a +// namespaced KubeProvider adds: its OWN namespace is always admitted, with no accessFrom and with +// the reference's namespace omitted; and a provider whose audit route another namespace holds is +// denied to every consumer, its own namespace included. +func TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute(t *testing.T) { + own := inClusterKubeProvider("local") + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: kpNamespace}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, + }, + } + + t.Run("own namespace is admitted without accessFrom", func(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(own).Build() + ok, _, _, err := (&GitTargetReconciler{Client: cl}).checkSourceAuthorization(context.Background(), target) + require.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("a route held by another namespace is refused", func(t *testing.T) { + // "team-b.edge" names team-b, which holds it while a provider there carries it, however + // much older the team-a provider spelling it out is. + squatter := own.DeepCopy() + squatter.CreationTimestamp = metav1.NewTime(metav1.Now().Add(-time.Hour)) + squatter.Spec.Attribution = &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: "team-b.edge"} + holder := inClusterKubeProvider("edge") + holder.Namespace = "team-b" + cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(squatter, holder).Build() + + ok, reason, msg, err := (&GitTargetReconciler{Client: cl}).checkSourceAuthorization( + context.Background(), + target, + ) + require.NoError(t, err) + assert.False(t, ok) + assert.Equal(t, authz.ReasonAuditRouteConflict, reason) + assert.Contains(t, msg, "team-b.edge") + }) +} + // TestCheckSourceAuthorization_ReadErrorsRequeue pins the fail-closed contract for TRANSIENT // failures. A read error is not a verdict: the gate must return an error so the reconcile requeues, // rather than returning authorized=false (which would look like a policy denial and flap the @@ -226,15 +277,12 @@ func TestCheckSourceAuthorization_ReadErrorsRequeue(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "team-a"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: "prod-eu-1"}, - }, - } - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: "platform", Name: "prod-eu-1"}, }, } + provider := remoteKubeProvider("kc", "") + provider.Namespace = "platform" + provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}} tests := []struct { name string @@ -242,9 +290,9 @@ func TestCheckSourceAuthorization_ReadErrorsRequeue(t *testing.T) { wantErr string }{ { - name: "ClusterProvider read fails", - failOn: func(o client.Object) bool { _, ok := o.(*configbutleraiv1alpha3.ClusterProvider); return ok }, - wantErr: "read ClusterProvider", + name: "KubeProvider read fails", + failOn: func(o client.Object) bool { _, ok := o.(*configbutleraiv1alpha3.KubeProvider); return ok }, + wantErr: "read KubeProvider", }, { name: "Namespace read fails", @@ -315,19 +363,17 @@ func TestDescribeKubeConfigKey(t *testing.T) { // TestReconcile_UnauthorizedNamespaceStartsNoWatch pins the whole security property in one place, // through the REAL Reconcile rather than checkSourceAuthorization in isolation: namespace // authorization is enforced at reconcile only (there is no admission webhook for it), so this path -// is the entire boundary. A GitTarget whose namespace its ClusterProvider does not admit must end +// is the entire boundary. A GitTarget whose namespace its KubeProvider does not admit must end // up Validated=False/NamespaceNotAuthorized AND must never reach DeclareForGitTarget — no watch is // started, so nothing is ever routed to a branch worker and nothing is written to Git. func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { const ns, providerName = "team-a", "prod-eu-1" + providerKey := k8stypes.NamespacedName{Namespace: "platform", Name: providerName} - // The provider admits team-b only; the GitTarget lives in team-a. - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: providerName}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}, - }, - } + // The provider lives in platform and admits team-b only; the GitTarget lives in team-a. + provider := remoteKubeProvider("kc", "") + provider.Namespace = providerKey.Namespace + provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}} gitProvider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "gp", Namespace: ns}, Spec: configbutleraiv1alpha3.GitProviderSpec{AllowedBranches: []string{"main"}}, @@ -335,9 +381,9 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: ns, UID: "gt-uid"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("gp", "main")}, - Path: "apps", - ClusterProviderRef: &meta.LocalObjectReference{Name: providerName}, + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("gp", "main")}, + Path: "apps", + KubeProviderRef: meta.NamespacedObjectReference{Namespace: providerKey.Namespace, Name: providerName}, }, } @@ -383,18 +429,19 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { // source cluster whether or not the owner's pass has run yet — which is exactly the record a // refused GitTarget must not produce. other := types.NewResourceReference("authorized", ns).WithUID("other-uid") - watchManager.DeclareForGitTarget(other, providerName, providerName, configbutleraiv1alpha3.PruneOnEvent) + watchManager.DeclareForGitTarget(other, providerKey.String(), provider.AuditRoute(), + configbutleraiv1alpha3.PruneOnEvent) declaredOther := watchManager.DeclareStatusForGitTarget(other) require.True(t, declaredOther.Declared, "the positive control must declare, or the assertion above proves nothing") - assert.Equal(t, providerName, declaredOther.ClusterID) + assert.Equal(t, providerKey.String(), declaredOther.ClusterID) } -func TestClusterProviderReadiness_AllScenarios(t *testing.T) { - provider := func(conds []metav1.Condition) *configbutleraiv1alpha3.ClusterProvider { - return &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, - Status: configbutleraiv1alpha3.ClusterProviderStatus{Conditions: conds}, +func TestKubeProviderReadiness_AllScenarios(t *testing.T) { + provider := func(conds []metav1.Condition) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1", Namespace: kpNamespace}, + Status: configbutleraiv1alpha3.KubeProviderStatus{Conditions: conds}, } } ready := metav1.Condition{Type: ConditionTypeReady, Status: metav1.ConditionTrue, Reason: ReasonSucceeded} @@ -413,11 +460,11 @@ func TestClusterProviderReadiness_AllScenarios(t *testing.T) { } // The absent-provider case is deliberately NOT here: this projection runs only after the - // Validated gate admitted the target through an existing ClusterProvider, and it is handed the + // Validated gate admitted the target through an existing KubeProvider, and it is handed the // provider that gate's reconcile already read. A provider that cannot be read never reaches it. tests := []struct { name string - cp *configbutleraiv1alpha3.ClusterProvider + cp *configbutleraiv1alpha3.KubeProvider want metav1.ConditionStatus }{ {"ready", provider([]metav1.Condition{ready}), metav1.ConditionTrue}, @@ -433,7 +480,7 @@ func TestClusterProviderReadiness_AllScenarios(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := clusterProviderReadiness(tc.cp) + got := kubeProviderReadiness(tc.cp) assert.Equal(t, tc.want, got.Status) assert.NotEmpty(t, got.Message) }) @@ -488,31 +535,32 @@ func TestGitBranchReadiness_AllScenarios(t *testing.T) { } } -// TestResolveSourceClusterProvider covers the single read that feeds both the audit route a -// GitTarget's streams are declared under and its ClusterProviderReady projection. +// TestResolveSourceKubeProvider covers the single read that feeds both the audit route a +// GitTarget's streams are declared under and its KubeProviderReady projection. // // The load-bearing case is the unreadable provider: it must ERROR, so the reconcile requeues -// without declaring. Resolving a route by falling back to the provider NAME is right only for a +// without declaring. Resolving a route by falling back to the provider's default is right only for a // provider that declares none — do it for one that declares `shared-route` and this target's // streams are keyed to a partition the audit handler never writes, which is exactly the silent // attribution loss this resolution exists to prevent. -func TestResolveSourceClusterProvider(t *testing.T) { +func TestResolveSourceKubeProvider(t *testing.T) { const providerName = "srcns-delegating" + // The provider is read from the namespace the reference names, not the GitTarget's own. target := func() *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "tenant"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: providerName}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: kpNamespace, Name: providerName}, }, } } - provider := func(attribution *configbutleraiv1alpha3.ClusterProviderAttribution) client.Object { - return &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: providerName}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{Attribution: attribution}, - } + provider := func(attribution *configbutleraiv1alpha3.KubeProviderAttribution) client.Object { + p := inClusterKubeProvider(providerName) + p.Spec.Attribution = attribution + return p } + defaultRoute := kpNamespace + "." + providerName tests := []struct { name string @@ -522,26 +570,26 @@ func TestResolveSourceClusterProvider(t *testing.T) { { name: "a declared route is used", objects: []client.Object{ - provider(&configbutleraiv1alpha3.ClusterProviderAttribution{AuditRoute: "default"}), + provider(&configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: "shared-route"}), }, - wantRoute: "default", + wantRoute: "shared-route", }, { - name: "an empty attribution block resolves the provider name", - objects: []client.Object{provider(&configbutleraiv1alpha3.ClusterProviderAttribution{})}, - wantRoute: providerName, + name: "an empty attribution block resolves .", + objects: []client.Object{provider(&configbutleraiv1alpha3.KubeProviderAttribution{})}, + wantRoute: defaultRoute, }, { - name: "no attribution block resolves the provider name", + name: "no attribution block resolves .", objects: []client.Object{provider(nil)}, - wantRoute: providerName, + wantRoute: defaultRoute, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(tc.objects...).Build() r := &GitTargetReconciler{Client: cl} - cp, err := r.resolveSourceClusterProvider(context.Background(), target()) + cp, err := r.resolveSourceKubeProvider(context.Background(), target()) require.NoError(t, err) assert.Equal(t, tc.wantRoute, cp.AuditRoute()) }) @@ -550,9 +598,59 @@ func TestResolveSourceClusterProvider(t *testing.T) { t.Run("an unreadable provider errors instead of guessing a route", func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scScheme(t)).Build() r := &GitTargetReconciler{Client: cl} - cp, err := r.resolveSourceClusterProvider(context.Background(), target()) + cp, err := r.resolveSourceKubeProvider(context.Background(), target()) require.Error(t, err, "a guessed route silently mis-keys this target's attribution facts") assert.Nil(t, cp) assert.Contains(t, err.Error(), providerName) }) } + +// TestObserveSourceReachable_Defaults pins what SourceClusterReachable says before a watch manager +// is wired: an inCluster provider names the cluster the operator runs in, reachable by definition; +// a secretRef provider is Unknown until the data plane's first discovery. +func TestObserveSourceReachable_Defaults(t *testing.T) { + target := &configbutleraiv1alpha3.GitTarget{ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: kpNamespace}} + tests := []struct { + name string + provider *configbutleraiv1alpha3.KubeProvider + wantStatus metav1.ConditionStatus + wantReason string + }{ + {"inCluster", inClusterKubeProvider("local"), metav1.ConditionTrue, "LocalCluster"}, + {"secretRef", remoteKubeProvider("kc", ""), metav1.ConditionUnknown, "AwaitingDiscovery"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := (&GitTargetReconciler{}).observeSourceReachable(target, tc.provider) + assert.Equal(t, tc.wantStatus, got.Status) + assert.Equal(t, tc.wantReason, got.Reason) + }) + } +} + +// TestKubeProviderToGitTargets: a provider event requeues the GitTargets that mirror through it, +// in any namespace its accessFrom may admit, matched on the provider's namespace AND name. +func TestKubeProviderToGitTargets(t *testing.T) { + target := func(namespace, name string, ref meta.NamespacedObjectReference) *configbutleraiv1alpha3.GitTarget { + return &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: configbutleraiv1alpha3.GitTargetSpec{KubeProviderRef: ref}, + } + } + provider := inClusterKubeProvider("local") + cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects( + target(kpNamespace, "own", meta.NamespacedObjectReference{Name: "local"}), + target("team-b", "admitted", meta.NamespacedObjectReference{Namespace: kpNamespace, Name: "local"}), + target("team-b", "same-name-elsewhere", meta.NamespacedObjectReference{Name: "local"}), + target(kpNamespace, "other-provider", meta.NamespacedObjectReference{Name: "remote"}), + ).Build() + + reqs := (&GitTargetReconciler{Client: cl}).kubeProviderToGitTargets(context.Background(), provider) + keys := make([]k8stypes.NamespacedName, 0, len(reqs)) + for _, req := range reqs { + keys = append(keys, req.NamespacedName) + } + assert.ElementsMatch(t, []k8stypes.NamespacedName{ + {Namespace: kpNamespace, Name: "own"}, {Namespace: "team-b", Name: "admitted"}, + }, keys) +} diff --git a/internal/controller/gittarget_status_test.go b/internal/controller/gittarget_status_test.go index dd24e8e5f..e87cd0b12 100644 --- a/internal/controller/gittarget_status_test.go +++ b/internal/controller/gittarget_status_test.go @@ -40,14 +40,14 @@ func TestGitTargetReadinessGates(t *testing.T) { gitPath watch.GitPathAcceptanceStatus render watch.RenderFidelityStatus provider conditionValue - clusterProvider conditionValue + kubeProvider conditionValue sourceReach conditionValue want kstatus.Status wantReadyReason string }{ { name: "everything healthy", streams: running, gitPath: accepted, render: matches, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.CurrentStatus, wantReadyReason: ReasonSucceeded, }, { @@ -56,7 +56,7 @@ func TestGitTargetReadinessGates(t *testing.T) { name: "no resolved types is converged, not perpetually reconciling", streams: watch.StreamSummary{Reason: watch.StreamReasonNoResolvedTypes, Message: "0/0 streams running"}, gitPath: accepted, render: matches, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.CurrentStatus, wantReadyReason: ReasonSucceeded, }, { @@ -65,7 +65,7 @@ func TestGitTargetReadinessGates(t *testing.T) { Total: 2, Ready: 1, Replaying: 1, Reason: watch.StreamReasonReplaying, Message: "1/2 streams running; 1 replaying", }, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.InProgressStatus, wantReadyReason: watch.StreamReasonReplaying, }, { @@ -74,7 +74,7 @@ func TestGitTargetReadinessGates(t *testing.T) { Total: 2, Ready: 1, Blocked: 1, Reason: watch.StreamReasonWatchError, Message: "1/2 streams running; 1 blocked", }, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.FailedStatus, wantReadyReason: watch.StreamReasonWatchError, }, { @@ -84,7 +84,7 @@ func TestGitTargetReadinessGates(t *testing.T) { Reason: GitTargetReasonUnsupportedContent, Message: "Git path refused at kustomization.yaml: uses patches", }, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.FailedStatus, wantReadyReason: GitTargetReasonUnsupportedContent, }, { @@ -96,7 +96,7 @@ func TestGitTargetReadinessGates(t *testing.T) { Reason: GitTargetReasonWriteBoundaryRefused, Message: "Git path refused at base/deployment.yaml: write-fan-in must be 1", }, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.FailedStatus, wantReadyReason: GitTargetReasonWriteBoundaryRefused, }, { @@ -104,7 +104,7 @@ func TestGitTargetReadinessGates(t *testing.T) { render: watch.RenderFidelityStatus{ State: git.RenderFidelityFalse, Reason: GitTargetReasonRenderDoesNotMatchLive, Message: "${REGION}", }, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.FailedStatus, wantReadyReason: GitTargetReasonRenderDoesNotMatchLive, }, { @@ -112,7 +112,7 @@ func TestGitTargetReadinessGates(t *testing.T) { render: watch.RenderFidelityStatus{ State: git.RenderFidelityUnknown, Reason: GitTargetReasonRenderRechecking, Message: "waiting", }, - provider: healthy, clusterProvider: healthy, sourceReach: healthy, + provider: healthy, kubeProvider: healthy, sourceReach: healthy, want: kstatus.InProgressStatus, wantReadyReason: GitTargetReasonRenderRechecking, }, { @@ -121,7 +121,7 @@ func TestGitTargetReadinessGates(t *testing.T) { provider: conditionValue{ Status: metav1.ConditionFalse, Reason: GitTargetReasonGitBranchNotReady, Message: "no repo", }, - clusterProvider: healthy, sourceReach: healthy, + kubeProvider: healthy, sourceReach: healthy, want: kstatus.InProgressStatus, wantReadyReason: GitTargetReasonGitBranchNotReady, }, { @@ -139,7 +139,7 @@ func TestGitTargetReadinessGates(t *testing.T) { provider: conditionValue{ Status: metav1.ConditionFalse, Reason: GitTargetReasonGitBranchNotReady, Message: "no repo", }, - clusterProvider: healthy, + kubeProvider: healthy, sourceReach: conditionValue{ Status: metav1.ConditionUnknown, Reason: "AwaitingDiscovery", Message: "not discovered yet", }, @@ -164,7 +164,7 @@ func TestGitTargetReadinessGates(t *testing.T) { st.setValue(GitTargetConditionRenderMatchesLive, observed.axes.Render) rd := newGitTargetReadiness() - gitTargetReadinessGates(rd, observed, tt.provider, tt.clusterProvider, tt.sourceReach, + gitTargetReadinessGates(rd, observed, tt.provider, tt.kubeProvider, tt.sourceReach, conditionValue{}) st.applyReadiness(rd) diff --git a/internal/controller/gittarget_worker_repoint_test.go b/internal/controller/gittarget_worker_repoint_test.go index 78580c85b..d99b9a2ea 100644 --- a/internal/controller/gittarget_worker_repoint_test.go +++ b/internal/controller/gittarget_worker_repoint_test.go @@ -52,8 +52,9 @@ func TestEnsureEventStream_RepointsTheStreamAtTheReplacementWorker(t *testing.T) target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "apps", Namespace: "shop"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("repo1", "main")}, - Path: "apps", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("repo1", "main")}, + Path: "apps", }, } before := git.RepoIdentity{ProviderUID: "uid-1", URL: "https://example.invalid/first.git"} @@ -112,8 +113,9 @@ func TestRecoverBranchAfterReplacement_ReachesEverySiblingOnTheBranch(t *testing return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "shop", UID: k8stypes.UID("uid-" + name)}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest(provider, branch)}, - Path: name, + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest(provider, branch)}, + Path: name, }, } } @@ -174,8 +176,9 @@ func TestEnsureEventStream_ARecoveryThatCouldNotBeDeliveredIsRetried(t *testing. target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "apps", Namespace: "shop", UID: k8stypes.UID("uid-apps")}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("repo1", "main")}, - Path: "apps", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("repo1", "main")}, + Path: "apps", }, } listFails := true diff --git a/internal/controller/clusterprovider_audit_facts.go b/internal/controller/kubeprovider_audit_facts.go similarity index 87% rename from internal/controller/clusterprovider_audit_facts.go rename to internal/controller/kubeprovider_audit_facts.go index b9fd7a798..1d7c27e46 100644 --- a/internal/controller/clusterprovider_audit_facts.go +++ b/internal/controller/kubeprovider_audit_facts.go @@ -8,7 +8,7 @@ import ( "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -32,7 +32,7 @@ type AuditRouteFacts interface { // FirstFactAt reports when the first fact was published on a route IN THIS PROCESS. A restart // empties it, which is why the condition latches in the persisted status instead. FirstFactAt(route string) (time.Time, bool) - // FirstFactEvents carries a ClusterProvider-shaped event naming the ROUTE whose first fact just + // FirstFactEvents carries a KubeProvider-shaped event naming the ROUTE whose first fact just // landed, so the providers reading it re-reconcile within seconds. FirstFactEvents() <-chan event.GenericEvent // TransportFailing and AuditDelivered are PROCESS-WIDE, not per route. They exist only to say @@ -57,15 +57,15 @@ type AuditRouteFacts interface { // // It never writes False. Silence BEFORE any proof is a misconfiguration signal; silence AFTER proof // is a quiet cluster, and no timer can tell those apart — the latch can, without one. -func (r *ClusterProviderReconciler) applyAuditFactsCondition( +func (r *KubeProviderReconciler) applyAuditFactsCondition( st *reconcileStatus, - provider *configbutleraiv1alpha3.ClusterProvider, + provider *configbutleraiv1alpha3.KubeProvider, ) { if r.AuditFacts == nil { // Attribution is off operator-wide. Removing rather than leaving the last answer standing // matters on the downgrade path: a True latched while attribution was on would otherwise // claim facts are arriving on a route nothing posts to any more. - st.remove(ClusterProviderConditionAuditFactsReceived) + st.remove(KubeProviderConditionAuditFactsReceived) return } @@ -77,22 +77,22 @@ func (r *ClusterProviderReconciler) applyAuditFactsCondition( latchedRoute := provider.Status.AuditRoute provider.Status.AuditRoute = route - existing := findCondition(provider.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + existing := findCondition(provider.Status.Conditions, KubeProviderConditionAuditFactsReceived) if existing != nil && existing.Status == metav1.ConditionTrue && latchedRoute == route { reason, message := conditionReasonMessage( existing, ReasonFactsReceived, auditFactsReceivedMessage(route, time.Time{})) - st.set(ClusterProviderConditionAuditFactsReceived, metav1.ConditionTrue, reason, message) + st.set(KubeProviderConditionAuditFactsReceived, metav1.ConditionTrue, reason, message) return } if at, seen := r.AuditFacts.FirstFactAt(route); seen { - st.set(ClusterProviderConditionAuditFactsReceived, metav1.ConditionTrue, + st.set(KubeProviderConditionAuditFactsReceived, metav1.ConditionTrue, ReasonFactsReceived, auditFactsReceivedMessage(route, at)) return } reason, message := r.auditFactsPending(route, provider.CreationTimestamp.Time) - st.set(ClusterProviderConditionAuditFactsReceived, metav1.ConditionUnknown, reason, message) + st.set(KubeProviderConditionAuditFactsReceived, metav1.ConditionUnknown, reason, message) } // auditFactsPending picks which of the three silences this provider is in, and says where to look. @@ -105,7 +105,7 @@ func (r *ClusterProviderReconciler) applyAuditFactsCondition( // Only the first-fact transition is pushed (see FirstFactEvents); a transport recovery or a first // delivery changes only these MESSAGES, so they converge on the periodic requeue rather than // enqueueing every provider in the cluster. -func (r *ClusterProviderReconciler) auditFactsPending(route string, createdAt time.Time) (string, string) { +func (r *KubeProviderReconciler) auditFactsPending(route string, createdAt time.Time) (string, string) { waited := auditFactsWaited(createdAt) switch { case r.AuditFacts.TransportFailing(): @@ -180,8 +180,8 @@ func auditFactsReceivedMessage(route string, at time.Time) string { // auditFactsPendingMessage is the RouteUnused message: audit is being delivered and the transport // is accepting writes, so this provider's route is the one thing left to look at. It names the -// route explicitly because the route is usually NOT the field anyone set — it defaults to the -// provider's name, which is exactly how a second provider on one cluster ends up reading a +// route explicitly because the route is usually NOT the field anyone set — it defaults to +// ., which is exactly how a second provider on one cluster ends up reading a // partition nothing writes. func auditFactsPendingMessage(route, waited string) string { return fmt.Sprintf( @@ -195,11 +195,11 @@ func auditFactsPendingMessage(route, waited string) string { route, waited, route) } -// auditRouteToClusterProviders maps a first-fact notification — whose object carries the ROUTE in -// its name, not a provider — to every ClusterProvider reading that route. Several providers may +// auditRouteToKubeProviders maps a first-fact notification — whose object carries the ROUTE in +// its name, not a provider — to every KubeProvider reading that route. Several providers may // declare one route, and the one that most needs telling is the one that never set the field and -// so reads its own name. -func (r *ClusterProviderReconciler) auditRouteToClusterProviders( +// so reads its default. +func (r *KubeProviderReconciler) auditRouteToKubeProviders( ctx context.Context, obj client.Object, ) []ctrlreconcile.Request { @@ -207,7 +207,7 @@ func (r *ClusterProviderReconciler) auditRouteToClusterProviders( if route == "" { return nil } - var providers configbutleraiv1alpha3.ClusterProviderList + var providers configbutleraiv1alpha3.KubeProviderList if err := r.List(ctx, &providers); err != nil { return nil } @@ -216,9 +216,7 @@ func (r *ClusterProviderReconciler) auditRouteToClusterProviders( if providers.Items[i].AuditRoute() != route { continue } - requests = append(requests, ctrlreconcile.Request{ - NamespacedName: k8stypes.NamespacedName{Name: providers.Items[i].Name}, - }) + requests = append(requests, ctrlreconcile.Request{NamespacedName: providers.Items[i].Key()}) } return requests } diff --git a/internal/controller/clusterprovider_audit_facts_test.go b/internal/controller/kubeprovider_audit_facts_test.go similarity index 58% rename from internal/controller/clusterprovider_audit_facts_test.go rename to internal/controller/kubeprovider_audit_facts_test.go index 849c272d5..50b7b6a18 100644 --- a/internal/controller/clusterprovider_audit_facts_test.go +++ b/internal/controller/kubeprovider_audit_facts_test.go @@ -18,37 +18,35 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/attribution" ) -// reconcileClusterProviderOnce runs one reconcile against a fake client seeded with the provider +// reconcileKubeProviderOnce runs one reconcile against a fake client seeded with the provider // and returns the persisted object. -func reconcileClusterProviderOnce( +func reconcileKubeProviderOnce( t *testing.T, - provider *configbutleraiv1alpha3.ClusterProvider, + provider *configbutleraiv1alpha3.KubeProvider, facts AuditRouteFacts, -) (*ClusterProviderReconciler, configbutleraiv1alpha3.ClusterProvider) { +) (*KubeProviderReconciler, configbutleraiv1alpha3.KubeProvider) { t.Helper() cl := fake.NewClientBuilder(). WithScheme(scScheme(t)). WithObjects(provider). - WithStatusSubresource(&configbutleraiv1alpha3.ClusterProvider{}). + WithStatusSubresource(&configbutleraiv1alpha3.KubeProvider{}). Build() - r := &ClusterProviderReconciler{Client: cl, OperatorNamespace: cpOperatorNS, AuditFacts: facts} + r := &KubeProviderReconciler{Client: cl, KubeConfigSafety: allowInCluster(provider), AuditFacts: facts} - _, err := r.Reconcile(context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: provider.Name}}) + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: provider.Key()}) require.NoError(t, err) - var got configbutleraiv1alpha3.ClusterProvider - require.NoError(t, cl.Get(context.Background(), - k8stypes.NamespacedName{Name: provider.Name}, &got)) + var got configbutleraiv1alpha3.KubeProvider + require.NoError(t, cl.Get(context.Background(), provider.Key(), &got)) return r, got } -// TestClusterProviderAuditFacts_UnknownUntilTheFirstFact pins the state a provider is created in: +// TestKubeProviderAuditFacts_UnknownUntilTheFirstFact pins the state a provider is created in: // nothing has been published on its route yet, and the message has to say which route that is, // how long it has been waiting, and where to look — the route usually being the one thing nobody -// set, since it defaults to the provider's own name. -func TestClusterProviderAuditFacts_UnknownUntilTheFirstFact(t *testing.T) { - provider := clusterProviderWithKubeConfig("srcns-delegating", "", "") +// set, since it defaults to .. +func TestKubeProviderAuditFacts_UnknownUntilTheFirstFact(t *testing.T) { + provider := inClusterKubeProvider("srcns-delegating") provider.CreationTimestamp = metav1.NewTime(time.Now().Add(-12 * time.Minute)) // Audit IS being delivered and the transport is healthy, which is what narrows the diagnosis to @@ -56,15 +54,15 @@ func TestClusterProviderAuditFacts_UnknownUntilTheFirstFact(t *testing.T) { health := &attribution.RouteHealth{} health.RecordAuditDelivery() - _, got := reconcileClusterProviderOnce(t, provider, health) + _, got := reconcileKubeProviderOnce(t, provider, health) - cond := findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + cond := findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived) require.NotNil(t, cond) assert.Equal(t, metav1.ConditionUnknown, cond.Status) assert.Equal(t, ReasonRouteUnused, cond.Reason) - assert.Contains(t, cond.Message, `"srcns-delegating"`, "the message must name the route") + assert.Contains(t, cond.Message, `"team-a.srcns-delegating"`, "the message must name the route") assert.Contains(t, cond.Message, "12m0s", "the message must say how long the wait has been") - assert.Contains(t, cond.Message, "/audit-webhook/srcns-delegating", + assert.Contains(t, cond.Message, "/audit-webhook/team-a.srcns-delegating", "the message must name the URL an operator has to check") assert.Contains(t, cond.Message, "annotation", "the shared-backend case reads the route from an annotation instead") @@ -75,47 +73,47 @@ func TestClusterProviderAuditFacts_UnknownUntilTheFirstFact(t *testing.T) { assert.Equal(t, metav1.ConditionFalse, findCondition(got.Status.Conditions, ConditionTypeStalled).Status) } -// TestClusterProviderAuditFacts_LatchesOnTheFirstFact covers the flip and the declared route: the -// condition follows spec.attribution.auditRoute, not the provider's name, which is exactly the +// TestKubeProviderAuditFacts_LatchesOnTheFirstFact covers the flip and the declared route: the +// condition follows spec.attribution.auditRoute, not the provider's default, which is exactly the // configuration a second provider on one cluster needs. -func TestClusterProviderAuditFacts_LatchesOnTheFirstFact(t *testing.T) { - provider := clusterProviderWithKubeConfig("srcns-delegating", "", "") - provider.Spec.Attribution = &configbutleraiv1alpha3.ClusterProviderAttribution{AuditRoute: "default"} +func TestKubeProviderAuditFacts_LatchesOnTheFirstFact(t *testing.T) { + provider := inClusterKubeProvider("srcns-delegating") + provider.Spec.Attribution = &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: "shared"} health := &attribution.RouteHealth{} - require.True(t, health.RecordFactPublished("default")) + require.True(t, health.RecordFactPublished("shared")) - _, got := reconcileClusterProviderOnce(t, provider, health) + _, got := reconcileKubeProviderOnce(t, provider, health) - cond := findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + cond := findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived) require.NotNil(t, cond) assert.Equal(t, metav1.ConditionTrue, cond.Status) assert.Equal(t, ReasonFactsReceived, cond.Reason) - assert.Contains(t, cond.Message, `"default"`) + assert.Contains(t, cond.Message, `"shared"`) assert.Contains(t, cond.Message, "arrived at", "the message carries the time of the first fact") } -// TestClusterProviderAuditFacts_FactOnAnotherRouteDoesNotLatch pins the partition: the route is +// TestKubeProviderAuditFacts_FactOnAnotherRouteDoesNotLatch pins the partition: the route is // what carries the facts, so traffic on a route this provider does not read proves nothing. -func TestClusterProviderAuditFacts_FactOnAnotherRouteDoesNotLatch(t *testing.T) { - provider := clusterProviderWithKubeConfig("srcns-delegating", "", "") +func TestKubeProviderAuditFacts_FactOnAnotherRouteDoesNotLatch(t *testing.T) { + provider := inClusterKubeProvider("srcns-delegating") health := &attribution.RouteHealth{} health.RecordAuditDelivery() - health.RecordFactPublished("default") + health.RecordFactPublished("shared") - _, got := reconcileClusterProviderOnce(t, provider, health) + _, got := reconcileKubeProviderOnce(t, provider, health) - cond := findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + cond := findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived) require.NotNil(t, cond) assert.Equal(t, metav1.ConditionUnknown, cond.Status) assert.Equal(t, ReasonRouteUnused, cond.Reason) } -// TestClusterProviderAuditFacts_NamesWhichSilenceItIsIn is the whole point of splitting the reason: +// TestKubeProviderAuditFacts_NamesWhichSilenceItIsIn is the whole point of splitting the reason: // three silences that look identical on the object send the reader to three different places. The // status stays Unknown for all three — a pipeline-wide fault must not become a per-provider // verdict — so the reason is what carries the diagnosis. -func TestClusterProviderAuditFacts_NamesWhichSilenceItIsIn(t *testing.T) { +func TestKubeProviderAuditFacts_NamesWhichSilenceItIsIn(t *testing.T) { tests := []struct { name string arrange func(h *attribution.RouteHealth) @@ -166,7 +164,7 @@ func TestClusterProviderAuditFacts_NamesWhichSilenceItIsIn(t *testing.T) { name: "audit is arriving and has never carried this route", arrange: func(h *attribution.RouteHealth) { h.RecordAuditDelivery() }, wantReason: ReasonRouteUnused, - wantMessage: []string{"audit requests are arriving", "/audit-webhook/srcns-delegating"}, + wantMessage: []string{"audit requests are arriving", "/audit-webhook/team-a.srcns-delegating"}, }, } for _, tt := range tests { @@ -174,10 +172,10 @@ func TestClusterProviderAuditFacts_NamesWhichSilenceItIsIn(t *testing.T) { health := &attribution.RouteHealth{} tt.arrange(health) - _, got := reconcileClusterProviderOnce(t, - clusterProviderWithKubeConfig("srcns-delegating", "", ""), health) + _, got := reconcileKubeProviderOnce(t, + inClusterKubeProvider("srcns-delegating"), health) - cond := findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + cond := findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived) require.NotNil(t, cond) assert.Equal(t, metav1.ConditionUnknown, cond.Status, "a pipeline-wide fault is still not this provider's verdict") @@ -194,45 +192,45 @@ func TestClusterProviderAuditFacts_NamesWhichSilenceItIsIn(t *testing.T) { } } -// TestClusterProviderAuditFacts_TransportRecoveryClearsTheReason pins that the transport flag +// TestKubeProviderAuditFacts_TransportRecoveryClearsTheReason pins that the transport flag // tracks the CURRENT state rather than accumulating history: a successful append after a failure // puts the provider back to the route diagnosis. -func TestClusterProviderAuditFacts_TransportRecoveryClearsTheReason(t *testing.T) { +func TestKubeProviderAuditFacts_TransportRecoveryClearsTheReason(t *testing.T) { health := &attribution.RouteHealth{} health.RecordAuditDelivery() health.RecordPublishFailure() - r, got := reconcileClusterProviderOnce(t, - clusterProviderWithKubeConfig("srcns-delegating", "", ""), health) + r, got := reconcileKubeProviderOnce(t, + inClusterKubeProvider("srcns-delegating"), health) require.Equal(t, ReasonTransportUnavailable, - findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived).Reason) + findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived).Reason) // A publish landing on some OTHER route is still proof the transport accepts writes. - health.RecordFactPublished("default") + health.RecordFactPublished("shared") _, err := r.Reconcile(context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: "srcns-delegating"}}) + ctrl.Request{NamespacedName: k8stypes.NamespacedName{Namespace: kpNamespace, Name: "srcns-delegating"}}) require.NoError(t, err) - var after configbutleraiv1alpha3.ClusterProvider + var after configbutleraiv1alpha3.KubeProvider require.NoError(t, r.Get(context.Background(), - k8stypes.NamespacedName{Name: "srcns-delegating"}, &after)) + k8stypes.NamespacedName{Namespace: kpNamespace, Name: "srcns-delegating"}, &after)) assert.Equal(t, ReasonRouteUnused, - findCondition(after.Status.Conditions, ClusterProviderConditionAuditFactsReceived).Reason) + findCondition(after.Status.Conditions, KubeProviderConditionAuditFactsReceived).Reason) } -// TestClusterProviderAuditFacts_NeverRegresses is the latch itself, in the two shapes that would +// TestKubeProviderAuditFacts_NeverRegresses is the latch itself, in the two shapes that would // otherwise break it: later silence on a live route, and a restart that empties the in-process // registry entirely (which is also every reconcile under the memory transport). The PERSISTED // status is the latch, so a reconcile that finds True keeps True — message and transition time // included. -func TestClusterProviderAuditFacts_NeverRegresses(t *testing.T) { - provider := clusterProviderWithKubeConfig("srcns-delegating", "", "") +func TestKubeProviderAuditFacts_NeverRegresses(t *testing.T) { + provider := inClusterKubeProvider("srcns-delegating") health := &attribution.RouteHealth{} - health.RecordFactPublished("srcns-delegating") + health.RecordFactPublished("team-a.srcns-delegating") - r, latched := reconcileClusterProviderOnce(t, provider, health) - first := findCondition(latched.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + r, latched := reconcileKubeProviderOnce(t, provider, health) + first := findCondition(latched.Status.Conditions, KubeProviderConditionAuditFactsReceived) require.NotNil(t, first) require.Equal(t, metav1.ConditionTrue, first.Status) @@ -246,13 +244,13 @@ func TestClusterProviderAuditFacts_NeverRegresses(t *testing.T) { t.Run(tc.name, func(t *testing.T) { r.AuditFacts = tc.facts _, err := r.Reconcile(context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: provider.Name}}) + ctrl.Request{NamespacedName: provider.Key()}) require.NoError(t, err) - var got configbutleraiv1alpha3.ClusterProvider + var got configbutleraiv1alpha3.KubeProvider require.NoError(t, r.Get(context.Background(), - k8stypes.NamespacedName{Name: provider.Name}, &got)) - cond := findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + provider.Key(), &got)) + cond := findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived) require.NotNil(t, cond) assert.Equal(t, metav1.ConditionTrue, cond.Status, "silence after proof is a quiet cluster") assert.Equal(t, ReasonFactsReceived, cond.Reason) @@ -262,72 +260,74 @@ func TestClusterProviderAuditFacts_NeverRegresses(t *testing.T) { } } -// TestClusterProviderAuditFacts_AbsentWhenAttributionIsOff covers --author-attribution=false: no +// TestKubeProviderAuditFacts_AbsentWhenAttributionIsOff covers --author-attribution=false: no // fact was ever expected, so the provider makes no claim at all rather than an Unknown that reads // as a fault. A latch stored while attribution was on is removed, not left standing. -func TestClusterProviderAuditFacts_AbsentWhenAttributionIsOff(t *testing.T) { - provider := clusterProviderWithKubeConfig("srcns-delegating", "", "") +func TestKubeProviderAuditFacts_AbsentWhenAttributionIsOff(t *testing.T) { + provider := inClusterKubeProvider("srcns-delegating") provider.Status.Conditions = []metav1.Condition{{ - Type: ClusterProviderConditionAuditFactsReceived, + Type: KubeProviderConditionAuditFactsReceived, Status: metav1.ConditionTrue, Reason: ReasonFactsReceived, - Message: "an attribution fact has arrived on audit route \"srcns-delegating\"", + Message: "an attribution fact has arrived on audit route \"team-a.srcns-delegating\"", LastTransitionTime: metav1.Now(), }} - _, got := reconcileClusterProviderOnce(t, provider, nil) + _, got := reconcileKubeProviderOnce(t, provider, nil) - assert.Nil(t, findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived), + assert.Nil(t, findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived), "with attribution disabled the condition is absent, not Unknown") assert.Equal(t, metav1.ConditionTrue, findCondition(got.Status.Conditions, ConditionTypeReady).Status) } -// TestAuditRouteToClusterProviders maps a first-fact notification to the providers that READ that -// route: the one that declared it and the one that defaults to its own name. A provider on another -// route is not woken. -func TestAuditRouteToClusterProviders(t *testing.T) { - declaring := clusterProviderWithKubeConfig("srcns-delegating", "", "") - declaring.Spec.Attribution = &configbutleraiv1alpha3.ClusterProviderAttribution{AuditRoute: "default"} - byName := clusterProviderWithKubeConfig("default", "", "") - elsewhere := clusterProviderWithKubeConfig("prod-eu-1", "", "") +// TestAuditRouteToKubeProviders maps a first-fact notification to the providers that READ that +// route: the one that declared it and the one whose default (.) it is. A provider +// on another route is not woken. +func TestAuditRouteToKubeProviders(t *testing.T) { + declaring := inClusterKubeProvider("srcns-delegating") + declaring.Spec.Attribution = &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: "team-b.edge"} + byDefault := inClusterKubeProvider("edge") + byDefault.Namespace = "team-b" + elsewhere := inClusterKubeProvider("prod-eu-1") + elsewhere.Namespace = "team-b" cl := fake.NewClientBuilder(). WithScheme(scScheme(t)). - WithObjects(declaring, byName, elsewhere). + WithObjects(declaring, byDefault, elsewhere). Build() - r := &ClusterProviderReconciler{Client: cl} + r := &KubeProviderReconciler{Client: cl} - requests := r.auditRouteToClusterProviders(context.Background(), - &configbutleraiv1alpha3.ClusterProvider{ObjectMeta: metav1.ObjectMeta{Name: "default"}}) + requests := r.auditRouteToKubeProviders(context.Background(), + &configbutleraiv1alpha3.KubeProvider{ObjectMeta: metav1.ObjectMeta{Name: "team-b.edge"}}) - names := make([]string, 0, len(requests)) + keys := make([]k8stypes.NamespacedName, 0, len(requests)) for _, req := range requests { - names = append(names, req.Name) + keys = append(keys, req.NamespacedName) } - assert.ElementsMatch(t, []string{"srcns-delegating", "default"}, names) + assert.ElementsMatch(t, []k8stypes.NamespacedName{declaring.Key(), byDefault.Key()}, keys) - assert.Empty(t, r.auditRouteToClusterProviders(context.Background(), - &configbutleraiv1alpha3.ClusterProvider{}), "an event with no route names nothing") + assert.Empty(t, r.auditRouteToKubeProviders(context.Background(), + &configbutleraiv1alpha3.KubeProvider{}), "an event with no route names nothing") } -// TestClusterProviderAuditFacts_RouteChangeStartsTheLatchOver is the one case where carrying the +// TestKubeProviderAuditFacts_RouteChangeStartsTheLatchOver is the one case where carrying the // latch forward would recreate the exact failure this condition exists to catch. -// spec.attribution.auditRoute is MUTABLE — only spec.kubeConfig is pinned — so a provider that +// spec.attribution.auditRoute is MUTABLE — only the credential is pinned — so a provider that // earned True on route A and is then repointed at route B would keep claiming facts it has never // received on B, which is a silent misconfiguration reporting itself as healthy. // // The latch is one-way for a GIVEN route, not for the object. -func TestClusterProviderAuditFacts_RouteChangeStartsTheLatchOver(t *testing.T) { - provider := clusterProviderWithKubeConfig("srcns-delegating", "", "") - provider.Spec.Attribution = &configbutleraiv1alpha3.ClusterProviderAttribution{AuditRoute: "old"} +func TestKubeProviderAuditFacts_RouteChangeStartsTheLatchOver(t *testing.T) { + provider := inClusterKubeProvider("srcns-delegating") + provider.Spec.Attribution = &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: "old"} health := &attribution.RouteHealth{} health.RecordAuditDelivery() health.RecordFactPublished("old") - r, latched := reconcileClusterProviderOnce(t, provider, health) + r, latched := reconcileKubeProviderOnce(t, provider, health) require.Equal(t, metav1.ConditionTrue, - findCondition(latched.Status.Conditions, ClusterProviderConditionAuditFactsReceived).Status) + findCondition(latched.Status.Conditions, KubeProviderConditionAuditFactsReceived).Status) assert.Equal(t, "old", latched.Status.AuditRoute, "the status records which route the latch was earned on") // Repoint the provider at a route nothing has ever posted under. @@ -335,14 +335,14 @@ func TestClusterProviderAuditFacts_RouteChangeStartsTheLatchOver(t *testing.T) { require.NoError(t, r.Update(context.Background(), &latched)) _, err := r.Reconcile(context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: provider.Name}}) + ctrl.Request{NamespacedName: provider.Key()}) require.NoError(t, err) - var got configbutleraiv1alpha3.ClusterProvider + var got configbutleraiv1alpha3.KubeProvider require.NoError(t, r.Get(context.Background(), - k8stypes.NamespacedName{Name: provider.Name}, &got)) + provider.Key(), &got)) - cond := findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived) + cond := findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived) require.NotNil(t, cond) assert.Equal(t, metav1.ConditionUnknown, cond.Status, "proof that one route delivered says nothing about another") @@ -353,10 +353,10 @@ func TestClusterProviderAuditFacts_RouteChangeStartsTheLatchOver(t *testing.T) { // And a fact on the NEW route latches it again, from scratch. health.RecordFactPublished("new") _, err = r.Reconcile(context.Background(), - ctrl.Request{NamespacedName: k8stypes.NamespacedName{Name: provider.Name}}) + ctrl.Request{NamespacedName: provider.Key()}) require.NoError(t, err) require.NoError(t, r.Get(context.Background(), - k8stypes.NamespacedName{Name: provider.Name}, &got)) + provider.Key(), &got)) assert.Equal(t, metav1.ConditionTrue, - findCondition(got.Status.Conditions, ClusterProviderConditionAuditFactsReceived).Status) + findCondition(got.Status.Conditions, KubeProviderConditionAuditFactsReceived).Status) } diff --git a/internal/controller/kubeprovider_controller.go b/internal/controller/kubeprovider_controller.go new file mode 100644 index 000000000..4e0688fca --- /dev/null +++ b/internal/controller/kubeprovider_controller.go @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + "sync" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/go-logr/logr" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" + "github.com/ConfigButler/gitops-reverser/internal/telemetry" +) + +// KubeProviderReconciler reconciles a KubeProvider object. It is the read-side peer of the +// GitProviderReconciler: it validates the cluster's credential without dialing, reports whether the +// provider may be used at all (the installation's inCluster consent, and its audit route), and owns +// the per-cluster status the watch engine and GitTargets project from. +// +// It takes no finalizer: nothing has to happen before a KubeProvider goes away. Attribution facts +// are keyed by (audit route, group/resource, uid, resourceVersion) and expire on their own, and +// several providers may share one route, so there is nothing to purge per object. +type KubeProviderReconciler struct { + client.Client + + Scheme *runtime.Scheme + + // KubeConfigSafety gates exec-auth and insecure-TLS kubeconfigs (reject-not-strip) and lists the + // providers allowed to use spec.inCluster, matching what the watch engine's resolver enforces, + // so Validated agrees with what a watch would use. + KubeConfigSafety kubeconfig.SafetyPolicy + + // AuditFacts is the per-audit-route fact registry the AuditFactsReceived condition latches on. + // Nil means the operator runs with author attribution disabled, and the condition is then + // absent rather than Unknown. + AuditFacts AuditRouteFacts + + firsts kubeProviderLogFirsts + + // Recorder emits a Kubernetes Event on every persisted Ready transition; nil disables Events. + Recorder record.EventRecorder +} + +// kubeProviderLogFirsts keeps startup progress visible without turning every routine +// revalidation into default-level log noise. +type kubeProviderLogFirsts struct { + validationSuccess sync.Once +} + +// This reconciler reads providers and writes their status. It never creates, updates or deletes +// one, so it takes none of those verbs. +// +kubebuilder:rbac:groups=configbutler.ai,resources=kubeproviders,verbs=get;list;watch +// +kubebuilder:rbac:groups=configbutler.ai,resources=kubeproviders/status,verbs=get;update;patch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// Reconcile validates a KubeProvider's inputs and updates its status. +func (r *KubeProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx).WithName("KubeProviderReconciler") + log.V(1).Info("Starting reconciliation", "namespacedName", req.NamespacedName) + + var provider configbutleraiv1alpha3.KubeProvider + if err := r.Get(ctx, req.NamespacedName, &provider); err != nil { + if client.IgnoreNotFound(err) == nil { + log.Info("KubeProvider not found, was likely deleted", "kubeProvider", req.NamespacedName) + // The object is gone, so its condition series must stop rather than latch its last + // value: a gauge that keeps reporting Ready=False for a deleted object is a permanent + // false positive that trains people to ignore the panel. + telemetry.ForgetResourceConditions(conditionKindKubeProvider, req.Namespace, req.Name) + return ctrl.Result{}, nil + } + log.Error(err, "unable to fetch KubeProvider", "kubeProvider", req.NamespacedName) + return ctrl.Result{}, err + } + if !provider.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + return r.reconcileKubeProvider(ctx, log, &provider) +} + +// reconcileKubeProvider performs the main validation logic. +func (r *KubeProviderReconciler) reconcileKubeProvider( + ctx context.Context, + log logr.Logger, + provider *configbutleraiv1alpha3.KubeProvider, +) (ctrl.Result, error) { + log.V(1).Info("Validating KubeProvider", + "kubeProvider", provider.Key(), + "inCluster", provider.IsInCluster(), + "generation", provider.Generation) + + st := beginStatus(r.Client, r.Recorder, provider) + + valid, reason, message, err := r.validateCredential(ctx, provider) + if err != nil { + log.Error(err, "failed to read KubeProvider kubeconfig Secret", "kubeProvider", provider.Key()) + return ctrl.Result{}, err + } + route, err := authz.AuditRouteHeld(ctx, r.Client, provider) + if err != nil { + return ctrl.Result{}, err + } + + validated := metav1.ConditionTrue + rd := newReadiness(message, "KubeProvider is not stalled") + if !valid { + validated = metav1.ConditionFalse + rd.stalled(reason, message) + } + // A route another namespace holds says nothing about the credential, so Validated stays what + // the credential earned; the provider is still not used, which is a stall a human must fix. + if !route.Allowed { + rd.stalled(route.Reason, route.Message) + } + st.set(KubeProviderConditionValidated, validated, reason, message) + // Written after the readiness trio and deliberately not contributing to it: an audit route + // nobody posts under costs the commit AUTHOR, never the mirror, so it must not make a working + // provider look broken to a kstatus reader. + r.applyAuditFactsCondition(st, provider) + st.applyReadiness(rd) + + // A failed status write is a real failure, not a verdict: propagate it so the provider is + // retried rather than left reporting a stale status for a whole steady interval. + if err := st.commit(ctx); err != nil { + log.Error(err, "failed to update KubeProvider status", "kubeProvider", provider.Key()) + return ctrl.Result{}, err + } + + if valid && route.Allowed { + r.firsts.validationSuccess.Do(func() { + log.Info("First KubeProvider validation completed successfully", "kubeProvider", provider.Key()) + }) + } + return ctrl.Result{RequeueAfter: st.requeueAfter(RequeueSteadyInterval)}, nil +} + +// validateCredential is the legibility gate for the provider's credential (feeds Validated). For +// spec.inCluster it is the installation's consent; for spec.secretRef it reads and parses the +// kubeconfig Secret from THE PROVIDER'S NAMESPACE and applies the safety policy. It deliberately +// does NOT dial the cluster — reachability is a runtime signal the watch engine records. It returns +// ok=false with a typed reason and a legible message when an input is wrong; a non-NotFound read +// error is returned as err so the reconcile requeues rather than falsely reporting a bad input. +func (r *KubeProviderReconciler) validateCredential( + ctx context.Context, + provider *configbutleraiv1alpha3.KubeProvider, +) (bool, string, string, error) { + if provider.IsInCluster() { + if rej := kubeconfig.CheckInCluster(provider.Key(), r.KubeConfigSafety); rej != nil { + // A verdict, not a reconcile error, as for the kubeconfig checks below. + //nolint:nilerr + return false, rej.Reason, rej.Message, nil + } + return true, ReasonInCluster, + "spec.inCluster: the operator's own identity, allowed by --in-cluster-kube-providers", nil + } + if provider.Spec.SecretRef == nil { + // The schema requires exactly one arm; this is a hand-built object in a test or a server + // that does not enforce the schema. + return false, ReasonKubeConfigInvalid, "set exactly one of spec.secretRef and spec.inCluster", nil + } + ref := provider.Spec.SecretRef + secretKey := k8stypes.NamespacedName{Namespace: provider.Namespace, Name: ref.Name} + + var secret corev1.Secret + if getErr := r.Get(ctx, secretKey, &secret); getErr != nil { + if apierrors.IsNotFound(getErr) { + return false, kubeconfig.ReasonSecretNotFound, fmt.Sprintf( + "spec.secretRef names Secret %s, which does not exist in the KubeProvider's namespace", + secretKey), nil + } + return false, "", "", fmt.Errorf("read kubeconfig Secret %s: %w", secretKey, getErr) + } + + raw, usedKey, present := kubeconfig.ResolveKey(secret.Data, ref.Key) + if !present { + return false, kubeconfig.ReasonKeyNotFound, fmt.Sprintf( + "kubeconfig Secret %s has no kubeconfig under key %q (set spec.secretRef.key)", + secretKey, describeKubeConfigKey(ref.Key)), nil + } + if rej := kubeconfig.Check(raw, r.KubeConfigSafety); rej != nil { + // A RejectionError is a validation VERDICT, not a reconcile error: surfaced as the + // Validated=False reason/message, reconcile requeues normally (err == nil). + //nolint:nilerr + return false, rej.Reason, fmt.Sprintf("kubeconfig Secret %s key %q: %s", secretKey, usedKey, rej.Message), nil + } + return true, ReasonSucceeded, fmt.Sprintf("kubeconfig Secret %s validated", secretKey), nil +} + +// kubeProviderReconcilePredicate admits spec changes only. Status writes, including this +// controller's own, must not loop back into a reconcile. +func kubeProviderReconcilePredicate() predicate.Predicate { + return predicate.GenerationChangedPredicate{} +} + +// SetupWithManager sets up the controller with the Manager. +func (r *KubeProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { + b := ctrl.NewControllerManagedBy(mgr). + For( + &configbutleraiv1alpha3.KubeProvider{}, + builder.WithPredicates(kubeProviderReconcilePredicate()), + ). + // The wake path for AuditRouteConflict. A provider stalled on its route clears only when + // another provider changes: the holder is deleted, or moves to another route. Without this + // it would wait out its steady requeue, five minutes, before it noticed. + Watches( + &configbutleraiv1alpha3.KubeProvider{}, + auditRouteSiblingsHandler(r.Client), + ). + Named("kubeprovider") + + // React to the FIRST attribution fact on an audit route so AuditFactsReceived latches True + // within seconds instead of on the provider's next periodic requeue, which for a converged + // provider is RequeueSteadyInterval. The event names the route; the map fans it out to every + // provider reading it. + if r.AuditFacts != nil { + if events := r.AuditFacts.FirstFactEvents(); events != nil { + b = b.WatchesRawSource(source.Channel( + events, + handler.EnqueueRequestsFromMapFunc(r.auditRouteToKubeProviders), + )) + } + } + + return b.Complete(r) +} + +// auditRouteSiblingsHandler enqueues, for a provider created, deleted or moved to another route, +// every OTHER provider that carries the route it held or now holds: each of them may have just won +// or lost it. An update that keeps the route enqueues nothing, so status writes cost no fan-out. +func auditRouteSiblingsHandler(reader client.Reader) handler.EventHandler { + enqueueRoute := func( + ctx context.Context, q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request], + self client.Object, route string, + ) { + var providers configbutleraiv1alpha3.KubeProviderList + if err := reader.List(ctx, &providers); err != nil { + return + } + for i := range providers.Items { + p := &providers.Items[i] + if p.AuditRoute() != route || (p.Namespace == self.GetNamespace() && p.Name == self.GetName()) { + continue + } + q.Add(ctrlreconcile.Request{NamespacedName: p.Key()}) + } + } + routeOf := func(obj client.Object) (string, bool) { + p, ok := obj.(*configbutleraiv1alpha3.KubeProvider) + if !ok { + return "", false + } + return p.AuditRoute(), true + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, + q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + if route, ok := routeOf(e.Object); ok { + enqueueRoute(ctx, q, e.Object, route) + } + }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, + q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + if route, ok := routeOf(e.Object); ok { + enqueueRoute(ctx, q, e.Object, route) + } + }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, + q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + oldRoute, okOld := routeOf(e.ObjectOld) + newRoute, okNew := routeOf(e.ObjectNew) + if !okOld || !okNew || oldRoute == newRoute { + return + } + enqueueRoute(ctx, q, e.ObjectNew, oldRoute) + enqueueRoute(ctx, q, e.ObjectNew, newRoute) + }, + } +} diff --git a/internal/controller/kubeprovider_controller_test.go b/internal/controller/kubeprovider_controller_test.go new file mode 100644 index 000000000..e2250ef6d --- /dev/null +++ b/internal/controller/kubeprovider_controller_test.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "time" + + meta "github.com/fluxcd/pkg/apis/meta" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" +) + +var _ = Describe("KubeProvider Controller", func() { + const timeout = 10 * time.Second + const interval = 200 * time.Millisecond + + condition := func(key types.NamespacedName, conditionType string) func() *metav1.Condition { + return func() *metav1.Condition { + var got configbutleraiv1alpha3.KubeProvider + if err := k8sClient.Get(context.Background(), key, &got); err != nil { + return nil + } + return findCondition(got.Status.Conditions, conditionType) + } + } + readyStatus := func(key types.NamespacedName) func() metav1.ConditionStatus { + return func() metav1.ConditionStatus { + c := condition(key, ConditionTypeReady)() + if c == nil { + return metav1.ConditionUnknown + } + return c.Status + } + } + reasonOf := func(key types.NamespacedName, conditionType string) func() string { + return func() string { + c := condition(key, conditionType)() + if c == nil { + return "" + } + return c.Reason + } + } + create := func(obj client.Object) { + Expect(k8sClient.Create(context.Background(), obj)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } + + It("validates the suite's listed inCluster provider and goes Ready", func() { + key := types.NamespacedName{Namespace: testKubeProviderNamespace, Name: testKubeProviderName} + Eventually(readyStatus(key), timeout, interval).Should(Equal(metav1.ConditionTrue)) + Expect(reasonOf(key, KubeProviderConditionValidated)()).To(Equal(ReasonInCluster)) + }) + + It("stalls an inCluster provider the installation does not list", func() { + provider := &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "unlisted"}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + }, + } + create(provider) + key := provider.Key() + + Eventually(reasonOf(key, ConditionTypeStalled), timeout, interval). + Should(Equal(kubeconfig.ReasonInClusterNotAuthorized)) + Expect(condition(key, KubeProviderConditionValidated)().Status).To(Equal(metav1.ConditionFalse)) + Expect(readyStatus(key)()).To(Equal(metav1.ConditionFalse)) + }) + + It("validates a remote provider whose kubeconfig Secret is in its own namespace", func() { + create(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "prod-eu-kc"}, + Data: map[string][]byte{"value": []byte(scValidKubeConfig)}, + }) + provider := &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "prod-eu-1"}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "prod-eu-kc"}, + }, + } + create(provider) + + Eventually(readyStatus(provider.Key()), timeout, interval).Should(Equal(metav1.ConditionTrue)) + }) + + It("stalls a remote provider whose kubeconfig Secret is missing", func() { + provider := &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "prod-us-1"}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "absent-kc"}, + }, + } + create(provider) + key := provider.Key() + + Eventually(readyStatus(key), timeout, interval).Should(Equal(metav1.ConditionFalse)) + Expect(reasonOf(key, KubeProviderConditionValidated)()).To(Equal(kubeconfig.ReasonSecretNotFound)) + Expect(condition(key, ConditionTypeStalled)().Status).To(Equal(metav1.ConditionTrue)) + }) + + It("holds the credential to exactly one immutable arm", func() { + ctx := context.Background() + spec := func(name string, s configbutleraiv1alpha3.KubeProviderSpec) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name}, Spec: s, + } + } + qps := int32(5) + + Expect(k8sClient.Create(ctx, spec("no-arm", configbutleraiv1alpha3.KubeProviderSpec{}))). + To(MatchError(ContainSubstring("set exactly one of spec.secretRef and spec.inCluster"))) + Expect(k8sClient.Create(ctx, spec("both-arms", configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + }))).To(MatchError(ContainSubstring("set exactly one of spec.secretRef and spec.inCluster"))) + Expect(k8sClient.Create(ctx, spec("throttled-in-cluster", configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + Client: &configbutleraiv1alpha3.KubeProviderClient{QPS: &qps}, + }))).To(MatchError(ContainSubstring("spec.client is refused beside spec.inCluster"))) + + provider := spec("immutable-credential", configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + }) + create(provider) + Eventually(func() error { + current := &configbutleraiv1alpha3.KubeProvider{} + if err := k8sClient.Get(ctx, provider.Key(), current); err != nil { + return err + } + current.Spec.SecretRef.Name = "other-kc" + return k8sClient.Update(ctx, current) + }, timeout, interval).Should(MatchError(ContainSubstring("spec.secretRef and spec.inCluster are immutable"))) + }) + + It("stalls the younger of two providers in different namespaces on one route, until the holder goes", func() { + // Through the real manager this also drives auditRouteSiblingsHandler: deleting the holder + // is the only event that can clear the conflict, and the sibling watch is what delivers it + // rather than the five-minute steady requeue. + const route = "envtest-shared-route" + otherNS := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kp-route-conflict"}} + create(otherNS) + + listed := func(namespace, name string) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "route-kc"}, + Attribution: &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: route}, + }, + } + } + for _, ns := range []string{"default", otherNS.Name} { + create(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "route-kc"}, + Data: map[string][]byte{"value": []byte(scValidKubeConfig)}, + }) + } + + holder := listed("default", "route-holder") + Expect(k8sClient.Create(context.Background(), holder)).To(Succeed()) + Eventually(readyStatus(holder.Key()), timeout, interval).Should(Equal(metav1.ConditionTrue)) + // creationTimestamp has one-second resolution: make the second provider unambiguously + // younger so the oldest-carrier rule, not the UID tie-break, decides. + time.Sleep(1100 * time.Millisecond) + + younger := listed(otherNS.Name, "route-loser") + create(younger) + Eventually(reasonOf(younger.Key(), ConditionTypeStalled), timeout, interval). + Should(Equal(authz.ReasonAuditRouteConflict)) + Expect(condition(younger.Key(), KubeProviderConditionValidated)().Status).To(Equal(metav1.ConditionTrue)) + Consistently(readyStatus(holder.Key()), time.Second, interval).Should(Equal(metav1.ConditionTrue)) + + Expect(k8sClient.Delete(context.Background(), holder)).To(Succeed()) + Eventually(readyStatus(younger.Key()), timeout, interval).Should(Equal(metav1.ConditionTrue)) + }) +}) diff --git a/internal/controller/kubeprovider_controller_unit_test.go b/internal/controller/kubeprovider_controller_unit_test.go new file mode 100644 index 000000000..4abc19209 --- /dev/null +++ b/internal/controller/kubeprovider_controller_unit_test.go @@ -0,0 +1,555 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "errors" + "testing" + "time" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/event" + ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" +) + +// kpNamespace is the namespace the KubeProvider fixtures below live in. +const kpNamespace = "team-a" + +// inClusterKubeProvider is a KubeProvider in kpNamespace that asks for the operator's own identity. +// It is honored only by a reconciler whose KubeConfigSafety lists it (allowInCluster). +func inClusterKubeProvider(name string) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: kpNamespace}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}}, + } +} + +// remoteKubeProvider is the KubeProvider kpNamespace/prod-eu-1 whose kubeconfig Secret, in that +// same namespace, is secretName. +func remoteKubeProvider(secretName, key string) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1", Namespace: kpNamespace}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: secretName, Key: key}, + }, + } +} + +// allowInCluster is the installation's --in-cluster-kube-providers listing exactly providers. +func allowInCluster(providers ...*configbutleraiv1alpha3.KubeProvider) kubeconfig.SafetyPolicy { + allowed := map[k8stypes.NamespacedName]struct{}{} + for _, p := range providers { + allowed[p.Key()] = struct{}{} + } + return kubeconfig.SafetyPolicy{InClusterProviders: allowed} +} + +// kubeconfigSecret is the Secret "kc" in namespace holding data. +func kubeconfigSecret(namespace string, data map[string][]byte) *corev1.Secret { + return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "kc"}, Data: data} +} + +func TestValidateCredential_AllScenarios(t *testing.T) { + listed := inClusterKubeProvider("local") + tests := []struct { + name string + provider *configbutleraiv1alpha3.KubeProvider + secret *corev1.Secret + safety kubeconfig.SafetyPolicy + wantOK bool + wantReason string + }{ + { + name: "inCluster listed by the installation is valid", + provider: listed, + safety: allowInCluster(listed), + wantOK: true, wantReason: ReasonInCluster, + }, + { + name: "inCluster the installation lists none of is refused", + provider: inClusterKubeProvider("local"), + wantOK: false, wantReason: kubeconfig.ReasonInClusterNotAuthorized, + }, + { + name: "inCluster under another name than the one listed is refused", + provider: inClusterKubeProvider("sneaky"), + safety: allowInCluster(listed), + wantOK: false, wantReason: kubeconfig.ReasonInClusterNotAuthorized, + }, + { + name: "neither arm set is invalid", + provider: &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: kpNamespace}, + }, + wantOK: false, + wantReason: ReasonKubeConfigInvalid, + }, + { + name: "missing Secret", + provider: remoteKubeProvider("absent", ""), + wantOK: false, wantReason: kubeconfig.ReasonSecretNotFound, + }, + { + // The Secret is read from the provider's OWN namespace, never the operator's or any + // other: a provider must not be able to wield a credential its namespace does not hold. + name: "Secret only in another namespace is not found", + provider: remoteKubeProvider("kc", ""), + secret: kubeconfigSecret("gitops-reverser-system", + map[string][]byte{"value": []byte(scValidKubeConfig)}), + wantOK: false, wantReason: kubeconfig.ReasonSecretNotFound, + }, + { + name: "missing key", + provider: remoteKubeProvider("kc", "value"), + secret: kubeconfigSecret(kpNamespace, map[string][]byte{"elsewhere": []byte(scValidKubeConfig)}), + wantOK: false, + wantReason: kubeconfig.ReasonKeyNotFound, + }, + { + name: "unparseable", + provider: remoteKubeProvider("kc", ""), + secret: kubeconfigSecret(kpNamespace, map[string][]byte{"value": []byte("not a kubeconfig")}), + wantOK: false, + wantReason: kubeconfig.ReasonInvalid, + }, + { + name: "exec rejected", + provider: remoteKubeProvider("kc", ""), + secret: kubeconfigSecret(kpNamespace, map[string][]byte{"value": []byte(scExecKubeConfig)}), + wantOK: false, + wantReason: kubeconfig.ReasonExecNotAllowed, + }, + { + name: "insecure TLS rejected", + provider: remoteKubeProvider("kc", ""), + secret: kubeconfigSecret(kpNamespace, map[string][]byte{"value": []byte(scInsecureKubeConfig)}), + wantOK: false, + wantReason: kubeconfig.ReasonInsecureTLSNotAllowed, + }, + { + name: "valid via value.yaml fallback in the provider's namespace", + provider: remoteKubeProvider("kc", ""), + secret: kubeconfigSecret(kpNamespace, map[string][]byte{"value.yaml": []byte(scValidKubeConfig)}), + wantOK: true, + wantReason: ReasonSucceeded, + }, + { + name: "exec allowed when opted in", + provider: remoteKubeProvider("kc", ""), + secret: kubeconfigSecret(kpNamespace, map[string][]byte{"value": []byte(scExecKubeConfig)}), + safety: kubeconfig.SafetyPolicy{AllowExec: true}, + wantOK: true, + wantReason: ReasonSucceeded, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + builder := fake.NewClientBuilder().WithScheme(scScheme(t)) + if tc.secret != nil { + builder = builder.WithObjects(tc.secret) + } + r := &KubeProviderReconciler{Client: builder.Build(), KubeConfigSafety: tc.safety} + ok, reason, msg, err := r.validateCredential(context.Background(), tc.provider) + require.NoError(t, err) + assert.Equal(t, tc.wantOK, ok) + assert.Equal(t, tc.wantReason, reason) + assert.NotEmpty(t, msg) + }) + } +} + +// TestValidateCredential_SecretReadErrorRequeues pins that a transient Secret read failure is an +// error, not a verdict: reporting it as SecretNotFound would flap the provider's status. +func TestValidateCredential_SecretReadErrorRequeues(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(_ context.Context, _ client.WithWatch, _ client.ObjectKey, _ client.Object, + _ ...client.GetOption) error { + return errors.New("api server unavailable") + }, + }).Build() + r := &KubeProviderReconciler{Client: cl} + ok, reason, _, err := r.validateCredential(context.Background(), remoteKubeProvider("kc", "")) + require.Error(t, err) + assert.False(t, ok) + assert.Empty(t, reason) +} + +// reconcileKubeProviders seeds a fake client with providers (and any extra objects), reconciles +// each provider once with safety, and returns the client to read the results from. +func reconcileKubeProviders( + t *testing.T, + safety kubeconfig.SafetyPolicy, + providers []*configbutleraiv1alpha3.KubeProvider, + extra ...client.Object, +) client.Client { + t.Helper() + objects := append([]client.Object{}, extra...) + for _, p := range providers { + objects = append(objects, p) + } + cl := fake.NewClientBuilder(). + WithScheme(scScheme(t)). + WithObjects(objects...). + WithStatusSubresource(&configbutleraiv1alpha3.KubeProvider{}). + Build() + r := &KubeProviderReconciler{Client: cl, KubeConfigSafety: safety} + for _, p := range providers { + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: p.Key()}) + require.NoError(t, err) + } + return cl +} + +func getKubeProvider(t *testing.T, cl client.Client, key k8stypes.NamespacedName) configbutleraiv1alpha3.KubeProvider { + t.Helper() + var got configbutleraiv1alpha3.KubeProvider + require.NoError(t, cl.Get(context.Background(), key, &got)) + return got +} + +// TestKubeProviderReconcile_InCluster checks the installation's consent decides an inCluster +// provider: listed in --in-cluster-kube-providers it validates and goes Ready; not listed it is +// Validated=False and Stalled with InClusterNotAuthorized, and is never used. +func TestKubeProviderReconcile_InCluster(t *testing.T) { + tests := []struct { + name string + allowed bool + wantValidated metav1.ConditionStatus + wantReason string + wantReady metav1.ConditionStatus + wantStalled metav1.ConditionStatus + }{ + { + name: "listed", allowed: true, + wantValidated: metav1.ConditionTrue, wantReason: ReasonInCluster, + wantReady: metav1.ConditionTrue, wantStalled: metav1.ConditionFalse, + }, + { + name: "not listed", allowed: false, + wantValidated: metav1.ConditionFalse, wantReason: kubeconfig.ReasonInClusterNotAuthorized, + wantReady: metav1.ConditionFalse, wantStalled: metav1.ConditionTrue, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider := inClusterKubeProvider("local") + safety := kubeconfig.SafetyPolicy{} + if tc.allowed { + safety = allowInCluster(provider) + } + cl := reconcileKubeProviders(t, safety, []*configbutleraiv1alpha3.KubeProvider{provider}) + + got := getKubeProvider(t, cl, provider.Key()) + validated := findCondition(got.Status.Conditions, KubeProviderConditionValidated) + require.NotNil(t, validated) + assert.Equal(t, tc.wantValidated, validated.Status) + assert.Equal(t, tc.wantReason, validated.Reason) + assert.Equal(t, tc.wantReady, findCondition(got.Status.Conditions, ConditionTypeReady).Status) + stalled := findCondition(got.Status.Conditions, ConditionTypeStalled) + assert.Equal(t, tc.wantStalled, stalled.Status) + if tc.wantStalled == metav1.ConditionTrue { + assert.Equal(t, tc.wantReason, stalled.Reason) + assert.Contains(t, stalled.Message, "--in-cluster-kube-providers") + } + assert.Equal(t, got.Generation, got.Status.ObservedGeneration) + }) + } +} + +// TestKubeProviderReconcile_SecretInOwnNamespace checks a secretRef provider reads its kubeconfig +// from its OWN namespace: present there it is Ready, present only in another namespace it is +// Validated=False with SecretNotFound. +func TestKubeProviderReconcile_SecretInOwnNamespace(t *testing.T) { + tests := []struct { + name string + secretNamespace string + wantValidated metav1.ConditionStatus + wantReason string + }{ + {"Secret in the provider's namespace", kpNamespace, metav1.ConditionTrue, ReasonSucceeded}, + {"Secret only in another namespace", "team-b", metav1.ConditionFalse, kubeconfig.ReasonSecretNotFound}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider := remoteKubeProvider("kc", "") + secret := kubeconfigSecret(tc.secretNamespace, map[string][]byte{"value": []byte(scValidKubeConfig)}) + cl := reconcileKubeProviders(t, kubeconfig.SafetyPolicy{}, + []*configbutleraiv1alpha3.KubeProvider{provider}, secret) + + got := getKubeProvider(t, cl, provider.Key()) + validated := findCondition(got.Status.Conditions, KubeProviderConditionValidated) + require.NotNil(t, validated) + assert.Equal(t, tc.wantValidated, validated.Status) + assert.Equal(t, tc.wantReason, validated.Reason) + assert.Equal(t, tc.wantValidated, findCondition(got.Status.Conditions, ConditionTypeReady).Status) + }) + } +} + +// TestKubeProviderReconcile_AuditRouteConflict covers two providers in different namespaces that +// spell out one route. The route names neither namespace, so the older provider's namespace holds +// it: the holder stays Ready, and the younger one is Stalled with AuditRouteConflict while its +// Validated keeps what its credential earned, because nothing is wrong with the credential. +func TestKubeProviderReconcile_AuditRouteConflict(t *testing.T) { + older := inClusterKubeProvider("first") + older.CreationTimestamp = metav1.NewTime(time.Now().Add(-time.Hour)) + older.Spec.Attribution = &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: "shared"} + younger := inClusterKubeProvider("second") + younger.Namespace = "team-b" + younger.CreationTimestamp = metav1.NewTime(time.Now()) + younger.Spec.Attribution = &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: "shared"} + + cl := reconcileKubeProviders(t, allowInCluster(older, younger), + []*configbutleraiv1alpha3.KubeProvider{older, younger}) + + holder := getKubeProvider(t, cl, older.Key()) + assert.Equal(t, metav1.ConditionTrue, findCondition(holder.Status.Conditions, ConditionTypeReady).Status) + assert.Equal(t, metav1.ConditionFalse, findCondition(holder.Status.Conditions, ConditionTypeStalled).Status) + + loser := getKubeProvider(t, cl, younger.Key()) + assert.Equal(t, metav1.ConditionTrue, + findCondition(loser.Status.Conditions, KubeProviderConditionValidated).Status, + "a route held elsewhere says nothing about the credential") + stalled := findCondition(loser.Status.Conditions, ConditionTypeStalled) + require.NotNil(t, stalled) + assert.Equal(t, metav1.ConditionTrue, stalled.Status) + assert.Equal(t, authz.ReasonAuditRouteConflict, stalled.Reason) + assert.Contains(t, stalled.Message, `"team-a"`, "the message names the namespace holding the route") + ready := findCondition(loser.Status.Conditions, ConditionTypeReady) + assert.Equal(t, metav1.ConditionFalse, ready.Status) + assert.Equal(t, authz.ReasonAuditRouteConflict, ready.Reason) +} + +// TestKubeProviderReconcile_AuditRouteListFails pins that a failed read of the other providers is +// an error the workqueue retries, never a verdict on the route. +func TestKubeProviderReconcile_AuditRouteListFails(t *testing.T) { + provider := inClusterKubeProvider("local") + cl := fake.NewClientBuilder(). + WithScheme(scScheme(t)). + WithObjects(provider). + WithStatusSubresource(&configbutleraiv1alpha3.KubeProvider{}). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(_ context.Context, _ client.WithWatch, _ client.ObjectList, _ ...client.ListOption) error { + return errors.New("list boom") + }, + }). + Build() + r := &KubeProviderReconciler{Client: cl, KubeConfigSafety: allowInCluster(provider)} + + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: provider.Key()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "list boom") +} + +// TestKubeProviderReconcile_TakesNoFinalizer pins the contract: this controller never makes a +// KubeProvider undeletable. Nothing has to happen before one goes away — attribution facts are +// keyed by (audit route, group/resource, uid, resourceVersion) and expire on their own, and several +// providers may share one route, so there is nothing to purge per object. +func TestKubeProviderReconcile_TakesNoFinalizer(t *testing.T) { + provider := inClusterKubeProvider("local") + cl := reconcileKubeProviders(t, allowInCluster(provider), []*configbutleraiv1alpha3.KubeProvider{provider}) + + got := getKubeProvider(t, cl, provider.Key()) + assert.Empty(t, got.Finalizers, "a KubeProvider must never be held by this controller") +} + +// TestKubeProviderReconcile_DeletingIsNoOp checks a provider already being deleted (held by some +// other controller's finalizer) gets no status write: it is on its way out. +func TestKubeProviderReconcile_DeletingIsNoOp(t *testing.T) { + provider := inClusterKubeProvider("local") + now := metav1.Now() + provider.DeletionTimestamp = &now + provider.Finalizers = []string{"example.com/elsewhere"} + cl := reconcileKubeProviders(t, allowInCluster(provider), []*configbutleraiv1alpha3.KubeProvider{provider}) + + got := getKubeProvider(t, cl, provider.Key()) + assert.Empty(t, got.Status.Conditions) +} + +// TestKubeProviderReconcile_InvalidStatusWriteFails checks that a failed status write on the +// INVALID path is propagated. The verdict itself (a bad kubeconfig) is not an error, but failing to +// record it is: without the error the provider would report a stale status for a whole steady +// interval before anything looked again. +func TestKubeProviderReconcile_InvalidStatusWriteFails(t *testing.T) { + provider := remoteKubeProvider("absent-kc", "") + cl := fake.NewClientBuilder(). + WithScheme(scScheme(t)). + WithObjects(provider). + WithStatusSubresource(&configbutleraiv1alpha3.KubeProvider{}). + WithInterceptorFuncs(interceptor.Funcs{ + SubResourcePatch: func( + _ context.Context, + _ client.Client, + _ string, + _ client.Object, + _ client.Patch, + _ ...client.SubResourcePatchOption, + ) error { + return errors.New("status write boom") + }, + }). + Build() + r := &KubeProviderReconciler{Client: cl} + + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: provider.Key()}) + require.Error(t, err, "a failed status write must requeue rather than report success") + assert.Contains(t, err.Error(), "status write boom") +} + +// TestKubeProviderReconcile_NotFound checks a deleted provider reconciles to a no-op. +func TestKubeProviderReconcile_NotFound(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scScheme(t)).Build() + r := &KubeProviderReconciler{Client: cl} + res, err := r.Reconcile(context.Background(), + ctrl.Request{NamespacedName: k8stypes.NamespacedName{Namespace: kpNamespace, Name: "gone"}}) + require.NoError(t, err) + assert.Zero(t, res.RequeueAfter) +} + +// TestKubeProviderReconcile_GetFails checks a non-NotFound read error is returned for a retry. +func TestKubeProviderReconcile_GetFails(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(_ context.Context, _ client.WithWatch, _ client.ObjectKey, _ client.Object, + _ ...client.GetOption) error { + return errors.New("get boom") + }, + }).Build() + r := &KubeProviderReconciler{Client: cl} + _, err := r.Reconcile(context.Background(), + ctrl.Request{NamespacedName: k8stypes.NamespacedName{Namespace: kpNamespace, Name: "local"}}) + require.Error(t, err) +} + +// TestReconcileStatusCommit_DeletedObject checks the shared status writer treats a vanished +// object as done rather than erroring: a reconcile that raced a delete must not fail the workqueue. +func TestReconcileStatusCommit_DeletedObject(t *testing.T) { + cl := fake.NewClientBuilder(). + WithScheme(scScheme(t)). + WithStatusSubresource(&configbutleraiv1alpha3.KubeProvider{}). + Build() + provider := inClusterKubeProvider("gone") + st := beginStatus(cl, nil, provider) + st.set(ConditionTypeReady, metav1.ConditionTrue, ReasonSucceeded, "gone but written") + + // The object was never created, so the patch returns NotFound -> treated as done, no error. + require.NoError(t, st.commit(context.Background())) +} + +// TestKubeProviderReconcile_RemoteInvalidKubeConfig checks a remote provider whose kubeconfig +// is missing goes Validated=False / Stalled, not Ready. +func TestKubeProviderReconcile_RemoteInvalidKubeConfig(t *testing.T) { + provider := remoteKubeProvider("absent-kc", "") + cl := reconcileKubeProviders(t, kubeconfig.SafetyPolicy{}, []*configbutleraiv1alpha3.KubeProvider{provider}) + + got := getKubeProvider(t, cl, provider.Key()) + validated := findCondition(got.Status.Conditions, KubeProviderConditionValidated) + assert.Equal(t, metav1.ConditionFalse, validated.Status) + assert.Equal(t, kubeconfig.ReasonSecretNotFound, validated.Reason) + assert.Equal(t, metav1.ConditionFalse, findCondition(got.Status.Conditions, ConditionTypeReady).Status) + assert.Equal(t, metav1.ConditionTrue, findCondition(got.Status.Conditions, ConditionTypeStalled).Status) +} + +// drainRequests empties q and returns what was queued. +func drainRequests(q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) []k8stypes.NamespacedName { + var out []k8stypes.NamespacedName + for q.Len() > 0 { + item, _ := q.Get() + q.Done(item) + out = append(out, item.NamespacedName) + } + return out +} + +// TestAuditRouteSiblingsHandler checks the wake path for AuditRouteConflict: a provider created, +// deleted or moved to another route enqueues every OTHER provider carrying the route it held or now +// holds, since each may have just won or lost it. An update keeping the route enqueues nothing, so +// status writes cost no fan-out. +func TestAuditRouteSiblingsHandler(t *testing.T) { + withRoute := func(namespace, name, route string) *configbutleraiv1alpha3.KubeProvider { + p := inClusterKubeProvider(name) + p.Namespace = namespace + if route != "" { + p.Spec.Attribution = &configbutleraiv1alpha3.KubeProviderAttribution{AuditRoute: route} + } + return p + } + self := withRoute("team-a", "self", "shared") + sharedSibling := withRoute("team-b", "sibling", "shared") + otherSibling := withRoute("team-b", "other", "other") + unrelated := withRoute("team-c", "unrelated", "") + + cl := fake.NewClientBuilder().WithScheme(scScheme(t)). + WithObjects(self, sharedSibling, otherSibling, unrelated).Build() + h := auditRouteSiblingsHandler(cl) + + moved := self.DeepCopy() + moved.Spec.Attribution.AuditRoute = "other" + statusOnly := self.DeepCopy() + statusOnly.Status.AuditRoute = "shared" + + tests := []struct { + name string + fire func(context.Context, workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) + want []k8stypes.NamespacedName + }{ + { + name: "create wakes the siblings on its route, not itself", + fire: func(ctx context.Context, q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + h.Create(ctx, event.CreateEvent{Object: self}, q) + }, + want: []k8stypes.NamespacedName{sharedSibling.Key()}, + }, + { + name: "delete frees the route for its siblings", + fire: func(ctx context.Context, q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + h.Delete(ctx, event.DeleteEvent{Object: self}, q) + }, + want: []k8stypes.NamespacedName{sharedSibling.Key()}, + }, + { + name: "a route change wakes the carriers of the old and the new route", + fire: func(ctx context.Context, q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + h.Update(ctx, event.UpdateEvent{ObjectOld: self, ObjectNew: moved}, q) + }, + want: []k8stypes.NamespacedName{sharedSibling.Key(), otherSibling.Key()}, + }, + { + name: "an update keeping the route wakes nothing", + fire: func(ctx context.Context, q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + h.Update(ctx, event.UpdateEvent{ObjectOld: self, ObjectNew: statusOnly}, q) + }, + }, + { + name: "an object that is not a KubeProvider wakes nothing", + fire: func(ctx context.Context, q workqueue.TypedRateLimitingInterface[ctrlreconcile.Request]) { + h.Create(ctx, event.CreateEvent{Object: &corev1.Namespace{}}, q) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + q := workqueue.NewTypedRateLimitingQueue( + workqueue.DefaultTypedControllerRateLimiter[ctrlreconcile.Request]()) + defer q.ShutDown() + tc.fire(context.Background(), q) + assert.ElementsMatch(t, tc.want, drainRequests(q)) + }) + } +} diff --git a/internal/controller/clusterprovider_kstatus_test.go b/internal/controller/kubeprovider_kstatus_test.go similarity index 54% rename from internal/controller/clusterprovider_kstatus_test.go rename to internal/controller/kubeprovider_kstatus_test.go index 5b719a690..0484f428b 100644 --- a/internal/controller/clusterprovider_kstatus_test.go +++ b/internal/controller/kubeprovider_kstatus_test.go @@ -11,14 +11,14 @@ import ( kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status" ) -// TestClusterProviderKstatusContract pins what a kstatus reader — `kubectl wait`, Flux health -// checks, an Argo sync wave — makes of a ClusterProvider's conditions. +// TestKubeProviderKstatusContract pins what a kstatus reader — `kubectl wait`, Flux health +// checks, an Argo sync wave — makes of a KubeProvider's conditions. // // The case that matters most is the last one: a provider whose audit route has never carried a // fact is Current. AuditFactsReceived is a report about the commit AUTHOR, and the object it hangs // on mirrors perfectly without it, so folding it into Ready would tell every one of those readers // the object is broken and block a rollout on a cluster that is working. -func TestClusterProviderKstatusContract(t *testing.T) { +func TestKubeProviderKstatusContract(t *testing.T) { tests := []struct { name string conds []map[string]interface{} @@ -26,12 +26,17 @@ func TestClusterProviderKstatusContract(t *testing.T) { wantMsg string }{ { - name: "in-cluster provider validated", + name: "inCluster provider validated", conds: []map[string]interface{}{ - conditionMap(ConditionTypeReady, "True", ReasonSucceeded, "in-cluster provider (no kubeConfig)"), + conditionMap( + ConditionTypeReady, + "True", + ReasonSucceeded, + "spec.inCluster: the operator's own identity", + ), conditionMap(ConditionTypeReconciling, "False", ReasonSucceeded, "Reconciliation complete"), - conditionMap(ConditionTypeStalled, "False", ReasonSucceeded, "ClusterProvider is not stalled"), - conditionMap(ClusterProviderConditionValidated, "True", ReasonInCluster, "the operator's own cluster"), + conditionMap(ConditionTypeStalled, "False", ReasonSucceeded, "KubeProvider is not stalled"), + conditionMap(KubeProviderConditionValidated, "True", ReasonInCluster, "the operator's own cluster"), }, wantStatus: kstatus.CurrentStatus, }, @@ -41,20 +46,38 @@ func TestClusterProviderKstatusContract(t *testing.T) { conditionMap(ConditionTypeReady, "False", ReasonKubeConfigInvalid, "exec auth is not permitted"), conditionMap(ConditionTypeReconciling, "False", ReasonKubeConfigInvalid, "Reconciliation is stalled"), conditionMap(ConditionTypeStalled, "True", ReasonKubeConfigInvalid, "exec auth is not permitted"), - conditionMap(ClusterProviderConditionValidated, "False", ReasonKubeConfigInvalid, "exec auth"), + conditionMap(KubeProviderConditionValidated, "False", ReasonKubeConfigInvalid, "exec auth"), }, wantStatus: kstatus.FailedStatus, wantMsg: "exec auth", }, + { + // A provider another namespace's route holds is not used, though its credential is fine: + // Validated stays True and the stall alone is what a kstatus reader sees. + name: "an audit route held elsewhere stalls a validated provider", + conds: []map[string]interface{}{ + conditionMap(ConditionTypeReady, "False", "AuditRouteConflict", "held by namespace team-b"), + conditionMap(ConditionTypeReconciling, "False", "AuditRouteConflict", "Reconciliation is stalled"), + conditionMap(ConditionTypeStalled, "True", "AuditRouteConflict", "held by namespace team-b"), + conditionMap(KubeProviderConditionValidated, "True", ReasonInCluster, "the operator's own cluster"), + }, + wantStatus: kstatus.FailedStatus, + wantMsg: "held by namespace team-b", + }, { name: "a route that has never carried a fact is still Current", conds: []map[string]interface{}{ - conditionMap(ConditionTypeReady, "True", ReasonSucceeded, "in-cluster provider (no kubeConfig)"), + conditionMap( + ConditionTypeReady, + "True", + ReasonSucceeded, + "spec.inCluster: the operator's own identity", + ), conditionMap(ConditionTypeReconciling, "False", ReasonSucceeded, "Reconciliation complete"), - conditionMap(ConditionTypeStalled, "False", ReasonSucceeded, "ClusterProvider is not stalled"), - conditionMap(ClusterProviderConditionValidated, "True", ReasonInCluster, "the operator's own cluster"), - conditionMap(ClusterProviderConditionAuditFactsReceived, "Unknown", ReasonRouteUnused, - `audit requests are arriving, but nothing has been published for route "srcns-delegating"`), + conditionMap(ConditionTypeStalled, "False", ReasonSucceeded, "KubeProvider is not stalled"), + conditionMap(KubeProviderConditionValidated, "True", ReasonInCluster, "the operator's own cluster"), + conditionMap(KubeProviderConditionAuditFactsReceived, "Unknown", ReasonRouteUnused, + `audit requests are arriving, but nothing has been published for route "team-a.srcns-delegating"`), }, wantStatus: kstatus.CurrentStatus, }, @@ -62,7 +85,7 @@ func TestClusterProviderKstatusContract(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := kstatus.Compute(clusterProviderStatusObject(tt.conds)) + result, err := kstatus.Compute(kubeProviderStatusObject(tt.conds)) require.NoError(t, err) assert.Equal(t, tt.wantStatus, result.Status) if tt.wantMsg != "" { @@ -72,12 +95,13 @@ func TestClusterProviderKstatusContract(t *testing.T) { } } -func clusterProviderStatusObject(conditions []map[string]interface{}) *unstructured.Unstructured { +func kubeProviderStatusObject(conditions []map[string]interface{}) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "configbutler.ai/v1alpha3", - "kind": "ClusterProvider", + "kind": "KubeProvider", "metadata": map[string]interface{}{ "name": "srcns-delegating", + "namespace": "team-a", "generation": int64(3), }, "status": map[string]interface{}{ diff --git a/internal/controller/kubeprovider_predicate_test.go b/internal/controller/kubeprovider_predicate_test.go new file mode 100644 index 000000000..87b52cc1b --- /dev/null +++ b/internal/controller/kubeprovider_predicate_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" +) + +// providerAt builds a KubeProvider with the given generation, optionally marked for deletion. +func providerAt(generation int64, deleting bool) *configbutleraiv1alpha3.KubeProvider { + p := &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1", Namespace: "team-a", Generation: generation}, + } + if deleting { + now := metav1.Now() + p.DeletionTimestamp = &now + p.Finalizers = []string{"example.com/elsewhere"} + } + return p +} + +// TestKubeProviderReconcilePredicate_AdmitsSpecChangesOnly pins the predicate contract. The +// controller takes no finalizer, so the start of a deletion has nothing for it to do, and a +// status-only update (its own writes included) must not loop back into a reconcile. +func TestKubeProviderReconcilePredicate_AdmitsSpecChangesOnly(t *testing.T) { + p := kubeProviderReconcilePredicate() + + tests := []struct { + name string + old *configbutleraiv1alpha3.KubeProvider + new *configbutleraiv1alpha3.KubeProvider + want bool + }{ + { + name: "spec change is admitted", + old: providerAt(3, false), + new: providerAt(4, false), + want: true, + }, + { + name: "status-only update is filtered", + old: providerAt(3, false), + new: providerAt(3, false), + want: false, + }, + { + name: "deletion beginning with no generation bump is filtered", + old: providerAt(3, false), + new: providerAt(3, true), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.Update(event.UpdateEvent{ObjectOld: tt.old, ObjectNew: tt.new}) + assert.Equal(t, tt.want, got) + }) + } + assert.True(t, p.Create(event.CreateEvent{Object: providerAt(1, false)}), "a new provider is reconciled") +} diff --git a/internal/controller/live_message_migration_test.go b/internal/controller/live_message_migration_test.go index 81c039053..aeac466ff 100644 --- a/internal/controller/live_message_migration_test.go +++ b/internal/controller/live_message_migration_test.go @@ -43,8 +43,9 @@ func legacyGitTarget(name string) *unstructured.Unstructured { "apiVersion": "configbutler.ai/v1alpha3", "kind": "GitTarget", "metadata": map[string]any{"name": name, "namespace": "default"}, "spec": map[string]any{ - "gitBranchRef": map[string]any{"name": "provider-main"}, - "path": name, + "gitBranchRef": map[string]any{"name": "provider-main"}, + "kubeProviderRef": map[string]any{"name": "local"}, + "path": name, "commit": map[string]any{ "message": map[string]any{"eventTemplate": "old event", "groupTemplate": "old group"}, }, diff --git a/internal/controller/object_selector_rule_test.go b/internal/controller/object_selector_rule_test.go index b043fa9cd..48e2b6367 100644 --- a/internal/controller/object_selector_rule_test.go +++ b/internal/controller/object_selector_rule_test.go @@ -47,7 +47,7 @@ func TestReconcile_WatchRuleWithAnInvalidObjectSelectorIsRefused(t *testing.T) { rule := wrsnWatchRule("") rule.Spec.Rules[0].ObjectSelector = invalidObjectSelector() f := newWRSNFixture(t, []client.Object{ - wrsnGitTarget(), wrsnGitProvider(), wrsnClusterProvider(false), rule, + wrsnGitTarget(), wrsnGitProvider(), wrsnKubeProvider(false), rule, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: wrsnTenantNS}}, }) @@ -82,7 +82,7 @@ func TestReconcile_ClusterWatchRuleWithAnInvalidObjectSelectorIsRefused(t *testi rule := cwaClusterWatchRule() rule.Spec.Rules[0].ObjectSelector = invalidObjectSelector() f := newCWAFixture(t, []client.Object{ - rule, cwaGitTarget(), cwaGitProvider(), cwaClusterProvider(cwaAdmitting()), + rule, cwaGitTarget(), cwaGitProvider(), cwaKubeProvider(cwaAdmitting()), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cwaTargetNS}}, }, interceptor.Funcs{}) @@ -97,7 +97,7 @@ func TestReconcile_ClusterWatchRuleWithAnInvalidObjectSelectorIsRefused(t *testi func TestReconcile_ClusterWatchRuleWithAConflictingObjectSelectorIsRefused(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ - cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), cwaClusterProvider(cwaAdmitting()), + cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), cwaKubeProvider(cwaAdmitting()), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cwaTargetNS}}, }, interceptor.Funcs{}) f.wm.collectionOverlap = "ClusterWatchRule mirror-everything is refused" diff --git a/internal/controller/removed_allow_any_source_namespace_test.go b/internal/controller/removed_allow_any_source_namespace_test.go deleted file mode 100644 index a46c9cd73..000000000 --- a/internal/controller/removed_allow_any_source_namespace_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package controller - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - - configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" -) - -// removedFieldMessage is what the spec-level CEL rule says of allowAnySourceNamespace: true. -const removedFieldMessage = "allowAnySourceNamespace was removed; set allowCrossNamespace.sources" - -// TestRemovedAllowAnySourceNamespace_TrueIsRefusedOnEverySpecChange pins how the removal of -// ClusterProvider.spec.allowAnySourceNamespace stays loud, against the generated CRD on a real API -// server. true is refused on create and on any spec change, also for a provider that stored true -// before the rule existed: the rule sits on spec, so CRD validation ratcheting does not wave the -// stored value through once anything in spec moves. false, which the old default stored on every -// provider, stays accepted, and so does removing the field. -func TestRemovedAllowAnySourceNamespace_TrueIsRefusedOnEverySpecChange(t *testing.T) { - env := &envtest.Environment{ - CRDDirectoryPaths: []string{"../../config/crd/bases"}, - ErrorIfCRDPathMissing: true, - } - cfg, err := env.Start() - if err != nil { - t.Fatalf("start envtest control plane: %v", err) - } - t.Cleanup(func() { _ = env.Stop() }) - sch := runtime.NewScheme() - for _, add := range []func(*runtime.Scheme) error{ - scheme.AddToScheme, configbutleraiv1alpha3.AddToScheme, apiextv1.AddToScheme, - } { - if err := add(sch); err != nil { - t.Fatal(err) - } - } - c, err := client.New(cfg, client.Options{Scheme: sch}) - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - provider := func(name string, removed *bool) *configbutleraiv1alpha3.ClusterProvider { - return &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}, - RemovedAllowAnySourceNamespace: removed, - }, - } - } - refused := func(what string, err error) { - t.Helper() - if err == nil || !strings.Contains(err.Error(), removedFieldMessage) { - t.Fatalf("%s: want the removal refused by name, got %v", what, err) - } - } - accepted := func(what string, err error) { - t.Helper() - if err != nil { - t.Fatalf("%s: want it accepted, got %v", what, err) - } - } - addNamespace := func(p *configbutleraiv1alpha3.ClusterProvider) { - p.Spec.AccessFrom.Names = append(p.Spec.AccessFrom.Names, fmt.Sprintf("team-%d", len(p.Spec.AccessFrom.Names))) - } - - refused("create with true", c.Create(ctx, provider("created-true", ptr.To(true)))) - - kept := provider("kept-false", ptr.To(false)) - createEventually(ctx, t, c, kept) - addNamespace(kept) - accepted("a spec change keeping false", c.Update(ctx, kept)) - kept.Spec.RemovedAllowAnySourceNamespace = ptr.To(true) - refused("an update setting true", c.Update(ctx, kept)) - if err := c.Get(ctx, client.ObjectKeyFromObject(kept), kept); err != nil { - t.Fatal(err) - } - kept.Spec.RemovedAllowAnySourceNamespace = nil - accepted("removing the field", c.Update(ctx, kept)) - - // A provider that stored true before the rule existed, as one from the release that read it. - setRemovedFieldRule(ctx, t, c, false) - stored := provider("stored-true", ptr.To(true)) - createEventually(ctx, t, c, stored) - setRemovedFieldRule(ctx, t, c, true) - requireRemovedFieldRefused(ctx, t, c) - - if err := c.Get(ctx, client.ObjectKeyFromObject(stored), stored); err != nil { - t.Fatal(err) - } - stored.Labels = map[string]string{"edited": "metadata-only"} - accepted("a metadata-only edit of a stored true, which CEL does not see", c.Update(ctx, stored)) - addNamespace(stored) - refused("a spec change on a stored true", c.Update(ctx, stored)) - stored.Spec.RemovedAllowAnySourceNamespace = nil - accepted("the same spec change, removing the field", c.Update(ctx, stored)) -} - -// setRemovedFieldRule takes the spec-level rule refusing allowAnySourceNamespace: true out of the -// served ClusterProvider schema, or puts it back, standing in for the CRD of the release before it. -func setRemovedFieldRule(ctx context.Context, t *testing.T, c client.Client, present bool) { - t.Helper() - deadline := time.Now().Add(30 * time.Second) - for { - var crd apiextv1.CustomResourceDefinition - if err := c.Get(ctx, client.ObjectKey{Name: "clusterproviders.configbutler.ai"}, &crd); err != nil { - t.Fatalf("get CRD: %v", err) - } - spec := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] - var rules apiextv1.ValidationRules - for _, rule := range spec.XValidations { - if !strings.Contains(rule.Rule, "allowAnySourceNamespace") { - rules = append(rules, rule) - } - } - if present { - rules = append(rules, apiextv1.ValidationRule{ - Rule: "!has(self.allowAnySourceNamespace) || !self.allowAnySourceNamespace", - Message: removedFieldMessage, - }) - } - spec.XValidations = rules - crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] = spec - err := c.Update(ctx, &crd) - if err == nil { - return - } - if time.Now().After(deadline) { - t.Fatalf("update CRD schema: %v", err) - } - time.Sleep(100 * time.Millisecond) - } -} - -// requireRemovedFieldRefused blocks until the restored rule is the one being served, proven by a -// create that must fail: without it, the assertions after could pass against the schema without -// the rule. -func requireRemovedFieldRefused(ctx context.Context, t *testing.T, c client.Client) { - t.Helper() - deadline := time.Now().Add(30 * time.Second) - for i := 0; time.Now().Before(deadline); i++ { - probe := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("removed-field-probe-%d", i)}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{RemovedAllowAnySourceNamespace: ptr.To(true)}, - } - if err := c.Create(ctx, probe); err != nil { - return - } - _ = c.Delete(ctx, probe) - time.Sleep(100 * time.Millisecond) - } - t.Fatal("the restored rule never took effect, so the assertions after would be vacuous") -} diff --git a/internal/controller/resource_condition_metrics.go b/internal/controller/resource_condition_metrics.go index fe3e896d6..e99cfa615 100644 --- a/internal/controller/resource_condition_metrics.go +++ b/internal/controller/resource_condition_metrics.go @@ -32,7 +32,7 @@ const ( conditionKindClusterWatchRule = "ClusterWatchRule" conditionKindGitProvider = "GitProvider" conditionKindGitBranch = "GitBranch" - conditionKindClusterProvider = "ClusterProvider" + conditionKindKubeProvider = "KubeProvider" conditionKindFluxTrigger = configbutleraiv1alpha3.ApplyTriggerKindFlux conditionKindArgoCDTrigger = configbutleraiv1alpha3.ApplyTriggerKindArgoCD ) @@ -58,8 +58,8 @@ func conditionMetricKind(obj client.Object) string { return conditionKindGitProvider case *configbutleraiv1alpha3.GitBranch: return conditionKindGitBranch - case *configbutleraiv1alpha3.ClusterProvider: - return conditionKindClusterProvider + case *configbutleraiv1alpha3.KubeProvider: + return conditionKindKubeProvider case *configbutleraiv1alpha3.FluxTrigger: return conditionKindFluxTrigger case *configbutleraiv1alpha3.ArgoCDTrigger: diff --git a/internal/controller/resource_condition_metrics_test.go b/internal/controller/resource_condition_metrics_test.go index 3a2f62b71..c39e454df 100644 --- a/internal/controller/resource_condition_metrics_test.go +++ b/internal/controller/resource_condition_metrics_test.go @@ -123,7 +123,7 @@ func TestConditionMetricKind_CoversTheFiveConfigKindsAndNothingElse(t *testing.T assert.Equal(t, conditionKindWatchRule, conditionMetricKind(&configbutleraiv1alpha3.WatchRule{})) assert.Equal(t, conditionKindClusterWatchRule, conditionMetricKind(&configbutleraiv1alpha3.ClusterWatchRule{})) assert.Equal(t, conditionKindGitProvider, conditionMetricKind(&configbutleraiv1alpha3.GitProvider{})) - assert.Equal(t, conditionKindClusterProvider, conditionMetricKind(&configbutleraiv1alpha3.ClusterProvider{})) + assert.Equal(t, conditionKindKubeProvider, conditionMetricKind(&configbutleraiv1alpha3.KubeProvider{})) assert.Empty(t, conditionMetricKind(&configbutleraiv1alpha3.CommitRequest{})) } diff --git a/internal/controller/status_observed_generation_test.go b/internal/controller/status_observed_generation_test.go index b89f5d582..950792b9d 100644 --- a/internal/controller/status_observed_generation_test.go +++ b/internal/controller/status_observed_generation_test.go @@ -35,8 +35,8 @@ func TestBeginStatus_StampsObservedGenerationForEveryKind(t *testing.T) { object: &configbutleraiv1alpha3.GitProvider{ObjectMeta: metav1.ObjectMeta{Generation: 7}}, }, { - name: "ClusterProvider", - object: &configbutleraiv1alpha3.ClusterProvider{ObjectMeta: metav1.ObjectMeta{Generation: 7}}, + name: "KubeProvider", + object: &configbutleraiv1alpha3.KubeProvider{ObjectMeta: metav1.ObjectMeta{Generation: 7}}, }, { name: "WatchRule", @@ -73,7 +73,7 @@ func observedGenerationOf(t *testing.T, object client.Object) int64 { return o.Status.ObservedGeneration case *configbutleraiv1alpha3.GitProvider: return o.Status.ObservedGeneration - case *configbutleraiv1alpha3.ClusterProvider: + case *configbutleraiv1alpha3.KubeProvider: return o.Status.ObservedGeneration case *configbutleraiv1alpha3.WatchRule: return o.Status.ObservedGeneration diff --git a/internal/controller/stream_status.go b/internal/controller/stream_status.go index 80a6c675e..9f6e5e613 100644 --- a/internal/controller/stream_status.go +++ b/internal/controller/stream_status.go @@ -121,7 +121,7 @@ func streamReasonIsStalled(reason string) bool { // verdict, so the cadence can never disagree with what status says. // // Only a CONVERGING rule takes the fast loop; a stalled one falls back to RequeueSteadyInterval, -// which is a backstop rather than the mechanism. What actually clears a stall — a ClusterProvider +// which is a backstop rather than the mechanism. What actually clears a stall — a KubeProvider // policy change, a source-cluster Namespace label change, an edit to the rule — has a watch edge // registered in SetupWithManager, so the rule is woken by an event long before the steady tick; // polling it faster would find nothing. diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 9e7604cc7..392a30dab 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -7,11 +7,13 @@ import ( "path/filepath" "testing" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -23,6 +25,7 @@ import ( configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/attribution" "github.com/ConfigButler/gitops-reverser/internal/git" + "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" "github.com/ConfigButler/gitops-reverser/internal/rulestore" "github.com/ConfigButler/gitops-reverser/internal/types" // +kubebuilder:scaffold:imports @@ -44,6 +47,20 @@ var ( testWorkerManager *git.WorkerManager ) +// The suite's shared KubeProvider: the operator's own identity, in the "default" namespace, listed +// in the suite reconciler's --in-cluster-kube-providers equivalent and admitting every namespace, +// the way an install that mirrors its own cluster ships one. A spec's GitTargets and triggers name +// it through testKubeProviderRef. +const ( + testKubeProviderNamespace = "default" + testKubeProviderName = "local" +) + +// testKubeProviderRef names the suite's shared in-cluster KubeProvider. +func testKubeProviderRef() meta.NamespacedObjectReference { + return meta.NamespacedObjectReference{Namespace: testKubeProviderNamespace, Name: testKubeProviderName} +} + func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -116,11 +133,13 @@ var _ = BeforeSuite(func() { // Wired with a live route registry, as the operator is by default (--author-attribution=true): // that is what exercises the first-fact channel source and the AuditFactsReceived condition. - err = (&ClusterProviderReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - OperatorNamespace: "default", - AuditFacts: &attribution.RouteHealth{}, + err = (&KubeProviderReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + KubeConfigSafety: kubeconfig.SafetyPolicy{InClusterProviders: map[k8stypes.NamespacedName]struct{}{ + {Namespace: testKubeProviderNamespace, Name: testKubeProviderName}: {}, + }}, + AuditFacts: &attribution.RouteHealth{}, }).SetupWithManager(mgr) Expect(err).NotTo(HaveOccurred()) @@ -169,13 +188,13 @@ var _ = BeforeSuite(func() { // Note: Old git.Worker has been replaced by WorkerManager + BranchWorker architecture // Webhook tests are handled separately in webhook package - // Ship the reserved "default" ClusterProvider the way a real install (chart / config SUT) does, - // so a GitTarget that references it (the schema default) passes the reconcile-time hard gate - // that now REQUIRES the referenced ClusterProvider to exist. Its empty selector admits every - // namespace, matching the chart default. - Expect(k8sClient.Create(ctx, &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: configbutleraiv1alpha3.DefaultClusterProviderName}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ + // Ship the suite's shared in-cluster KubeProvider, so a GitTarget or trigger naming it + // (testKubeProviderRef) passes the reconcile-time hard gate that REQUIRES the referenced + // KubeProvider to exist. Its empty selector admits every namespace the specs create. + Expect(k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testKubeProviderNamespace, Name: testKubeProviderName}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}, }, })).To(Succeed()) diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index 5fdc70741..d90b1c2c1 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -66,11 +66,12 @@ var _ = Describe("Superseded source-scope fields", func() { // With the removed fields gone there is nothing left to reject, so the only thing worth // pinning is that what REPLACED them is really served. A typo in one of these names would // otherwise surface as a silently pruned field and a mirror behaving as if unconfigured. - provider := &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "renamed-source-scope"}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ + provider := &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "renamed-source-scope", Namespace: "default"}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "kc"}, AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}, - AllowCrossNamespace: &configbutleraiv1alpha3.ClusterProviderCrossNamespace{ + AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{ Sources: true, Appliers: true, }, }, @@ -78,8 +79,8 @@ var _ = Describe("Superseded source-scope fields", func() { Expect(k8sClient.Create(ctx, provider)).To(Succeed()) DeferCleanup(func() { _ = k8sClient.Delete(ctx, provider) }) - var storedProvider configbutleraiv1alpha3.ClusterProvider - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: provider.Name}, &storedProvider)).To(Succeed()) + var storedProvider configbutleraiv1alpha3.KubeProvider + Expect(k8sClient.Get(ctx, provider.Key(), &storedProvider)).To(Succeed()) Expect(storedProvider.Spec.AccessFrom).NotTo(BeNil(), "accessFrom must round-trip, not be pruned") Expect(storedProvider.AllowsCrossNamespaceSources()).To(BeTrue(), "allowCrossNamespace must round-trip") Expect(storedProvider.AllowsCrossNamespaceAppliers()).To(BeTrue()) @@ -87,8 +88,9 @@ var _ = Describe("Superseded source-scope fields", func() { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "renamed-commit", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("any-provider", "main")}, - Path: "clusters/prod", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("any-provider", "main")}, + Path: "clusters/prod", Commit: &configbutleraiv1alpha3.GitTargetCommitSpec{ Window: &configbutleraiv1alpha3.CommitWindow{ IdleTimeout: &metav1.Duration{Duration: 30 * time.Second}, @@ -202,8 +204,9 @@ var _ = Describe("Reference names must not be empty", func() { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "empty-branch-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: ""}, - Path: "clusters/prod", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{Name: ""}, + Path: "clusters/prod", }, } Expect(k8sClient.Create(ctx, target)).NotTo(Succeed(), @@ -232,41 +235,48 @@ var _ = Describe("Reference names must not be empty", func() { }) }) -// The one exception to deletion above. ClusterProvider.spec.qps and spec.burst moved to spec.client, -// and pruning them would drop a deliberate throttle without a word, so the old spellings stay in the -// schema, are never read, and a CEL rule refuses them by name. -var _ = Describe("Retained and refused throttle fields", func() { - legacyProvider := func(name string, spec map[string]any) *unstructured.Unstructured { +// spec.clusterProviderRef was replaced by spec.kubeProviderRef on every kind that named a source +// cluster. A pruned field would apply cleanly and leave a manifest written for the old API looking +// accepted, so the old spelling stays in each schema and a CEL rule refuses it, naming +// kubeProviderRef. +var _ = Describe("Retained and refused clusterProviderRef", func() { + referrer := func(kind, name string, spec map[string]any) *unstructured.Unstructured { + switch kind { + case "GitTarget": + spec["gitBranchRef"] = map[string]any{"name": "main"} + spec["path"] = "apps" + case "FluxTrigger": + spec["kustomizationRef"] = map[string]any{"name": "apps"} + case "ArgoCDTrigger": + spec["applicationRef"] = map[string]any{"name": "apps"} + } return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "configbutler.ai/v1alpha3", - "kind": "ClusterProvider", - "metadata": map[string]any{"name": name}, + "kind": kind, + "metadata": map[string]any{"name": name, "namespace": "default"}, "spec": spec, }} } - It("refuses spec.qps and spec.burst, naming spec.client", func() { - ctx := context.Background() - - for name, spec := range map[string]map[string]any{ - "legacy-qps": {"qps": int64(5)}, - "legacy-burst": {"burst": int64(7)}, - } { - err := k8sClient.Create(ctx, legacyProvider(name, spec)) - Expect(err).To(HaveOccurred(), "%s: the old spelling must not apply cleanly", name) - Expect(err.Error()).To(ContainSubstring("spec.client.qps and spec.client.burst"), name) - } - }) - - It("accepts spec.client", func() { - ctx := context.Background() + for _, kind := range []string{"GitTarget", "FluxTrigger", "ArgoCDTrigger"} { + It("refuses spec.clusterProviderRef on a "+kind+", naming spec.kubeProviderRef", func() { + ctx := context.Background() + obj := referrer(kind, "legacy-cluster-provider", map[string]any{ + "kubeProviderRef": map[string]any{"name": "local"}, + "clusterProviderRef": map[string]any{"name": "default"}, + }) + err := k8sClient.Create(ctx, obj) + Expect(err).To(HaveOccurred(), "the old field must not apply cleanly") + Expect(err.Error()).To(ContainSubstring("spec.clusterProviderRef was replaced by spec.kubeProviderRef")) + }) - provider := legacyProvider("client-throttle", map[string]any{ - "client": map[string]any{"qps": int64(5), "burst": int64(7)}, + It("requires spec.kubeProviderRef on a "+kind+", with no default", func() { + ctx := context.Background() + err := k8sClient.Create(ctx, referrer(kind, "no-kube-provider-ref", map[string]any{})) + Expect(err).To(HaveOccurred(), "an omitted reference must not fall back to any provider") + Expect(err.Error()).To(ContainSubstring("kubeProviderRef")) }) - Expect(k8sClient.Create(ctx, provider)).To(Succeed()) - DeferCleanup(func() { _ = k8sClient.Delete(ctx, provider) }) - }) + } }) // GitTarget.spec.gitProviderRef, spec.branch and spec.parentBranch moved to a GitBranch. A pruned @@ -275,6 +285,7 @@ var _ = Describe("Retained and refused throttle fields", func() { var _ = Describe("Retained and refused GitTarget branch fields", func() { legacyTarget := func(name string, spec map[string]any) *unstructured.Unstructured { spec["gitBranchRef"] = map[string]any{"name": "main"} + spec["kubeProviderRef"] = map[string]any{"name": "local"} spec["path"] = "apps" return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "configbutler.ai/v1alpha3", diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index 6053b0600..7f0e50bef 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -56,7 +56,7 @@ type WatchRuleReconciler struct { // +kubebuilder:rbac:groups=configbutler.ai,resources=watchrules/status,verbs=get;update;patch // +kubebuilder:rbac:groups=configbutler.ai,resources=gittargets,verbs=get;list;watch // +kubebuilder:rbac:groups=configbutler.ai,resources=gitproviders,verbs=get;list;watch -// +kubebuilder:rbac:groups=configbutler.ai,resources=clusterproviders,verbs=get;list;watch +// +kubebuilder:rbac:groups=configbutler.ai,resources=kubeproviders,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch @@ -269,7 +269,7 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { // write bumps resourceVersion and fires an Update watch event that EnqueueRequestForObject // turns straight back into a queued request, un-rate-limited. reconcileStatus.commit() // already suppresses no-op writes, so the loop has no fuel; this makes it structural, and - // matches what GitProvider and ClusterProvider already do. + // matches what GitProvider and KubeProvider already do. For(&configbutleraiv1alpha3.WatchRule{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // This rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has // to see the status-only update that carries a heal — GenerationChangedPredicate would drop @@ -280,16 +280,16 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitTargetToWatchRules), builder.WithPredicates(gitTargetReadyProjectionChanged()), ). - // React to a ClusterProvider's allowCrossNamespace.sources (or accessFrom) + // React to a KubeProvider's allowCrossNamespace.sources (or accessFrom) // changing. The GitTarget->WatchRules edge above still cannot be relied on to carry this: a - // ClusterProvider change reaches the GitTarget as a status update that need not move the + // KubeProvider change reaches the GitTarget as a status update that need not move the // GitTarget's readiness at all, and only a readiness move re-enqueues the rules. Without // this mapper, flipping the delegation flag would leave every affected WatchRule // un-reconciled until its periodic requeue — so a REVOCATION would take minutes. Watches( - &configbutleraiv1alpha3.ClusterProvider{}, - handler.EnqueueRequestsFromMapFunc(r.clusterProviderToWatchRules), - builder.WithPredicates(clusterProviderReadyOrSpecChanged()), + &configbutleraiv1alpha3.KubeProvider{}, + handler.EnqueueRequestsFromMapFunc(r.kubeProviderToWatchRules), + builder.WithPredicates(kubeProviderReadyOrSpecChanged()), ). Named("watchrule") @@ -308,10 +308,10 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } -// clusterProviderToWatchRules maps a ClusterProvider change to every WatchRule whose GitTarget +// kubeProviderToWatchRules maps a KubeProvider change to every WatchRule whose GitTarget // mirrors through that provider, so a delegation grant or revocation converges on the event rather // than on the periodic reconcile. -func (r *WatchRuleReconciler) clusterProviderToWatchRules( +func (r *WatchRuleReconciler) kubeProviderToWatchRules( ctx context.Context, obj client.Object, ) []ctrlreconcile.Request { @@ -326,7 +326,7 @@ func (r *WatchRuleReconciler) clusterProviderToWatchRules( affected := make(map[types.NamespacedName]struct{}, len(targets.Items)) for i := range targets.Items { t := &targets.Items[i] - if t.SourceCluster() != obj.GetName() { + if t.KubeProviderKey() != client.ObjectKeyFromObject(obj) { continue } affected[types.NamespacedName{Name: t.Name, Namespace: t.Namespace}] = struct{}{} diff --git a/internal/controller/watchrule_controller_test.go b/internal/controller/watchrule_controller_test.go index 6db8678c7..0f0a21690 100644 --- a/internal/controller/watchrule_controller_test.go +++ b/internal/controller/watchrule_controller_test.go @@ -79,8 +79,11 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("test-provider", "main")}, - Path: "default/test", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{ + Name: gitBranchNameForTest("test-provider", "main"), + }, + Path: "default/test", }, } Expect(k8sClient.Create(ctx, target)).To(Succeed()) @@ -192,8 +195,11 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("local-provider", "main")}, - Path: "ns/default", + KubeProviderRef: testKubeProviderRef(), + GitBranchRef: meta.NamespacedObjectReference{ + Name: gitBranchNameForTest("local-provider", "main"), + }, + Path: "ns/default", }, } Expect(k8sClient.Create(ctx, target)).Should(Succeed()) diff --git a/internal/controller/watchrule_source_namespace.go b/internal/controller/watchrule_source_namespace.go index 180e8a596..eac3b2d7b 100644 --- a/internal/controller/watchrule_source_namespace.go +++ b/internal/controller/watchrule_source_namespace.go @@ -38,7 +38,7 @@ const ( // compiles a rule. It runs after the GitTarget and GitProvider are resolved and instead of a bare // AddOrUpdateWatchRule, so there is no ungated path from a WatchRule to a compiled rule. // -// The gate is cross-object (WatchRule → GitTarget → ClusterProvider), so it is not expressible in +// The gate is cross-object (WatchRule → GitTarget → KubeProvider), so it is not expressible in // CEL and is a reconciler check rather than a webhook, per docs/spec/where-validation-lives.md — // the same shape and ordering as checkSourceAuthorization. Running it on every reconcile is what // makes a delegation WITHDRAWN after a rule was accepted revoke that rule. @@ -94,7 +94,7 @@ func (r *WatchRuleReconciler) gateSourceNamespace( // asserts the terminal condition must also be able to assert the rule is already gone. // // The refusal is terminal (Stalled=True, Reconciling=False) rather than a retry: nothing this -// controller does will change the verdict. Recovery arrives as an EVENT — a ClusterProvider flag or +// controller does will change the verdict. Recovery arrives as an EVENT — a KubeProvider flag or // accessFrom change — through the mappers registered in SetupWithManager. func (r *WatchRuleReconciler) refuseSourceNamespace( ctx context.Context, diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index df88b903c..9d924a137 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -14,7 +14,6 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8stypes "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -30,15 +29,17 @@ const ( wrsnTarget = "acme" wrsnRule = "repo-config-rule" wrsnProvider = "workspaces" + // wrsnProviderNS holds the KubeProvider, so wrsnTenantNS reaches it only through accessFrom. + wrsnProviderNS = "platform" ) func wrsnGitTarget() *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: wrsnTarget, Namespace: wrsnTenantNS}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, - ClusterProviderRef: &meta.LocalObjectReference{Name: wrsnProvider}, - Path: "tenants/acme", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: wrsnProviderNS, Name: wrsnProvider}, + Path: "tenants/acme", }, } } @@ -49,14 +50,15 @@ func wrsnGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func wrsnClusterProvider(delegate bool) *configbutleraiv1alpha3.ClusterProvider { - return &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: wrsnProvider}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ +func wrsnKubeProvider(delegate bool) *configbutleraiv1alpha3.KubeProvider { + return &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: wrsnProvider, Namespace: wrsnProviderNS}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "kc"}, AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{ Names: []string{wrsnTenantNS}, }, - AllowCrossNamespace: &configbutleraiv1alpha3.ClusterProviderCrossNamespace{Sources: delegate}, + AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, }, } } @@ -146,7 +148,7 @@ func wrsnBaseObjects( return []client.Object{ wrsnGitTarget(), wrsnGitProvider(), - wrsnClusterProvider(delegate), + wrsnKubeProvider(delegate), wrsnWatchRule(sourceNamespaces...), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: wrsnTenantNS}}, } @@ -193,30 +195,6 @@ func TestReconcile_DeniedSourceNamespaceStartsNoWatch(t *testing.T) { "the message must name the fix") } -// TestReconcile_TheRemovedFieldDelegatesNothing: a ClusterProvider that still stores -// allowAnySourceNamespace: true, from the release that read it, delegates nothing; the WatchRule -// that relied on it is refused, and the message names the field that replaced it. -func TestReconcile_TheRemovedFieldDelegatesNothing(t *testing.T) { - ctx := context.Background() - objects := wrsnBaseObjects(false, wrsnSourceNS) - for _, obj := range objects { - if provider, ok := obj.(*configbutleraiv1alpha3.ClusterProvider); ok { - provider.Spec.AllowCrossNamespace = nil - provider.Spec.RemovedAllowAnySourceNamespace = ptr.To(true) - } - } - f := newWRSNFixture(t, objects) - - _, err := f.reconcile(ctx) - - require.NoError(t, err) - assert.Empty(t, f.compiledNames(), "the removed field is never read") - cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) - assert.Equal(t, metav1.ConditionFalse, cond.Status) - assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) - assert.Contains(t, cond.Message, "does not set spec.allowCrossNamespace.sources") -} - // TestReconcile_DeniedSourceNamespacePublishesTheFailedTrio pins the whole kstatus verdict a // refusal produces: Failed, under the one reason an operator greps for. func TestReconcile_DeniedSourceNamespacePublishesTheFailedTrio(t *testing.T) { @@ -282,8 +260,9 @@ func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *tes require.Equal(t, []string{wrsnRule}, f.compiledNames(), "precondition: the rule is compiled") // The platform admin withdraws the delegation. - var provider configbutleraiv1alpha3.ClusterProvider - require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Name: wrsnProvider}, &provider)) + var provider configbutleraiv1alpha3.KubeProvider + require.NoError(t, f.client.Get(ctx, + k8stypes.NamespacedName{Namespace: wrsnProviderNS, Name: wrsnProvider}, &provider)) provider.Spec.AllowCrossNamespace = nil require.NoError(t, f.client.Update(ctx, &provider)) @@ -302,7 +281,7 @@ func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *tes assert.Equal(t, metav1.ConditionFalse, cond.Status) } -// A ClusterProvider that delegates puts NO further namespace policy in the way: the source +// A KubeProvider that delegates puts NO further namespace policy in the way: the source // credential's own RBAC is the bound, and this gate does not try to predict it. That is the whole // simplification, so it is asserted rather than left implied — the previous release refused this // exact rule unless a GitTarget policy also listed the namespace. @@ -320,9 +299,9 @@ func TestReconcile_DelegationIsTheOnlyPolicyLeft(t *testing.T) { assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) } -// TestReconcile_ClusterProviderReadErrorRequeuesWithoutDenying: a transient apiserver failure must +// TestReconcile_KubeProviderReadErrorRequeuesWithoutDenying: a transient apiserver failure must // surface as an error the controller requeues on, and must NOT tear down an already-compiled rule. -func TestReconcile_ClusterProviderReadErrorRequeuesWithoutDenying(t *testing.T) { +func TestReconcile_KubeProviderReadErrorRequeuesWithoutDenying(t *testing.T) { ctx := context.Background() boom := errors.New("etcdserver: request timed out") @@ -334,7 +313,7 @@ func TestReconcile_ClusterProviderReadErrorRequeuesWithoutDenying(t *testing.T) require.NoError(t, err) require.Equal(t, []string{wrsnRule}, f.compiledNames()) - // Now make the ClusterProvider read fail. + // Now make the KubeProvider read fail. f.reconciler.Client = fake.NewClientBuilder(). WithScheme(scScheme(t)). WithObjects(wrsnBaseObjects( @@ -345,7 +324,7 @@ func TestReconcile_ClusterProviderReadErrorRequeuesWithoutDenying(t *testing.T) ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption, ) error { - if _, ok := obj.(*configbutleraiv1alpha3.ClusterProvider); ok { + if _, ok := obj.(*configbutleraiv1alpha3.KubeProvider); ok { return boom } return c.Get(ctx, key, obj, opts...) @@ -426,3 +405,31 @@ func TestReconcile_WildcardWithoutDelegationIsRefused(t *testing.T) { assert.Equal(t, metav1.ConditionTrue, wrsnCondition(t, rule, ConditionTypeStalled).Status, "a refusal is terminal: nothing this controller does will change the verdict") } + +// TestKubeProviderToWatchRules: a provider change requeues exactly the WatchRules whose GitTarget +// mirrors through THAT provider. The match is on the provider's namespace and name: a provider of +// the same name in another namespace is another provider. +func TestKubeProviderToWatchRules(t *testing.T) { + sameName := wrsnGitTarget() + sameName.Name = "same-name" + sameName.Spec.KubeProviderRef = meta.NamespacedObjectReference{Name: wrsnProvider} + sameNameRule := wrsnWatchRule(wrsnSourceNS) + sameNameRule.Name = "same-name-rule" + sameNameRule.Spec.GitTargetRef.Name = sameName.Name + + cl := fake.NewClientBuilder().WithScheme(scScheme(t)). + WithObjects(wrsnGitTarget(), sameName, wrsnWatchRule(wrsnSourceNS), sameNameRule).Build() + r := &WatchRuleReconciler{Client: cl} + + reqs := r.kubeProviderToWatchRules(context.Background(), wrsnKubeProvider(true)) + keys := make([]k8stypes.NamespacedName, 0, len(reqs)) + for _, req := range reqs { + keys = append(keys, req.NamespacedName) + } + assert.Equal(t, []k8stypes.NamespacedName{{Namespace: wrsnTenantNS, Name: wrsnRule}}, keys) + + unrelated := wrsnKubeProvider(true) + unrelated.Name = "unrelated" + assert.Empty(t, r.kubeProviderToWatchRules(context.Background(), unrelated), + "no GitTarget mirrors through it, so no WatchRule is requeued") +} diff --git a/internal/controller/wiring/readiness_wiring_test.go b/internal/controller/wiring/readiness_wiring_test.go index 0df215ebc..0ad17582b 100644 --- a/internal/controller/wiring/readiness_wiring_test.go +++ b/internal/controller/wiring/readiness_wiring_test.go @@ -108,11 +108,11 @@ func startManager(t *testing.T, register func(manager.Manager) error) { require.True(t, mgr.GetCache().WaitForCacheSync(mgrCtx)) } -// seedTarget creates a namespace, a GitProvider and a GitTarget, and returns the namespace. When -// clusterProvider is non-empty it also creates that ClusterProvider admitting every namespace and -// points the GitTarget at it: a ClusterWatchRule is refused (and its GitTargetReady overwritten with -// the refusal) unless the source cluster admits the target's namespace. -func seedTarget(ctx context.Context, t *testing.T, name, clusterProvider string) string { +// seedTarget creates a namespace, a GitProvider, a KubeProvider and a GitTarget mirroring through +// it, and returns the namespace. The KubeProvider is in the target's own namespace, which it always +// admits: a ClusterWatchRule is refused (and its GitTargetReady overwritten with the refusal) unless +// the source cluster admits the target's namespace. +func seedTarget(ctx context.Context, t *testing.T, name string) string { t.Helper() namespace := name @@ -127,26 +127,20 @@ func seedTarget(ctx context.Context, t *testing.T, name, clusterProvider string) SecretRef: &meta.LocalObjectReference{Name: "git-credentials"}, }, })) - target := &configbutleraiv1alpha3.GitTarget{ + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + }, + })) + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: "provider-main"}, - Path: "mirror", + KubeProviderRef: meta.NamespacedObjectReference{Name: "source"}, + GitBranchRef: meta.NamespacedObjectReference{Name: "provider-main"}, + Path: "mirror", }, - } - if clusterProvider != "" { - // selector: {} is the "every namespace" form; names: ["*"] would be a literal name. - require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: clusterProvider}, - Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{}, - }, - }, - })) - target.Spec.ClusterProviderRef = &meta.LocalObjectReference{Name: clusterProvider} - } - require.NoError(t, k8sClient.Create(ctx, target)) + })) return namespace } @@ -231,7 +225,7 @@ func TestWatchRule_FollowsGitTargetStatusOnlyRecovery(t *testing.T) { }).SetupWithManager(m) }) - namespace := seedTarget(ctx, t, "wiring-watchrule", "") + namespace := seedTarget(ctx, t, "wiring-watchrule") rule := &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "rule", Namespace: namespace}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ @@ -267,7 +261,7 @@ func TestClusterWatchRule_FollowsGitTargetStatusOnlyRecovery(t *testing.T) { }).SetupWithManager(m) }) - namespace := seedTarget(ctx, t, "wiring-clusterwatchrule", "wiring-source") + namespace := seedTarget(ctx, t, "wiring-clusterwatchrule") rule := &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "cluster-rule"}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ diff --git a/internal/git/apply_groups.go b/internal/git/apply_groups.go index 6b6232eed..4b10911cb 100644 --- a/internal/git/apply_groups.go +++ b/internal/git/apply_groups.go @@ -23,7 +23,7 @@ func sourceGroup(trigger v1alpha3.ApplyTrigger) string { if !ok { return "" } - return trigger.ClusterProvider() + "/" + source.Namespace + "/" + source.Name + return trigger.KubeProviderKey().String() + "/" + source.Namespace + "/" + source.Name } // group is the applier's group, as listed with any GitTarget naming it. diff --git a/internal/git/apply_groups_test.go b/internal/git/apply_groups_test.go index 45dbe7b10..c773fb9f2 100644 --- a/internal/git/apply_groups_test.go +++ b/internal/git/apply_groups_test.go @@ -74,3 +74,19 @@ func TestGroups_TheSourceIsSharedInTurn(t *testing.T) { loop.serviceAppliers() } } + +// TestSourceGroup_KeysByTheTriggersKubeProvider: a group is one GitRepository in one cluster, and a +// cluster is a KubeProvider's namespace/name, so the same GitRepository reached through providers +// sharing a name in two namespaces is two groups. A trigger that has read no source shares none. +func TestSourceGroup_KeysByTheTriggersKubeProvider(t *testing.T) { + assert.Empty(t, sourceGroup(fluxTrigger("alpha")), "a trigger with no source shares nothing") + + own := fluxTrigger("alpha", applying("platform")) + assert.Equal(t, "team-a/"+testKubeProvider+"/flux-system/platform", sourceGroup(own)) + + elsewhere := fluxTrigger("alpha", applying("platform"), func(trigger *configv1alpha3.FluxTrigger) { + trigger.Spec.KubeProviderRef.Namespace = "platform" + }) + assert.Equal(t, "platform/"+testKubeProvider+"/flux-system/platform", sourceGroup(elsewhere)) + assert.NotEqual(t, sourceGroup(own), sourceGroup(elsewhere)) +} diff --git a/internal/git/apply_request.go b/internal/git/apply_request.go index aa4e21853..b43a01b4d 100644 --- a/internal/git/apply_request.go +++ b/internal/git/apply_request.go @@ -22,8 +22,8 @@ import ( type ApplierRef struct { // Trigger is the apply trigger that holds the applier. Trigger TriggerRef - // Cluster is the ClusterProvider whose cluster the applier lives in (the trigger's - // spec.clusterProviderRef). + // Cluster is the KubeProvider (namespace/name) whose cluster the applier lives in (the trigger's + // spec.kubeProviderRef). Cluster string // Kind is v1alpha3.ApplierKindKustomization or v1alpha3.ApplierKindApplication. Kind string @@ -50,7 +50,7 @@ func ApplierOf(trigger v1alpha3.ApplyTrigger) ApplierRef { applier := trigger.Applier() return ApplierRef{ Trigger: TriggerRef{Kind: trigger.TriggerKind(), Namespace: trigger.GetNamespace(), Name: trigger.GetName()}, - Cluster: trigger.ClusterProvider(), Kind: trigger.ApplierKind(), + Cluster: trigger.KubeProviderKey().String(), Kind: trigger.ApplierKind(), Namespace: applier.Namespace, Name: applier.Name, } } @@ -174,7 +174,7 @@ const ( // configuration changes: it applies another repository or branch, or no GitRepository. ApplyRejected ApplyOutcome = "Rejected" // ApplyDropped asked nothing, because none of the targets may still ask for the reason the - // request carried: each changed, is no longer admitted by its ClusterProvider, or withdrew its + // request carried: each changed, is no longer admitted by its KubeProvider, or withdrew its // consent to a Refusal. The worker decides again from what the targets are now. ApplyDropped ApplyOutcome = "Dropped" // ApplyStale asked nothing, because the decision no longer held when the write was due: the diff --git a/internal/git/apply_trigger_test.go b/internal/git/apply_trigger_test.go index b786eb2ba..d8adc8fbc 100644 --- a/internal/git/apply_trigger_test.go +++ b/internal/git/apply_trigger_test.go @@ -90,7 +90,7 @@ func TestApplyInbox_RestorePutsBackWhatWasTaken(t *testing.T) { func applierRef(name string) ApplierRef { return ApplierRef{ Trigger: TriggerRef{Kind: configv1alpha3.ApplyTriggerKindFlux, Namespace: "team-a", Name: name}, - Cluster: configv1alpha3.DefaultClusterProviderName, Kind: configv1alpha3.ApplierKindKustomization, + Cluster: "team-a/" + testKubeProvider, Kind: configv1alpha3.ApplierKindKustomization, Namespace: "team-a", Name: name, } } @@ -99,7 +99,7 @@ func applierRef(name string) ApplierRef { func argoApplierRef(name string) ApplierRef { return ApplierRef{ Trigger: TriggerRef{Kind: configv1alpha3.ApplyTriggerKindArgoCD, Namespace: "team-a", Name: name}, - Cluster: configv1alpha3.DefaultClusterProviderName, Kind: configv1alpha3.ApplierKindApplication, + Cluster: "team-a/" + testKubeProvider, Kind: configv1alpha3.ApplierKindApplication, Namespace: "argocd", Name: name, } } @@ -312,8 +312,8 @@ func TestServiceAppliers_ANewlyNamedApplierIsCaughtUp(t *testing.T) { require.NoError(t, loop.w.Client.Create(context.Background(), &configv1alpha3.ArgoCDTrigger{ ObjectMeta: metav1.ObjectMeta{Name: "argo", Namespace: "team-a"}, Spec: configv1alpha3.ArgoCDTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: configv1alpha3.DefaultClusterProviderName}, - ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "argo"}, + KubeProviderRef: meta.NamespacedObjectReference{Name: testKubeProvider}, + ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: "argo"}, }, })) loop.serviceAppliers() @@ -438,19 +438,25 @@ func TestApplyTrigger_ARejectedPushAsksForTheReplayedTip(t *testing.T) { } // TestApplierOf_KeysTheApplierByItsTriggerClusterAndKind: one name in two clusters is two appliers, -// and the trigger's spec.clusterProviderRef, not the GitTarget's source, decides which cluster. A -// Kustomization and an Application of one name are two appliers too. +// and the trigger's spec.kubeProviderRef, not the GitTarget's source, decides which cluster: the +// KubeProvider's namespace/name, its namespace defaulted to the trigger's own. A Kustomization and +// an Application of one name are two appliers too. func TestApplierOf_KeysTheApplierByItsTriggerClusterAndKind(t *testing.T) { flux := fluxTrigger("apps", func(t *configv1alpha3.FluxTrigger) { - t.Spec.ClusterProviderRef.Name = "prod-eu-1" + t.Spec.KubeProviderRef.Name = "prod-eu-1" }) kustomization := configv1alpha3.ApplierKindKustomization byFlux := TriggerRef{Kind: configv1alpha3.ApplyTriggerKindFlux, Namespace: "team-a", Name: "apps"} - assert.Equal(t, ApplierRef{Trigger: byFlux, Cluster: "prod-eu-1", Kind: kustomization, Namespace: "team-a", - Name: "apps"}, ApplierOf(flux)) + assert.Equal(t, ApplierRef{Trigger: byFlux, Cluster: "team-a/prod-eu-1", Kind: kustomization, + Namespace: "team-a", Name: "apps"}, ApplierOf(flux)) + + flux.Spec.KubeProviderRef.Name = "hub" + assert.NotEqual(t, ApplierRef{Trigger: byFlux, Cluster: "team-a/prod-eu-1", Kind: kustomization, + Namespace: "team-a", Name: "apps"}, ApplierOf(flux)) - flux.Spec.ClusterProviderRef.Name = "hub" - assert.NotEqual(t, ApplierRef{Trigger: byFlux, Cluster: "prod-eu-1", Kind: kustomization, + // One provider name in another namespace is another cluster. + flux.Spec.KubeProviderRef = meta.NamespacedObjectReference{Namespace: "platform", Name: "prod-eu-1"} + assert.Equal(t, ApplierRef{Trigger: byFlux, Cluster: "platform/prod-eu-1", Kind: kustomization, Namespace: "team-a", Name: "apps"}, ApplierOf(flux)) argo := testTrigger("team-a", &configv1alpha3.ApplyTriggerReference{ diff --git a/internal/git/refusal_reapply_test.go b/internal/git/refusal_reapply_test.go index 01d939ac6..cae56adef 100644 --- a/internal/git/refusal_reapply_test.go +++ b/internal/git/refusal_reapply_test.go @@ -479,14 +479,18 @@ func testApplier(name string) *configv1alpha3.ApplyTriggerReference { return &configv1alpha3.ApplyTriggerReference{Kind: configv1alpha3.ApplyTriggerKindFlux, Name: name} } -// fluxTrigger is a FluxTrigger in team-a holding the Kustomization of its own name, in the default -// cluster. +// testKubeProvider is the KubeProvider the test triggers reach their appliers through, in the +// trigger's own namespace. +const testKubeProvider = "local" + +// fluxTrigger is a FluxTrigger in team-a holding the Kustomization of its own name, in the cluster of +// testKubeProvider. func fluxTrigger(name string, mutate ...func(*configv1alpha3.FluxTrigger)) *configv1alpha3.FluxTrigger { trigger := &configv1alpha3.FluxTrigger{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "team-a"}, Spec: configv1alpha3.FluxTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: configv1alpha3.DefaultClusterProviderName}, - KustomizationRef: meta.NamespacedObjectReference{Name: name}, + KubeProviderRef: meta.NamespacedObjectReference{Name: testKubeProvider}, + KustomizationRef: meta.NamespacedObjectReference{Name: name}, }, } for _, m := range mutate { @@ -528,8 +532,8 @@ func testTrigger(namespace string, ref *configv1alpha3.ApplyTriggerReference) cl return &configv1alpha3.ArgoCDTrigger{ ObjectMeta: metav1.ObjectMeta{Name: ref.Name, Namespace: namespace}, Spec: configv1alpha3.ArgoCDTriggerSpec{ - ClusterProviderRef: &meta.LocalObjectReference{Name: configv1alpha3.DefaultClusterProviderName}, - ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: ref.Name}, + KubeProviderRef: meta.NamespacedObjectReference{Name: testKubeProvider}, + ApplicationRef: meta.NamespacedObjectReference{Namespace: "argocd", Name: ref.Name}, }, } } diff --git a/internal/git/types.go b/internal/git/types.go index df10a33bb..d331af935 100644 --- a/internal/git/types.go +++ b/internal/git/types.go @@ -332,9 +332,9 @@ type ResolvedTargetMetadata struct { // when the current policy is stricter, because tightening exists to stop deletions that have // not landed yet. PruneMode v1alpha3.PruneMode - // SourceCluster is the NAME of the source cluster the GitTarget mirrors from — - // (api/v1alpha3).GitTarget.SourceCluster(), the referenced ClusterProvider's name - // ("default" for the in-cluster provider). The resync mark-and-sweep resolves this subtree's + // SourceCluster is the source cluster the GitTarget mirrors from — + // (api/v1alpha3).GitTarget.SourceCluster(), the referenced KubeProvider's namespace/name. + // The resync mark-and-sweep resolves this subtree's // documents' GVK->GVR against that cluster's registry, so a folder mirroring a remote is swept // against the right cluster's mapping. SourceCluster string @@ -767,9 +767,9 @@ type Event struct { // GitTargetNamespace is the namespace of the target owning this event. GitTargetNamespace string - // SourceCluster is the NAME of the source cluster this object was watched on — - // (api/v1alpha3).GitTarget.SourceCluster(), the referenced ClusterProvider's name - // ("default" for the in-cluster provider). The writer resolves this document's GVK->GVR + // SourceCluster is the source cluster this object was watched on — + // (api/v1alpha3).GitTarget.SourceCluster(), the referenced KubeProvider's namespace/name. + // The writer resolves this document's GVK->GVR // against that cluster's type registry, so a folder mirroring a remote is never indexed // against the local cluster's mapping. SourceCluster string diff --git a/internal/kubeconfig/kubeconfig.go b/internal/kubeconfig/kubeconfig.go index 5fa8d5eed..46b968cd9 100644 --- a/internal/kubeconfig/kubeconfig.go +++ b/internal/kubeconfig/kubeconfig.go @@ -12,7 +12,10 @@ package kubeconfig import ( "errors" "fmt" + "sort" + "strings" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" @@ -41,15 +44,78 @@ const ( // Secrets and ship them to a remote API server it names. Always rejected — there is no safe // opt-in, unlike exec/insecure-TLS: require the embedded *-data (or inline token) forms. ReasonFileReferenceNotAllowed = "KubeConfigFileReferenceNotAllowed" + // ReasonInClusterNotAuthorized: a KubeProvider sets spec.inCluster, the operator's own + // identity, and --in-cluster-kube-providers does not list it. + ReasonInClusterNotAuthorized = "InClusterNotAuthorized" ) -// SafetyPolicy is the operator's opt-in to the two footguns this package rejects by default. -// Both default to false — the safe posture, diverging from Flux's silent strip. +// SafetyPolicy is the operator's opt-in to what this package rejects by default: the two kubeconfig +// footguns, and the operator's own identity. All default off — the safe posture, diverging from +// Flux's silent strip. type SafetyPolicy struct { // AllowExec permits exec auth providers (--insecure-kubeconfig-exec). AllowExec bool // AllowInsecureTLS permits insecure-skip-tls-verify (--insecure-kubeconfig-tls). AllowInsecureTLS bool + // InClusterProviders are the KubeProviders allowed to use spec.inCluster + // (--in-cluster-kube-providers). Empty, none is. + InClusterProviders map[types.NamespacedName]struct{} +} + +// The allow-list is an exact set of names on purpose. A wildcard, a prefix or a namespace-wide +// grant would let anyone who can create a KubeProvider in that scope pick up the operator's own +// identity, which is exactly the path this list exists to close. + +// ParseInClusterProviders parses the --in-cluster-kube-providers value: a comma-separated list of +// exact namespace/name entries. Empty entries are ignored; anything else that is not one +// namespace/name pair is an error naming the entry. +func ParseInClusterProviders(value string) (map[types.NamespacedName]struct{}, error) { + out := map[types.NamespacedName]struct{}{} + for _, entry := range strings.Split(value, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + namespace, name, ok := strings.Cut(entry, "/") + if !ok || namespace == "" || name == "" || strings.Contains(name, "/") || strings.Contains(entry, "*") { + return nil, fmt.Errorf( + "in-cluster-kube-providers entry %q is not namespace/name; list each KubeProvider allowed to "+ + "use spec.inCluster exactly, for example gitops-reverser/local", entry) + } + out[types.NamespacedName{Namespace: namespace, Name: name}] = struct{}{} + } + return out, nil +} + +// AllowsInCluster reports whether the KubeProvider named provider may use the operator's own +// identity. +func (p SafetyPolicy) AllowsInCluster(provider types.NamespacedName) bool { + _, ok := p.InClusterProviders[provider] + return ok +} + +// CheckInCluster is the consent check for a KubeProvider that sets spec.inCluster: nil when the +// installation lists it, and a RejectionError naming the flag when it does not. +func CheckInCluster(provider types.NamespacedName, policy SafetyPolicy) *RejectionError { + if policy.AllowsInCluster(provider) { + return nil + } + allowed := make([]string, 0, len(policy.InClusterProviders)) + for key := range policy.InClusterProviders { + allowed = append(allowed, key.String()) + } + sort.Strings(allowed) + listed := "lists none" + if len(allowed) > 0 { + listed = "lists only " + strings.Join(allowed, ", ") + } + return &RejectionError{ + Reason: ReasonInClusterNotAuthorized, + Message: fmt.Sprintf( + "KubeProvider %s sets spec.inCluster, the operator's own identity, and the installation does not "+ + "allow it: --in-cluster-kube-providers %s. It is never used; set spec.secretRef instead, or ask "+ + "the installation's owner to list it", provider, listed), + } } // RejectionError is a typed, legible reason a kubeconfig was not accepted. It carries both the diff --git a/internal/kubeconfig/kubeconfig_test.go b/internal/kubeconfig/kubeconfig_test.go index 586de5af1..95a745aca 100644 --- a/internal/kubeconfig/kubeconfig_test.go +++ b/internal/kubeconfig/kubeconfig_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/types" ) const validKubeConfig = `apiVersion: v1 @@ -182,3 +183,64 @@ func TestBuildRESTConfig(t *testing.T) { require.True(t, ok) assert.Equal(t, ReasonExecNotAllowed, rej.Reason) } + +func TestParseInClusterProviders(t *testing.T) { + tests := []struct { + name string + value string + want []types.NamespacedName + wantErr string + }{ + {name: "empty lists none", value: ""}, + {name: "blank entries are ignored", value: " , ,"}, + { + name: "exact entries, trimmed", + value: "gitops-reverser/local, platform/ops", + want: []types.NamespacedName{ + {Namespace: "gitops-reverser", Name: "local"}, {Namespace: "platform", Name: "ops"}, + }, + }, + {name: "a bare name is refused", value: "local", wantErr: `"local" is not namespace/name`}, + {name: "an empty namespace is refused", value: "/local", wantErr: `"/local"`}, + {name: "an empty name is refused", value: "platform/", wantErr: `"platform/"`}, + {name: "an extra segment is refused", value: "a/b/c", wantErr: `"a/b/c"`}, + {name: "a wildcard is refused", value: "platform/*", wantErr: `"platform/*"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseInClusterProviders(tt.value) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Len(t, got, len(tt.want)) + for _, key := range tt.want { + assert.Contains(t, got, key) + } + }) + } +} + +func TestCheckInCluster(t *testing.T) { + local := types.NamespacedName{Namespace: "gitops-reverser", Name: "local"} + other := types.NamespacedName{Namespace: "tenant-a", Name: "local"} + + rej := CheckInCluster(local, SafetyPolicy{}) + require.NotNil(t, rej, "an empty allow-list allows nobody") + assert.Equal(t, ReasonInClusterNotAuthorized, rej.Reason) + assert.Contains(t, rej.Message, "lists none") + + policy := SafetyPolicy{InClusterProviders: map[types.NamespacedName]struct{}{local: {}}} + assert.Nil(t, CheckInCluster(local, policy)) + assert.True(t, policy.AllowsInCluster(local)) + + rej = CheckInCluster(other, policy) + require.NotNil(t, rej, "the same name in another namespace is another provider") + assert.Contains(t, rej.Message, "tenant-a/local") + assert.Contains(t, rej.Message, "lists only gitops-reverser/local") + + _, isRejection := AsRejection(rej) + assert.True(t, isRejection) +} diff --git a/internal/queue/key_prefix.go b/internal/queue/key_prefix.go index 14a03eb5d..4be44954d 100644 --- a/internal/queue/key_prefix.go +++ b/internal/queue/key_prefix.go @@ -19,9 +19,9 @@ const DefaultKeyPrefix = "gitops-reverser" // routeKeyInfix carries the AUDIT ROUTE dimension so a fact from cluster A never joins a watch // event from cluster B — the rv-only hatch especially, since RV is not globally unique. The route -// is what the audit events arrived under, NOT the ClusterProvider's name: an API server has one +// is what the audit events arrived under, NOT the KubeProvider's name: an API server has one // webhook backend and posts under one route, so several providers naming one cluster all declare -// that route and share its facts (ClusterProvider.AuditRoute()). It is spelled "route" rather than +// that route and share its facts (KubeProvider.AuditRoute()). It is spelled "route" rather than // "auditRoute" because the key already says audit one segment earlier, and it sits directly after // the domain suffix so one route's streams share a single glob prefix. const routeKeyInfix = "route:" diff --git a/internal/telemetry/apply_requests.go b/internal/telemetry/apply_requests.go index b8e0fa310..ccda08aed 100644 --- a/internal/telemetry/apply_requests.go +++ b/internal/telemetry/apply_requests.go @@ -21,7 +21,7 @@ import "go.opentelemetry.io/otel/metric" // ApplyRequestFailed Event marks the first failure of a run. // dropped — the attempt asked nothing, because no GitTarget may still ask for its reason: each // was deleted or recreated, moved to another branch or applier, is no longer admitted by -// its ClusterProvider, or withdrew its consent to a Refusal. +// its KubeProvider, or withdrew its consent to a Refusal. // stale — the attempt asked nothing, because the decision no longer held when the write was due: // the branch had moved on. The worker decides again at once, at the new tip. Frequent // stale attempts mean the branch keeps moving within an attempt's lifetime. diff --git a/internal/telemetry/branch_targets.go b/internal/telemetry/branch_targets.go index 54545ffea..f094162a9 100644 --- a/internal/telemetry/branch_targets.go +++ b/internal/telemetry/branch_targets.go @@ -40,15 +40,13 @@ import ( // # source_cluster // // It carries the cluster the GitTarget mirrors FROM, so the same join answers "which tenant's -// cluster" as well as "which GitTarget". The value is GitTarget.SourceCluster(), which is the -// referenced ClusterProvider's NAME and defaults to "default" — a concrete, non-empty string, so -// there is no empty label value that PromQL cannot tell apart from a missing one. It is NOT the -// internal config-plane sentinel, which is deliberately the empty string and is a different -// identity; see the doc on SourceCluster for why the name is a convention rather than a claim -// about which physical cluster it is. +// cluster" as well as "which GitTarget". The value is GitTarget.SourceCluster(), the referenced +// KubeProvider's namespace/name, so two providers with one name in two namespaces are two values. +// It is NOT the internal config-plane sentinel, which is deliberately the empty string and is a +// different identity. // // It rides this series rather than being added to the nine GitTarget-labelled instruments because -// spec.clusterProviderRef is CEL-immutable: a GitTarget's cluster is fixed for its life, so one +// spec.kubeProviderRef is CEL-immutable: a GitTarget's cluster is fixed for its life, so one // join recovers the axis for all of them. The Git-side instruments cannot carry it at all — a // branch is shared by GitTargets that may mirror different clusters, so a single value there would // have to be invented, which is the same objection that keeps gittarget_* off git_commits_total. diff --git a/internal/telemetry/branch_targets_test.go b/internal/telemetry/branch_targets_test.go index b03bc3f16..38b5ded3a 100644 --- a/internal/telemetry/branch_targets_test.go +++ b/internal/telemetry/branch_targets_test.go @@ -106,19 +106,19 @@ func TestBranchTargets_RepeatedRecordStaysOneSeries(t *testing.T) { assert.Equal(t, int64(1), value) } -// The cluster axis. A local and a remote target publish distinguishable values, and the local one -// is a concrete name rather than an empty string: GitTarget.SourceCluster() defaults to "default", -// so PromQL never has to tell an empty label value apart from a missing label. +// The cluster axis. A local and a remote target publish distinguishable values, each a concrete +// KubeProvider namespace/name (GitTarget.SourceCluster()) rather than an empty string, so PromQL +// never has to tell an empty label value apart from a missing label. func TestBranchTargets_SourceClusterDistinguishesLocalFromRemote(t *testing.T) { reader, err := InitTestExporter() require.NoError(t, err) defer ForgetBranchTarget("team-a", "local") defer ForgetBranchTarget("team-a", "remote") - RecordBranchTarget("team-a", "local", "team-a", "acme", "main", "default") - RecordBranchTarget("team-a", "remote", "team-a", "acme", "main", "tenant-eu-1") + RecordBranchTarget("team-a", "local", "team-a", "acme", "main", "gitops-reverser/local") + RecordBranchTarget("team-a", "remote", "team-a", "acme", "main", "team-a/tenant-eu-1") - for name, cluster := range map[string]string{"local": "default", "remote": "tenant-eu-1"} { + for name, cluster := range map[string]string{"local": "gitops-reverser/local", "remote": "team-a/tenant-eu-1"} { value, ok := CollectInt64Sum(reader, branchTargetsMetric, map[string]string{ "gittarget_name": name, "source_cluster": cluster, @@ -156,7 +156,7 @@ func TestBranchTargets_OneBranchCanSpanTwoClusters(t *testing.T) { } } -// A GitTarget's cluster is fixed for its life (spec.clusterProviderRef is CEL-immutable), which is +// A GitTarget's cluster is fixed for its life (spec.kubeProviderRef is CEL-immutable), which is // what makes one join series enough for every GitTarget-labelled instrument. The recording site is // still a replace, so a re-reconcile cannot leave two clusters published for one target. func TestBranchTargets_RerecordReplacesTheClusterRatherThanAdding(t *testing.T) { diff --git a/internal/watch/applier_dynamic_client_test.go b/internal/watch/applier_dynamic_client_test.go index 23fbb5464..fd35509fa 100644 --- a/internal/watch/applier_dynamic_client_test.go +++ b/internal/watch/applier_dynamic_client_test.go @@ -18,7 +18,7 @@ func TestApplierDynamicClient_RefusesTheConfigPlane(t *testing.T) { m := &Manager{Log: logr.Discard()} _, err := m.ApplierDynamicClient(context.Background(), configPlaneClusterID) - require.Error(t, err, "the config plane is not a ClusterProvider") + require.Error(t, err, "the config plane is not a KubeProvider") } // TestApplierDynamicClient_ServesAnUndeclaredClusterWithoutKeepingIt: an applier may live in a @@ -30,10 +30,10 @@ func TestApplierDynamicClient_ServesAnUndeclaredClusterWithoutKeepingIt(t *testi SourceClusters: stubSourceClusterResolver{cfg: &rest.Config{Host: "https://hub.example"}, version: "v1"}, } - dc, err := m.ApplierDynamicClient(context.Background(), "hub") + dc, err := m.ApplierDynamicClient(context.Background(), "platform/hub") require.NoError(t, err) require.NotNil(t, dc) - assert.Nil(t, m.clusterContextByID("hub"), "an applier-only cluster creates no cluster context") + assert.Nil(t, m.clusterContextByID("platform/hub"), "an applier-only cluster creates no cluster context") } // TestApplierDynamicClient_ReusesADeclaredClustersClient: a cluster some GitTarget mirrors from @@ -44,15 +44,15 @@ func TestApplierDynamicClient_ReusesADeclaredClustersClient(t *testing.T) { Log: logr.Discard(), SourceClusters: stubSourceClusterResolver{cfg: &rest.Config{Host: "https://prod.example"}, version: "v1"}, } - m.rememberGitTargetCluster(gd("checkout"), "prod-eu-1") + m.rememberGitTargetCluster(gd("checkout"), "team-a/prod-eu-1") - first, err := m.ApplierDynamicClient(context.Background(), "prod-eu-1") + first, err := m.ApplierDynamicClient(context.Background(), "team-a/prod-eu-1") require.NoError(t, err) - second, err := m.ApplierDynamicClient(context.Background(), "prod-eu-1") + second, err := m.ApplierDynamicClient(context.Background(), "team-a/prod-eu-1") require.NoError(t, err) assert.Same(t, first, second, "the cluster's cached client is reused, not rebuilt per call") - cc := m.clusterContextByID("prod-eu-1") + cc := m.clusterContextByID("team-a/prod-eu-1") require.NotNil(t, cc) assert.Same(t, first, cc.dynamicClient, "it is the client the cluster's watches run on") } diff --git a/internal/watch/audit_route_test.go b/internal/watch/audit_route_test.go index 2e7ac08d7..c601d79ce 100644 --- a/internal/watch/audit_route_test.go +++ b/internal/watch/audit_route_test.go @@ -8,32 +8,50 @@ import ( "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + k8stypes "k8s.io/apimachinery/pkg/types" + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/types" ) // TestManager_AuditRouteForCluster covers the read side of the Declare-time capture. The watch -// manager holds no Kubernetes client for ClusterProviders, so the controller hands it the route and +// manager holds no Kubernetes client for KubeProviders, so the controller hands it the route and // this is where an event's author lookup picks it up. func TestManager_AuditRouteForCluster(t *testing.T) { m := &Manager{} + const delegating, prod = "platform/srcns-delegating", "team-a/prod-eu-1" - // Nothing captured yet: a lookup racing the first Declare must read the cluster id, which is the - // provider's own name and exactly what AuditRoute() defaults to. Anything else would key the - // read to a partition the handler never writes. - assert.Equal(t, "srcns-delegating", m.auditRouteForCluster("srcns-delegating")) + // Nothing captured yet: a lookup racing the first Declare must read the provider's default route, + // ., which is exactly what AuditRoute() resolves when none is set. Anything else + // would key the read to a partition the handler never writes. + assert.Equal(t, "platform.srcns-delegating", m.auditRouteForCluster(delegating)) - m.rememberClusterAuditRoute("srcns-delegating", "default") - assert.Equal(t, "default", m.auditRouteForCluster("srcns-delegating")) + m.rememberClusterAuditRoute(delegating, "platform.local") + assert.Equal(t, "platform.local", m.auditRouteForCluster(delegating)) // An empty route never overwrites a captured one: a Declare that could not resolve the provider // passes the fallback, and that must not clobber a route already learned. - m.rememberClusterAuditRoute("srcns-delegating", "") - assert.Equal(t, "default", m.auditRouteForCluster("srcns-delegating")) + m.rememberClusterAuditRoute(delegating, "") + assert.Equal(t, "platform.local", m.auditRouteForCluster(delegating)) // A different cluster keeps its own route, so the partition still separates clusters. - m.rememberClusterAuditRoute("prod-eu-1", "prod-eu-1") - assert.Equal(t, "prod-eu-1", m.auditRouteForCluster("prod-eu-1")) + m.rememberClusterAuditRoute(prod, "prod-eu-1") + assert.Equal(t, "prod-eu-1", m.auditRouteForCluster(prod)) +} + +// TestManager_AuditRouteForCluster_FallbackSeparatesNamespaces: two providers sharing a name in two +// namespaces are two clusters, and their uncaptured default routes differ too, so neither tenant +// reads the other's facts while a Declare is still in flight. +func TestManager_AuditRouteForCluster_FallbackSeparatesNamespaces(t *testing.T) { + m := &Manager{} + teamA := SourceClusterID(k8stypes.NamespacedName{Namespace: "team-a", Name: "prod"}) + teamB := SourceClusterID(k8stypes.NamespacedName{Namespace: "team-b", Name: "prod"}) + + assert.Equal(t, "team-a.prod", m.auditRouteForCluster(teamA)) + assert.Equal(t, "team-b.prod", m.auditRouteForCluster(teamB)) + assert.Equal(t, + configv1alpha3.DefaultAuditRoute(k8stypes.NamespacedName{Namespace: "team-a", Name: "prod"}), + m.auditRouteForCluster(teamA), "the fallback is the API's own default") } // TestManager_AuditRouteDiesWithItsCluster pins the teardown: a route captured for a cluster is @@ -42,12 +60,13 @@ func TestManager_AuditRouteForCluster(t *testing.T) { func TestManager_AuditRouteDiesWithItsCluster(t *testing.T) { m := &Manager{Log: logr.Discard()} gitDest := types.NewResourceReference("target", "ns").WithUID("uid-1") + const cluster = "team-a/prod-eu-1" - m.rememberGitTargetCluster(gitDest, "prod-eu-1") - m.rememberClusterAuditRoute("prod-eu-1", "shared-route") - require.Equal(t, "shared-route", m.auditRouteForCluster("prod-eu-1")) + m.rememberGitTargetCluster(gitDest, cluster) + m.rememberClusterAuditRoute(cluster, "shared-route") + require.Equal(t, "shared-route", m.auditRouteForCluster(cluster)) m.forgetGitTargetCluster(gitDest) - assert.Equal(t, "prod-eu-1", m.auditRouteForCluster("prod-eu-1"), - "the captured route must die with its cluster, leaving the provider-name default") + assert.Equal(t, "team-a.prod-eu-1", m.auditRouteForCluster(cluster), + "the captured route must die with its cluster, leaving the provider's default route") } diff --git a/internal/watch/author_resolver.go b/internal/watch/author_resolver.go index 2df95c3af..5de0b59aa 100644 --- a/internal/watch/author_resolver.go +++ b/internal/watch/author_resolver.go @@ -47,7 +47,7 @@ type AttributionLookup interface { // old six arguments, so that tier would have been unreachable from the resolver. type AuthorQuery struct { // AuditRoute partitions the facts. It is the route the API server posts audit under, NOT the - // ClusterProvider's name: several providers may name one cluster and all read its facts. + // KubeProvider's name: several providers may name one cluster and all read its facts. AuditRoute string // GVR is the watched type. The version serves the metric labels only; the join is keyed on the // group/resource, which is what a fact carries. @@ -250,7 +250,7 @@ func attributionEventKind(query AuthorQuery) string { } // warnIfRouteNeverResolves says, once per audit route, that a route has produced a run of -// unresolved events and has never resolved one. That is the shape of a ClusterProvider pointed at a +// unresolved events and has never resolved one. That is the shape of a KubeProvider pointed at a // route no API server posts under, which is otherwise invisible: mirroring stays correct and only // the commit author is lost. The message names the fix rather than the symptom. func (r *attributionResolver) warnIfRouteNeverResolves(auditRoute string, gvr schema.GroupVersionResource) { @@ -260,7 +260,7 @@ func (r *attributionResolver) warnIfRouteNeverResolves(auditRoute string, gvr sc } r.log.Info("no audit facts have ever arrived on this audit route; every commit through it is "+ "authored as attribution-unresolved. An API server posts audit under ONE route, so a second "+ - "ClusterProvider naming the same cluster must set spec.attribution.auditRoute to the route "+ - "that cluster actually posts to (it defaults to the provider's own name)", + "KubeProvider naming the same cluster must set spec.attribution.auditRoute to the route "+ + "that cluster actually posts to (it defaults to .)", "auditRoute", auditRoute, "unresolvedInARow", streak, "gvr", gvr.String()) } diff --git a/internal/watch/author_resolver_test.go b/internal/watch/author_resolver_test.go index ab04c430b..d7944abde 100644 --- a/internal/watch/author_resolver_test.go +++ b/internal/watch/author_resolver_test.go @@ -296,7 +296,7 @@ func TestAuthorResolver_AuthorlessFactIsUnresolved(t *testing.T) { // health counter, so the warning path is exercised the way production reaches it: repeated events // on one audit route that never match a fact. // -// This is the loud half of the audit-route fix. A ClusterProvider pointed at a route no API server +// This is the loud half of the audit-route fix. A KubeProvider pointed at a route no API server // posts under mirrors correctly and loses only the commit author, which is why the original bug went // unnoticed until an explicit unresolved-author placeholder made it visible in Git. func TestAuthorResolver_WarnsOnceForARouteThatNeverResolves(t *testing.T) { diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index bb3cd3450..cb95a5674 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -20,7 +20,7 @@ import ( ) // Bootstrap runs BEFORE the first reconcile on every restart, so it is the second call site that -// must apply the ClusterProvider admission gate. A helper only the reconciler used would leave the +// must apply the KubeProvider admission gate. A helper only the reconciler used would leave the // whole startup window unguarded — long enough to compile a rule and plan a stream for a GitTarget // the provider never admitted. @@ -29,15 +29,18 @@ const ( bootTargetName = "prod-mirror" bootTargetNS = "team-a" bootProviderName = "prod-eu-1" + // bootProviderNS is the KubeProvider's namespace, not the GitTarget's, so whether it admits the + // GitTarget is its spec.accessFrom's decision rather than the own-namespace rule's. + bootProviderNS = "platform" ) func bootGitTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: bootTargetName, Namespace: bootTargetNS}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, - ClusterProviderRef: &meta.LocalObjectReference{Name: bootProviderName}, - Path: "clusters/prod", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: bootProviderNS, Name: bootProviderName}, + Path: "clusters/prod", }, } } @@ -48,10 +51,13 @@ func bootGitProvider() *configv1alpha3.GitProvider { } } -func bootClusterProvider(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.ClusterProvider { - return &configv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: bootProviderName}, - Spec: configv1alpha3.ClusterProviderSpec{AccessFrom: policy}, +func bootKubeProvider(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: bootProviderNS, Name: bootProviderName}, + Spec: configv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "prod-eu-1-kubeconfig"}, + AccessFrom: policy, + }, } } @@ -90,7 +96,7 @@ func bootCompiledNames(m *Manager) []string { func TestBootstrapClusterWatchRule_RefusesUnauthorizedGitTargetNamespace(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}}), + bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -101,9 +107,9 @@ func TestBootstrapClusterWatchRule_RefusesUnauthorizedGitTargetNamespace(t *test assert.Empty(t, bootCompiledNames(m), "a refused rule must never reach the store") } -// TestBootstrapClusterWatchRule_RefusesMissingClusterProvider covers the hard-gate half: an +// TestBootstrapClusterWatchRule_RefusesMissingKubeProvider covers the hard-gate half: an // undeclared provider is not an implicit allow at startup either. -func TestBootstrapClusterWatchRule_RefusesMissingClusterProvider(t *testing.T) { +func TestBootstrapClusterWatchRule_RefusesMissingKubeProvider(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, @@ -120,7 +126,7 @@ func TestBootstrapClusterWatchRule_RefusesMissingClusterProvider(t *testing.T) { func TestBootstrapClusterWatchRule_SeedsAdmittedRule(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -141,9 +147,9 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { admittedTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "ok-mirror", Namespace: "team-ok"}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, - ClusterProviderRef: &meta.LocalObjectReference{Name: bootProviderName}, - Path: "clusters/ok", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: bootProviderNS, Name: bootProviderName}, + Path: "clusters/ok", }, } admittedRule := bootClusterWatchRule() @@ -157,7 +163,7 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { &configv1alpha3.GitProvider{ObjectMeta: metav1.ObjectMeta{Name: "git", Namespace: "team-ok"}}, admittedRule, // Admits team-ok only, so the team-a rule is refused. - bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"team-ok"}}), + bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"team-ok"}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-ok"}}, ) @@ -183,7 +189,7 @@ func TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes(t m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -195,13 +201,13 @@ func TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes(t // TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule is the REVOCATION contract for the // cluster kind: a rule accepted earlier and then refused must have its compiled rule REMOVED, not -// merely reported unready. The refusal used here is the ClusterProvider withdrawing the target's +// merely reported unready. The refusal used here is the KubeProvider withdrawing the target's // namespace, which is the one refusal the compile path can reach at runtime. func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing.T) { ctx := context.Background() m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -211,9 +217,10 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. require.True(t, decision.Admitted) require.Len(t, bootCompiledNames(m), 1, "precondition: the rule is compiled") - // The platform admin narrows the ClusterProvider so it no longer admits the target's namespace. - var provider configv1alpha3.ClusterProvider - require.NoError(t, m.Client.Get(ctx, client.ObjectKey{Name: bootProviderName}, &provider)) + // The platform admin narrows the KubeProvider so it no longer admits the target's namespace. + var provider configv1alpha3.KubeProvider + providerKey := client.ObjectKey{Namespace: bootProviderNS, Name: bootProviderName} + require.NoError(t, m.Client.Get(ctx, providerKey, &provider)) provider.Spec.AccessFrom = &configv1alpha3.NamespaceMatcher{Names: []string{"someone-else"}} require.NoError(t, m.Client.Update(ctx, &provider)) diff --git a/internal/watch/cluster_context.go b/internal/watch/cluster_context.go index 7b979ed32..716f6b35f 100644 --- a/internal/watch/cluster_context.go +++ b/internal/watch/cluster_context.go @@ -13,11 +13,13 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" "github.com/ConfigButler/gitops-reverser/internal/types" "github.com/ConfigButler/gitops-reverser/internal/typeset" @@ -27,33 +29,45 @@ import ( // in, where its own CRs live. It owns the operator's own duties (the CRD/APIService trigger // informers, the singleton catalog metrics, the injected test clients) and is always present. // -// It is deliberately the empty string, which no ClusterProvider name can ever be (name is required, -// MinLength=1), so the config plane can never collide with — or be mistaken for — a source cluster. -// NO PROVIDER NAME IS SPECIAL: what makes a source cluster in-cluster is an absent -// spec.kubeConfig, resolved per provider, never the name "default". +// It is deliberately the empty string, which no source-cluster id can ever be (an id is a +// KubeProvider's namespace/name), so the config plane can never collide with — or be mistaken for — +// a source cluster. NO PROVIDER NAME IS SPECIAL: what makes a source cluster in-cluster is the +// provider's spec.inCluster, resolved per provider and consented to by the installation. const configPlaneClusterID = "" -// inClusterConfigVersion is the version token the resolver returns for a provider that omits -// kubeConfig. It is constant because there is no Secret to rotate: the in-cluster config is fixed -// for the process, so a credential refresh can never see it "change". +// inClusterConfigVersion prefixes the version token the resolver returns for an inCluster provider. +// There is no Secret to rotate, so only the provider's UID follows it. const inClusterConfigVersion = "in-cluster" -// SourceClusterResolver turns a source-cluster NAME (a ClusterProvider's name) into a rest.Config -// by looking up the ClusterProvider and reading the kubeconfig Secret it names from the operator +// SourceClusterResolver turns a source cluster (a KubeProvider's namespace and name) into a +// rest.Config by reading the KubeProvider and the kubeconfig Secret it names from its own // namespace. It is an interface so the watch manager grows no Kubernetes client of its own for // this, and so tests can stand up a remote cluster without a Secret. The concrete implementation // lives in source_cluster_resolver.go. type SourceClusterResolver interface { - // ResolveSourceCluster returns the rest.Config for a ClusterProvider name, and an opaque - // version token that changes when the resolved config changes (the provider generation and - // the kubeconfig Secret's resourceVersion). An unknown or unreadable name is an error: + // ResolveSourceCluster returns the rest.Config for a KubeProvider, and an opaque version token + // that changes when the resolved config changes (the provider's UID and generation, and the + // kubeconfig Secret's resourceVersion). An unknown or unreadable provider is an error: // mirroring the wrong cluster into a folder is worse than mirroring none. // - // A NIL config with a nil error means the provider omits spec.kubeConfig and therefore names - // the operator's OWN cluster — the in-cluster answer, available to every provider name. This - // is the ONLY authority on whether a source cluster is in-cluster; nothing keys that off the - // provider's name. - ResolveSourceCluster(ctx context.Context, providerName string) (cfg *rest.Config, version string, err error) + // A NIL config with a nil error means the provider sets spec.inCluster and the installation + // allows it: the operator's OWN cluster. This is the ONLY authority on whether a source cluster + // is in-cluster; nothing keys that off the provider's name, and a provider the installation does + // not allow is an error here, never the operator's identity. + ResolveSourceCluster( + ctx context.Context, provider k8stypes.NamespacedName, + ) (cfg *rest.Config, version string, err error) +} + +// SourceClusterID is the id the watch data plane keys a source cluster's context on: the +// KubeProvider's namespace/name. Two providers with one name in two namespaces are two clusters. +func SourceClusterID(provider k8stypes.NamespacedName) string { return provider.String() } + +// sourceClusterKey is the KubeProvider a source-cluster id names. Neither a namespace nor a name +// can contain "/", so the id splits exactly once. +func sourceClusterKey(clusterID string) k8stypes.NamespacedName { + namespace, name, _ := strings.Cut(clusterID, "/") + return k8stypes.NamespacedName{Namespace: namespace, Name: name} } // clusterContext holds everything that used to be a Manager-wide singleton and is in fact a @@ -62,10 +76,10 @@ type SourceClusterResolver interface { // // There are two kinds. The CONFIG PLANE context (configPlaneClusterID) is the operator's own // cluster: it owns the API-surface trigger informers, the singleton catalog metrics, and the -// injected test clients, and it is never torn down. A SOURCE context is one per ClusterProvider a +// injected test clients, and it is never torn down. A SOURCE context is one per KubeProvider a // GitTarget mirrors from, created on first use and torn down when the last such GitTarget is gone; // whether it talks to the operator's own cluster or a remote is decided by RESOLVING its provider -// (spec.kubeConfig absent ⇒ in-cluster), never by its name. +// (spec.inCluster, where the installation allows it), never by its name. type clusterContext struct { id string @@ -74,7 +88,7 @@ type clusterContext struct { configPlane bool // inCluster records that this SOURCE cluster resolved to the operator's own cluster — its - // ClusterProvider omits spec.kubeConfig. It is RESOLVED, not inferred from the id: it is + // KubeProvider sets spec.inCluster and the installation allows it. It is RESOLVED, not inferred from the id: it is // false until the first successful resolution, so an unreached provider is treated as remote // (fail-closed — a cluster we have not resolved never silently borrows in-cluster // credentials). Guarded by clientsMu alongside the config fields below. @@ -135,7 +149,7 @@ const ( // "Status and conditions"). The local cluster is reachable by definition; a remote failure // is classified by what the discovery attempt hit. const ( - // reasonLocalCluster is the SourceClusterReachable=True reason when kubeConfig is omitted. + // reasonLocalCluster is the SourceClusterReachable=True reason for an inCluster provider. reasonLocalCluster = "LocalCluster" // reasonSourceClusterReachable is the SourceClusterReachable=True reason for a remote whose // API discovery succeeded. @@ -159,8 +173,8 @@ func newClusterContext(id string) *clusterContext { } // isLocal reports whether this context talks to the cluster the operator runs in — either because -// it IS the config plane, or because its ClusterProvider resolved without a kubeConfig. It is never -// a test on the provider's name. +// it IS the config plane, or because its KubeProvider resolved to spec.inCluster. It is never a +// test on the provider's name. func (c *clusterContext) isLocal() bool { if c.configPlane { return true @@ -220,7 +234,7 @@ func (m *Manager) seedConfigPlaneLocked(cc *clusterContext) { // configPlaneCluster is the operator's OWN cluster: where its CRs live, what the API-surface // trigger informers watch, and what the singleton catalog metrics describe. It is not a source -// cluster and has no ClusterProvider — a GitTarget mirrors from a provider, never from this. +// cluster and has no KubeProvider — a GitTarget mirrors from a provider, never from this. func (m *Manager) configPlaneCluster() *clusterContext { return m.cluster(configPlaneClusterID) } // registryForGitTarget returns the followability registry of the cluster a GitTarget mirrors @@ -247,7 +261,7 @@ func (m *Manager) ClusterTypeLookup(clusterID string) typeset.Lookup { // activeClusterIDs is every source cluster some GitTarget currently mirrors from, plus the config // plane. The config plane is always active: the operator's own CRs live there and its catalog arms // the API-surface trigger informers, so a rule-less install still refreshes it. The source ids come -// from the Declare-time capture (gitTargetClusters), not from the rules — spec.clusterProviderRef +// from the Declare-time capture (gitTargetClusters), not from the rules — spec.kubeProviderRef // is immutable and a GitTarget property, so there is no rules-disagree window to reconcile. func (m *Manager) activeClusterIDs() []string { seen := map[string]struct{}{configPlaneClusterID: {}} @@ -273,7 +287,7 @@ func (m *Manager) rememberDeclareCapture(gitDest types.ResourceReference, cluste // rememberGitTargetCluster captures the source cluster a GitTarget mirrors from, keyed by // GitTarget — the same capture-on-Declare pattern as rememberGitTargetUID. Because -// spec.clusterProviderRef is immutable, this is learned once and never changes for a given +// spec.kubeProviderRef is immutable, this is learned once and never changes for a given // GitTarget, so there is no per-rule propagation and no cross-rule disagreement window. func (m *Manager) rememberGitTargetCluster(gitDest types.ResourceReference, clusterID string) { m.mutateWatchPlane(func(s *watchPlaneState) bool { @@ -289,13 +303,13 @@ func (m *Manager) rememberGitTargetCluster(gitDest types.ResourceReference, clus } // rememberClusterAuditRoute captures the audit route a source cluster's attribution facts are keyed -// under, from (api/v1alpha3).ClusterProvider.AuditRoute(). It is keyed by CLUSTER, not by GitTarget: +// under, from (api/v1alpha3).KubeProvider.AuditRoute(). It is keyed by CLUSTER, not by GitTarget: // the route is a property of the provider, so every GitTarget mirroring from it writes the same // value and the last Declare wins harmlessly. // // Unlike the cluster id, this is MUTABLE — spec.attribution.auditRoute may be edited to correct a -// misconfiguration. The GitTarget controller re-declares on a ClusterProvider spec change -// (clusterProviderReadyOrSpecChanged), so an edit lands here without a restart. +// misconfiguration. The GitTarget controller re-declares on a KubeProvider spec change +// (kubeProviderReadyOrSpecChanged), so an edit lands here without a restart. func (m *Manager) rememberClusterAuditRoute(clusterID, auditRoute string) { if auditRoute == "" { return @@ -310,14 +324,14 @@ func (m *Manager) rememberClusterAuditRoute(clusterID, auditRoute string) { } // auditRouteForCluster resolves the audit route a source cluster's facts are keyed under. An -// uncaptured cluster falls back to the cluster id, which is the ClusterProvider's own name and -// exactly what AuditRoute() defaults to, so a lookup racing the first Declare reads the same key a +// uncaptured cluster falls back to its provider's default route (.), exactly what +// AuditRoute() resolves when none is set, so a lookup racing the first Declare reads the same key a // provider with no auditRoute set would. func (m *Manager) auditRouteForCluster(clusterID string) string { if route := m.watchPlane().auditRoutes[clusterID]; route != "" { return route } - return clusterID + return configv1alpha3.DefaultAuditRoute(sourceClusterKey(clusterID)) } // clusterIDForGitTarget resolves the source cluster of a GitTarget from the Declare-time @@ -387,7 +401,7 @@ func (m *Manager) teardownCluster(clusterID string) { // outcome of a discovery attempt. Only the CONFIG PLANE is skipped — it is the operator's own // cluster, reachable by definition and not a source. A source cluster that resolved in-cluster is // still recorded from its real attempt (it reports reasonLocalCluster on success), because "this -// provider omits kubeConfig" is a resolved fact, not an assumption made from its name. A failure +// provider is inCluster" is a resolved fact, not an assumption made from its name. A failure // class (unreachable / auth / access denied) is derived by classifySourceClusterReachFailure. func (m *Manager) recordClusterReachability(cc *clusterContext, err error) { if cc.configPlane { @@ -408,13 +422,18 @@ func (m *Manager) recordClusterReachability(cc *clusterContext, err error) { } // classifySourceClusterReachFailure maps a discovery-attempt error onto the -// SourceClusterReachable reason it should surface. A 401 is an authentication failure (the -// credential was rejected), a 403 is access denied (the identity lacks read on discovery), and -// everything else — DNS, TCP, TLS, timeout — is "unreachable". The message carries the raw -// error so the human fix is legible. +// SourceClusterReachable reason it should surface. A credential the operator refused to use +// (an unsafe kubeconfig, or an inCluster provider the installation does not list) keeps its own +// reason: nothing was dialed, so calling it unreachable would send the reader to the network. A +// 401 is an authentication failure (the credential was rejected), a 403 is access denied (the +// identity lacks read on discovery), and everything else — DNS, TCP, TLS, timeout — is +// "unreachable". The message carries the raw error so the human fix is legible. func classifySourceClusterReachFailure(err error) sourceClusterReachability { reason := reasonSourceClusterUnreachable + rej, refused := kubeconfig.AsRejection(err) switch { + case refused: + reason = rej.Reason case apierrors.IsUnauthorized(err): reason = reasonSourceClusterAuthFailed case apierrors.IsForbidden(err): @@ -505,7 +524,7 @@ func (m *Manager) clusterRESTConfigLocked(ctx context.Context, cc *clusterContex } // inClusterRESTConfig is the operator's own cluster config, used by the config plane and by any -// ClusterProvider that omits spec.kubeConfig. +// KubeProvider the installation allows to set spec.inCluster. func inClusterRESTConfig() (*rest.Config, error) { cfg, err := ctrl.GetConfig() if err != nil { @@ -514,10 +533,10 @@ func inClusterRESTConfig() (*rest.Config, error) { return cfg, nil } -// resolveSourceConfig resolves a SOURCE cluster's config from its ClusterProvider, and reports +// resolveSourceConfig resolves a SOURCE cluster's config from its KubeProvider, and reports // whether that provider turned out to be in-cluster. This is the ONLY place in-cluster-ness is -// decided for a source cluster: a provider that omits spec.kubeConfig resolves to the operator's -// own config whatever it is named, and one that sets it is remote even if it is named "default". +// decided for a source cluster: a provider that sets spec.inCluster, and that the installation +// allows, resolves to the operator's own config whatever it is named. // // It returns the verdict rather than storing it, because one caller resolves OUTSIDE clientsMu // (the credential refresh) and must not race the field. The returns are, in order: the config, its @@ -530,22 +549,23 @@ func (m *Manager) resolveSourceConfig( return nil, "", false, fmt.Errorf( "cannot reach source cluster %q: no source-cluster resolver configured", clusterID) } - resolved, version, err := m.SourceClusters.ResolveSourceCluster(ctx, clusterID) + resolved, version, err := m.SourceClusters.ResolveSourceCluster(ctx, sourceClusterKey(clusterID)) if err != nil { return nil, "", false, fmt.Errorf("resolve source cluster %q: %w", clusterID, err) } if resolved == nil { - // The provider omits kubeConfig: it names the operator's own cluster. + // The provider sets spec.inCluster and the installation allows it: the operator's own + // cluster. local, localErr := inClusterRESTConfig() if localErr != nil { return nil, "", false, localErr } - return local, inClusterConfigVersion, true, nil + return local, version, true, nil } return resolved, version, false, nil } -// refreshClusterCredentials re-reads a remote cluster's kubeconfig Secret on the catalog-refresh +// refreshClusterCredentials re-resolves a source cluster's credential on the catalog-refresh // cadence (every 30s and on every rule change), never on the hot watch reconnect path. It is the // data plane's half of the reconcile model: it does not watch Secrets, it RE-CHECKS them, and a // changed or vanished value is the moment to clean up. On a value CHANGE (rotation, or a repoint at @@ -554,8 +574,13 @@ func (m *Manager) resolveSourceConfig( // unsafe/unparseable) it drops the clients fail-closed and invalidates the watches so mirroring // stops — the enqueued reconcile then holds each GitTarget Validated=False. A transient read error // (slow apiserver, momentary blip) changes nothing: the next refresh retries. +// +// An inCluster source is re-resolved too. Its version is its provider's UID, so a provider deleted +// and recreated under the same name, possibly with the other credential arm, rebuilds the clients +// and re-plans the watches instead of carrying the old object's identity forward; and a provider +// dropped from the installation's allow-list loses the operator's identity on the next refresh. func (m *Manager) refreshClusterCredentials(ctx context.Context, cc *clusterContext) { - if cc.isLocal() { + if cc.configPlane { return } cfg, version, inCluster, err := m.resolveSourceConfig(ctx, cc.id) @@ -697,7 +722,7 @@ func (m *Manager) clusterDynamicClient(ctx context.Context, clusterID string) (d } // ApplierDynamicClient returns the client an applier in clusterID is asked through, with that -// ClusterProvider's identity, so anything asked through it is asked as that identity and against +// KubeProvider's identity, so anything asked through it is asked as that identity and against // that cluster, never the operator's own. A cluster some GitTarget mirrors from is asked through // the client its watches already run on. // @@ -708,10 +733,10 @@ func (m *Manager) clusterDynamicClient(ctx context.Context, clusterID string) (d // process. The client is built for this call from the provider as it resolves now, and dropped // with it. // -// The empty id is the config plane, which is not a ClusterProvider, and is refused. +// The empty id is the config plane, which is not a KubeProvider, and is refused. func (m *Manager) ApplierDynamicClient(ctx context.Context, clusterID string) (dynamic.Interface, error) { if clusterID == configPlaneClusterID { - return nil, errors.New("no cluster named: the config plane is not a ClusterProvider") + return nil, errors.New("no cluster named: the config plane is not a KubeProvider") } if m.sourceClusterDeclared(clusterID) { return m.clusterDynamicClient(ctx, clusterID) diff --git a/internal/watch/cluster_context_test.go b/internal/watch/cluster_context_test.go index 44d9813a6..882cc7d84 100644 --- a/internal/watch/cluster_context_test.go +++ b/internal/watch/cluster_context_test.go @@ -4,6 +4,7 @@ package watch import ( "errors" + "fmt" "testing" "github.com/go-logr/logr" @@ -13,6 +14,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" v1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -34,12 +36,13 @@ func TestConfigPlaneCluster_SeededAndReachable(t *testing.T) { } // TestSourceClusterInClusterIsResolvedNotNamed pins the model: a source cluster is in-cluster -// because its ClusterProvider omits kubeConfig, never because of what it is called. A provider -// named "default" is an ordinary source context and starts out un-resolved (fail-closed). +// because its KubeProvider resolved to spec.inCluster (and the installation allows it), never +// because of what it is called. A provider named "default" is an ordinary source context and starts +// out un-resolved (fail-closed). func TestSourceClusterInClusterIsResolvedNotNamed(t *testing.T) { m := &Manager{Log: logr.Discard()} - named := m.cluster("default") + named := m.cluster("platform/default") assert.False(t, named.configPlane, "no provider name is the config plane") assert.False(t, named.isLocal(), "un-resolved source clusters are treated as remote until their provider says otherwise") @@ -148,6 +151,13 @@ func TestClassifySourceClusterReachFailure(t *testing.T) { {"unauthorized", apierrors.NewUnauthorized("bad token"), reasonSourceClusterAuthFailed}, {"forbidden", apierrors.NewForbidden(gvr, "x", errors.New("nope")), reasonSourceClusterAccessDenied}, {"other", errors.New("dial tcp: i/o timeout"), reasonSourceClusterUnreachable}, + { + "a refused credential keeps its own reason", + fmt.Errorf("resolve source cluster %q: %w", "team-a/local", &kubeconfig.RejectionError{ + Reason: kubeconfig.ReasonInClusterNotAuthorized, Message: "not listed", + }), + kubeconfig.ReasonInClusterNotAuthorized, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/watch/cluster_model_test.go b/internal/watch/cluster_model_test.go index 5eaa37644..1b261d9a5 100644 --- a/internal/watch/cluster_model_test.go +++ b/internal/watch/cluster_model_test.go @@ -5,86 +5,108 @@ package watch import ( "context" "errors" + "os" + "path/filepath" + "strings" "testing" - meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/rest" - - configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// stubResolver is a SourceClusterResolver whose answer is fixed per provider name, so the -// in-cluster verdict can be driven without a cluster or a Secret. +// stubResolver is a SourceClusterResolver whose answer is fixed whatever provider is asked for, so +// the in-cluster verdict can be driven without a cluster or a Secret. It records the providers it was +// asked for. type stubResolver struct { cfg *rest.Config version string err error + asked *[]k8stypes.NamespacedName } -func (s stubResolver) ResolveSourceCluster(context.Context, string) (*rest.Config, string, error) { +func (s stubResolver) ResolveSourceCluster( + _ context.Context, provider k8stypes.NamespacedName, +) (*rest.Config, string, error) { + if s.asked != nil { + *s.asked = append(*s.asked, provider) + } return s.cfg, s.version, s.err } +// operatorHost is the server of the operator's own cluster under useOperatorKubeconfig. +const operatorHost = "https://operator.example:6443" + +// useOperatorKubeconfig points the operator's own config (ctrl.GetConfig) at a kubeconfig naming +// operatorHost, so the in-cluster branch resolves without a cluster under the unit test. +func useOperatorKubeconfig(t *testing.T) { + t.Helper() + path := filepath.Join(t.TempDir(), "kubeconfig") + require.NoError(t, os.WriteFile(path, []byte(replaceHost(resolverKubeConfig, operatorHost)), 0o600)) + t.Setenv("KUBECONFIG", path) +} + +// replaceHost is a test kubeconfig with its server replaced by host. +func replaceHost(kubeconfig, host string) string { + return strings.Replace(kubeconfig, "https://192.0.2.1:6443", host, 1) +} + // TestResolveSourceConfig_InClusterIsResolvedFromTheProvider is the core of the cluster model: a -// provider that omits spec.kubeConfig resolves to the operator's own cluster, and one that sets it -// is remote — for ANY provider name. Nothing keys this off the name "default". +// provider the resolver answers in-cluster (spec.inCluster, allowed by the installation) resolves to +// the operator's own cluster, and one it answers with a config is remote — for ANY provider name. +// Nothing keys this off a name such as "default". func TestResolveSourceConfig_InClusterIsResolvedFromTheProvider(t *testing.T) { + useOperatorKubeconfig(t) remote := &rest.Config{Host: "https://192.0.2.1:6443"} tests := []struct { name string - providerName string - resolver SourceClusterResolver + provider k8stypes.NamespacedName + resolver stubResolver wantInCluster bool }{ { - name: "a provider with a kubeConfig is remote", - providerName: configv1alpha3.DefaultClusterProviderName, - resolver: stubResolver{cfg: remote, version: "v1"}, + name: "a provider with a secretRef is remote", + provider: k8stypes.NamespacedName{Namespace: "team-a", Name: "team-a/prod-eu-1"}, + resolver: stubResolver{cfg: remote, version: "uid-1/1/7"}, wantInCluster: false, }, { name: "even when it is named default", - providerName: "default", - resolver: stubResolver{cfg: remote, version: "v1"}, + provider: k8stypes.NamespacedName{Namespace: "team-a", Name: "default"}, + resolver: stubResolver{cfg: remote, version: "uid-1/1/7"}, wantInCluster: false, }, { - name: "a provider without a kubeConfig is in-cluster", - providerName: "prod-eu-1", - resolver: stubResolver{cfg: nil, version: inClusterConfigVersion}, + name: "an allowed inCluster provider is in-cluster", + provider: k8stypes.NamespacedName{Namespace: "platform", Name: "team-a/prod-eu-1"}, + resolver: stubResolver{cfg: nil, version: inClusterConfigVersion + "/uid-1"}, wantInCluster: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + var asked []k8stypes.NamespacedName + tt.resolver.asked = &asked m := &Manager{Log: logr.Discard(), SourceClusters: tt.resolver} - cc := m.cluster(tt.providerName) + cc := m.cluster(SourceClusterID(tt.provider)) cfg, version, inCluster, err := m.resolveSourceConfig(context.Background(), cc.id) + require.NoError(t, err) + assert.Equal(t, []k8stypes.NamespacedName{tt.provider}, asked, + "the resolver is asked for the provider the cluster id names") + assert.Equal(t, tt.resolver.version, version, "the resolver's version token is kept as is") + assert.Equal(t, tt.wantInCluster, inCluster) if tt.wantInCluster { - // There is no cluster under a unit test, so the in-cluster branch surfaces - // ctrl.GetConfig's failure. Either way it must have taken the in-cluster path - // rather than treating the provider as remote. - if err != nil { - assert.Contains(t, err.Error(), "operator's own cluster") - return - } - assert.True(t, inCluster) - assert.Equal(t, inClusterConfigVersion, version) + assert.Equal(t, operatorHost, cfg.Host, "the operator's own config") return } - require.NoError(t, err) - assert.False(t, inCluster, "a provider with a kubeConfig is never in-cluster") assert.Equal(t, remote, cfg) - assert.Equal(t, "v1", version) }) } } @@ -95,7 +117,7 @@ func TestResolveSourceConfig_InClusterIsResolvedFromTheProvider(t *testing.T) { func TestResolveSourceConfig_Failures(t *testing.T) { t.Run("no resolver configured", func(t *testing.T) { m := &Manager{Log: logr.Discard()} - _, _, inCluster, err := m.resolveSourceConfig(context.Background(), "prod-eu-1") + _, _, inCluster, err := m.resolveSourceConfig(context.Background(), "team-a/prod-eu-1") require.Error(t, err) assert.Contains(t, err.Error(), "no source-cluster resolver configured") assert.False(t, inCluster) @@ -103,7 +125,7 @@ func TestResolveSourceConfig_Failures(t *testing.T) { t.Run("resolver error is propagated, not defaulted to in-cluster", func(t *testing.T) { m := &Manager{Log: logr.Discard(), SourceClusters: stubResolver{err: errors.New("boom")}} - _, _, inCluster, err := m.resolveSourceConfig(context.Background(), "prod-eu-1") + _, _, inCluster, err := m.resolveSourceConfig(context.Background(), "team-a/prod-eu-1") require.Error(t, err) assert.Contains(t, err.Error(), "resolve source cluster") assert.False(t, inCluster) @@ -119,7 +141,7 @@ func TestClusterContext_IsLocal(t *testing.T) { assert.True(t, cp.isLocal(), "the config plane is always the operator's own cluster") assert.True(t, cp.isLocalLocked()) - src := m.cluster("prod-eu-1") + src := m.cluster("team-a/prod-eu-1") assert.False(t, src.isLocal(), "an unresolved source is treated as remote (fail-closed)") src.clientsMu.Lock() @@ -132,7 +154,7 @@ func TestClusterContext_IsLocal(t *testing.T) { // TestDescribeCluster keeps the config plane legible in logs without giving it a provider name. func TestDescribeCluster(t *testing.T) { assert.Equal(t, "config-plane", describeCluster(configPlaneClusterID)) - assert.Equal(t, "prod-eu-1", describeCluster("prod-eu-1")) + assert.Equal(t, "team-a/prod-eu-1", describeCluster("team-a/prod-eu-1")) } // TestSourceClusterReachable_Projection pins the GitTarget-facing status contract: the tri-state a @@ -180,12 +202,12 @@ func TestSourceClusterReachable_Projection(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := &Manager{Log: logr.Discard()} - cc := m.cluster("prod-eu-1") + cc := m.cluster("team-a/prod-eu-1") m.clustersMu.Lock() cc.reachable = tt.reach m.clustersMu.Unlock() - got := m.SourceClusterReachable("prod-eu-1") + got := m.SourceClusterReachable("team-a/prod-eu-1") assert.Equal(t, tt.wantState, got.State) assert.Equal(t, tt.wantReason, got.Reason) assert.Equal(t, tt.wantMessage, got.Message) @@ -208,17 +230,17 @@ func TestSourceClusterReachable_ConfigPlaneAlwaysReachable(t *testing.T) { func TestRecordClusterReachability_InClusterSourceReportsLocalCluster(t *testing.T) { m := &Manager{Log: logr.Discard()} - cc := m.cluster("prod-eu-1") + cc := m.cluster("team-a/prod-eu-1") cc.clientsMu.Lock() cc.inCluster = true cc.clientsMu.Unlock() m.recordClusterReachability(cc, nil) - assert.Equal(t, reasonLocalCluster, m.SourceClusterReachable("prod-eu-1").Reason) + assert.Equal(t, reasonLocalCluster, m.SourceClusterReachable("team-a/prod-eu-1").Reason) - remote := m.cluster("prod-us-1") + remote := m.cluster("team-a/prod-us-1") m.recordClusterReachability(remote, nil) - assert.Equal(t, reasonSourceClusterReachable, m.SourceClusterReachable("prod-us-1").Reason) + assert.Equal(t, reasonSourceClusterReachable, m.SourceClusterReachable("team-a/prod-us-1").Reason) // The config plane is skipped entirely: it is not a source and has no discovery verdict. cp := m.configPlaneCluster() @@ -226,40 +248,22 @@ func TestRecordClusterReachability_InClusterSourceReportsLocalCluster(t *testing assert.Equal(t, "True", m.SourceClusterReachable(configPlaneClusterID).State) } -// TestClusterProviderIsInCluster mirrors the API-side predicate the resolver implements, so the -// two cannot drift: an absent kubeConfig is what makes a provider local, not its name. -func TestClusterProviderIsInCluster(t *testing.T) { - local := &configv1alpha3.ClusterProvider{ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}} - assert.True(t, local.IsInCluster(), "no kubeConfig means the operator's own cluster") - - named := &configv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: configv1alpha3.DefaultClusterProviderName}, - Spec: configv1alpha3.ClusterProviderSpec{KubeConfig: remoteKubeConfigRef()}, - } - assert.False(t, named.IsInCluster(), `"default" with a kubeConfig is a remote cluster`) -} - -// remoteKubeConfigRef is a minimal kubeConfig reference marking a provider as remote. -func remoteKubeConfigRef() *meta.KubeConfigReference { - return &meta.KubeConfigReference{SecretRef: &meta.SecretKeyReference{Name: "kc"}} -} - // TestOrderedClusters_ConfigPlaneFirstThenByName pins the published snapshot's ordering. The git // writer's cluster-scoped GVK lookup reads this slice per document, so a stable order keeps status // reads and lookups deterministic rather than map-iteration random. func TestOrderedClusters_ConfigPlaneFirstThenByName(t *testing.T) { m := &Manager{Log: logr.Discard()} // Create out of order on purpose. - m.cluster("prod-us-1") - m.cluster("alpha") + m.cluster("team-a/prod-us-1") + m.cluster("team-a/alpha") m.configPlaneCluster() - m.cluster("prod-eu-1") + m.cluster("team-a/prod-eu-1") ids := make([]string, 0, 4) for _, cc := range m.orderedClusters() { ids = append(ids, cc.id) } - assert.Equal(t, []string{configPlaneClusterID, "alpha", "prod-eu-1", "prod-us-1"}, ids) + assert.Equal(t, []string{configPlaneClusterID, "team-a/alpha", "team-a/prod-eu-1", "team-a/prod-us-1"}, ids) } // TestOrderedClusters_ForcesConfigPlaneIntoExistence: a Manager nobody has touched still publishes @@ -287,14 +291,14 @@ func TestClusterDynamicClient_UsesInjectedClientForConfigPlane(t *testing.T) { func TestTeardownCluster_NeverDropsTheConfigPlane(t *testing.T) { m := &Manager{Log: logr.Discard()} m.configPlaneCluster() - m.cluster("prod-eu-1") + m.cluster("team-a/prod-eu-1") m.teardownCluster(configPlaneClusterID) assert.NotNil(t, m.clusterContextByID(configPlaneClusterID), "the config plane is never torn down") - m.teardownCluster("prod-eu-1") - assert.Nil(t, m.clusterContextByID("prod-eu-1"), "a source cluster is torn down") + m.teardownCluster("team-a/prod-eu-1") + assert.Nil(t, m.clusterContextByID("team-a/prod-eu-1"), "a source cluster is torn down") // Tearing down an id that is not live is a no-op, not a panic. - m.teardownCluster("never-created") + m.teardownCluster("team-a/never-created") } diff --git a/internal/watch/config_plane_split_review_fixes_test.go b/internal/watch/config_plane_split_review_fixes_test.go index eb7b9d560..4bbc32f49 100644 --- a/internal/watch/config_plane_split_review_fixes_test.go +++ b/internal/watch/config_plane_split_review_fixes_test.go @@ -16,6 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/event" @@ -40,7 +41,7 @@ type stubSourceClusterResolver struct { } func (s stubSourceClusterResolver) ResolveSourceCluster( - context.Context, string, + context.Context, k8stypes.NamespacedName, ) (*rest.Config, string, error) { return s.cfg, s.version, s.err } @@ -92,7 +93,7 @@ func TestIsDefinitiveCredentialFailure_ClassifiesCredentialDeath(t *testing.T) { }{ { "secret deleted is definitive", - fmt.Errorf("resolve source cluster %q: %w", "team-a/kc/value", notFound), + fmt.Errorf("resolve source cluster %q: %w", "team-a/prod", notFound), true, }, { @@ -122,7 +123,7 @@ func TestIsDefinitiveCredentialFailure_ClassifiesCredentialDeath(t *testing.T) { func TestRefreshClusterCredentials_DropsClientsWhenCredentialGone(t *testing.T) { notFound := apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, "kc") m := &Manager{Log: logr.Discard(), SourceClusters: stubSourceClusterResolver{err: notFound}} - cc := m.cluster("team-a/kc/value") + cc := m.cluster("team-a/prod") cc.restConfig = &rest.Config{Host: "https://192.0.2.1:6443"} cc.dynamicClient = dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) cc.configVersion = "7" @@ -139,7 +140,7 @@ func TestRefreshClusterCredentials_KeepsClientsOnTransientError(t *testing.T) { Log: logr.Discard(), SourceClusters: stubSourceClusterResolver{err: errors.New("dial tcp: i/o timeout")}, } - cc := m.cluster("team-a/kc/value") + cc := m.cluster("team-a/prod") cc.restConfig = &rest.Config{Host: "https://192.0.2.1:6443"} cc.dynamicClient = dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) cc.configVersion = "7" @@ -187,7 +188,7 @@ func drainEnqueuedNames(ch <-chan event.GenericEvent, limit int) []string { func TestInvalidateClusterWatches_CancelsAndEnqueuesOnlyThatClusterTargets(t *testing.T) { m := &Manager{Log: logr.Discard()} - const clusterA, clusterB = "team-a/kc/value", "team-b/kc/value" + const clusterA, clusterB = "team-a/prod", "team-b/prod" m.rememberGitTargetCluster(gd("a1"), clusterA) m.rememberGitTargetCluster(gd("a2"), clusterA) m.rememberGitTargetCluster(gd("b1"), clusterB) @@ -222,7 +223,7 @@ func TestRefreshClusterCredentials_InvalidatesWatchesOnRotation(t *testing.T) { Log: logr.Discard(), SourceClusters: stubSourceClusterResolver{cfg: &rest.Config{Host: "h"}, version: "v2"}, } - const cluster = "team-a/kc/value" + const cluster = "team-a/prod" cc := m.cluster(cluster) cc.restConfig = &rest.Config{Host: "h"} cc.configVersion = "v1" // the watches were built on the old version @@ -246,12 +247,12 @@ func TestClusterMappingFingerprint_MovesOnRetarget(t *testing.T) { m := &Manager{Log: logr.Discard()} base := m.clusterMappingFingerprint() - m.rememberGitTargetCluster(gd("t"), "team-a/kc/value") + m.rememberGitTargetCluster(gd("t"), "team-a/prod") afterA := m.clusterMappingFingerprint() assert.NotEqual(t, base, afterA, "capturing a source cluster moves the fingerprint") assert.Equal(t, afterA, m.clusterMappingFingerprint(), "stable while the mapping is unchanged") - m.rememberGitTargetCluster(gd("t"), "team-b/kc2/value") + m.rememberGitTargetCluster(gd("t"), "team-b/prod") afterB := m.clusterMappingFingerprint() assert.NotEqual(t, afterA, afterB, "a GitTarget switching source clusters must move the fingerprint") } @@ -264,7 +265,7 @@ func TestRefreshWatchedTypeTables_RetargetReResolvesAtEqualRevisions(t *testing. store := rulestore.NewStore() m := &Manager{Log: logr.Discard(), RuleStore: store, resourceCatalog: newCommonTestCatalog(t)} - const clusterA, clusterB = "team-a/kc/value", "team-b/kc/value" + const clusterA, clusterB = "team-a/prod", "team-b/prod" ccA := seedClusterCatalog(t, m, clusterA, oneScopedResourceDiscovery("", "namespaces", "Namespace", false)) ccB := seedClusterCatalog(t, m, clusterB, oneScopedResourceDiscovery("", "nodes", "Node", false)) require.Equal(t, ccA.registry.Revision(), ccB.registry.Revision(), @@ -297,7 +298,7 @@ func TestRefreshWatchedTypeTables_RetargetReResolvesAtEqualRevisions(t *testing. func TestResolveWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { m := &Manager{Log: logr.Discard(), resourceCatalog: newCommonTestCatalog(t)} - const remote = "team-a/kc/value" + const remote = "team-a/prod" seedClusterCatalog(t, m, remote, oneResourceDiscovery("example.com", "widgets", "Widget")) m.rememberGitTargetCluster(types.NewResourceReference("t", "test-ns"), remote) @@ -330,7 +331,7 @@ func TestResolveWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { func TestResolveClusterWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { m := &Manager{Log: logr.Discard(), resourceCatalog: newCommonTestCatalog(t)} - const remote = "team-a/kc/value" + const remote = "team-a/prod" seedClusterCatalog(t, m, remote, oneScopedResourceDiscovery("example.com", "widgetclasses", "WidgetClass", false)) m.rememberGitTargetCluster(types.NewResourceReference("t", "test-ns"), remote) diff --git a/internal/watch/kube_provider_cluster_test.go b/internal/watch/kube_provider_cluster_test.go new file mode 100644 index 000000000..0d432114f --- /dev/null +++ b/internal/watch/kube_provider_cluster_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "testing" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" +) + +// TestSourceClusterID_RoundTrips: the id the data plane keys a source cluster on is the provider's +// namespace/name, and sourceClusterKey reads exactly that provider back out of it. Neither a +// namespace nor a name can hold "/", so the id splits once. +func TestSourceClusterID_RoundTrips(t *testing.T) { + for _, key := range []k8stypes.NamespacedName{ + {Namespace: "team-a", Name: "prod-eu-1"}, + {Namespace: "platform", Name: "local"}, + {Namespace: "a", Name: "b.c-d"}, + } { + id := SourceClusterID(key) + assert.Equal(t, key.Namespace+"/"+key.Name, id) + assert.Equal(t, key, sourceClusterKey(id)) + } + + assert.NotEqual(t, + SourceClusterID(k8stypes.NamespacedName{Namespace: "team-a", Name: "prod"}), + SourceClusterID(k8stypes.NamespacedName{Namespace: "team-b", Name: "prod"}), + "one provider name in two namespaces is two clusters") + assert.NotEqual(t, configPlaneClusterID, SourceClusterID(k8stypes.NamespacedName{}), + "no provider key, not even the zero one, is the config plane's id") +} + +// TestRefreshClusterCredentials_RebuildsAnInClusterSourceOnANewVersion: an inCluster source is +// re-resolved on the refresh cadence like a remote one, and its version is the provider's UID. A +// provider deleted and recreated under the same name is another object, so its clients are rebuilt +// and its watches re-planned rather than carried forward on the old object's identity. +func TestRefreshClusterCredentials_RebuildsAnInClusterSourceOnANewVersion(t *testing.T) { + useOperatorKubeconfig(t) + const cluster = "platform/local" + m := &Manager{ + Log: logr.Discard(), + SourceClusters: stubSourceClusterResolver{version: inClusterConfigVersion + "/uid-2"}, + } + cc := m.cluster(cluster) + cc.restConfig = &rest.Config{Host: operatorHost} + cc.configVersion = inClusterConfigVersion + "/uid-1" // built for the provider's previous incarnation + cc.inCluster = true + cc.dynamicClient = dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) + m.rememberGitTargetCluster(gd("t"), cluster) + cancelled := installFakeWatch(m, gd("t")) + ch := m.GitPathEvents() + + m.refreshClusterCredentials(context.Background(), cc) + m.applyPendingTeardowns(logr.Discard()) + + assert.Equal(t, inClusterConfigVersion+"/uid-2", cc.configVersion, "clients rebuilt for the new provider") + assert.True(t, cc.isLocal(), "it is still in-cluster") + assert.Nil(t, cc.dynamicClient, "the cached client is dropped so the next use rebuilds it") + assert.True(t, *cancelled, "the active watch is invalidated so it re-establishes") + assert.Equal(t, []string{"t"}, drainEnqueuedNames(ch, 2)) +} + +// TestRefreshClusterCredentials_KeepsAnUnchangedInClusterSource: the same provider at the same UID +// changes nothing, so a healthy in-cluster watch is never churned by the refresh. +func TestRefreshClusterCredentials_KeepsAnUnchangedInClusterSource(t *testing.T) { + useOperatorKubeconfig(t) + const cluster = "platform/local" + m := &Manager{ + Log: logr.Discard(), + SourceClusters: stubSourceClusterResolver{version: inClusterConfigVersion + "/uid-1"}, + } + cc := m.cluster(cluster) + cc.restConfig = &rest.Config{Host: operatorHost} + cc.configVersion = inClusterConfigVersion + "/uid-1" + cc.inCluster = true + dc := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) + cc.dynamicClient = dc + m.rememberGitTargetCluster(gd("t"), cluster) + cancelled := installFakeWatch(m, gd("t")) + + m.refreshClusterCredentials(context.Background(), cc) + m.applyPendingTeardowns(logr.Discard()) + + assert.Same(t, dc, cc.dynamicClient, "an unchanged version keeps the cached client") + assert.False(t, *cancelled, "and the active watch") +} + +// TestRefreshClusterCredentials_DropsAnInClusterSourceTheInstallationNoLongerAllows: a provider +// taken off --in-cluster-kube-providers loses the operator's identity on the next refresh. The +// rejection is definitive, so the cached clients are dropped fail-closed and the watches stopped. +func TestRefreshClusterCredentials_DropsAnInClusterSourceTheInstallationNoLongerAllows(t *testing.T) { + useOperatorKubeconfig(t) + local := k8stypes.NamespacedName{Namespace: "platform", Name: "local"} + control := fake.NewClientBuilder().WithScheme(resolverScheme(t)). + WithObjects(inClusterKubeProvider(local.Namespace, local.Name, "uid-1")).Build() + m := &Manager{Log: logr.Discard(), SourceClusters: NewSecretSourceClusterResolver(control, allowInCluster(local))} + cluster := SourceClusterID(local) + m.rememberGitTargetCluster(gd("t"), cluster) + cc := m.cluster(cluster) + + m.refreshClusterCredentials(context.Background(), cc) + require.NotNil(t, cc.restConfig, "an allowed inCluster provider resolves to the operator's own config") + assert.True(t, cc.isLocal()) + cancelled := installFakeWatch(m, gd("t")) + + m.SourceClusters = NewSecretSourceClusterResolver(control, kubeconfig.SafetyPolicy{}) + m.refreshClusterCredentials(context.Background(), cc) + m.applyPendingTeardowns(logr.Discard()) + + assert.Nil(t, cc.restConfig, "the operator's identity is withdrawn with the allow-list entry") + assert.Empty(t, cc.configVersion) + assert.True(t, *cancelled, "the watches running on that identity are stopped") +} + +// TestClusterContexts_OneNameInTwoNamespacesIsTwoClusters: two tenants may each name their provider +// "prod". Each is its own cluster context, resolved from its own namespace's Secret, so one tenant's +// watches can never run on the other tenant's credential. +func TestClusterContexts_OneNameInTwoNamespacesIsTwoClusters(t *testing.T) { + provider := func(namespace string) client.Object { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "prod", UID: k8stypes.UID("uid-" + namespace)}, + Spec: configv1alpha3.KubeProviderSpec{SecretRef: &meta.SecretKeyReference{Name: "kc"}}, + } + } + secret := func(namespace, host string) client.Object { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "kc"}, + Data: map[string][]byte{ + "value": []byte(replaceHost(resolverKubeConfig, host)), + }, + } + } + control := fake.NewClientBuilder().WithScheme(resolverScheme(t)).WithObjects( + provider("team-a"), secret("team-a", "https://a.example:6443"), + provider("team-b"), secret("team-b", "https://b.example:6443"), + ).Build() + m := &Manager{ + Log: logr.Discard(), + SourceClusters: NewSecretSourceClusterResolver(control, kubeconfig.SafetyPolicy{}), + } + + teamA := SourceClusterID(k8stypes.NamespacedName{Namespace: "team-a", Name: "prod"}) + teamB := SourceClusterID(k8stypes.NamespacedName{Namespace: "team-b", Name: "prod"}) + ccA, ccB := m.cluster(teamA), m.cluster(teamB) + assert.NotSame(t, ccA, ccB) + + for cc, want := range map[*clusterContext]string{ccA: "https://a.example:6443", ccB: "https://b.example:6443"} { + cc.clientsMu.Lock() + cfg, err := m.clusterRESTConfigLocked(context.Background(), cc) + cc.clientsMu.Unlock() + require.NoError(t, err) + assert.Equal(t, want, cfg.Host, "cluster %s resolves its own namespace's credential", cc.id) + } + assert.NotEqual(t, ccA.configVersion, ccB.configVersion) +} diff --git a/internal/watch/manager.go b/internal/watch/manager.go index f31745295..cb1c5433e 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -102,12 +102,12 @@ type Manager struct { opts metav1.ListOptions, ) (*unstructured.UnstructuredList, error) - // SourceClusters resolves a GitTarget's source cluster — a ClusterProvider NAME — into a - // rest.Config, reading the kubeconfig Secret the provider names from the config plane. It is + // SourceClusters resolves a GitTarget's source cluster — a KubeProvider's namespace/name — into + // a rest.Config, reading the kubeconfig Secret the provider names from its own namespace. It is // required for any GitTarget to mirror, single-cluster installs included: a source cluster is - // always a ClusterProvider, and only this resolver can say whether that provider is in-cluster - // (kubeConfig omitted) or remote. Nil leaves every source cluster unresolvable; only the config - // plane, which needs no provider, still works. + // always a KubeProvider, and only this resolver can say whether that provider is in-cluster (and + // allowed to be) or remote. Nil leaves every source cluster unresolvable; only the config plane, + // which needs no provider, still works. SourceClusters SourceClusterResolver // snapshotsMu guards settledSnapshots: per GitTarget key, the collections a snapshot of which @@ -117,7 +117,7 @@ type Manager struct { // clusters holds one clusterContext per distinct cluster — its API catalog, type registry, // and clients. configPlaneClusterID is the operator's own cluster (always present, never a - // source); every other key is a ClusterProvider name. See cluster_context.go. + // source); every other key is a KubeProvider's namespace/name. See cluster_context.go. clustersMu sync.Mutex clusters map[string]*clusterContext // clusterOrder is the published, ordered snapshot of clusters (local first). The git diff --git a/internal/watch/manager_catalog.go b/internal/watch/manager_catalog.go index 43cc5420c..d913df0a0 100644 --- a/internal/watch/manager_catalog.go +++ b/internal/watch/manager_catalog.go @@ -224,7 +224,7 @@ const ( // // The guards were correct when there was one cluster: the metrics carried no cluster label, so // publishing a remote's stats under them would have overwritten the local cluster's series. Since -// the config-plane split a GitTarget can mirror a remote source cluster through spec.kubeConfig, +// the config-plane split a GitTarget can mirror a remote source cluster through a KubeProvider, // and a degraded APIService THERE produced no signal at all — api_catalog_group_versions sat // reassuringly at zero degraded while part of that cluster's API surface was invisible. The label // is the fix; the guard was the bug. diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index be0ee03ff..83c7784c0 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -106,8 +106,9 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingGitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "ops-target", Namespace: "ops"}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("ops-provider", "main")}, - Path: "cluster-state", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("ops-provider", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, + Path: "cluster-state", }, } existingGitProvider := &configv1alpha3.GitProvider{ @@ -118,17 +119,14 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. }, } - // existingGitTarget omits clusterProviderRef, which resolves to the reserved "default" - // ClusterProvider. Bootstrap now applies that provider's namespace admission before seeding a - // ClusterWatchRule, so the fixture must ship the provider the way a real install does (an - // empty selector admits every namespace — the chart default). Without it the seed is correctly - // REFUSED, which is the behavior TestBootstrapClusterWatchRule_RefusesMissingClusterProvider + // existingGitTarget references the KubeProvider ops/local, in its own namespace. Bootstrap + // applies that provider's namespace admission before seeding a ClusterWatchRule, so the fixture + // must ship the provider (its own namespace is always admitted). Without it the seed is + // correctly REFUSED, which is the behavior TestBootstrapClusterWatchRule_RefusesMissingKubeProvider // covers; this test is about the admitted path. - defaultClusterProvider := &configv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: configv1alpha3.DefaultClusterProviderName}, - Spec: configv1alpha3.ClusterProviderSpec{ - AccessFrom: &configv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}, - }, + localKubeProvider := &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "ops"}, + Spec: configv1alpha3.KubeProviderSpec{InCluster: &configv1alpha3.KubeProviderInCluster{}}, } fakeClient := fake.NewClientBuilder(). @@ -138,7 +136,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingClusterWatchRule, existingGitTarget, existingGitProvider, - defaultClusterProvider, + localKubeProvider, ). Build() diff --git a/internal/watch/materialization.go b/internal/watch/materialization.go index 9564c717a..c61402e41 100644 --- a/internal/watch/materialization.go +++ b/internal/watch/materialization.go @@ -12,10 +12,10 @@ import ( // DeclareForGitTarget submits the GitTarget's declaration to the watch-plane owner and returns. // It does no work on the caller's goroutine. // -// clusterID is (api/v1alpha3).GitTarget.SourceCluster() — the referenced ClusterProvider's name, -// "default" for the cluster the operator runs in. It is captured with the UID and the audit -// route, because none of the three can be derived from the rule-derived watch tables the owner -// plans from. +// clusterID is (api/v1alpha3).GitTarget.SourceCluster() — the referenced KubeProvider's +// namespace/name, whether it names a remote cluster or the operator's own. It is captured with the +// UID and the audit route, because none of the three can be derived from the rule-derived watch +// tables the owner plans from. // // pruneMode is (api/v1alpha3).GitTarget.EffectivePruneMode(). Unlike the other two it is mutable, // and widening it to a sweeping mode forces a fresh replay — see prune_declaration.go for why the diff --git a/internal/watch/source_cluster_resolver.go b/internal/watch/source_cluster_resolver.go index 7ebc179e9..e2210b249 100644 --- a/internal/watch/source_cluster_resolver.go +++ b/internal/watch/source_cluster_resolver.go @@ -9,6 +9,7 @@ import ( "time" corev1 "k8s.io/api/core/v1" + k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -30,80 +31,67 @@ const sourceClusterDialTimeout = 15 * time.Second const sourceClusterKeepAlive = 30 * time.Second // defaultSourceClusterQPS / -Burst are the client-side throttle for a remote source cluster whose -// ClusterProvider sets no spec.client: a remote is reached over a network the in-cluster config is +// KubeProvider sets no spec.client: a remote is reached over a network the in-cluster config is // not, and is only read (list/watch/get). const ( defaultSourceClusterQPS = 20 defaultSourceClusterBurst = 30 ) -// secretSourceClusterResolver resolves a source-cluster NAME — a ClusterProvider's name, as -// (api/v1alpha3).GitTarget.SourceCluster() carries it — into a rest.Config by looking up the -// cluster-scoped ClusterProvider and reading its kubeConfig Secret from the OPERATOR NAMESPACE -// (the config plane): the cluster the operator runs in. +// secretSourceClusterResolver resolves a source cluster — a KubeProvider's namespace and name, as +// (api/v1alpha3).GitTarget.KubeProviderKey() carries it — into a rest.Config, reading the +// kubeconfig Secret the provider names from the PROVIDER'S OWN namespace. A tenant's connection +// keeps its credential with the tenant, and the watched cluster holds nothing but the watched +// resources. // -// This is the whole point of the split. The credential for a cluster never has to live on that -// cluster, and the watched cluster holds nothing but the watched resources — no Secret, no -// configbutler.ai CRDs at all. The resolver is only ever asked to resolve a REMOTE provider; -// the in-cluster "default" provider is served by the manager's own in-cluster config and never -// reaches here. +// It is also the one place the operator's own identity is handed out: a provider that sets +// spec.inCluster resolves to it only while the installation lists it, so every credential use (the +// watches, discovery, and the appliers) passes the same consent check. type secretSourceClusterResolver struct { - // client reads the ClusterProvider (cluster-scoped) and its kubeConfig Secret. Secret reads - // bypass the controller-runtime cache (the manager client sets Cache.DisableFor on Secrets), - // so a rotated kubeconfig is seen without a Secret informer — the same reasoning as the SOPS - // age-key reads. + // client reads the KubeProvider and its kubeconfig Secret. Secret reads bypass the + // controller-runtime cache (the manager client sets Cache.DisableFor on Secrets), so a rotated + // kubeconfig is seen without a Secret informer — the same reasoning as the SOPS age-key reads. client client.Client - // operatorNamespace is where a ClusterProvider's kubeConfig Secret is pinned. A cluster-scoped - // provider has no namespace of its own, so the credential for a cluster is always read from - // here — never from the source cluster. - operatorNamespace string - - // safety is the operator's exec / insecure-TLS opt-in. Both default off: an - // operator-supplied kubeconfig is attacker-adjacent input, so we REJECT rather than + // safety is the operator's exec / insecure-TLS opt-in and its inCluster allow-list. All default + // off: an operator-supplied kubeconfig is attacker-adjacent input, so we REJECT rather than // silently strip (diverging from Flux) unless a flag opts in. safety kubeconfig.SafetyPolicy } // NewSecretSourceClusterResolver builds the production source-cluster resolver. -func NewSecretSourceClusterResolver( - c client.Client, - operatorNamespace string, - safety kubeconfig.SafetyPolicy, -) SourceClusterResolver { - return &secretSourceClusterResolver{ - client: c, - operatorNamespace: operatorNamespace, - safety: safety, - } +func NewSecretSourceClusterResolver(c client.Client, safety kubeconfig.SafetyPolicy) SourceClusterResolver { + return &secretSourceClusterResolver{client: c, safety: safety} } func (r *secretSourceClusterResolver) ResolveSourceCluster( ctx context.Context, - providerName string, + key k8stypes.NamespacedName, ) (*rest.Config, string, error) { - var provider configv1alpha3.ClusterProvider - if err := r.client.Get(ctx, client.ObjectKey{Name: providerName}, &provider); err != nil { - return nil, "", fmt.Errorf("read ClusterProvider %q: %w", providerName, err) + var provider configv1alpha3.KubeProvider + if err := r.client.Get(ctx, key, &provider); err != nil { + return nil, "", fmt.Errorf("read KubeProvider %s: %w", key, err) } - if provider.Spec.KubeConfig == nil { - // No kubeConfig means the operator's OWN cluster — legal for every provider name, so this - // is the in-cluster answer (nil config), never an error. The name is irrelevant: what makes - // a provider local is the absent kubeConfig, not being called "default". - return nil, inClusterConfigVersion, nil + if provider.IsInCluster() { + if rej := kubeconfig.CheckInCluster(key, r.safety); rej != nil { + return nil, "", rej + } + // The UID is the whole version: there is no Secret to rotate, and a provider deleted and + // recreated under the same name is a different object whose arm may differ. + return nil, inClusterConfigVersion + "/" + string(provider.UID), nil } - if provider.Spec.KubeConfig.SecretRef == nil { + if provider.Spec.SecretRef == nil { return nil, "", &kubeconfig.RejectionError{ Reason: kubeconfig.ReasonInvalid, - Message: fmt.Sprintf("ClusterProvider %q sets kubeConfig without a secretRef", providerName), + Message: fmt.Sprintf("KubeProvider %s sets neither spec.secretRef nor spec.inCluster", key), } } - ref := provider.Spec.KubeConfig.SecretRef + ref := provider.Spec.SecretRef var secret corev1.Secret - secretKey := client.ObjectKey{Namespace: r.operatorNamespace, Name: ref.Name} + secretKey := client.ObjectKey{Namespace: provider.Namespace, Name: ref.Name} if err := r.client.Get(ctx, secretKey, &secret); err != nil { - return nil, "", fmt.Errorf("read kubeconfig Secret %s for ClusterProvider %q: %w", secretKey, providerName, err) + return nil, "", fmt.Errorf("read kubeconfig Secret %s for KubeProvider %s: %w", secretKey, key, err) } raw, usedKey, ok := kubeconfig.ResolveKey(secret.Data, ref.Key) if !ok { @@ -115,7 +103,7 @@ func (r *secretSourceClusterResolver) ResolveSourceCluster( } // Parse and REJECT unsafe kubeconfigs before building the config — a legible failure that the - // ClusterProvider's Validated gate reports with the same typed reason. Never dials. + // KubeProvider's Validated gate reports with the same typed reason. Never dials. cfg, err := kubeconfig.BuildRESTConfig(raw, r.safety) if err != nil { return nil, "", fmt.Errorf("kubeconfig Secret %s key %q: %w", secretKey, usedKey, err) @@ -130,15 +118,16 @@ func (r *secretSourceClusterResolver) ResolveSourceCluster( cfg.Dial = (&net.Dialer{Timeout: sourceClusterDialTimeout, KeepAlive: sourceClusterKeepAlive}).DialContext // The version token changes exactly when the resolved config could change: on a kubeconfig - // Secret rotation (its resourceVersion) OR on a provider spec change that alters qps/burst - // (its generation). A change in either re-resolves and rebuilds the clients. - version := fmt.Sprintf("%d/%s", provider.Generation, secret.ResourceVersion) + // Secret rotation (its resourceVersion), on a provider spec change that alters qps/burst (its + // generation), and on a provider deleted and recreated under the same name (its UID, since a new + // object starts at generation 1 again). A change in any re-resolves and rebuilds the clients. + version := fmt.Sprintf("%s/%d/%s", provider.UID, provider.Generation, secret.ResourceVersion) return cfg, version, nil } // throttleFor returns the effective client QPS/burst for a provider: its spec.client override // when set, else the built-in default. -func (r *secretSourceClusterResolver) throttleFor(provider *configv1alpha3.ClusterProvider) (float32, int) { +func (r *secretSourceClusterResolver) throttleFor(provider *configv1alpha3.KubeProvider) (float32, int) { qps := float32(defaultSourceClusterQPS) burst := defaultSourceClusterBurst client := provider.Spec.Client diff --git a/internal/watch/source_cluster_resolver_test.go b/internal/watch/source_cluster_resolver_test.go index f328d89c3..b248f87d1 100644 --- a/internal/watch/source_cluster_resolver_test.go +++ b/internal/watch/source_cluster_resolver_test.go @@ -4,14 +4,17 @@ package watch import ( "context" + "strings" "testing" meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -19,8 +22,6 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/kubeconfig" ) -const resolverOperatorNS = "gitops-reverser-system" - const resolverKubeConfig = `apiVersion: v1 kind: Config clusters: @@ -64,20 +65,55 @@ func resolverScheme(t *testing.T) *runtime.Scheme { return s } -// clusterProvider builds the remote ClusterProvider "prod-eu-1" whose kubeconfig Secret is "kc" +// resolverProviderNS is the namespace the test KubeProvider "prod-eu-1" and its kubeconfig Secret +// live in. resolverOtherNS is a namespace that holds neither. +const ( + resolverProviderNS = "team-a" + resolverOtherNS = "gitops-reverser-system" +) + +// resolverProviderKey is the test KubeProvider's namespace/name. +var resolverProviderKey = k8stypes.NamespacedName{Namespace: resolverProviderNS, Name: "prod-eu-1"} + +// kubeProvider builds the remote KubeProvider team-a/prod-eu-1 whose kubeconfig Secret is "kc" // under the given data key. -func clusterProvider(key string) *configv1alpha3.ClusterProvider { - return &configv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, - Spec: configv1alpha3.ClusterProviderSpec{ - KubeConfig: &meta.KubeConfigReference{SecretRef: &meta.SecretKeyReference{Name: "kc", Key: key}}, +func kubeProvider(key string) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: resolverProviderNS, Name: "prod-eu-1", UID: "uid-1", Generation: 3, + }, + Spec: configv1alpha3.KubeProviderSpec{ + SecretRef: &meta.SecretKeyReference{Name: "kc", Key: key}, }, } } +// inClusterKubeProvider builds a KubeProvider that asks for the operator's own identity. +func inClusterKubeProvider(namespace, name string, uid k8stypes.UID) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: uid}, + Spec: configv1alpha3.KubeProviderSpec{InCluster: &configv1alpha3.KubeProviderInCluster{}}, + } +} + +// allowInCluster is a SafetyPolicy listing exactly the given KubeProviders as allowed to use +// spec.inCluster. +func allowInCluster(keys ...k8stypes.NamespacedName) kubeconfig.SafetyPolicy { + allowed := map[k8stypes.NamespacedName]struct{}{} + for _, key := range keys { + allowed[key] = struct{}{} + } + return kubeconfig.SafetyPolicy{InClusterProviders: allowed} +} + +// kubeconfigSecret is the Secret "kc" in the test provider's namespace, at resourceVersion 42. func kubeconfigSecret(key, body string) *corev1.Secret { + return kubeconfigSecretIn(resolverProviderNS, key, body) +} + +func kubeconfigSecretIn(namespace, key, body string) *corev1.Secret { return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Namespace: resolverOperatorNS, Name: "kc"}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "kc", ResourceVersion: "42"}, Data: map[string][]byte{key: []byte(body)}, } } @@ -85,30 +121,52 @@ func kubeconfigSecret(key, body string) *corev1.Secret { func newResolver(t *testing.T, safety kubeconfig.SafetyPolicy, objs ...client.Object) SourceClusterResolver { t.Helper() cl := fake.NewClientBuilder().WithScheme(resolverScheme(t)).WithObjects(objs...).Build() - return NewSecretSourceClusterResolver(cl, resolverOperatorNS, safety) + return NewSecretSourceClusterResolver(cl, safety) } func TestResolveSourceCluster_ValidAppliesThrottleAndVersion(t *testing.T) { r := newResolver(t, kubeconfig.SafetyPolicy{}, - clusterProvider("value"), kubeconfigSecret("value", resolverKubeConfig)) + kubeProvider("value"), kubeconfigSecret("value", resolverKubeConfig)) - cfg, version, err := r.ResolveSourceCluster(context.Background(), "prod-eu-1") + cfg, version, err := r.ResolveSourceCluster(context.Background(), resolverProviderKey) require.NoError(t, err) require.NotNil(t, cfg) assert.Equal(t, "https://192.0.2.1:6443", cfg.Host) assert.InDelta(t, 20.0, cfg.QPS, 0.001, "the default source-cluster QPS applies") assert.Equal(t, 30, cfg.Burst, "the default source-cluster burst applies") - assert.NotEmpty(t, version, "the provider generation + Secret resourceVersion form the version token") + assert.Equal(t, "uid-1/3/42", version, + "the provider UID + generation + Secret resourceVersion form the version token") +} + +// TestResolveSourceCluster_VersionChangesWithTheProvidersUID: a provider deleted and recreated under +// the same name starts at generation 1 again, and its Secret may be untouched, so only the UID tells +// the two objects apart. Without it the refresh would keep the old object's clients. +func TestResolveSourceCluster_VersionChangesWithTheProvidersUID(t *testing.T) { + original := kubeProvider("value") + recreated := kubeProvider("value") + recreated.UID = "uid-2" + + _, before, err := newResolver(t, kubeconfig.SafetyPolicy{}, + original, kubeconfigSecret("value", resolverKubeConfig)). + ResolveSourceCluster(context.Background(), resolverProviderKey) + require.NoError(t, err) + _, after, err := newResolver(t, kubeconfig.SafetyPolicy{}, + recreated, kubeconfigSecret("value", resolverKubeConfig)). + ResolveSourceCluster(context.Background(), resolverProviderKey) + require.NoError(t, err) + + assert.NotEqual(t, before, after) + assert.True(t, strings.HasPrefix(after, "uid-2/"), after) } func TestResolveSourceCluster_PerProviderThrottleOverride(t *testing.T) { qps := int32(5) burst := int32(7) - provider := clusterProvider("value") - provider.Spec.Client = &configv1alpha3.ClusterProviderClient{QPS: &qps, Burst: &burst} + provider := kubeProvider("value") + provider.Spec.Client = &configv1alpha3.KubeProviderClient{QPS: &qps, Burst: &burst} r := newResolver(t, kubeconfig.SafetyPolicy{}, provider, kubeconfigSecret("value", resolverKubeConfig)) - cfg, _, err := r.ResolveSourceCluster(context.Background(), "prod-eu-1") + cfg, _, err := r.ResolveSourceCluster(context.Background(), resolverProviderKey) require.NoError(t, err) assert.InDelta(t, 5.0, cfg.QPS, 0.001, "spec.client.qps overrides the default") assert.Equal(t, 7, cfg.Burst, "spec.client.burst overrides the default") @@ -117,34 +175,113 @@ func TestResolveSourceCluster_PerProviderThrottleOverride(t *testing.T) { func TestResolveSourceCluster_KeyFallbackValueYaml(t *testing.T) { // Secret stores under value.yaml (Flux Kustomization shape); the provider's secretRef.key is empty. r := newResolver(t, kubeconfig.SafetyPolicy{}, - clusterProvider(""), kubeconfigSecret("value.yaml", resolverKubeConfig)) + kubeProvider(""), kubeconfigSecret("value.yaml", resolverKubeConfig)) - cfg, _, err := r.ResolveSourceCluster(context.Background(), "prod-eu-1") + cfg, _, err := r.ResolveSourceCluster(context.Background(), resolverProviderKey) require.NoError(t, err) assert.Equal(t, "https://192.0.2.1:6443", cfg.Host) } func TestResolveSourceCluster_MissingProviderSecretAndKey(t *testing.T) { - // Absent ClusterProvider -> error. + // Absent KubeProvider -> error. r := newResolver(t, kubeconfig.SafetyPolicy{}) - _, _, err := r.ResolveSourceCluster(context.Background(), "absent") - require.Error(t, err, "an absent ClusterProvider is an error, not a nil config") + absent := k8stypes.NamespacedName{Namespace: resolverProviderNS, Name: "absent"} + _, _, err := r.ResolveSourceCluster(context.Background(), absent) + require.Error(t, err, "an absent KubeProvider is an error, not a nil config") // Provider present, kubeconfig Secret absent -> error. - r = newResolver(t, kubeconfig.SafetyPolicy{}, clusterProvider("value")) - _, _, err = r.ResolveSourceCluster(context.Background(), "prod-eu-1") + r = newResolver(t, kubeconfig.SafetyPolicy{}, kubeProvider("value")) + _, _, err = r.ResolveSourceCluster(context.Background(), resolverProviderKey) require.Error(t, err, "an absent Secret is an error, not a nil config") // Secret present but no kubeconfig under the resolved key -> typed KeyNotFound. r = newResolver(t, kubeconfig.SafetyPolicy{}, - clusterProvider("value"), kubeconfigSecret("elsewhere", resolverKubeConfig)) - _, _, err = r.ResolveSourceCluster(context.Background(), "prod-eu-1") + kubeProvider("value"), kubeconfigSecret("elsewhere", resolverKubeConfig)) + _, _, err = r.ResolveSourceCluster(context.Background(), resolverProviderKey) require.Error(t, err) rej, ok := kubeconfig.AsRejection(err) require.True(t, ok) assert.Equal(t, kubeconfig.ReasonKeyNotFound, rej.Reason) } +// TestResolveSourceCluster_ReadsTheSecretFromTheProvidersNamespace: the credential stays with the +// tenant that declared the provider. A Secret of the same name anywhere else, the operator's own +// namespace included, is never read, and its absence where it belongs is a definitive loss. +func TestResolveSourceCluster_ReadsTheSecretFromTheProvidersNamespace(t *testing.T) { + r := newResolver(t, kubeconfig.SafetyPolicy{}, + kubeProvider("value"), kubeconfigSecretIn(resolverOtherNS, "value", resolverKubeConfig)) + _, _, err := r.ResolveSourceCluster(context.Background(), resolverProviderKey) + require.Error(t, err, "a Secret only in another namespace is not the provider's") + assert.True(t, apierrors.IsNotFound(err), "the Secret is NotFound in the provider's namespace: %v", err) + assert.True(t, isDefinitiveCredentialFailure(err)) + + r = newResolver(t, kubeconfig.SafetyPolicy{}, + kubeProvider("value"), kubeconfigSecretIn(resolverProviderNS, "value", resolverKubeConfig)) + cfg, _, err := r.ResolveSourceCluster(context.Background(), resolverProviderKey) + require.NoError(t, err) + assert.Equal(t, "https://192.0.2.1:6443", cfg.Host) +} + +// TestResolveSourceCluster_InClusterNeedsTheInstallationsConsent: spec.inCluster is the operator's +// own identity, so a provider gets it only while --in-cluster-kube-providers lists it exactly. An +// unlisted one is a typed rejection, never a silent fall through to that identity, and a refresh +// treats it as a definitive loss so cached clients are dropped. +func TestResolveSourceCluster_InClusterNeedsTheInstallationsConsent(t *testing.T) { + local := k8stypes.NamespacedName{Namespace: "platform", Name: "local"} + provider := inClusterKubeProvider(local.Namespace, local.Name, "uid-local") + + tests := []struct { + name string + safety kubeconfig.SafetyPolicy + }{ + {name: "the allow-list is empty", safety: kubeconfig.SafetyPolicy{}}, + { + name: "the same name is listed in another namespace", + safety: allowInCluster(k8stypes.NamespacedName{Namespace: "team-a", Name: "local"}), + }, + { + name: "another name is listed in the same namespace", + safety: allowInCluster(k8stypes.NamespacedName{Namespace: "platform", Name: "other"}), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg, version, err := newResolver(t, tc.safety, provider). + ResolveSourceCluster(context.Background(), local) + require.Error(t, err) + assert.Nil(t, cfg) + assert.Empty(t, version) + rej, ok := kubeconfig.AsRejection(err) + require.True(t, ok, "an unlisted inCluster provider is a typed rejection: %v", err) + assert.Equal(t, kubeconfig.ReasonInClusterNotAuthorized, rej.Reason) + assert.True(t, isDefinitiveCredentialFailure(err), "retrying cannot make an unlisted provider listed") + }) + } + + t.Run("listed", func(t *testing.T) { + cfg, version, err := newResolver(t, allowInCluster(local), provider). + ResolveSourceCluster(context.Background(), local) + require.NoError(t, err) + assert.Nil(t, cfg, "a nil config is the in-cluster answer") + assert.Equal(t, inClusterConfigVersion+"/uid-local", version, "the provider's UID is the whole version") + }) +} + +// TestResolveSourceCluster_NeitherArmIsRefused: an unvalidated provider that sets neither +// spec.secretRef nor spec.inCluster must never fall through to the operator's own identity. +func TestResolveSourceCluster_NeitherArmIsRefused(t *testing.T) { + provider := kubeProvider("value") + provider.Spec.SecretRef = nil + r := newResolver(t, allowInCluster(resolverProviderKey), provider) + + cfg, _, err := r.ResolveSourceCluster(context.Background(), resolverProviderKey) + require.Error(t, err) + assert.Nil(t, cfg) + rej, ok := kubeconfig.AsRejection(err) + require.True(t, ok) + assert.Equal(t, kubeconfig.ReasonInvalid, rej.Reason) +} + // TestDescribeKey covers the hint a "key not found" error carries. An omitted secretRef.key is // not "no key": the resolver tried both fallbacks, and the message must say so or the human is // told to look for a key they never wrote. @@ -166,8 +303,8 @@ func TestDescribeKey(t *testing.T) { func TestResolveSourceCluster_RejectsUnsafe(t *testing.T) { r := newResolver(t, kubeconfig.SafetyPolicy{}, - clusterProvider("value"), kubeconfigSecret("value", resolverExecKubeConfig)) - _, _, err := r.ResolveSourceCluster(context.Background(), "prod-eu-1") + kubeProvider("value"), kubeconfigSecret("value", resolverExecKubeConfig)) + _, _, err := r.ResolveSourceCluster(context.Background(), resolverProviderKey) require.Error(t, err) rej, ok := kubeconfig.AsRejection(err) require.True(t, ok) @@ -175,7 +312,7 @@ func TestResolveSourceCluster_RejectsUnsafe(t *testing.T) { // Opting in (a deliberate trust decision) lets it through. r = newResolver(t, kubeconfig.SafetyPolicy{AllowExec: true}, - clusterProvider("value"), kubeconfigSecret("value", resolverExecKubeConfig)) - _, _, err = r.ResolveSourceCluster(context.Background(), "prod-eu-1") + kubeProvider("value"), kubeconfigSecret("value", resolverExecKubeConfig)) + _, _, err = r.ResolveSourceCluster(context.Background(), resolverProviderKey) require.NoError(t, err) } diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index eec2e4afe..691ee4f7d 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -26,15 +26,18 @@ const ( snbTarget = "acme" snbRule = "repo-config-rule" snbProvider = "workspaces" + // snbProviderNS is the KubeProvider's namespace: another than the tenant's, so the tenant is + // admitted through spec.accessFrom. + snbProviderNS = "platform" ) func snbGitTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: snbTarget, Namespace: snbTenantNS}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, - ClusterProviderRef: &meta.LocalObjectReference{Name: snbProvider}, - Path: "tenants/acme", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("git", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Namespace: snbProviderNS, Name: snbProvider}, + Path: "tenants/acme", }, } } @@ -45,12 +48,13 @@ func snbGitProvider() *configv1alpha3.GitProvider { } } -func snbClusterProvider(delegate bool) *configv1alpha3.ClusterProvider { - return &configv1alpha3.ClusterProvider{ - ObjectMeta: metav1.ObjectMeta{Name: snbProvider}, - Spec: configv1alpha3.ClusterProviderSpec{ +func snbKubeProvider(delegate bool) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: snbProviderNS, Name: snbProvider}, + Spec: configv1alpha3.KubeProviderSpec{ + InCluster: &configv1alpha3.KubeProviderInCluster{}, AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{snbTenantNS}}, - AllowCrossNamespace: &configv1alpha3.ClusterProviderCrossNamespace{Sources: delegate}, + AllowCrossNamespace: &configv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, }, } } @@ -97,7 +101,7 @@ func snbCompiledNames(m *Manager) []string { func TestBootstrap_DeniedSourceNamespaceIsNotCompiledOnRestart(t *testing.T) { m := snbManager(t, // The provider does NOT delegate, so the override is refused. - snbGitTarget(), snbGitProvider(), snbClusterProvider(false), + snbGitTarget(), snbGitProvider(), snbKubeProvider(false), snbWatchRule(snbSourceNS), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, ) @@ -116,7 +120,7 @@ func TestBootstrap_DeniedSourceNamespaceIsNotCompiledOnRestart(t *testing.T) { // exactly as before, with no provider involvement at all. func TestBootstrap_LegacyWatchRuleStillCompiles(t *testing.T) { m := snbManager(t, - snbGitTarget(), snbGitProvider(), snbClusterProvider(false), + snbGitTarget(), snbGitProvider(), snbKubeProvider(false), snbWatchRule(""), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, ) @@ -133,7 +137,7 @@ func TestBootstrap_LegacyWatchRuleStillCompiles(t *testing.T) { // The admitted override path seeds the EFFECTIVE namespace, not the rule's own. func TestBootstrap_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T) { m := snbManager(t, - snbGitTarget(), snbGitProvider(), snbClusterProvider(true), + snbGitTarget(), snbGitProvider(), snbKubeProvider(true), snbWatchRule(snbSourceNS), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, ) @@ -153,7 +157,7 @@ func TestBootstrap_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T func TestCompileWatchRule_WildcardCompilesToTheClusterWideCollection(t *testing.T) { ctx := context.Background() m := snbManager(t, - snbGitTarget(), snbGitProvider(), snbClusterProvider(true), + snbGitTarget(), snbGitProvider(), snbKubeProvider(true), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, ) @@ -173,7 +177,7 @@ func TestCompileWatchRule_WildcardCompilesToTheClusterWideCollection(t *testing. func TestCompileWatchRule_WildcardIsRefusedWithoutDelegation(t *testing.T) { ctx := context.Background() m := snbManager(t, - snbGitTarget(), snbGitProvider(), snbClusterProvider(false), + snbGitTarget(), snbGitProvider(), snbKubeProvider(false), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, ) @@ -193,7 +197,7 @@ func TestCompileWatchRule_WildcardIsRefusedWithoutDelegation(t *testing.T) { func TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule(t *testing.T) { ctx := context.Background() m := snbManager(t, - snbGitTarget(), snbGitProvider(), snbClusterProvider(true), + snbGitTarget(), snbGitProvider(), snbKubeProvider(true), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, ) @@ -206,8 +210,9 @@ func TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule(t *testing require.Len(t, m.RuleStore.SnapshotWatchRules(), 1, "precondition: the rule is compiled") // The platform admin withdraws the delegation. - var stored configv1alpha3.ClusterProvider - require.NoError(t, m.Client.Get(ctx, k8stypes.NamespacedName{Name: snbProvider}, &stored)) + var stored configv1alpha3.KubeProvider + providerKey := k8stypes.NamespacedName{Namespace: snbProviderNS, Name: snbProvider} + require.NoError(t, m.Client.Get(ctx, providerKey, &stored)) stored.Spec.AllowCrossNamespace = nil require.NoError(t, m.Client.Update(ctx, &stored)) diff --git a/internal/watch/target_watch.go b/internal/watch/target_watch.go index 61e3b1df6..859fcfca5 100644 --- a/internal/watch/target_watch.go +++ b/internal/watch/target_watch.go @@ -681,7 +681,7 @@ func targetStreamStateForSessionEnd(err error) (StreamState, string, bool) { // no-op in configured-author mode, where no follower runs and no subscription is meaningful. // // The route rather than the cluster id is what the stream is keyed on, for the same reason the join -// is: an API server posts audit under ONE route, so several ClusterProviders naming one cluster all +// is: an API server posts audit under ONE route, so several KubeProviders naming one cluster all // declare that route and share its facts. func (m *Manager) followFactsForWatch(gitDest types.ResourceReference, key targetWatchKey) func() { if m.FactStreams == nil { @@ -1281,7 +1281,7 @@ func (m *Manager) attachAuthor( // author fact's post-write RV, so it may consult the /last pointer; a create/update is // exact-capable and must not fall through to /last. exactCapable := event.Operation != string(types.OperationDelete) - // event.SourceCluster (stamped just above, before this call) is the ClusterProvider NAME this + // event.SourceCluster (stamped just above, before this call) is the KubeProvider (namespace/name) this // event was watched on; auditRouteForCluster turns it into the AUDIT ROUTE the handler filed // facts under. The two differ whenever several providers name one cluster: an API server has one // webhook backend and posts under one route, so every other provider for that cluster declares diff --git a/internal/watch/watch_plane_state.go b/internal/watch/watch_plane_state.go index 4b2a34f02..1b738d1fb 100644 --- a/internal/watch/watch_plane_state.go +++ b/internal/watch/watch_plane_state.go @@ -75,8 +75,8 @@ type DeclareStatus struct { // gate refused never reaches Declare, so it never appears here, which makes "an unauthorized // namespace starts no watch" assertable from outside this package. Declared bool - // ClusterID is the source cluster the declaration named — a ClusterProvider name, empty for - // the cluster the operator runs in. Meaningful only when Declared. + // ClusterID is the source cluster the declaration named — a KubeProvider's namespace/name, + // empty for the config plane. Meaningful only when Declared. ClusterID string // Pending reports whether a plan pass is pending — the target is dirty, or has never // successfully applied a plan. diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index 1043f395b..4549e5f0b 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -96,7 +96,7 @@ func itemNamespaces(resolved authz.ResolvedSourceScope) [][]string { // // Two refusals, both terminal, in this order: // -// 1. the referenced GitTarget's namespace must be admitted by that target's ClusterProvider — a +// 1. the referenced GitTarget's namespace must be admitted by that target's KubeProvider — a // ClusterWatchRule's gitTargetRef carries a namespace, so it can name a target in ANY namespace // and widen that target's mirror scope cluster-wide; // 2. the rule must not carry a stored scope other than "Cluster". Admission rejects the value on @@ -147,7 +147,7 @@ func CompileClusterWatchRule( // keeps the two from drifting. const ( // ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized is the terminal reason when the - // referenced GitTarget's namespace is not admitted by that target's ClusterProvider — either + // referenced GitTarget's namespace is not admitted by that target's KubeProvider — either // because spec.accessFrom excludes it or because the provider does not exist at all. // // One rule-side reason covers both provider-side causes on purpose: from the ClusterWatchRule's diff --git a/internal/webhook/audit_fact_publish_test.go b/internal/webhook/audit_fact_publish_test.go index 5156330dc..44f17ad47 100644 --- a/internal/webhook/audit_fact_publish_test.go +++ b/internal/webhook/audit_fact_publish_test.go @@ -421,7 +421,7 @@ func TestAuditHandler_AnEventThatProducesNoFactIsCountedAsSuch(t *testing.T) { } // TestAuditHandler_RecordsTheRouteOfEveryAppendedBatch pins the publish-side signal the -// ClusterProvider AuditFactsReceived condition latches on: the route is recorded once a batch has +// KubeProvider AuditFactsReceived condition latches on: the route is recorded once a batch has // actually appended, and it is the route the events were posted under. func TestAuditHandler_RecordsTheRouteOfEveryAppendedBatch(t *testing.T) { publisher := &fakeFactPublisher{} @@ -473,7 +473,7 @@ func TestAuditHandler_RecordsNoRouteWhenNothingAppends(t *testing.T) { } // TestAuditHandler_RecordsDeliveryAndTransportFailure pins the two process-wide signals the -// ClusterProvider condition uses to say WHICH silence a provider with no facts is in. Delivery is +// KubeProvider condition uses to say WHICH silence a provider with no facts is in. Delivery is // recorded for the request itself, independently of whether anything in it could name an author; // the transport flag follows the append. func TestAuditHandler_RecordsDeliveryAndTransportFailure(t *testing.T) { diff --git a/internal/webhook/audit_handler.go b/internal/webhook/audit_handler.go index e364bb159..3ebfde7a7 100644 --- a/internal/webhook/audit_handler.go +++ b/internal/webhook/audit_handler.go @@ -69,7 +69,7 @@ type AuditHandlerConfig struct { // dropped and the join falls back to scope matching. Zero means queue.DefaultCollectionUIDCap. CollectionUIDCap int // RouteHealth is the process-wide per-route registry. Every fact batch that actually appends - // records its route there, which is what latches the ClusterProvider AuditFactsReceived + // records its route there, which is what latches the KubeProvider AuditFactsReceived // condition for the providers reading that route. Nil disables the bookkeeping. RouteHealth *attribution.RouteHealth // AuditRouteAnnotationKey enables the bare /audit-webhook endpoint for a SHARED audit stream @@ -118,7 +118,7 @@ func NewAuditHandler(config AuditHandlerConfig) (*AuditHandler, error) { // not serve looked exactly like an apiserver posting nothing at all. // // bare_endpoint_disabled is named for what it actually covers, which is NOT "a route no -// ClusterProvider claims": resolveRoute accepts any named route as-is, deliberately, because a +// KubeProvider claims": resolveRoute accepts any named route as-is, deliberately, because a // route is a partition name rather than a claim about an object. The only route rejection that // exists is the bare /audit-webhook endpoint when no annotation key is configured. const ( @@ -180,7 +180,7 @@ func (h *AuditHandler) resolveRoute(w http.ResponseWriter, r *http.Request) (aud return auditRoute{annotationKey: h.config.AuditRouteAnnotationKey}, true } - // A named route is accepted as-is. Ingestion does NOT check that a ClusterProvider carries this + // A named route is accepted as-is. Ingestion does NOT check that a KubeProvider carries this // route: the route is a partition name, not a claim about an object, and a fact filed under a // route nobody reads costs one key that expires on the fact TTL. Refusing here instead dropped // audit batches in flight while a provider was being created or recreated, which the API server @@ -508,7 +508,7 @@ func (b *factStreamBatches) add(accepted acceptedFact) { // a wrong route would let a user from one logical cluster author a matching object in another. The // rejection is per EVENT so correctly-annotated events in the same batch still land. // -// An annotation naming a route no ClusterProvider has declared is NOT rejected. The route is a +// An annotation naming a route no KubeProvider 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 ever joins it. That keeps ingestion free of Kubernetes reads on the hot path, and keeps a // provider that is created (or recreated) after its events have started flowing from losing them. @@ -553,7 +553,7 @@ func (h *AuditHandler) rejectUnroutableEvent( } h.firsts.unroutableEvent.Do(func() { log.Info("Rejected an unroutable audit event on the shared /audit-webhook endpoint; it names no "+ - "audit route any ClusterProvider carries and is never credited to a fallback. Stamp the "+ + "audit route any KubeProvider carries and is never credited to a fallback. Stamp the "+ "annotation, or point this producer at /audit-webhook/", fields...) }) log.V(1).Info("Rejected unroutable audit event", fields...) @@ -693,9 +693,9 @@ func effectiveAuditUsername(event auditv1.Event) string { } // validateAuditWebhookPath accepts the bare shared /audit-webhook and a single-segment -// /audit-webhook/. The segment is an audit route, not necessarily a ClusterProvider -// name: a provider joins a route through spec.attribution.auditRoute, which merely defaults to its -// own name. It is a purely SYNTACTIC check; whether any provider carries the named route, and +// /audit-webhook/. The segment is an audit route, not a KubeProvider name: a provider +// joins a route through spec.attribution.auditRoute, which defaults to .. It is a +// purely SYNTACTIC check; whether any provider carries the named route, and // whether the bare endpoint is enabled at all, is enforced in ServeHTTP. It rejects a trailing // slash and any extra path segment. func validateAuditWebhookPath(path string) error { @@ -710,7 +710,7 @@ func validateAuditWebhookPath(path string) error { return errors.New("audit webhook path must not include a trailing slash") } if strings.Contains(segment, "/") { - return errors.New("audit webhook path must name exactly one ClusterProvider: /audit-webhook/") + return errors.New("audit webhook path must name exactly one audit route: /audit-webhook/") } return nil } diff --git a/internal/webhook/audit_handler_test.go b/internal/webhook/audit_handler_test.go index 097753f12..0b385d2f6 100644 --- a/internal/webhook/audit_handler_test.go +++ b/internal/webhook/audit_handler_test.go @@ -146,7 +146,7 @@ const acceptedCreateEvent = `{"kind":"Event","level":"RequestResponse","auditID" `"responseObject":{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm","namespace":"default"}}}` // TestAuditHandler_NamedDefaultRouteThreadsItsProvider checks that /audit-webhook/default is an -// ordinary named route: it records its facts under the "default" ClusterProvider name. +// ordinary named route: it records its facts under the route "default", which no name privileges. func TestAuditHandler_NamedDefaultRouteThreadsItsProvider(t *testing.T) { recorder := &fakeFactSink{} handler, err := NewAuditHandler(routedConfig(AuditHandlerConfig{FactPublisher: recorder})) @@ -160,7 +160,7 @@ func TestAuditHandler_NamedDefaultRouteThreadsItsProvider(t *testing.T) { } // TestAuditHandler_NamedRouting checks the /audit-webhook/ mapping: the route is taken from -// the path verbatim and keys the facts, with NO check that a ClusterProvider declares it. Ingestion +// the path verbatim and keys the facts, with NO check that a KubeProvider carries it. Ingestion // is a partition write, not a claim about an object, so a route nobody reads costs one expiring key // and a provider created later still joins its facts. func TestAuditHandler_NamedRouting(t *testing.T) { @@ -175,7 +175,7 @@ func TestAuditHandler_NamedRouting(t *testing.T) { assert.Equal(t, "prod-eu-1", recorder.lastProvider()) }) - t.Run("a route no ClusterProvider declares is stored, not refused", func(t *testing.T) { + t.Run("a route no KubeProvider carries is stored, not refused", func(t *testing.T) { recorder := &fakeFactSink{} handler, err := NewAuditHandler(AuditHandlerConfig{FactPublisher: recorder}) require.NoError(t, err) @@ -229,7 +229,7 @@ func annotatedEvent(auditID string, annotations map[string]string) string { // TestAuditHandler_AnnotationRouting pins the shared-stream contract: with an annotation key // configured the bare endpoint reads the AUDIT ROUTE per event, so one batch fans out to several // routes, and an event that names none is rejected by itself, never credited to a fallback, while -// the rest of the batch still lands. A route no ClusterProvider has declared is NOT a rejection. +// the rest of the batch still lands. A route no KubeProvider carries is NOT a rejection. func TestAuditHandler_AnnotationRouting(t *testing.T) { newHandler := func(t *testing.T, recorder *fakeFactSink) *AuditHandler { t.Helper() @@ -280,7 +280,7 @@ func TestAuditHandler_AnnotationRouting(t *testing.T) { } }) - t.Run("a route no ClusterProvider declares is stored like any other", func(t *testing.T) { + t.Run("a route no KubeProvider carries is stored like any other", func(t *testing.T) { recorder := &fakeFactSink{} handler := newHandler(t, recorder) @@ -353,7 +353,7 @@ func TestAuditHandler_MethodAndPathValidation(t *testing.T) { // The bare endpoint only means something with an annotation key configured; this handler has // none, so a producer posting there is misconfigured rather than routed to a default. {"bare endpoint rejected without an annotation key", http.MethodPost, "/audit-webhook", http.StatusBadRequest}, - // A name with no ClusterProvider behind it is 404 — the gate applies to every name. + // Any one-segment route is served: ingestion does not check that a KubeProvider carries it. {"any named route is served", http.MethodPost, "/audit-webhook/prod-eu-1", http.StatusOK}, } diff --git a/internal/webhook/audit_metrics_test.go b/internal/webhook/audit_metrics_test.go index 4a2c69ff8..3e5f2f314 100644 --- a/internal/webhook/audit_metrics_test.go +++ b/internal/webhook/audit_metrics_test.go @@ -190,7 +190,7 @@ func TestServeHTTP_NonScaleSubresourceDropped(t *testing.T) { // A request refused at the door must still be counted. // // This was the ingress gap: method, path and route rejections returned BEFORE any instrument was -// touched, so an apiserver posting audit to the wrong path — or under a route no ClusterProvider +// touched, so an apiserver posting audit to the wrong path — or under a route no KubeProvider // claims, which is the likeliest audit misconfiguration there is — produced exactly the same // ingress metrics as an apiserver posting nothing at all. func TestServeHTTP_RejectedRequestsAreCounted(t *testing.T) { diff --git a/test/e2e/Taskfile-e2e.yml b/test/e2e/Taskfile-e2e.yml index 32488b6dd..f156957db 100644 --- a/test/e2e/Taskfile-e2e.yml +++ b/test/e2e/Taskfile-e2e.yml @@ -349,7 +349,7 @@ tasks: test-e2e-source-cluster: desc: >- Run the source-cluster corner: kcp workspaces as remote source clusters - (GitTarget.spec.kubeConfig). Installs kcp into the cluster. + (KubeProvider.spec.secretRef). Installs kcp into the cluster. vars: # One process: the specs share the kcp front-proxy port-forward and the workspaces # they provision, and the suite is Ordered — parallel processes would contend on the @@ -1304,9 +1304,9 @@ tasks: # _crds-installed applies the project CRDs and waits for them to be Established BEFORE any # kustomize/flat-file install applies a custom resource of them. The config-dir bundle (and, - # once regenerated, dist/install.yaml) ships the reserved `default` ClusterProvider CR in the + # once regenerated, dist/install.yaml) ships the `local` KubeProvider CR in the # SAME `kubectl apply` as its CRD; kubectl builds its RESTMapper up front, so a CR whose CRD - # is created in that same apply fails with `no matches for kind "ClusterProvider"`. Applying + # is created in that same apply fails with `no matches for kind "KubeProvider"`. Applying # the CRDs first — and waiting for Established — closes that race. Re-runs whenever the # generated CRDs change (sources). The Helm chart ships CRDs in charts/gitops-reverser/crds/ # (helm's own first phase), so _install-helm deliberately does NOT depend on this: the @@ -1325,8 +1325,9 @@ tasks: # Not a bare `kubectl wait`: on a CRD created by the apply above it can fail at once. - | CTX="{{.CTX}}" KUBECTL="{{.KUBECTL}}" bash hack/e2e/wait-crds-established.sh 60 \ - crd/clusterproviders.configbutler.ai crd/clusterwatchrules.configbutler.ai \ + crd/kubeproviders.configbutler.ai crd/clusterwatchrules.configbutler.ai \ crd/commitrequests.configbutler.ai crd/gitproviders.configbutler.ai \ + crd/gitbranches.configbutler.ai \ crd/gittargets.configbutler.ai crd/watchrules.configbutler.ai \ crd/fluxtriggers.configbutler.ai crd/argocdtriggers.configbutler.ai - touch "{{.CS}}/crds.installed" diff --git a/test/e2e/apply_trigger.go b/test/e2e/apply_trigger.go index 21374a59b..09c840513 100644 --- a/test/e2e/apply_trigger.go +++ b/test/e2e/apply_trigger.go @@ -19,7 +19,7 @@ const ( ) // createFluxTrigger creates the FluxTrigger name in namespace, holding the Kustomization -// flux-system/ in the operator's own cluster (the "default" ClusterProvider). +// flux-system/ in the operator's own cluster (the install's "local" KubeProvider). func createFluxTrigger(namespace, name, kustomization string) { GinkgoHelper() manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 @@ -28,12 +28,11 @@ metadata: name: %s namespace: %s spec: - clusterProviderRef: - name: default +%s kustomizationRef: namespace: flux-system name: %s -`, name, namespace, kustomization) +`, name, namespace, localKubeProviderRef(), kustomization) _, err := kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to create FluxTrigger %s/%s", namespace, name) } @@ -48,25 +47,24 @@ metadata: name: %s namespace: %s spec: - clusterProviderRef: - name: default +%s applicationRef: namespace: %s name: %s -`, name, namespace, appNamespace, application) +`, name, namespace, localKubeProviderRef(), appNamespace, application) _, err := kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to create ArgoCDTrigger %s/%s", namespace, name) } -// allowCrossNamespaceAppliers lets the triggers that reach their applier through the default -// ClusterProvider name one in another namespace, as the e2e appliers are: in flux-system and -// argocd, while each spec's triggers live in its own namespace. Granting it is idempotent, and no -// spec relies on its absence on the default provider, so parallel specs may all ask for it. +// allowCrossNamespaceAppliers lets the triggers that reach their applier through the install's +// KubeProvider name one in another namespace, as the e2e appliers are: in flux-system and argocd, +// while each spec's triggers live in its own namespace. Granting it is idempotent, and no spec +// relies on its absence on that provider, so parallel specs may all ask for it. func allowCrossNamespaceAppliers() { GinkgoHelper() - _, err := kubectlRun("patch", "clusterprovider", "default", "--type=merge", "-p", - `{"spec":{"allowCrossNamespace":{"appliers":true}}}`) - Expect(err).NotTo(HaveOccurred(), "failed to let the default ClusterProvider's triggers reach other namespaces") + _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, + "--type=merge", "-p", `{"spec":{"allowCrossNamespace":{"appliers":true}}}`) + Expect(err).NotTo(HaveOccurred(), "failed to let the install KubeProvider's triggers reach other namespaces") } // nameApplyTrigger points a GitTarget at the trigger of kind (FluxTrigger or ArgoCDTrigger) and diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index 0d933311e..e71f08400 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -15,26 +15,25 @@ import ( ) // This spec pins the bug a downstream consumer reported: every object mirrored through a DEDICATED -// in-cluster ClusterProvider committed as "unknown (attribution unresolved)", while the same actor's -// writes through "default" attributed correctly. +// KubeProvider on the operator's own cluster committed as "unknown (attribution unresolved)", while +// the same actor's writes through the install's provider attributed correctly. // // The cause is structural rather than stochastic. A kube-apiserver takes one // --audit-webhook-config-file naming one server URL, so it posts audit under exactly ONE route (here -// /audit-webhook/default). Attribution facts are partitioned by that route, so a second -// ClusterProvider on the same cluster reads a partition nothing writes unless it declares the same -// route via spec.attribution.auditRoute. +// /audit-webhook/.local). Attribution facts are partitioned by that route, so a +// second KubeProvider on the same cluster reads a partition nothing writes unless it declares the +// same route via spec.attribution.auditRoute — and only a provider in the namespace holding that +// route may: one in another namespace reports AuditRouteConflict and is not used, so a tenant cannot +// read another namespace's facts by naming its route. // // Nothing else in the suite catches this. commit_author_attribution_e2e_test.go proves the OIDC -// claim chain, but only through the default provider, which is the one route the apiserver feeds. +// claim chain, but only through the install's provider, which is the one route the apiserver feeds. // source_namespace_e2e_test.go creates dedicated in-cluster providers but asserts Git paths, not // commit authors. The gap between them is exactly where the loss lived. // // See docs/spec/attribution.md. var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { const ( - // The apiserver's webhook-config.yaml posts to /audit-webhook/default, so this is the only - // route that carries facts on this cluster (test/e2e/cluster/audit/webhook-config.yaml). - fedRoute = "default" basePath = "e2e/audit-route-attribution" claimName = "configbutler.ai/claims/display-name" claimMail = "configbutler.ai/claims/email" @@ -47,8 +46,13 @@ var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { gitProvName string gitTargetName string watchRuleName string - clusterProv string + dedicatedProv string quietProv string + borrowingProv string + // fedRoute is the route the apiserver's webhook-config.yaml posts to, the only route that + // carries facts on this cluster (test/e2e/cluster/audit/webhook-config.yaml): the default + // route of the install's KubeProvider, which the operator's namespace therefore holds. + fedRoute = resolveE2ENamespace() + "." + e2eKubeProviderName ) BeforeAll(func() { @@ -71,40 +75,44 @@ var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { gitProvName = fmt.Sprintf("audit-route-gitprovider-%d", seed) gitTargetName = fmt.Sprintf("audit-route-gittarget-%d", seed) watchRuleName = fmt.Sprintf("audit-route-watchrule-%d", seed) - clusterProv = fmt.Sprintf("audit-route-cp-%d", seed) + dedicatedProv = fmt.Sprintf("audit-route-kp-%d", seed) quietProv = fmt.Sprintf("audit-route-quiet-%d", seed) - - By("declaring a DEDICATED in-cluster ClusterProvider that declares the fed audit route") - // kubeConfig is omitted, so this names the operator's own cluster, exactly as the reported - // srcns-delegating provider did. Its NAME is not the route the apiserver posts to, which is - // the whole point: without spec.attribution.auditRoute it would read a partition nothing - // writes. It also delegates source-namespace selection, so the second case below can watch - // a namespace other than the rule's own, reproducing the reported shape exactly. - Expect(applyInClusterClusterProviderWithAuditRoute( - clusterProv, testNs, sourceNs, true, fedRoute)).Error(). - NotTo(HaveOccurred(), "failed to apply the dedicated ClusterProvider") - - By("declaring a ClusterProvider on an audit route NOBODY posts under") + borrowingProv = fmt.Sprintf("audit-route-borrow-%d", seed) + + By("declaring a DEDICATED KubeProvider on the operator's own cluster that declares the fed route") + // It reaches this cluster through a kubeconfig, as a second connection to one API server + // does, and lives in the operator's namespace: that namespace holds the fed route, so it may + // share it. Its own default route (.) is not the route the apiserver posts + // to, which is the whole point: without spec.attribution.auditRoute it would read a partition + // nothing writes. It also delegates source-namespace selection, so the second case below can + // watch a namespace other than the rule's own, reproducing the reported shape exactly. + writeKubeConfigSecret(resolveE2ENamespace(), dedicatedProv, "value", sameClusterKubeConfig()) + Expect(applyKubeProviderWithAuditRoute(resolveE2ENamespace(), dedicatedProv, dedicatedProv, + []string{testNs, sourceNs}, true, fedRoute)).Error(). + NotTo(HaveOccurred(), "failed to apply the dedicated KubeProvider") + + By("declaring a KubeProvider on an audit route NOBODY posts under") // The negative half of AuditFactsReceived, and the exact misconfiguration this whole spec - // exists for: a provider that never sets spec.attribution.auditRoute reads a partition named - // after ITSELF, which this apiserver — posting only to /audit-webhook/default — never writes. - Expect(applyInClusterClusterProviderWithoutAuditRoute(quietProv, testNs)).Error(). - NotTo(HaveOccurred(), "failed to apply the quiet ClusterProvider") + // exists for: a provider that never sets spec.attribution.auditRoute reads its default + // partition, ., which this apiserver never writes. + writeKubeConfigSecret(testNs, "audit-route-kubeconfig", "value", sameClusterKubeConfig()) + Expect(applyKubeProviderWithAuditRoute(testNs, quietProv, "audit-route-kubeconfig", + nil, false, "")).Error().NotTo(HaveOccurred(), "failed to apply the quiet KubeProvider") // A 0s commit window makes every watched event its own commit, so each assertion reads an // author scoped to its own file path and concurrent audit traffic cannot change it. createReadyGitProvider(gitProvName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP) By("creating a GitTarget that mirrors through the dedicated provider") - Expect(applyGitTargetForClusterProvider( - testNs, gitTargetName, gitProvName, basePath, clusterProv)).Error(). + Expect(applyGitTargetForKubeProvider( + testNs, gitTargetName, gitProvName, basePath, resolveE2ENamespace(), dedicatedProv)).Error(). NotTo(HaveOccurred(), "failed to apply the GitTarget") verifyResourceCondition("gittarget", gitTargetName, testNs, "Validated", "True", "Succeeded", "") }) AfterAll(func() { - deleteClusterProvider(clusterProv) - deleteClusterProvider(quietProv) + deleteKubeProvider(resolveE2ENamespace(), dedicatedProv) + _, _ = kubectlRunInNamespace(resolveE2ENamespace(), "delete", "secret", dedicatedProv, "--ignore-not-found") cleanupPipeline(testNs, gitProvName, gitTargetName, watchRuleName) cleanupNamespace(testNs) cleanupNamespace(sourceNs) @@ -138,7 +146,7 @@ var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { out, logErr := gitRun(repo.CheckoutDir, "log", "-1", "--pretty=%an <%ae>", "--", repoPath) g.Expect(logErr).NotTo(HaveOccurred(), "git log author failed: %s", out) g.Expect(strings.TrimSpace(out)).To(Equal(fmt.Sprintf("%s <%s>", displayName, email)), - "a commit through a dedicated ClusterProvider must carry the real actor, not the "+ + "a commit through a dedicated KubeProvider must carry the real actor, not the "+ "unresolved-attribution placeholder") }).Should(Succeed()) @@ -147,32 +155,49 @@ var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { // The quiet provider is asserted BEFORE any write, because that is the state the condition has // to report honestly: a route with no proof yet. It is a separate object from the mirroring one - // on purpose — this apiserver feeds /audit-webhook/default continuously, so the route the + // on purpose — this apiserver feeds the fed route continuously, so the route the // mirroring provider declares is already latched by the time any spec here runs, and asserting // "no facts yet" against it would assert a race. - It("says so when a ClusterProvider's audit route has never carried a fact", func() { + It("says so when a KubeProvider's audit route has never carried a fact", func() { // RouteUnused, not one of the other two silences: this cluster's apiserver IS delivering // audit (every other spec here depends on it) and the transport is accepting writes, so the // only thing left to look at is this provider's own route. That is exactly the distinction // the three reasons exist to make. - verifyResourceCondition("clusterprovider", quietProv, "", - "AuditFactsReceived", "Unknown", "RouteUnused", quietProv) + verifyResourceCondition("kubeprovider", quietProv, testNs, + "AuditFactsReceived", "Unknown", "RouteUnused", testNs+"."+quietProv) // The whole reason this is not folded into Ready: nothing about the provider is broken. // Mirroring through it would work; only the commit AUTHOR would be lost. - verifyResourceCondition("clusterprovider", quietProv, "", "Ready", "True", "", "") + verifyResourceCondition("kubeprovider", quietProv, testNs, "Ready", "True", "", "") // And it stays Unknown rather than decaying to False on a timer: silence is not yet a // verdict, and no grace window can tell a misconfigured route from a quiet cluster. Consistently(func(g Gomega) { - out, err := kubectlRun("get", "clusterprovider", quietProv, + out, err := kubectlRunInNamespace(testNs, "get", "kubeprovider", quietProv, "-o", `jsonpath={.status.conditions[?(@.type=="AuditFactsReceived")].status}`) g.Expect(err).NotTo(HaveOccurred()) g.Expect(strings.TrimSpace(out)).To(Equal("Unknown")) }, 20*time.Second, 5*time.Second).Should(Succeed()) }) - It("attributes a commit mirrored through a dedicated in-cluster ClusterProvider", func() { + // A tenant cannot read the facts of a route another namespace holds by spelling it out: the + // provider that tries is not used, and neither is anything that mirrors through it. + It("does not use a KubeProvider that borrows another namespace's audit route", func() { + Expect(applyKubeProviderWithAuditRoute(testNs, borrowingProv, "audit-route-kubeconfig", + nil, false, fedRoute)).Error().NotTo(HaveOccurred(), "failed to apply the borrowing KubeProvider") + DeferCleanup(func() { deleteKubeProvider(testNs, borrowingProv) }) + verifyResourceCondition("kubeprovider", borrowingProv, testNs, "Validated", "True", "Succeeded", "") + verifyResourceCondition("kubeprovider", borrowingProv, testNs, + "Stalled", "True", "AuditRouteConflict", resolveE2ENamespace()) + + target := gitTargetName + "-borrowing" + Expect(applyGitTargetForKubeProvider(testNs, target, gitProvName, basePath+"-borrowing", + testNs, borrowingProv)).Error().NotTo(HaveOccurred()) + DeferCleanup(func() { cleanupNamespacedResource(testNs, "gittarget", target) }) + verifyResourceCondition("gittarget", target, testNs, "Validated", "False", "AuditRouteConflict", "") + }) + + It("attributes a commit mirrored through a dedicated KubeProvider", func() { By("creating a WatchRule in the GitTarget's own namespace") data := struct{ Name, Namespace, DestinationName string }{ Name: watchRuleName, Namespace: testNs, DestinationName: gitTargetName, @@ -187,8 +212,8 @@ var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { // The fact that named that author was published on the declared route, so the provider // reading it latches True. This is the signal an operator gets in `kubectl get - // clusterprovider` (the Facts column) instead of having to read commit authors out of Git. - verifyResourceCondition("clusterprovider", clusterProv, "", + // kubeprovider` (the FactsReceived column) instead of having to read commit authors out of Git. + verifyResourceCondition("kubeprovider", dedicatedProv, resolveE2ENamespace(), "AuditFactsReceived", "True", "Received", fedRoute) }) @@ -213,47 +238,47 @@ var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { }) }) -// applyInClusterClusterProviderWithAuditRoute applies a ClusterProvider that OMITS kubeConfig (so it -// names the operator's own cluster) and declares the audit route its facts arrive on. The route is -// what lets several providers on one cluster share the single stream that cluster's apiserver posts. -func applyInClusterClusterProviderWithAuditRoute( - name, allowedNS, extraNS string, delegate bool, auditRoute string, -) (string, error) { - manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: %s -spec: - accessFrom: - names: [%s, %s] - allowCrossNamespace: - sources: %t - attribution: - auditRoute: %s -`, name, allowedNS, extraNS, delegate, auditRoute) - return kubectlRunWithStdin("", manifest, "apply", "-f", "-") +// sameClusterKubeConfig is a kubeconfig for the cluster the operator runs in, addressed the way a +// Pod reaches it: a second connection to one API server, which is what a dedicated KubeProvider for +// the operator's own cluster is when the installation does not list it for spec.inCluster. +func sameClusterKubeConfig() string { + GinkgoHelper() + return rawKubeConfigWithServer("https://kubernetes.default.svc") } -// applyInClusterClusterProviderWithoutAuditRoute applies a ClusterProvider that OMITS both -// kubeConfig and spec.attribution, so its audit route defaults to its own NAME. On a cluster whose -// apiserver posts to a single other route, that is a provider reading a partition nothing writes. -func applyInClusterClusterProviderWithoutAuditRoute(name, allowedNS string) (string, error) { +// applyKubeProviderWithAuditRoute applies a KubeProvider in namespace reaching its cluster through +// the kubeconfig Secret secretName in that namespace, admitting the namespaces listed, and declaring +// auditRoute when it is not empty. The route is what lets several providers on one cluster share +// the single stream that cluster's apiserver posts. +func applyKubeProviderWithAuditRoute( + namespace, name, secretName string, admits []string, delegate bool, auditRoute string, +) (string, error) { + extra := "" + if len(admits) > 0 { + extra += fmt.Sprintf("\n accessFrom:\n names: [%s]", strings.Join(admits, ", ")) + } + if auditRoute != "" { + extra += fmt.Sprintf("\n attribution:\n auditRoute: %s", auditRoute) + } manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider +kind: KubeProvider metadata: name: %s + namespace: %s spec: - accessFrom: - names: [%s] -`, name, allowedNS) - return kubectlRunWithStdin("", manifest, "apply", "-f", "-") + secretRef: + name: %s + allowCrossNamespace: + sources: %t%s +`, name, namespace, secretName, delegate, extra) + return kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") } -// applyGitTargetForClusterProvider applies a GitTarget that mirrors through a named -// ClusterProvider. Which SOURCE namespaces may reach it is bounded by that provider's +// applyGitTargetForKubeProvider applies a GitTarget that mirrors through the KubeProvider +// providerNS/provider. Which SOURCE namespaces may reach it is bounded by that provider's // spec.allowCrossNamespace.sources plus the source credential's own RBAC, not by any field here. -func applyGitTargetForClusterProvider( - ns, name, gitProvider, targetPath, clusterProvider string, +func applyGitTargetForKubeProvider( + ns, name, gitProvider, targetPath, providerNS, provider string, ) (string, error) { manifest := gitBranchManifest(ns, gitProvider) + fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -263,13 +288,14 @@ metadata: spec: gitBranchRef: name: %s - path: %s - clusterProviderRef: + kubeProviderRef: name: %s + namespace: %s + path: %s commit: window: idleTimeout: "0s" -`, name, ns, gitBranchName(gitProvider, "main"), targetPath, clusterProvider) +`, name, ns, gitBranchName(gitProvider, "main"), provider, providerNS, targetPath) return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") } diff --git a/test/e2e/cluster/audit/webhook-config.yaml b/test/e2e/cluster/audit/webhook-config.yaml index e89c2d8dd..deb6b92e0 100644 --- a/test/e2e/cluster/audit/webhook-config.yaml +++ b/test/e2e/cluster/audit/webhook-config.yaml @@ -9,10 +9,11 @@ preferences: {} clusters: - name: audit-webhook cluster: - # Audit routes are NAMED: this cluster's source identity is the "default" ClusterProvider the - # install creates. The bare /audit-webhook is the shared, annotation-routed endpoint and is - # rejected with 400 unless --author-attribution-audit-route-annotation-key is set. - server: https://127.0.0.1:30444/audit-webhook/default + # Audit routes are NAMED: this cluster's source identity is the "local" KubeProvider the install + # creates in the gitops-reverser namespace, whose route defaults to gitops-reverser.local. The + # bare /audit-webhook is the shared, annotation-routed endpoint and is rejected with 400 unless + # --author-attribution-audit-route-annotation-key is set. + server: https://127.0.0.1:30444/audit-webhook/gitops-reverser.local insecure-skip-tls-verify: true contexts: - name: webhook diff --git a/test/e2e/commit_request_e2e_test.go b/test/e2e/commit_request_e2e_test.go index c64ac01f0..934397f79 100644 --- a/test/e2e/commit_request_e2e_test.go +++ b/test/e2e/commit_request_e2e_test.go @@ -186,9 +186,9 @@ var _ = Describe("Commit Request", Label("commit-request", "audit-consumer"), Or waitForMetric( fmt.Sprintf( `sum(gitopsreverser_git_branch_targets{gittarget_namespace=%q,gittarget_name=%q,`+ - `provider_namespace=%q,provider_name=%q,branch="main",source_cluster="default"}) `+ + `provider_namespace=%q,provider_name=%q,branch="main",source_cluster=%q}) `+ `or vector(0)`, - testNs, gitTargetName, testNs, gitProvName), + testNs, gitTargetName, testNs, gitProvName, resolveE2ENamespace()+"/"+e2eKubeProviderName), func(v float64) bool { return v == 1 }, "the join series maps this GitTarget to the provider and branch its commits are labelled by", ) diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index f02466d25..f8e639d66 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -304,6 +304,20 @@ func createGitTargetWithParentBranch(name, namespace, providerName, path, branch e2eEncryptionRefName, false, gitTargetCommitOptions{Window: e2eCommitWindow}) } +// e2eKubeProviderName is the KubeProvider every install path renders in the operator's namespace, +// with the operator's own identity: config/kubeprovider-local.yaml, and the chart's kubeProvider. +const e2eKubeProviderName = "local" + +// localKubeProviderRef is the spec.kubeProviderRef block, indented for a GitTarget's spec, that +// names the install's KubeProvider. Every GitTarget names its provider; nothing defaults. +func localKubeProviderRef() string { + return fmt.Sprintf( + " kubeProviderRef:\n name: %s\n namespace: %s", + e2eKubeProviderName, + resolveE2ENamespace(), + ) +} + func applyGitTarget( name, namespace, @@ -332,6 +346,7 @@ func applyGitTarget( CommitMaxDuration string LiveTemplate string ReconcileTemplate string + KubeProviderRef string }{ Name: name, Namespace: namespace, @@ -346,6 +361,7 @@ func applyGitTarget( CommitMaxDuration: e2eCommitMaxDuration, LiveTemplate: commit.LiveTemplate, ReconcileTemplate: commit.ReconcileTemplate, + KubeProviderRef: localKubeProviderRef(), } err := applyFromTemplate("test/e2e/templates/gittarget.tmpl", data, namespace) diff --git a/test/e2e/prune_mode_e2e_test.go b/test/e2e/prune_mode_e2e_test.go index 4fa0c7998..1410dbe8c 100644 --- a/test/e2e/prune_mode_e2e_test.go +++ b/test/e2e/prune_mode_e2e_test.go @@ -325,21 +325,23 @@ func retentionModeOf(g Gomega, name, namespace string) string { func applyPruneGitTarget(name, namespace, providerName, targetPath, mode string) { GinkgoHelper() data := struct { - Name string - Namespace string - GitBranchName string - ProviderName string - Branch string - Path string - PruneMode string + Name string + Namespace string + GitBranchName string + ProviderName string + Branch string + Path string + PruneMode string + KubeProviderRef string }{ - Name: name, - Namespace: namespace, - GitBranchName: gitBranchName(providerName, "main"), - ProviderName: providerName, - Branch: "main", - Path: targetPath, - PruneMode: mode, + Name: name, + Namespace: namespace, + GitBranchName: gitBranchName(providerName, "main"), + ProviderName: providerName, + Branch: "main", + Path: targetPath, + PruneMode: mode, + KubeProviderRef: localKubeProviderRef(), } Expect(applyFromTemplate("test/e2e/templates/manager/gittarget-prune.tmpl", data, namespace)). To(Succeed(), "failed to apply GitTarget %q with prune mode %q", name, mode) @@ -358,12 +360,13 @@ metadata: spec: gitBranchRef: name: %s +%s path: %s commit: window: idleTimeout: "0s" %s -`, name, namespace, gitBranchName(providerName, "main"), targetPath, pruneBlock) +`, name, namespace, gitBranchName(providerName, "main"), localKubeProviderRef(), targetPath, pruneBlock) out, err := kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") if err != nil { return fmt.Errorf("%w: %s", err, out) diff --git a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml index 7f0aa1f02..402fac77a 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml @@ -26,6 +26,9 @@ metadata: spec: gitBranchRef: name: demo-nl-stuff + kubeProviderRef: + name: local + namespace: gitops-reverser path: podinfos commit: window: diff --git a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml index dbe24cef4..19b4da223 100644 --- a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml @@ -26,6 +26,9 @@ metadata: spec: gitBranchRef: name: demo-coffeeconfig-demo-test + kubeProviderRef: + name: local + namespace: gitops-reverser path: voter-coffee commit: window: diff --git a/test/e2e/shared_gitbranch_e2e_test.go b/test/e2e/shared_gitbranch_e2e_test.go index e09c379a3..dc52a0f33 100644 --- a/test/e2e/shared_gitbranch_e2e_test.go +++ b/test/e2e/shared_gitbranch_e2e_test.go @@ -209,11 +209,13 @@ metadata: spec: gitBranchRef: %s +%s path: %s commit: window: idleTimeout: "%s" maxDuration: "%s" -`, name, namespace, ref, targetPath, e2eCommitWindow, e2eCommitMaxDuration), "apply", "-f", "-") +`, name, namespace, ref, localKubeProviderRef(), targetPath, e2eCommitWindow, e2eCommitMaxDuration), + "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitTarget %s/%s", namespace, name) } diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go index 8d4b61108..b9097f676 100644 --- a/test/e2e/source_cluster_e2e_test.go +++ b/test/e2e/source_cluster_e2e_test.go @@ -13,22 +13,24 @@ import ( . "github.com/onsi/gomega" ) -// This file is the source-cluster corner for multi-cluster author attribution: a GitTarget names -// the cluster it mirrors FROM by referencing a cluster-scoped ClusterProvider -// (spec.clusterProviderRef). The -// ClusterProvider is the home for that cluster's kubeconfig credential (spec.kubeConfig, a Flux -// meta.KubeConfigReference resolved from the operator namespace), namespace-access authorization, -// and connectivity status. +// This file is the source-cluster corner for multi-cluster mirroring: a GitTarget names the +// cluster it mirrors FROM by referencing a namespaced KubeProvider (spec.kubeProviderRef). The +// KubeProvider is the home for that cluster's credential (spec.secretRef, a kubeconfig Secret in +// the provider's OWN namespace, or spec.inCluster where the installation allows it), for which +// other namespaces may use it (spec.accessFrom), and for its audit route. // -// Two kinds of spec live here: -// - Input-validation specs (Scenario 1) assert the ClusterProvider reconciler's Validated -// verdict on bad kubeconfigs, without a remote cluster and without a dial. -// - Reachability specs (Scenarios 2-3) assert the GitTarget's SourceClusterReachable projection -// for valid-but-unroutable and default (local) providers. -// - Remote-mirror specs (Scenarios 4, 8) mirror real REMOTE clusters — kcp WORKSPACES, cheap +// Three kinds of spec live here: +// - Input-validation specs (Scenario 1) assert the KubeProvider reconciler's Validated verdict on +// bad kubeconfigs and on an inCluster provider the installation does not list, without a +// remote cluster and without a dial. +// - Reachability and admission specs (Scenarios 2-3) assert the GitTarget's projections for a +// valid-but-unroutable provider, the install's own provider, a missing one, and one in another +// tenant's namespace. +// - Remote-mirror specs (Scenarios 4, 8, 9) mirror real REMOTE clusters — kcp WORKSPACES, cheap // logical clusters installed by Flux (test/e2e/setup/kcp, see kcp_workspace_test.go). -// Scenario 8 is the centerpiece: three workspaces holding the SAME namespace + resource with -// different content, proving state is keyed by source cluster, not (namespace, GVR). +// Scenario 8 holds the SAME namespace + resource with different content in three workspaces, +// proving state is keyed by source cluster, not (namespace, GVR); Scenario 9 gives two +// providers ONE name in two namespaces, and recreates one of them. // // The whole suite is gated by skipUnlessSourceClusterEnabled() (env E2E_ENABLE_SOURCE_CLUSTER), // and the kcp specs additionally Skip when kcp is not installed — so a default `task test-e2e` @@ -40,9 +42,6 @@ const ( // server, guaranteed not to route, so a kubeconfig pointing at it parses cleanly // (Validated=True) yet can never be dialed (SourceClusterReachable=False). unreachableAPIServer = "https://192.0.2.1:6443" - // sourceClusterOperatorNS is the namespace a ClusterProvider's kubeConfig Secret is pinned to - // (the operator's own namespace). A cluster-scoped provider has no namespace of its own. - sourceClusterOperatorNS = defaultE2ENamespace ) func sourceClusterEnabled() bool { @@ -146,9 +145,9 @@ users: ` } -// writeKubeConfigSecret applies a Secret holding a kubeconfig under the given key, in the OPERATOR -// namespace — a ClusterProvider's secretRef is resolved from there, never from the source cluster. -func writeKubeConfigSecret(name, key, kubeconfig string) { +// writeKubeConfigSecret applies a Secret holding a kubeconfig under the given key, in namespace — +// the KubeProvider's own: its secretRef is resolved there, never from the source cluster. +func writeKubeConfigSecret(namespace, name, key, kubeconfig string) { GinkgoHelper() f, err := os.CreateTemp("", "e2e-kubeconfig-*.yaml") Expect(err).NotTo(HaveOccurred()) @@ -157,47 +156,56 @@ func writeKubeConfigSecret(name, key, kubeconfig string) { Expect(err).NotTo(HaveOccurred()) Expect(f.Close()).To(Succeed()) - manifest, err := kubectlRunInNamespace(sourceClusterOperatorNS, "create", "secret", "generic", name, + manifest, err := kubectlRunInNamespace(namespace, "create", "secret", "generic", name, "--from-file="+key+"="+f.Name(), "--dry-run=client", "-o", "yaml") Expect(err).NotTo(HaveOccurred(), "failed to render kubeconfig Secret manifest") - _, err = kubectlRunWithStdin(sourceClusterOperatorNS, manifest, "apply", "-f", "-") + _, err = kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply kubeconfig Secret") } -// applyClusterProvider applies a cluster-scoped ClusterProvider whose kubeConfig.secretRef names a -// kubeconfig Secret in the operator namespace, allowing the given namespace to reference it. It -// returns the kubectl error so a spec can assert the apply succeeded (the CR is well-formed; the -// kubeconfig, not the CR, is what a validation case makes bad). -func applyClusterProvider(name, secretName, key, allowedNS string) (string, error) { +// applyKubeProvider applies a KubeProvider in namespace whose secretRef names a kubeconfig Secret in +// that same namespace, admitting the other namespaces listed. It returns the kubectl error so a spec +// can assert the apply succeeded (the CR is well-formed; the kubeconfig, not the CR, is what a +// validation case makes bad). +func applyKubeProvider(namespace, name, secretName, key string, admits ...string) (string, error) { keyLine := "" if key != "" { - keyLine = "\n key: " + key + keyLine = "\n key: " + key + } + accessFrom := "" + if len(admits) > 0 { + accessFrom = fmt.Sprintf("\n accessFrom:\n names: [%s]", strings.Join(admits, ", ")) } manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider +kind: KubeProvider metadata: name: %s + namespace: %s spec: - kubeConfig: - secretRef: - name: %s%s - accessFrom: - names: [%s] -`, name, secretName, keyLine, allowedNS) - return kubectlRunWithStdin("", manifest, "apply", "-f", "-") + secretRef: + name: %s%s%s +`, name, namespace, secretName, keyLine, accessFrom) + return kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") } -// deleteClusterProvider removes a cluster-scoped ClusterProvider (not covered by namespace cleanup). -func deleteClusterProvider(name string) { - _, _ = kubectlRun("delete", "clusterprovider", name, "--ignore-not-found", "--wait=false") +// deleteKubeProvider removes a KubeProvider and waits until it is gone, so a recreate under the +// same name is a new object. +func deleteKubeProvider(namespace, name string) { + _, _ = kubectlRunInNamespace(namespace, "delete", "kubeprovider", name, "--ignore-not-found") } -// applyGitTargetWithClusterProvider applies a GitTarget whose spec.clusterProviderRef names a -// ClusterProvider (the source cluster it mirrors from). -// -//nolint:unparam // gitProvider is kept an explicit argument for readability; the corner uses one. -func applyGitTargetWithClusterProvider(ns, name, gitProvider, path, clusterProvider string) (string, error) { - manifest := gitBranchManifest(ns, gitProvider) + fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +// applyGitTargetWithKubeProvider applies a GitTarget in ns, on the GitBranch of gitProvider in +// branchNS (empty: ns), whose spec.kubeProviderRef names providerNS/provider (the source cluster it +// mirrors from). +func applyGitTargetWithKubeProvider( + ns, name, gitProvider, branchNS, path, providerNS, provider string, +) (string, error) { + manifest := "" + if branchNS == "" { + branchNS = ns + manifest = gitBranchManifest(ns, gitProvider) + } + manifest += fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitTarget metadata: name: %s @@ -205,16 +213,68 @@ metadata: spec: gitBranchRef: name: %s + namespace: %s + kubeProviderRef: + name: %s + namespace: %s path: %s commit: window: idleTimeout: "0s" - clusterProviderRef: - name: %s -`, name, ns, gitBranchName(gitProvider, "main"), path, clusterProvider) +`, name, ns, gitBranchName(gitProvider, "main"), branchNS, provider, providerNS, path) return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") } +// mirrorWorkspaceConfigMap stands up one remote mirror: a kcp workspace holding the ConfigMap name +// with which=value in the namespace sourceNS, a KubeProvider providerNS/provider whose Secret +// carries the workspace's kubeconfig, and a GitTarget plus WatchRule in targetNS mirroring that +// namespace into folder. The Secret, GitTarget and WatchRule follow the names +// kcpTunnel.cleanupWorkspaceTarget deletes. +// +//nolint:unparam // gitProvider is kept an explicit argument for readability; the corner uses one. +func mirrorWorkspaceConfigMap( + kcp *kcpTunnel, ws, targetNS, providerNS, provider, gitProvider, branchNS, folder, name, value string, +) { + GinkgoHelper() + hash := kcp.createWorkspace(ws) + // A WatchRule watches its OWN namespace name on the source cluster, so the ConfigMap must + // live under that namespace IN the workspace — a separate cluster, so creating a namespace of + // that name there is independent of the local one. + _, err := kcp.wsKubectl(hash, "create", "namespace", targetNS) + Expect(err).NotTo(HaveOccurred(), "create watched namespace in %s", ws) + _, err = kcp.wsKubectl(hash, "-n", targetNS, "create", "configmap", name, "--from-literal=which="+value) + Expect(err).NotTo(HaveOccurred(), "create ConfigMap %s in %s", name, ws) + + writeKubeConfigSecret(providerNS, ws+"-kubeconfig", "value", kcp.operatorKubeConfig(hash)) + _, err = applyKubeProvider(providerNS, provider, ws+"-kubeconfig", "") + Expect(err).NotTo(HaveOccurred()) + target := ws + "-target" + _, err = applyGitTargetWithKubeProvider(targetNS, target, gitProvider, branchNS, folder, providerNS, provider) + Expect(err).NotTo(HaveOccurred()) + verifyResourceCondition("gittarget", target, targetNS, "SourceClusterReachable", "True", "", "", "180s") + + rule := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: {name: %s-rule, namespace: %s} +spec: + gitTargetRef: {name: %s} + rules: + - resources: ["configmaps"] +`, ws, targetNS, target) + _, err = kubectlRunWithStdin(targetNS, rule, "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred()) +} + +// expectMirroredValue asserts that folder holds name.yaml carrying which=value. +func expectMirroredValue(g Gomega, checkoutDir, folder, name, value string) { + hit := findFileByBasename(filepath.Join(checkoutDir, folder), name+".yaml") + g.Expect(hit).NotTo(BeEmpty(), "expected %s mirrored under %s", name, folder) + content, readErr := os.ReadFile(hit) + g.Expect(readErr).NotTo(HaveOccurred()) + g.Expect(string(content)).To(ContainSubstring("which: "+value), + "folder %s must carry its own source cluster's value", folder) +} + // findFileByBasename walks a checkout and returns the first path whose basename matches, // so a mirror assertion need not hard-code the exact placement path. func findFileByBasename(root, basename string) string { @@ -228,12 +288,15 @@ func findFileByBasename(root, basename string) string { return hit } -var _ = Describe("Manager source cluster / ClusterProvider attribution", Label("source-cluster"), Ordered, func() { +var _ = Describe("Manager source cluster / KubeProvider", Label("source-cluster"), Ordered, func() { const providerName = "sc-provider" var ( testNs string - repo *RepoArtifacts + // tenantNs is a second tenant: it writes through testNs's GitBranch (admitted by the + // GitProvider) and is admitted by testNs's KubeProviders only where a spec says so. + tenantNs string + repo *RepoArtifacts // kcp is the workspace harness for the remote-mirror specs. It is nil when kcp is not // installed (a default `task test-e2e` run) — those specs then Skip rather than fail. kcp *kcpTunnel @@ -243,13 +306,20 @@ var _ = Describe("Manager source cluster / ClusterProvider attribution", Label(" skipUnlessSourceClusterEnabled() testNs = testNamespaceFor("source-cluster") + tenantNs = testNamespaceFor("source-cluster-tenant") _, _ = kubectlRun("create", "namespace", testNs) + _, _ = kubectlRun("create", "namespace", tenantNs) repo = SetupRepo(resolveE2EContext(), testNs, fmt.Sprintf("e2e-source-cluster-%d", GinkgoRandomSeed())) _, err := kubectlRunInNamespace(testNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred(), "failed to apply repo secrets") createReadyGitProvider(providerName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP) + // The tenant writes through this namespace's GitBranch, so the GitProvider admits it; which + // KubeProvider it may READ through is what the specs below decide. + _, err = kubectlRunInNamespace(testNs, "patch", "gitprovider", providerName, "--type=merge", "-p", + fmt.Sprintf(`{"spec":{"accessFrom":{"names":[%q]}}}`, tenantNs)) + Expect(err).NotTo(HaveOccurred(), "failed to admit the tenant namespace to the GitProvider") if kcpAvailable() { kcp = startKcpTunnel() @@ -260,17 +330,18 @@ var _ = Describe("Manager source cluster / ClusterProvider attribution", Label(" if kcp != nil { kcp.stop() } + cleanupNamespace(tenantNs) cleanupNamespace(testNs) }) SetDefaultEventuallyTimeout(60 * time.Second) SetDefaultEventuallyPollingInterval(2 * time.Second) - // Scenario 1 — the ClusterProvider reconciler's input validation is legible, and never dials. + // Scenario 1 — the KubeProvider reconciler's credential validation is legible, and never dials. inputCases := []struct { name string reason string - setup func(cpName string) (secretName, key string) + setup func(provider string) (secretName, key string) }{ { name: "a missing Secret", @@ -280,169 +351,203 @@ var _ = Describe("Manager source cluster / ClusterProvider attribution", Label(" { name: "a missing key", reason: "KubeConfigKeyNotFound", - setup: func(cp string) (string, string) { - writeKubeConfigSecret(cp+"-kc", "somewhere-else", rawKubeConfigWithServer(unreachableAPIServer)) - return cp + "-kc", "value" + setup: func(p string) (string, string) { + writeKubeConfigSecret(testNs, p+"-kc", "somewhere-else", rawKubeConfigWithServer(unreachableAPIServer)) + return p + "-kc", "value" }, }, { name: "an unparseable kubeconfig", reason: "KubeConfigInvalid", - setup: func(cp string) (string, string) { - writeKubeConfigSecret(cp+"-kc", "value", "this is not a kubeconfig") - return cp + "-kc", "" + setup: func(p string) (string, string) { + writeKubeConfigSecret(testNs, p+"-kc", "value", "this is not a kubeconfig") + return p + "-kc", "" }, }, { name: "an exec auth provider", reason: "KubeConfigExecNotAllowed", - setup: func(cp string) (string, string) { - writeKubeConfigSecret(cp+"-kc", "value", execKubeConfig()) - return cp + "-kc", "" + setup: func(p string) (string, string) { + writeKubeConfigSecret(testNs, p+"-kc", "value", execKubeConfig()) + return p + "-kc", "" }, }, { name: "insecure TLS", reason: "KubeConfigInsecureTLSNotAllowed", - setup: func(cp string) (string, string) { - writeKubeConfigSecret(cp+"-kc", "value", insecureKubeConfig()) - return cp + "-kc", "" + setup: func(p string) (string, string) { + writeKubeConfigSecret(testNs, p+"-kc", "value", insecureKubeConfig()) + return p + "-kc", "" }, }, { name: "a file-path credential", reason: "KubeConfigFileReferenceNotAllowed", - setup: func(cp string) (string, string) { - writeKubeConfigSecret(cp+"-kc", "value", fileReferenceKubeConfig()) - return cp + "-kc", "" + setup: func(p string) (string, string) { + writeKubeConfigSecret(testNs, p+"-kc", "value", fileReferenceKubeConfig()) + return p + "-kc", "" + }, + }, + { + // The operator's own namespace holds a Secret of this name; the provider's does not, and + // a Secret is only ever read from the provider's own namespace. + name: "a Secret that exists only in another namespace", + reason: "KubeConfigSecretNotFound", + setup: func(p string) (string, string) { + writeKubeConfigSecret(resolveE2ENamespace(), p+"-kc", "value", + rawKubeConfigWithServer(unreachableAPIServer)) + DeferCleanup(func() { + _, _ = kubectlRunInNamespace( + resolveE2ENamespace(), + "delete", + "secret", + p+"-kc", + "--ignore-not-found", + ) + }) + return p + "-kc", "" }, }, } - for _, tc := range inputCases { - It("fails ClusterProvider Validated (no dial) for "+tc.name+" with reason "+tc.reason, func() { - cpName := "sc-input-" + strings.ToLower(tc.reason) - secretName, key := tc.setup(cpName) - DeferCleanup(func() { deleteClusterProvider(cpName) }) - // The ClusterProvider CR is well-formed (the kubeconfig is bad, not the CR), so the + for i, tc := range inputCases { + It("fails KubeProvider Validated (no dial) for "+tc.name+" with reason "+tc.reason, func() { + name := fmt.Sprintf("sc-input-%d", i) + secretName, key := tc.setup(name) + DeferCleanup(func() { deleteKubeProvider(testNs, name) }) + // The KubeProvider CR is well-formed (the kubeconfig is bad, not the CR), so the // apply itself must succeed — the controller then reports the typed reason on Validated. - _, err := applyClusterProvider(cpName, secretName, key, testNs) + _, err := applyKubeProvider(testNs, name, secretName, key) Expect(err).NotTo(HaveOccurred()) - verifyResourceCondition("clusterprovider", cpName, "", "Validated", "False", tc.reason, "") + verifyResourceCondition("kubeprovider", name, testNs, "Validated", "False", tc.reason, "") }) } - // Scenario 2 — a valid kubeconfig that cannot be dialed: the ClusterProvider is Validated=True, + // Scenario 1b — the operator's own identity needs the installation's consent. A KubeProvider in a + // tenant namespace that sets inCluster is not on --in-cluster-kube-providers, so it is never + // used: it stalls, and a GitTarget mirroring through it never reaches an API server. + It("never uses the operator's identity for an inCluster provider the installation does not list", func() { + const name = "sc-own-identity" + DeferCleanup(func() { deleteKubeProvider(testNs, name) }) + _, err := kubectlRunWithStdin(testNs, fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: {name: %s, namespace: %s} +spec: + inCluster: {} +`, name, testNs), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred()) + verifyResourceCondition("kubeprovider", name, testNs, "Validated", "False", "InClusterNotAuthorized", "") + verifyResourceCondition("kubeprovider", name, testNs, "Stalled", "True", "InClusterNotAuthorized", "") + + target := "sc-own-identity-target" + _, err = applyGitTargetWithKubeProvider(testNs, target, providerName, "", "clusters/own", testNs, name) + Expect(err).NotTo(HaveOccurred()) + verifyResourceCondition("gittarget", target, testNs, "KubeProviderReady", "False", "KubeProviderNotReady", + "--in-cluster-kube-providers") + verifyResourceCondition("gittarget", target, testNs, + "SourceClusterReachable", "False", "InClusterNotAuthorized", "", "150s") + }) + + // Scenario 2 — a valid kubeconfig that cannot be dialed: the KubeProvider is Validated=True, // and the GitTarget that references it projects SourceClusterReachable=False. It("separates Validated (inputs) from SourceClusterReachable (runtime)", func() { - const cpName = "sc-unreachable" - writeKubeConfigSecret(cpName+"-kc", "value", rawKubeConfigWithServer(unreachableAPIServer)) - DeferCleanup(func() { deleteClusterProvider(cpName) }) - _, err := applyClusterProvider(cpName, cpName+"-kc", "", testNs) + const name = "sc-unreachable" + writeKubeConfigSecret(testNs, name+"-kc", "value", rawKubeConfigWithServer(unreachableAPIServer)) + _, err := applyKubeProvider(testNs, name, name+"-kc", "") Expect(err).NotTo(HaveOccurred()) - verifyResourceCondition("clusterprovider", cpName, "", "Validated", "True", "Succeeded", "") + verifyResourceCondition("kubeprovider", name, testNs, "Validated", "True", "Succeeded", "") target := "sc-unreachable-target" - _, err = applyGitTargetWithClusterProvider(testNs, target, providerName, "clusters/unreachable", cpName) + _, err = applyGitTargetWithKubeProvider(testNs, target, providerName, "", "clusters/unreachable", testNs, name) Expect(err).NotTo(HaveOccurred()) verifyResourceCondition("gittarget", target, testNs, "Validated", "True", "Succeeded", "") verifyResourceCondition("gittarget", target, testNs, "SourceClusterReachable", "False", "SourceClusterUnreachable", "", "150s") }) - // Scenario 3 — the default (in-cluster) provider: an omitted clusterProviderRef defaults to - // {name: default} and mirrors the operator's own cluster. - It("treats an omitted clusterProviderRef as the default (local) cluster", func() { + // Scenario 2b — a tenant's KubeProvider is the tenant's. Another namespace may not mirror + // through it until its accessFrom admits that namespace. + It("refuses a second tenant a KubeProvider whose accessFrom does not admit it", func() { + const name = "sc-unreachable" + target := "sc-tenant-target" + _, err := applyGitTargetWithKubeProvider( + tenantNs, + target, + providerName, + testNs, + "clusters/tenant", + testNs, + name, + ) + Expect(err).NotTo(HaveOccurred()) + verifyResourceCondition("gittarget", target, tenantNs, "Validated", "False", "NamespaceNotAuthorized", + "KubeProvider "+testNs+"/"+name) + + _, err = applyKubeProvider(testNs, name, name+"-kc", "", tenantNs) + Expect(err).NotTo(HaveOccurred()) + verifyResourceCondition("gittarget", target, tenantNs, "Validated", "True", "Succeeded", "") + }) + + // Scenario 3 — the install's own provider: "local" in the operator's namespace mirrors the + // operator's own cluster, and admits a GitTarget in any namespace through its accessFrom. + It("mirrors the operator's own cluster through the install's KubeProvider", func() { target := "sc-local-target" - manifest := gitBranchManifest(testNs, providerName) + fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 -kind: GitTarget -metadata: {name: %s, namespace: %s} -spec: - gitBranchRef: {name: %s} - path: clusters/local - commit: - window: - idleTimeout: "0s" -`, target, testNs, gitBranchName(providerName, "main")) - _, err := kubectlRunWithStdin(testNs, manifest, "apply", "-f", "-") + _, err := applyGitTargetWithKubeProvider(testNs, target, providerName, "", "clusters/local", + resolveE2ENamespace(), e2eKubeProviderName) Expect(err).NotTo(HaveOccurred()) verifyResourceCondition("gittarget", target, testNs, "SourceClusterReachable", "True", "LocalCluster", "") }) - // Scenario 3b — the hard gate: a GitTarget that references a ClusterProvider which does NOT - // exist is held NotReady (Validated=False, ClusterProviderNotFound) and never mirrors. This is - // the regression for "clusterProvider.createDefault: false" — with no default provider a - // GitTarget referencing it (or any missing provider) does not fall back to an implicit local - // identity, and the operator never creates the object to rescue it. - It("holds a GitTarget NotReady when its ClusterProvider does not exist (no bypass)", func() { - target := "sc-missing-cp-target" - _, err := applyGitTargetWithClusterProvider( - testNs, target, providerName, "clusters/missing", "sc-does-not-exist") + // Scenario 3b — the hard gate: a GitTarget that references a KubeProvider which does NOT exist + // is held NotReady (Validated=False, KubeProviderNotFound) and never mirrors. Nothing falls back + // to an implicit local identity, and the operator never creates the object to rescue it. + It("holds a GitTarget NotReady when its KubeProvider does not exist (no bypass)", func() { + target := "sc-missing-provider-target" + _, err := applyGitTargetWithKubeProvider(testNs, target, providerName, "", "clusters/missing", + testNs, "sc-does-not-exist") Expect(err).NotTo(HaveOccurred()) verifyResourceCondition("gittarget", target, testNs, - "Validated", "False", "ClusterProviderNotFound", "") + "Validated", "False", "KubeProviderNotFound", "") }) - // Scenario 4 — a REAL remote cluster: mirror a ConfigMap out of one kcp workspace. Proves the - // whole remote path end to end (ClusterProvider resolver -> per-cluster clientContext -> - // per-cluster discovery -> per-cluster watch -> target-scoped writer) against an actually-remote - // API, not the self-referencing in-cluster server the scaffold used to fake it with. + // Scenario 4 — a REAL remote cluster: a tenant namespace mirrors a ConfigMap out of one kcp + // workspace through its own KubeProvider and Secret. Proves the whole remote path end to end + // (KubeProvider resolver -> per-cluster clientContext -> per-cluster discovery -> per-cluster + // watch -> target-scoped writer) against an actually-remote API. It("mirrors a ConfigMap from a kcp workspace", func() { if kcp == nil { Skip("kcp is not installed; run this corner via `task test-e2e-source-cluster`") } const ws = "sc-mirror" - hash := kcp.createWorkspace(ws) DeferCleanup(func() { kcp.cleanupWorkspaceTarget(testNs, ws) }) - DeferCleanup(func() { deleteClusterProvider(ws) }) - - // A WatchRule watches its OWN namespace name on the source cluster, so the ConfigMap must - // live under that namespace (testNs) IN the workspace — a separate cluster, so creating a - // namespace of that name there is independent of the local one. - _, err := kcp.wsKubectl(hash, "create", "namespace", testNs) - Expect(err).NotTo(HaveOccurred(), "create watched namespace in the workspace") - _, err = kcp.wsKubectl(hash, "-n", testNs, "create", "configmap", "sc-remote-cm", - "--from-literal=hello=from-kcp") - Expect(err).NotTo(HaveOccurred(), "create ConfigMap in the workspace") - - writeKubeConfigSecret(ws+"-kubeconfig", "value", kcp.operatorKubeConfig(hash)) - _, err = applyClusterProvider(ws, ws+"-kubeconfig", "", testNs) - Expect(err).NotTo(HaveOccurred()) - target := ws + "-target" - _, err = applyGitTargetWithClusterProvider(testNs, target, providerName, "clusters/kcp", ws) - Expect(err).NotTo(HaveOccurred()) - verifyResourceCondition("gittarget", target, testNs, "SourceClusterReachable", "True", "", "", "180s") - - rule := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 -kind: WatchRule -metadata: {name: %s-rule, namespace: %s} -spec: - gitTargetRef: {name: %s} - rules: - - resources: ["configmaps"] -`, ws, testNs, target) - _, err = kubectlRunWithStdin(testNs, rule, "apply", "-f", "-") - Expect(err).NotTo(HaveOccurred()) - waitForStreamsRunning(target, testNs) + DeferCleanup(func() { deleteKubeProvider(testNs, ws) }) + mirrorWorkspaceConfigMap( + kcp, + ws, + testNs, + testNs, + ws, + providerName, + "", + "clusters/kcp", + "sc-remote-cm", + "from-kcp", + ) + waitForStreamsRunning(ws+"-target", testNs) Eventually(func(g Gomega) { pullLatestRepoState(g, repo.CheckoutDir) - hit := findFileByBasename(filepath.Join(repo.CheckoutDir, "clusters/kcp"), "sc-remote-cm.yaml") - g.Expect(hit).NotTo(BeEmpty(), "expected the ConfigMap mirrored from the kcp workspace") - content, readErr := os.ReadFile(hit) - g.Expect(readErr).NotTo(HaveOccurred()) - g.Expect(string(content)).To(ContainSubstring("from-kcp")) + expectMirroredValue(g, repo.CheckoutDir, "clusters/kcp", "sc-remote-cm", "from-kcp") }).WithTimeout(180 * time.Second).Should(Succeed()) }) - // Scenario 8 — the centerpiece: source-cluster identity is load-bearing. Three workspaces each - // hold the SAME namespace + the SAME resource name (demo/ConfigMap "shared") with DIFFERENT - // content, mirrored by three GitTargets (each naming its own ClusterProvider) into three - // folders. If the operator keyed state by (namespace, GVR) alone — a union / first-wins lookup — - // the three identical identities would collapse into one; that they land as three distinct - // files, each carrying its own workspace's value, is the proof that everything is keyed by - // SOURCE CLUSTER (the ClusterProvider name). + // Scenario 8 — source-cluster identity is load-bearing. Three workspaces each hold the SAME + // namespace + the SAME resource name (ConfigMap "shared") with DIFFERENT content, mirrored by + // three GitTargets (each naming its own KubeProvider) into three folders. If the operator keyed + // state by (namespace, GVR) alone the three identical identities would collapse into one; that + // they land as three distinct files, each carrying its own workspace's value, proves everything + // is keyed by SOURCE CLUSTER. It("mirrors identical resources from three workspaces as distinct GitOps state", func() { if kcp == nil { Skip("kcp is not installed; run this corner via `task test-e2e-source-cluster`") @@ -459,52 +564,67 @@ spec: // Eventually is generous because three remote initial-snapshots run concurrently. for i := range cases { c := cases[i] - hash := kcp.createWorkspace(c.ws) DeferCleanup(func() { kcp.cleanupWorkspaceTarget(testNs, c.ws) }) - DeferCleanup(func() { deleteClusterProvider(c.ws) }) - - // The SAME namespace name (testNs) and the SAME resource name (shared) in every - // workspace; only the value differs. A WatchRule watches its own namespace name on the - // source cluster, so testNs is created inside each workspace (a distinct cluster). - _, err := kcp.wsKubectl(hash, "create", "namespace", testNs) - Expect(err).NotTo(HaveOccurred(), "create namespace in %s", c.ws) - _, err = kcp.wsKubectl(hash, "-n", testNs, "create", "configmap", "shared", - "--from-literal=which="+c.value) - Expect(err).NotTo(HaveOccurred(), "create ConfigMap shared in %s", c.ws) - - writeKubeConfigSecret(c.ws+"-kubeconfig", "value", kcp.operatorKubeConfig(hash)) - _, err = applyClusterProvider(c.ws, c.ws+"-kubeconfig", "", testNs) - Expect(err).NotTo(HaveOccurred()) - target := c.ws + "-target" - _, err = applyGitTargetWithClusterProvider(testNs, target, providerName, c.folder, c.ws) - Expect(err).NotTo(HaveOccurred()) - verifyResourceCondition("gittarget", target, testNs, "SourceClusterReachable", "True", "", "", "180s") - - rule := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 -kind: WatchRule -metadata: {name: %s-rule, namespace: %s} -spec: - gitTargetRef: {name: %s} - rules: - - resources: ["configmaps"] -`, c.ws, testNs, target) - _, err = kubectlRunWithStdin(testNs, rule, "apply", "-f", "-") - Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { deleteKubeProvider(testNs, c.ws) }) + mirrorWorkspaceConfigMap(kcp, c.ws, testNs, testNs, c.ws, providerName, "", c.folder, "shared", c.value) } Eventually(func(g Gomega) { pullLatestRepoState(g, repo.CheckoutDir) for _, c := range cases { - hit := findFileByBasename(filepath.Join(repo.CheckoutDir, c.folder), "shared.yaml") - g.Expect(hit).NotTo(BeEmpty(), "expected shared mirrored under %s", c.folder) - content, readErr := os.ReadFile(hit) - g.Expect(readErr).NotTo(HaveOccurred()) - g.Expect(string(content)).To(ContainSubstring("which: "+c.value), - "folder %s must carry workspace %s's own value, not another workspace's", - c.folder, c.ws) + expectMirroredValue(g, repo.CheckoutDir, c.folder, "shared", c.value) } // Generous: the initial snapshot writes each mirror promptly, but the ceiling also // covers one RequeueSteadyInterval (5m) periodic resync as a fallback. }).WithTimeout(420 * time.Second).WithPolling(5 * time.Second).Should(Succeed()) }) + + // Scenario 9 — two KubeProviders with ONE name in two namespaces are two source clusters: their + // watches, caches and audit routes are keyed by namespace/name, so each folder carries its own + // workspace's value. Deleting one and recreating it under the same name against a third + // workspace must start a new connection rather than reuse the old one's clients or catalog, and + // must leave the other namespace's mirror alone. + It("keeps two same-named KubeProviders in two namespaces apart, also across a recreate", func() { + if kcp == nil { + Skip("kcp is not installed; run this corner via `task test-e2e-source-cluster`") + } + const provider = "sc-twin" + DeferCleanup(func() { kcp.cleanupWorkspaceTarget(testNs, "sc-twin-a") }) + DeferCleanup(func() { kcp.cleanupWorkspaceTarget(tenantNs, "sc-twin-b") }) + DeferCleanup(func() { deleteKubeProvider(testNs, provider) }) + DeferCleanup(func() { deleteKubeProvider(tenantNs, provider) }) + + mirrorWorkspaceConfigMap(kcp, "sc-twin-a", testNs, testNs, provider, providerName, "", + "clusters/twin-a", "twin", "alpha") + mirrorWorkspaceConfigMap(kcp, "sc-twin-b", tenantNs, tenantNs, provider, providerName, testNs, + "clusters/twin-b", "twin", "beta") + Eventually(func(g Gomega) { + pullLatestRepoState(g, repo.CheckoutDir) + expectMirroredValue(g, repo.CheckoutDir, "clusters/twin-a", "twin", "alpha") + expectMirroredValue(g, repo.CheckoutDir, "clusters/twin-b", "twin", "beta") + }).WithTimeout(300 * time.Second).WithPolling(5 * time.Second).Should(Succeed()) + verifyResourceCondition("kubeprovider", provider, testNs, "Ready", "True", "", "") + verifyResourceCondition("kubeprovider", provider, tenantNs, "Ready", "True", "", "") + + By("recreating the tenant's provider under the same name against another workspace") + DeferCleanup(func() { kcp.deleteWorkspace("sc-twin-c") }) + hash := kcp.createWorkspace("sc-twin-c") + _, err := kcp.wsKubectl(hash, "create", "namespace", tenantNs) + Expect(err).NotTo(HaveOccurred()) + _, err = kcp.wsKubectl(hash, "-n", tenantNs, "create", "configmap", "twin", "--from-literal=which=gamma") + Expect(err).NotTo(HaveOccurred()) + deleteKubeProvider(tenantNs, provider) + writeKubeConfigSecret(tenantNs, "sc-twin-c-kubeconfig", "value", kcp.operatorKubeConfig(hash)) + DeferCleanup(func() { + _, _ = kubectlRunInNamespace(tenantNs, "delete", "secret", "sc-twin-c-kubeconfig", "--ignore-not-found") + }) + _, err = applyKubeProvider(tenantNs, provider, "sc-twin-c-kubeconfig", "") + Expect(err).NotTo(HaveOccurred()) + + Eventually(func(g Gomega) { + pullLatestRepoState(g, repo.CheckoutDir) + expectMirroredValue(g, repo.CheckoutDir, "clusters/twin-b", "twin", "gamma") + expectMirroredValue(g, repo.CheckoutDir, "clusters/twin-a", "twin", "alpha") + }).WithTimeout(420 * time.Second).WithPolling(5 * time.Second).Should(Succeed()) + }) }) diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index 1038b0e59..921100df4 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -32,8 +32,8 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() const ( providerName = "gitprovider-srcns" // Two GitTargets: one whose provider delegates the override, one whose provider does not. - delegatingCP = "srcns-delegating" - nonDelegatingCP = "srcns-non-delegating" + delegatingKP = "srcns-delegating" + nonDelegatingKP = "srcns-non-delegating" grantedTarget = "srcns-granted" wildcardTarget = "srcns-wildcard" refusedTarget = "srcns-refused" @@ -83,28 +83,30 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() verifyResourceStatus("gitprovider", providerName, configNS, "True", "Succeeded", "Repository connectivity validated") - By("declaring two in-cluster ClusterProviders that differ ONLY in the delegation flag") - // Both omit kubeConfig, so both name the operator's own cluster: this is deliberately the - // SHARPER of the two cases the design describes, where an authorized override bypasses live - // namespace RBAC. Dedicated providers keep the shared "default" one untouched. - Expect(applyInClusterClusterProvider(delegatingCP, configNS, true)).Error(). - NotTo(HaveOccurred(), "failed to apply delegating ClusterProvider") - Expect(applyInClusterClusterProvider(nonDelegatingCP, configNS, false)).Error(). - NotTo(HaveOccurred(), "failed to apply non-delegating ClusterProvider") + By("declaring two KubeProviders on the operator's own cluster that differ ONLY in the delegation flag") + // Both reach the operator's own cluster with one cluster-admin kubeconfig: this is + // deliberately the SHARPER of the two cases the design describes, where an authorized override + // bypasses live namespace RBAC. Dedicated providers keep the install's one untouched, and + // live in the GitTargets' own namespace, which every KubeProvider admits. + writeKubeConfigSecret(configNS, "srcns-kubeconfig", "value", sameClusterKubeConfig()) + Expect(applyKubeProviderWithAuditRoute(configNS, delegatingKP, "srcns-kubeconfig", nil, true, "")).Error(). + NotTo(HaveOccurred(), "failed to apply delegating KubeProvider") + Expect(applyKubeProviderWithAuditRoute(configNS, nonDelegatingKP, "srcns-kubeconfig", nil, false, "")). + Error().NotTo(HaveOccurred(), "failed to apply non-delegating KubeProvider") By("creating two GitTargets behind the delegating provider and one behind the refusing one") - // The targets are identical. The only thing that differs is which ClusterProvider they + // The targets are identical. The only thing that differs is which KubeProvider they // mirror through, which is now the whole of the source-namespace policy: there is no // per-target allow-list any more. The wildcard gets a target of its own because one // GitTarget cannot watch a type both in all namespaces and in a named one. - Expect(applyGitTargetForClusterProvider( - configNS, grantedTarget, providerName, grantedPath, delegatingCP)).Error(). + Expect(applyGitTargetForKubeProvider( + configNS, grantedTarget, providerName, grantedPath, configNS, delegatingKP)).Error(). NotTo(HaveOccurred(), "failed to apply granted GitTarget") - Expect(applyGitTargetForClusterProvider( - configNS, wildcardTarget, providerName, wildcardPath, delegatingCP)).Error(). + Expect(applyGitTargetForKubeProvider( + configNS, wildcardTarget, providerName, wildcardPath, configNS, delegatingKP)).Error(). NotTo(HaveOccurred(), "failed to apply wildcard GitTarget") - Expect(applyGitTargetForClusterProvider( - configNS, refusedTarget, providerName, refusedPath, nonDelegatingCP)).Error(). + Expect(applyGitTargetForKubeProvider( + configNS, refusedTarget, providerName, refusedPath, configNS, nonDelegatingKP)).Error(). NotTo(HaveOccurred(), "failed to apply refused GitTarget") verifyResourceCondition("gittarget", grantedTarget, configNS, "Validated", "True", "Succeeded", "") @@ -113,8 +115,6 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() }) AfterAll(func() { - deleteClusterProvider(delegatingCP) - deleteClusterProvider(nonDelegatingCP) cleanupNamespace(configNS) cleanupNamespace(sourceNS) cleanupNamespace(wildcardNS) @@ -256,7 +256,7 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() }).Should(Succeed()) }) - It("refuses an override the ClusterProvider does not delegate, and writes nothing", func() { + It("refuses an override the KubeProvider does not delegate, and writes nothing", func() { By("recording the repository head before the refused rule exists") var headBefore string Eventually(func(g Gomega) { @@ -296,23 +296,6 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() }) }) -// applyInClusterClusterProvider applies a ClusterProvider that OMITS kubeConfig — so it names the -// operator's own cluster — admitting one namespace and optionally delegating source-namespace -// selection to the GitTargets it admits. -func applyInClusterClusterProvider(name, allowedNS string, delegate bool) (string, error) { - manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: %s -spec: - accessFrom: - names: [%s] - allowCrossNamespace: - sources: %t -`, name, allowedNS, delegate) - return kubectlRunWithStdin("", manifest, "apply", "-f", "-") -} - // applyWildcardConfigMapWatchRule applies a WatchRule whose single item asks for ConfigMaps in // every namespace. One type, so the cluster-wide reach is observable without mirroring every // service-account token in the cluster into the fixture repository. diff --git a/test/e2e/suspend_e2e_test.go b/test/e2e/suspend_e2e_test.go index 2bc749c38..1249f23b4 100644 --- a/test/e2e/suspend_e2e_test.go +++ b/test/e2e/suspend_e2e_test.go @@ -215,12 +215,13 @@ metadata: spec: gitBranchRef: name: %s +%s path: %s commit: window: idleTimeout: "0s" suspend: %t -`, name, namespace, gitBranchName(providerName, "main"), targetPath, suspend) +`, name, namespace, gitBranchName(providerName, "main"), localKubeProviderRef(), targetPath, suspend) out, err := kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitTarget %q with suspend %t: %s", name, suspend, out) diff --git a/test/e2e/templates/gittarget.tmpl b/test/e2e/templates/gittarget.tmpl index fefb90231..6e1f67c53 100644 --- a/test/e2e/templates/gittarget.tmpl +++ b/test/e2e/templates/gittarget.tmpl @@ -21,6 +21,7 @@ metadata: spec: gitBranchRef: name: {{ .GitBranchName }} +{{ .KubeProviderRef }} path: {{ .Path }} commit: window: diff --git a/test/e2e/templates/manager/gittarget-prune.tmpl b/test/e2e/templates/manager/gittarget-prune.tmpl index 3671d471d..50dc7d066 100644 --- a/test/e2e/templates/manager/gittarget-prune.tmpl +++ b/test/e2e/templates/manager/gittarget-prune.tmpl @@ -18,6 +18,7 @@ metadata: spec: gitBranchRef: name: {{ .GitBranchName }} +{{ .KubeProviderRef }} path: {{ .Path }} commit: window: diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/clusterprovider.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/clusterprovider.yaml deleted file mode 100644 index 11eed5156..000000000 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/clusterprovider.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Required, because the WatchRule beside this file names source namespaces other -# than its own. This is the ONE grant that permits that: the platform admin who -# owns the ClusterProvider delegates the choice of source namespace to the -# GitTargets it admits. What those targets can actually read is bounded by the -# source credential's own RBAC. -apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: prod -spec: - accessFrom: - names: [homelab-config] - allowCrossNamespace: - sources: true diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml index 94855c4d8..181f6152d 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml @@ -16,11 +16,11 @@ metadata: spec: gitBranchRef: name: mirror-repository-main - clusterProviderRef: + kubeProviderRef: name: prod path: mirror/prod # The WatchRule beside this file names source namespaces other than its own. The - # ClusterProvider's allowCrossNamespace.sources is the only grant that needs, and the + # KubeProvider's allowCrossNamespace.sources is the only grant that needs, and the # source credential's RBAC bounds what it can actually read. # Flat is a DECLARED shape. Without a template the built-in ladder ends at the # built-in identity path, which is a tree. One file per object, directly in the diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml new file mode 100644 index 000000000..935413459 --- /dev/null +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml @@ -0,0 +1,16 @@ +# Required, because the WatchRule beside this file names source namespaces other +# than its own. This is the ONE grant that permits that: the owner of the +# KubeProvider delegates the choice of source namespace to the GitTargets it +# admits, which here are those in its own namespace. What those targets can +# actually read is bounded by the source credential's own RBAC. +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: + name: prod + namespace: homelab-config +spec: + # A kubeconfig Secret in this provider's own namespace. + secretRef: + name: prod-kubeconfig + allowCrossNamespace: + sources: true diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml index eee2e2e28..1a854400f 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml @@ -24,7 +24,7 @@ metadata: spec: gitBranchRef: name: artifacts-repository-main - clusterProviderRef: + kubeProviderRef: name: apps path: apps/checkout placement: diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml index 03390b448..c432e5d7b 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: artifacts-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout # One file per object, flat, and no {namespace} in the template: the folder is # single-namespace by construction. diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/clusterprovider.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/clusterprovider.yaml deleted file mode 100644 index 55974d237..000000000 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/clusterprovider.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Required, because the WatchRule beside this file names source namespaces other -# than its own. This is the ONE grant that permits that: the platform admin who -# owns the ClusterProvider delegates the choice of source namespace to the -# GitTargets it admits. What those targets can actually read is bounded by the -# source credential's own RBAC. -apiVersion: configbutler.ai/v1alpha3 -kind: ClusterProvider -metadata: - name: home -spec: - accessFrom: - names: [homelab-config] - allowCrossNamespace: - sources: true diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml index 7b0c22bf2..78b585521 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml @@ -16,11 +16,11 @@ metadata: spec: gitBranchRef: name: mirror-repository-main - clusterProviderRef: + kubeProviderRef: name: home path: clusters/home # The WatchRule beside this file names source namespaces other than its own. The - # ClusterProvider's allowCrossNamespace.sources is the only grant that needs, and the + # KubeProvider's allowCrossNamespace.sources is the only grant that needs, and the # source credential's RBAC bounds what it can actually read. # No placement declared and no kustomization anywhere in the subtree, so the # ladder falls through to the built-in identity path: diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml new file mode 100644 index 000000000..9316cd8d8 --- /dev/null +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml @@ -0,0 +1,16 @@ +# Required, because the WatchRule beside this file names source namespaces other +# than its own. This is the ONE grant that permits that: the owner of the +# KubeProvider delegates the choice of source namespace to the GitTargets it +# admits, which here are those in its own namespace. What those targets can +# actually read is bounded by the source credential's own RBAC. +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: + name: home + namespace: homelab-config +spec: + # A kubeconfig Secret in this provider's own namespace. + secretRef: + name: home-kubeconfig + allowCrossNamespace: + sources: true diff --git a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml index 8e6bb2b1f..59c455262 100644 --- a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: artifacts-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout # A tree WITHOUT {namespace}: the subfolders group by type, not by namespace, # so the folder stays single-namespace. That is the precondition for the flag diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml index 1e700abb0..ee2494af8 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml @@ -18,6 +18,9 @@ metadata: spec: gitBranchRef: name: app-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout # There is nothing to infer from, so both halves are declared. useKustomize # writes the root; serializeNamespace: false is honest BECAUSE the operator owns diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml index 0a141e414..f64b4398d 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: app-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout # Nothing else is declared, and that is the shape: the folder already has one # kustomization.yaml, so a new file is placed beside it, registered in its diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml index 1ca657f27..516edd6a3 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml @@ -25,4 +25,7 @@ metadata: spec: gitBranchRef: name: app-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml index 5dcba34bc..a7761f93a 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: app-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser # A LEAF overlay, not the app root. One target = one overlay = one environment = # one write partition. apps/checkout would cover four roots at once, which is # LayoutResolved=Ambiguous, and would make the shared base writable from prod. diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml index f9e603ba4..e4af09cf2 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml @@ -18,4 +18,7 @@ metadata: spec: gitBranchRef: name: app-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/checkout/overlays/test diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml index 0031c40f8..c63de1c68 100644 --- a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: app-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser # The leaf again, and here it is not a preference. envs/prod and envs/test both # include layers/observability, so every file in that layer has fan-in 2 and is # refused as a write target (L2). The leaf is the only folder in this tree with a diff --git a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml index 0dfdadcc0..7dab35e8e 100644 --- a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: app-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser # The leaf overlay. The base one directory up is READ to render this folder and is # never written: read scope is wider than write scope, always. path: apps/checkout/overlays/prod diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml index dadfc192b..eda89caf8 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: homelab-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: bootstrap/argocd-applications # Safe here only because the folder's kustomization.yaml supplies # namespace: argocd — and that file belongs to the repository owner, not to us. diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml index ecc0f0583..9bfe4b360 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml @@ -20,5 +20,8 @@ metadata: spec: gitBranchRef: name: homelab-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser path: apps/home/media serializeNamespace: false # the folder's own kustomization.yaml supplies it diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml index f480b5945..d5945fef9 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: homelab-repository-main + kubeProviderRef: + name: local + namespace: gitops-reverser # Not clusters/home/flux-system. That directory belongs to `flux bootstrap`, and a # GitTarget must not point at a path another controller writes. path: infrastructure/home/sources diff --git a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md index 461ac68af..c8f66e619 100644 --- a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md +++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md @@ -21,17 +21,19 @@ namespace, and every `GitTarget` writing one repository branch must refer to the ## Source cluster authority -The examples here use the operator's in-cluster source. A target that captures objects from a -different source namespace needs one grant, and it belongs to the platform admin: the -`ClusterProvider` delegates the *ability* to choose a source namespace -(`allowCrossNamespace.sources`). What may actually be read is bounded by that provider credential's own -RBAC in the source cluster. Concrete specimens are in -[`../../shapes/1-flat-serialized/config/`](../../shapes/1-flat-serialized/config/clusterprovider.yaml) -and [`../../shapes/3-tree-serialized/config/`](../../shapes/3-tree-serialized/config/clusterprovider.yaml). - -The source-cluster rule and source-namespace authorization are separate checks. `accessFrom` decides -which configuration namespaces may reference the provider at all; `allowCrossNamespace.sources` decides -whether those namespaces' `GitTarget`s may look outside their own. +Every `GitTarget` names its source cluster with `spec.kubeProviderRef`. The examples here use the +`KubeProvider` the chart renders, `local` in `gitops-reverser`, which uses the operator's own +identity. A target that captures objects from a different source namespace needs one grant, and it +belongs to the owner of the `KubeProvider`: the provider delegates the *ability* to choose a source +namespace (`allowCrossNamespace.sources`). What may actually be read is bounded by that provider +credential's own RBAC in the source cluster. Concrete specimens are in +[`../../shapes/1-flat-serialized/config/`](../../shapes/1-flat-serialized/config/kubeprovider.yaml) +and [`../../shapes/3-tree-serialized/config/`](../../shapes/3-tree-serialized/config/kubeprovider.yaml). + +The source-cluster rule and source-namespace authorization are separate checks. A `KubeProvider` +admits consumers in its own namespace, and `accessFrom` decides which other configuration +namespaces may reference it; `allowCrossNamespace.sources` decides whether those namespaces' +`GitTarget`s may look outside their own. ## Credentials stay out of this design diff --git a/test/mutationlab/README.md b/test/mutationlab/README.md index 8f6ead991..73c3c6bdd 100644 --- a/test/mutationlab/README.md +++ b/test/mutationlab/README.md @@ -86,8 +86,8 @@ new audit policy, webhook config, or certificates. `task lab-e2e` does this on t already-prepared e2e cluster, then drives the scenarios serially. Reusing the wiring means reusing whatever the cluster's audit kubeconfig points at, and -this one names its route: it posts to `/audit-webhook/default`, the "default" -ClusterProvider, because the bare path is the product's shared annotation-routed +this one names its route: it posts to `/audit-webhook/gitops-reverser.local`, the route of the +`local` KubeProvider in `gitops-reverser`, because the bare path is the product's shared annotation-routed endpoint. The lab therefore serves the whole `/audit-webhook/` subtree and records what arrives whichever source it was addressed to. Serving only the exact bare path 404s every event the cluster sends — which reads as a lab that captures watches and admission but diff --git a/test/playground/config/gittarget.yaml b/test/playground/config/gittarget.yaml index 1ab205474..269f04796 100644 --- a/test/playground/config/gittarget.yaml +++ b/test/playground/config/gittarget.yaml @@ -16,6 +16,9 @@ metadata: spec: gitBranchRef: name: playground-provider-main + kubeProviderRef: + name: local + namespace: gitops-reverser # Keep playground writes under a folder. Use "." only when testing repo-root ownership. path: live-cluster # Every event is its own commit, so a playground change shows up in Git immediately.