Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .fossa.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: 3

paths:
exclude:
- external-sources
25 changes: 21 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -779,10 +779,10 @@ jobs:
k3d_agent_count: "0"
- name: full-core
script: "task test-e2e"
# `bi-directional` moved out to its own leg (it needs an Argo CD
# install that `task prepare-e2e` does not perform), so this filter is
# now a strict subset of what it used to run.
e2e_label_filter: "!manager && !image-refresh && !bi-directional"
# `bi-directional` and `source-cluster` moved out to their own legs (they
# install Argo CD / kcp that `task prepare-e2e` does not perform), so this
# filter is now a strict subset of what it used to run.
e2e_label_filter: "!manager && !image-refresh && !bi-directional && !source-cluster"
e2e_report_name: "full-core"
needs_artifact: false
coverage: "1"
Expand All @@ -805,6 +805,23 @@ jobs:
needs_artifact: false
coverage: "1"
k3d_agent_count: "0"
# The source-cluster corner: the only leg that installs kcp, and the only
# place gitops-reverser mirrors REMOTE clusters (GitTarget.spec.kubeConfig).
# kcp workspaces are cheap logical clusters, so its own runner installs a
# small kcp control plane and provisions three workspaces as source clusters.
# docs/design/config-plane-split.md.
#
# `task test-e2e-source-cluster` pins its own label filter and Ginkgo procs
# (SOURCE_CLUSTER_GINKGO_PROCS=1 — the specs share one kcp port-forward), so
# e2e_label_filter and e2e_ginkgo_procs are deliberately absent here. It uses
# the default config-dir install, so it carries the GOCOVERDIR overlay and its
# coverage unions with the others'.
- name: source-cluster
script: "task test-e2e-source-cluster"
e2e_report_name: "source-cluster"
needs_artifact: false
coverage: "1"
k3d_agent_count: "0"
# Quickstart chain, sharded across two runners (was the single
# `quickstart` lane). quickstart-install runs the two install-mode
# validations on one cluster (cleanup-installs.sh resets the namespace
Expand Down
48 changes: 48 additions & 0 deletions api/v1alpha3/gittarget_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package v1alpha3

import (
meta "github.com/fluxcd/pkg/apis/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand Down Expand Up @@ -45,6 +46,20 @@ type GitProviderReference struct {
// +kubebuilder:validation:XValidation:rule="self.providerRef == oldSelf.providerRef",message="spec.providerRef is immutable; delete and recreate the GitTarget to change its destination"
// +kubebuilder:validation:XValidation:rule="self.branch == oldSelf.branch",message="spec.branch is immutable; delete and recreate the GitTarget to change its destination"
// +kubebuilder:validation:XValidation:rule="self.path == oldSelf.path",message="spec.path is immutable; delete and recreate the GitTarget to change its destination"
//
// spec.kubeConfig is immutable — the source of a folder's content is destination identity, like
// providerRef/branch/path above. Delete and recreate to change the cluster a GitTarget mirrors.
// +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 GitTarget to change the cluster it mirrors"
//
// configMapRef (provider / workload-identity auth) is present in meta.KubeConfigReference's schema
// but not yet implemented here; reject it at admission so the v1alpha3 contract is "secretRef only".
// Deleting this one rule, plus wiring the provider path in the resolver, is the whole future enablement.
// +kubebuilder:validation:XValidation:rule="!has(self.kubeConfig) || !has(self.kubeConfig.configMapRef)",message="spec.kubeConfig.configMapRef (provider auth) is not yet supported; use secretRef"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
//
// secretRef.name comes from the external meta.KubeConfigReference schema, which marks it required but
// permits the empty string; an empty name is meaningless (it can never resolve a Secret), so reject it
// at admission rather than letting it surface later as a Validated=False "Secret not found".
// +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"
type GitTargetSpec struct {
// ProviderRef references the GitProvider that backs this target.
// Immutable: delete and recreate the GitTarget to change its destination.
Expand Down Expand Up @@ -80,6 +95,19 @@ type GitTargetSpec struct {
// change only affects resources created after the change.
// +optional
Placement *GitTargetPlacementSpec `json:"placement,omitempty"`

// KubeConfig names the SOURCE CLUSTER this GitTarget mirrors FROM: the kubeconfig
// determines both the cluster and the credentials to reach it. Omitted means the cluster
// the operator runs in, the single-cluster default that behaves exactly as before. Its
// Secret is read from the GitTarget's OWN namespace, on the cluster the operator runs in —
// the credential for a cluster never has to live on that cluster. When SecretRef.Key is
// empty the resolver reads "value" then "value.yaml" (Flux's order). Immutable: the source
// of a folder's content is part of what the folder means; delete and recreate to change it.
// Only kubeConfig.secretRef is honored in v1alpha3 (configMapRef is rejected at admission);
// unsafe kubeconfigs (exec auth providers, insecure-skip-tls-verify) are rejected by the
// controller with a legible Validated=False reason unless the operator opts in via flags.
// +optional
KubeConfig *meta.KubeConfigReference `json:"kubeConfig,omitempty"`
}

// GitTargetPlacementSpec declares where NEW resources are written when no document
Expand Down Expand Up @@ -180,6 +208,8 @@ type GitTargetStreamsStatus struct {
// +kubebuilder:printcolumn:name="GitPathAccepted",type=string,JSONPath=`.status.conditions[?(@.type=="GitPathAccepted")].status`,priority=1
// +kubebuilder:printcolumn:name="RenderMatchesLive",type=string,JSONPath=`.status.conditions[?(@.type=="RenderMatchesLive")].status`,priority=1
// +kubebuilder:printcolumn:name="StreamsRunning",type=string,JSONPath=`.status.conditions[?(@.type=="StreamsRunning")].status`,priority=1
// +kubebuilder:printcolumn:name="SourceReachable",type=string,JSONPath=`.status.conditions[?(@.type=="SourceClusterReachable")].reason`,priority=1
// +kubebuilder:printcolumn:name="ProviderReady",type=string,JSONPath=`.status.conditions[?(@.type=="GitProviderReady")].status`,priority=1
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1
// +kubebuilder:printcolumn:name="Encryption",type=string,JSONPath=`.spec.encryption.provider`,priority=1
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
Expand All @@ -201,6 +231,24 @@ type GitTarget struct {
Status GitTargetStatus `json:"status,omitempty,omitzero"`
}

// SourceClusterID renders the identity the watch data plane keys a GitTarget's source
// cluster on: "<namespace>/<name>/<key>", where namespace/name locate the kubeconfig Secret
// in the GitTarget's own (config-plane) namespace and key is the SecretRef key AS WRITTEN in
// spec — an empty key is its own identity, distinct from an explicit one, because the
// resolver's value→value.yaml fallback only runs when the key is omitted. A GitTarget with no
// spec.kubeConfig (or no secretRef) mirrors the cluster the operator runs in and returns "",
// the local-cluster id every source-cluster-unaware code path already lands on.
//
// Neither a namespace, a Secret name, nor a Secret data key may contain "/", so the three
// segments are unambiguous; the resolver splits them back with SplitN(id, "/", 3).
func (g *GitTarget) SourceClusterID() string {
if g.Spec.KubeConfig == nil || g.Spec.KubeConfig.SecretRef == nil {
return ""
}
ref := g.Spec.KubeConfig.SecretRef
return g.Namespace + "/" + ref.Name + "/" + ref.Key
}

// +kubebuilder:object:root=true

// GitTargetList contains a list of GitTarget.
Expand Down
6 changes: 6 additions & 0 deletions api/v1alpha3/zz_generated.deepcopy.go

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

47 changes: 41 additions & 6 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3"
"github.com/ConfigButler/gitops-reverser/internal/controller"
"github.com/ConfigButler/gitops-reverser/internal/git"
"github.com/ConfigButler/gitops-reverser/internal/kubeconfig"
"github.com/ConfigButler/gitops-reverser/internal/queue"
"github.com/ConfigButler/gitops-reverser/internal/rulestore"
"github.com/ConfigButler/gitops-reverser/internal/telemetry"
Expand All @@ -64,6 +65,11 @@ const (
defaultAuditIdleTimeout = 60 * time.Second
defaultAuditShutdownTimeout = 10 * time.Second
defaultBranchBufferMaxSizeStr = "8Mi"
// defaultSourceClusterQPS / -Burst are the client-side throttle for a remote source
// cluster reached via GitTarget.spec.kubeConfig — a conservative default since a remote is
// reached over a network the in-cluster config is not, and is only read (list/watch/get).
defaultSourceClusterQPS = 20.0
defaultSourceClusterBurst = 30
)

func init() {
Expand Down Expand Up @@ -137,6 +143,11 @@ func main() {
RuleStore: ruleStore,
EventRouter: nil, // Will be set below
SensitiveResources: cfg.sensitiveResources,
// Resolve a GitTarget.spec.kubeConfig into a rest.Config by reading its Secret from the
// config plane. 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, float32(cfg.sourceClusterQPS), cfg.sourceClusterBurst),
}

// Initialize EventRouter with all dependencies. The streaming-snapshot resync
Expand All @@ -154,8 +165,12 @@ func main() {

// Inject the live followability registry into the writer, so a GVR-only DELETE
// event resolves to a manifest moved off its canonical path (M6 in the writer).
// The registry is a stable pointer the watch manager refreshes in place.
// The registry is a stable pointer the watch manager refreshes in place. SetMapper is
// the LOCAL cluster's resolver; SetClusterMapper gives the writer each SOURCE cluster's
// registry so a folder mirroring a remote resolves its documents' GVK->GVR against that
// remote — never a union of all clusters.
workerManager.SetMapper(watchMgr.TypeRegistry())
workerManager.SetClusterMapper(watchMgr.ClusterTypeLookup)

// Give the workers a way to surface a refused live write plan. Live events are committed
// off a timer with no result channel, so without this a refusal (acceptance gate or a
Expand Down Expand Up @@ -257,10 +272,11 @@ func main() {
os.Exit(1)
}
if err := (&controller.GitTargetReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
WorkerManager: workerManager,
EventRouter: eventRouter,
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
WorkerManager: workerManager,
EventRouter: eventRouter,
KubeConfigSafety: cfg.kubeConfigSafety,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "GitTarget")
os.Exit(1)
Expand Down Expand Up @@ -357,7 +373,16 @@ type appConfig struct {
branchBufferMaxBytes int64
sensitiveResources types.SensitiveResourcePolicy
sshHostKeys git.SSHHostKeyConfig
zapOpts zap.Options
// sourceClusterQPS / sourceClusterBurst bound the rate at which the operator talks to a
// source cluster reached through a GitTarget.spec.kubeConfig. A remote is reached over a
// network the in-cluster config is not, so it carries client-side throttling by default.
sourceClusterQPS float64
sourceClusterBurst int
// 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 kubeconfig.SafetyPolicy
zapOpts zap.Options
}

// parseFlags parses CLI flags and returns the application configuration.
Expand Down Expand Up @@ -470,6 +495,16 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) {
"",
"Comma-separated additional sensitive resources in resource or group/resource form.",
)
fs.Float64Var(&cfg.sourceClusterQPS, "source-cluster-qps", defaultSourceClusterQPS,
"Client-side QPS limit for talking to a source cluster reached via GitTarget.spec.kubeConfig.")
fs.IntVar(&cfg.sourceClusterBurst, "source-cluster-burst", defaultSourceClusterBurst,
"Client-side burst limit for talking to a source cluster reached via GitTarget.spec.kubeConfig.")
fs.BoolVar(&cfg.kubeConfigSafety.AllowExec, "insecure-kubeconfig-exec", false,
"Allow a source-cluster kubeconfig to use an exec auth provider (runs a binary in the "+
"operator Pod). Rejected by default; enabling this is a deliberate trust decision.")
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.")
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.")
Expand Down
Loading