From c6ce3607b899298ca8d598c5b4f1d29ce406dbbb Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Tue, 16 Sep 2025 16:35:36 +0200 Subject: [PATCH 01/18] deploy HelmRelease for external-dns --- .../dns.openmcp.cloud_dnsserviceconfigs.yaml | 15 +- api/dns/v1alpha1/config_types.go | 17 +- api/dns/v1alpha1/zz_generated.deepcopy.go | 19 ++- api/go.mod | 3 +- api/go.sum | 5 +- api/install/install.go | 5 + cmd/platform-service-dns/app/run.go | 9 +- go.mod | 32 ++-- go.sum | 64 +++---- internal/controllers/cluster/controller.go | 158 ++++++++++++++---- 10 files changed, 239 insertions(+), 88 deletions(-) diff --git a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml index 46cd644..dd8db87 100644 --- a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml +++ b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml @@ -53,7 +53,12 @@ spec: description: ExternalDNSPurposeConfig holds a purpose selector and the DNS configuration to apply if the selector matches. properties: - config: + helmReleaseReconciliationInterval: + description: |- + HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. + If not set, the global HelmReleaseReconciliationInterval is used. + type: string + helmValues: description: HelmValues are the helm values to deploy external-dns with, if the purpose selector matches. name: @@ -82,7 +87,7 @@ spec: rule: size(self.filter(property, size(self[property]) > 0)) == 1 required: - - config + - helmValues type: object type: array externalDNSSource: @@ -653,6 +658,12 @@ spec: - message: Exactly one of 'helm', 'git', or 'oci' must be set rule: size(self.filter(property, (property != "copyAuthSecret") && (size(self[property]) > 0))) == 1 + helmReleaseReconciliationInterval: + description: |- + HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. + The value can be overwritten for specific purposes using ExternalDNSForPurposes. + If not set, a default of 1h is used. + type: string selector: description: |- Selector is a label selector. diff --git a/api/dns/v1alpha1/config_types.go b/api/dns/v1alpha1/config_types.go index abc5433..0e36323 100644 --- a/api/dns/v1alpha1/config_types.go +++ b/api/dns/v1alpha1/config_types.go @@ -3,8 +3,8 @@ package v1alpha1 import ( "slices" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" fluxv1 "github.com/fluxcd/source-controller/api/v1" @@ -21,6 +21,12 @@ type DNSServiceConfigSpec struct { // ExternalDNSSource is the source of the external-dns helm chart. ExternalDNSSource ExternalDNSSource `json:"externalDNSSource"` + // HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. + // The value can be overwritten for specific purposes using ExternalDNSForPurposes. + // If not set, a default of 1h is used. + // +optional + HelmReleaseReconciliationInterval *metav1.Duration `json:"helmReleaseReconciliationInterval,omitempty"` + // ExternalDNSForPurposes is a list of DNS configurations in combination with purpose selectors. // The first matching purpose selector will be applied to the Cluster. // If no selector matches, no configuration will be applied. @@ -52,13 +58,20 @@ type ExternalDNSPurposeConfig struct { // It can be set to more easily identify the configuration in logs and events. // +optional Name string `json:"name,omitempty"` + // PurposeSelector is a selector to match against the list of purposes of a Cluster. // If not set, all Clusters are matched. // +optional PurposeSelector *PurposeSelector `json:"purposeSelector,omitempty"` + + // HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. + // If not set, the global HelmReleaseReconciliationInterval is used. + // +optional + HelmReleaseReconciliationInterval *metav1.Duration `json:"helmReleaseReconciliationInterval,omitempty"` + // HelmValues are the helm values to deploy external-dns with, if the purpose selector matches. // +kubebuilder:validation:Schemaless - HelmValues runtime.RawExtension `json:"config"` + HelmValues *apiextensionsv1.JSON `json:"helmValues"` } // PurposeSelector is a selector to match against the list of purposes of a Cluster. diff --git a/api/dns/v1alpha1/zz_generated.deepcopy.go b/api/dns/v1alpha1/zz_generated.deepcopy.go index 21079ef..1081ef9 100644 --- a/api/dns/v1alpha1/zz_generated.deepcopy.go +++ b/api/dns/v1alpha1/zz_generated.deepcopy.go @@ -7,8 +7,9 @@ package v1alpha1 import ( apiv1 "github.com/fluxcd/source-controller/api/v1" "github.com/openmcp-project/openmcp-operator/api/common" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -78,6 +79,11 @@ func (in *DNSServiceConfigSpec) DeepCopyInto(out *DNSServiceConfigSpec) { (*in).DeepCopyInto(*out) } in.ExternalDNSSource.DeepCopyInto(&out.ExternalDNSSource) + if in.HelmReleaseReconciliationInterval != nil { + in, out := &in.HelmReleaseReconciliationInterval, &out.HelmReleaseReconciliationInterval + *out = new(v1.Duration) + **out = **in + } if in.ExternalDNSForPurposes != nil { in, out := &in.ExternalDNSForPurposes, &out.ExternalDNSForPurposes *out = make([]ExternalDNSPurposeConfig, len(*in)) @@ -105,7 +111,16 @@ func (in *ExternalDNSPurposeConfig) DeepCopyInto(out *ExternalDNSPurposeConfig) *out = new(PurposeSelector) (*in).DeepCopyInto(*out) } - in.HelmValues.DeepCopyInto(&out.HelmValues) + if in.HelmReleaseReconciliationInterval != nil { + in, out := &in.HelmReleaseReconciliationInterval, &out.HelmReleaseReconciliationInterval + *out = new(v1.Duration) + **out = **in + } + if in.HelmValues != nil { + in, out := &in.HelmValues, &out.HelmValues + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDNSPurposeConfig. diff --git a/api/go.mod b/api/go.mod index 190bd13..3f4cdbc 100644 --- a/api/go.mod +++ b/api/go.mod @@ -3,6 +3,7 @@ module github.com/openmcp-project/platform-service-dns/api go 1.25.1 require ( + github.com/fluxcd/helm-controller/api v1.3.0 github.com/fluxcd/source-controller/api v1.6.2 github.com/openmcp-project/openmcp-operator/api v0.14.0 k8s.io/apiextensions-apiserver v0.34.0 @@ -13,12 +14,12 @@ require ( require ( github.com/fluxcd/pkg/apis/acl v0.7.0 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.10.0 // indirect github.com/fluxcd/pkg/apis/meta v1.12.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kr/text v0.2.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/api/go.sum b/api/go.sum index 827b2cc..841f1de 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,10 +1,13 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fluxcd/helm-controller/api v1.3.0 h1:PupXPuQbksmU0g2Lc6NjIYal2HJGL+6xohsf82eGVjo= +github.com/fluxcd/helm-controller/api v1.3.0/go.mod h1:4b8PfdH0e/9Pfol2ogdMYbQ1nLjcVu9gAv27cQzIPK4= github.com/fluxcd/pkg/apis/acl v0.7.0 h1:dMhZJH+g6ZRPjs4zVOAN9vHBd1DcavFgcIFkg5ooOE0= github.com/fluxcd/pkg/apis/acl v0.7.0/go.mod h1:uv7pXXR/gydiX4MUwlQa7vS8JONEDztynnjTvY3JxKQ= +github.com/fluxcd/pkg/apis/kustomize v1.10.0 h1:47EeSzkQvlQZdH92vHMe2lK2iR8aOSEJq95avw5idts= +github.com/fluxcd/pkg/apis/kustomize v1.10.0/go.mod h1:UsqMV4sqNa1Yg0pmTsdkHRJr7bafBOENIJoAN+3ezaQ= github.com/fluxcd/pkg/apis/meta v1.12.0 h1:XW15TKZieC2b7MN8VS85stqZJOx+/b8jATQ/xTUhVYg= github.com/fluxcd/pkg/apis/meta v1.12.0/go.mod h1:+son1Va60x2eiDcTwd7lcctbI6C+K3gM7R+ULmEq1SI= github.com/fluxcd/source-controller/api v1.6.2 h1:UmodAeqLIeF29HdTqf2GiacZyO+hJydJlepDaYsMvhc= diff --git a/api/install/install.go b/api/install/install.go index eb4cfe4..64de53e 100644 --- a/api/install/install.go +++ b/api/install/install.go @@ -6,6 +6,9 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + fluxhelmv2 "github.com/fluxcd/helm-controller/api/v2" + fluxsourcev1 "github.com/fluxcd/source-controller/api/v1" + dnsv1alpha1 "github.com/openmcp-project/platform-service-dns/api/dns/v1alpha1" ) @@ -21,6 +24,8 @@ func InstallCRDAPIs(scheme *runtime.Scheme) *runtime.Scheme { func InstallOperatorAPIsPlatform(scheme *runtime.Scheme) *runtime.Scheme { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(dnsv1alpha1.AddToScheme(scheme)) + utilruntime.Must(fluxsourcev1.AddToScheme(scheme)) + utilruntime.Must(fluxhelmv2.AddToScheme(scheme)) return scheme } diff --git a/cmd/platform-service-dns/app/run.go b/cmd/platform-service-dns/app/run.go index cb818a1..a6e7570 100644 --- a/cmd/platform-service-dns/app/run.go +++ b/cmd/platform-service-dns/app/run.go @@ -17,6 +17,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" "github.com/openmcp-project/controller-utils/pkg/logging" + + providerscheme "github.com/openmcp-project/platform-service-dns/api/install" ) var setupLog logging.Logger @@ -184,10 +186,9 @@ func (o *RunOptions) Complete(ctx context.Context) error { } func (o *RunOptions) Run(ctx context.Context) error { - // TODO: CRDs required? - // if err := o.PlatformCluster.InitializeClient(providerscheme.InstallProviderAPIs(runtime.NewScheme())); err != nil { - // return err - // } + if err := o.PlatformCluster.InitializeClient(providerscheme.InstallOperatorAPIsPlatform(runtime.NewScheme())); err != nil { + return err + } setupLog = o.Log.WithName("setup") setupLog.Info("Environment", "value", o.Environment) diff --git a/go.mod b/go.mod index aade720..5bcdb87 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,17 @@ go 1.25.1 replace github.com/openmcp-project/platform-service-dns/api => ./api require ( + github.com/fluxcd/helm-controller/api v1.3.0 + github.com/fluxcd/pkg/apis/meta v1.12.0 github.com/fluxcd/source-controller/api v1.6.2 - github.com/openmcp-project/controller-utils v0.19.0 + github.com/openmcp-project/controller-utils v0.21.1-0.20250916143313-911636c58c14 github.com/openmcp-project/openmcp-operator/api v0.14.0 github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250910074108-2166b543cee3 github.com/openmcp-project/platform-service-dns/api v0.0.0-00010101000000-000000000000 github.com/spf13/cobra v1.9.1 - k8s.io/api v0.34.0 - k8s.io/apimachinery v0.34.0 - k8s.io/client-go v0.34.0 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 sigs.k8s.io/controller-runtime v0.22.1 sigs.k8s.io/yaml v1.6.0 ) @@ -30,7 +32,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fluxcd/pkg/apis/acl v0.7.0 // indirect - github.com/fluxcd/pkg/apis/meta v1.12.0 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.12.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -59,7 +61,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -75,13 +77,13 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect - golang.org/x/net v0.43.0 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.44.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.10.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect @@ -91,9 +93,9 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.0 // indirect - k8s.io/apiserver v0.34.0 // indirect - k8s.io/component-base v0.34.0 // indirect + k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/apiserver v0.34.1 // indirect + k8s.io/component-base v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect diff --git a/go.sum b/go.sum index 935a7fb..4de6a1b 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,12 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/helm-controller/api v1.3.0 h1:PupXPuQbksmU0g2Lc6NjIYal2HJGL+6xohsf82eGVjo= +github.com/fluxcd/helm-controller/api v1.3.0/go.mod h1:4b8PfdH0e/9Pfol2ogdMYbQ1nLjcVu9gAv27cQzIPK4= github.com/fluxcd/pkg/apis/acl v0.7.0 h1:dMhZJH+g6ZRPjs4zVOAN9vHBd1DcavFgcIFkg5ooOE0= github.com/fluxcd/pkg/apis/acl v0.7.0/go.mod h1:uv7pXXR/gydiX4MUwlQa7vS8JONEDztynnjTvY3JxKQ= +github.com/fluxcd/pkg/apis/kustomize v1.12.0 h1:KvZN6xwgP/dNSeckL4a/Uv715XqiN1C3xS+jGcPejtE= +github.com/fluxcd/pkg/apis/kustomize v1.12.0/go.mod h1:OojLxIdKm1JAAdh3sL4j4F+vfrLKb7kq1vr8bpyEKgg= github.com/fluxcd/pkg/apis/meta v1.12.0 h1:XW15TKZieC2b7MN8VS85stqZJOx+/b8jATQ/xTUhVYg= github.com/fluxcd/pkg/apis/meta v1.12.0/go.mod h1:+son1Va60x2eiDcTwd7lcctbI6C+K3gM7R+ULmEq1SI= github.com/fluxcd/source-controller/api v1.6.2 h1:UmodAeqLIeF29HdTqf2GiacZyO+hJydJlepDaYsMvhc= @@ -101,8 +105,8 @@ github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/openmcp-project/controller-utils v0.19.0 h1:D4Ht3LI/Ue5yk2wdAnJEpChUVmB6xM7kglwhn7a2J3g= -github.com/openmcp-project/controller-utils v0.19.0/go.mod h1:zxcbcmedLdlQ//X/nwdPvq/nM3ikyR13DbOivou2I4Y= +github.com/openmcp-project/controller-utils v0.21.1-0.20250916143313-911636c58c14 h1:w08DQVsSFxCAbBgmTMGusTdm9sCfrUaaIkPlVijf5u0= +github.com/openmcp-project/controller-utils v0.21.1-0.20250916143313-911636c58c14/go.mod h1:b8VcTK6iXFgkW6pXtIEDbaiQtzqYycVMFmElc7SFBQQ= github.com/openmcp-project/openmcp-operator/api v0.14.0 h1:/Ogg13b/IBBmAVQEuFnOKvHuVV3o2DlrQpSQhXca0+g= github.com/openmcp-project/openmcp-operator/api v0.14.0/go.mod h1:mgckJa59TpgTjta8+BWHwbOxlY1zVasqQTDFQLCs6M8= github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250910074108-2166b543cee3 h1:RJmtE7Iqt8LS2ruGwJed6CqEoVgO1XoPQdDrnDwcaI8= @@ -126,8 +130,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -180,42 +184,42 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -240,18 +244,18 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= -k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= -k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= -k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= -k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= -k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= -k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= -k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= -k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= -k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= -k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= +k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= +k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 8fb50a1..793ce5d 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -5,16 +5,20 @@ import ( "fmt" "maps" "strings" + "time" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" - fluxv1 "github.com/fluxcd/source-controller/api/v1" + fluxhelmv2 "github.com/fluxcd/helm-controller/api/v2" + fluxmeta "github.com/fluxcd/pkg/apis/meta" + fluxsourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/openmcp-project/controller-utils/pkg/clusters" "github.com/openmcp-project/controller-utils/pkg/collections" @@ -55,10 +59,18 @@ func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.Eve var _ reconcile.Reconciler = &ClusterReconciler{} type ReconcileResult struct { - Result reconcile.Result + // Result is the result to return from the Reconcile function. + Result reconcile.Result + // ReconcileError is the error to return from the Reconcile function, if any occurred. ReconcileError errutils.ReasonableError - Config *dnsv1alpha1.ExternalDNSPurposeConfig - ConfigIndex int + // Config is the selected configuration that was applied to the Cluster, if it could be determined. + Config *dnsv1alpha1.ExternalDNSPurposeConfig + // ConfigIndex is the index of the selected configuration in the DNSServiceConfig, or -1 if no configuration was selected. + ConfigIndex int + // SourceKind is the kind of Flux source that was deployed (HelmRepository, GitRepository, OCIRepository), if any. + SourceKind string + // AccessRequest is the AccessRequest that provides access to the Cluster, if access was successfully obtained. + AccessRequest *clustersv1alpha1.AccessRequest } func (r *ClusterReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { @@ -149,7 +161,6 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request if len(ControllerName+"--"+localName) > 63 { localName = ctrlutils.K8sNameUUIDUnsafe(c.Name) } - // TODO: use access _, ar, err := accessMgr.WaitForClusterAccess(ctx, localName, nil, &commonapi.ObjectReference{ Name: c.Name, Namespace: c.Namespace, @@ -168,9 +179,10 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting access to Cluster '%s/%s': %w", c.Namespace, c.Name, err), clusterconst.ReasonInternalError) return rr } + rr.AccessRequest = ar // inject labels into AccessRequest - if err := ctrlutils.EnsureLabel(ctx, r.PlatformCluster.Client(), ar, openmcpconst.ManagedByLabel, ControllerName, true, ctrlutils.OVERWRITE); err != nil { + if err := ctrlutils.EnsureLabel(ctx, r.PlatformCluster.Client(), rr.AccessRequest, openmcpconst.ManagedByLabel, ControllerName, true, ctrlutils.OVERWRITE); err != nil { rr.ReconcileError = errutils.WithReason(fmt.Errorf("error ensuring labels on AccessRequest: %w", err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr } @@ -180,12 +192,17 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request return rr } - rr = r.deployFluxSource(ctx, c, expectedLabels, rr) + rr = r.deployHelmChartSource(ctx, c, expectedLabels, rr) if rr.ReconcileError != nil { return rr } - // TODO: deploy Flux Kustomization to deploy external-dns with selected config onto Cluster + rr = r.deployHelmRelease(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil { + return rr + } + + // TODO: wait for HelmRelease to be ready? } else { // DELETE log.Info("Cluster marked for deletion, cleaning up DNS configuration") @@ -208,6 +225,7 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request return rr } +// deployAuthSecret copies the auth secret (for access to the helm chart source) into the Cluster namespace if configured. func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { log := logging.FromContextOrPanic(ctx) @@ -264,26 +282,27 @@ func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1a return rr } -func (r *ClusterReconciler) deployFluxSource(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { +// deployHelmChartSource deploys the configured Flux source (HelmRepository, GitRepository, OCIRepository) into the Cluster namespace. +// It sets 'SourceKind' in the ReconcileResult. +func (r *ClusterReconciler) deployHelmChartSource(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { log := logging.FromContextOrPanic(ctx) // deploy Flux Source var fluxSource client.Object var setSpec func(obj client.Object) error - var kind string - sourceName := c.Name + ".external-dns" // TODO: shorten if too long + sourceName := clusterBasedResourceName(c.Name) // list existing Flux sources to detect obsolete ones - existingHelm := &fluxv1.HelmRepositoryList{} + existingHelm := &fluxsourcev1.HelmRepositoryList{} if err := r.PlatformCluster.Client().List(ctx, existingHelm, client.InNamespace(c.Namespace), client.MatchingLabels(expectedLabels)); err != nil { rr.ReconcileError = errutils.WithReason(fmt.Errorf("error listing existing HelmRepository resources in target namespace '%s': %w", c.Namespace, err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr } - existingGit := &fluxv1.GitRepositoryList{} + existingGit := &fluxsourcev1.GitRepositoryList{} if err := r.PlatformCluster.Client().List(ctx, existingGit, client.InNamespace(c.Namespace), client.MatchingLabels(expectedLabels)); err != nil { rr.ReconcileError = errutils.WithReason(fmt.Errorf("error listing existing GitRepository resources in target namespace '%s': %w", c.Namespace, err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr } - existingOCI := &fluxv1.OCIRepositoryList{} + existingOCI := &fluxsourcev1.OCIRepositoryList{} if err := r.PlatformCluster.Client().List(ctx, existingOCI, client.InNamespace(c.Namespace), client.MatchingLabels(expectedLabels)); err != nil { rr.ReconcileError = errutils.WithReason(fmt.Errorf("error listing existing OCIRepository resources in target namespace '%s': %w", c.Namespace, err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr @@ -291,74 +310,74 @@ func (r *ClusterReconciler) deployFluxSource(ctx context.Context, c *clustersv1a toBeDeleted := []client.Object{} // determine which type of source to create and which existing sources to delete if r.Config.Spec.ExternalDNSSource.Helm != nil { - fluxSource = &fluxv1.HelmRepository{} + fluxSource = &fluxsourcev1.HelmRepository{} setSpec = func(obj client.Object) error { - helmRepo, ok := obj.(*fluxv1.HelmRepository) + helmRepo, ok := obj.(*fluxsourcev1.HelmRepository) if !ok { return fmt.Errorf("expected HelmRepository object, got %T", obj) } helmRepo.Spec = *r.Config.Spec.ExternalDNSSource.Helm.DeepCopy() return nil } - kind = "HelmRepository" + rr.SourceKind = "HelmRepository" for i := range existingHelm.Items { obj := &existingHelm.Items[i] if obj.GetName() != sourceName { toBeDeleted = append(toBeDeleted, obj) } } - toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingGit.Items, func(obj fluxv1.GitRepository) client.Object { return &obj })...) - toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingOCI.Items, func(obj fluxv1.OCIRepository) client.Object { return &obj })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingGit.Items, func(obj fluxsourcev1.GitRepository) client.Object { return &obj })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingOCI.Items, func(obj fluxsourcev1.OCIRepository) client.Object { return &obj })...) } else if r.Config.Spec.ExternalDNSSource.Git != nil { - fluxSource = &fluxv1.GitRepository{} + fluxSource = &fluxsourcev1.GitRepository{} setSpec = func(obj client.Object) error { - gitRepo, ok := obj.(*fluxv1.GitRepository) + gitRepo, ok := obj.(*fluxsourcev1.GitRepository) if !ok { return fmt.Errorf("expected GitRepository object, got %T", obj) } gitRepo.Spec = *r.Config.Spec.ExternalDNSSource.Git.DeepCopy() return nil } - kind = "GitRepository" + rr.SourceKind = "GitRepository" for i := range existingGit.Items { obj := &existingGit.Items[i] if obj.GetName() != sourceName { toBeDeleted = append(toBeDeleted, obj) } } - toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingHelm.Items, func(obj fluxv1.HelmRepository) client.Object { return &obj })...) - toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingOCI.Items, func(obj fluxv1.OCIRepository) client.Object { return &obj })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingHelm.Items, func(obj fluxsourcev1.HelmRepository) client.Object { return &obj })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingOCI.Items, func(obj fluxsourcev1.OCIRepository) client.Object { return &obj })...) } else if r.Config.Spec.ExternalDNSSource.OCI != nil { - fluxSource = &fluxv1.OCIRepository{} + fluxSource = &fluxsourcev1.OCIRepository{} setSpec = func(obj client.Object) error { - ociRepo, ok := obj.(*fluxv1.OCIRepository) + ociRepo, ok := obj.(*fluxsourcev1.OCIRepository) if !ok { return fmt.Errorf("expected OCIRepository object, got %T", obj) } ociRepo.Spec = *r.Config.Spec.ExternalDNSSource.OCI.DeepCopy() return nil } - kind = "OCIRepository" + rr.SourceKind = "OCIRepository" for i := range existingOCI.Items { obj := &existingOCI.Items[i] if obj.GetName() != sourceName { toBeDeleted = append(toBeDeleted, obj) } } - toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingHelm.Items, func(obj fluxv1.HelmRepository) client.Object { return &obj })...) - toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingGit.Items, func(obj fluxv1.GitRepository) client.Object { return &obj })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingHelm.Items, func(obj fluxsourcev1.HelmRepository) client.Object { return &obj })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingGit.Items, func(obj fluxsourcev1.GitRepository) client.Object { return &obj })...) } else { rr.ReconcileError = errutils.WithReason(fmt.Errorf("no flux source configured"), clusterconst.ReasonConfigurationProblem) return rr } fluxSource.SetName(sourceName) fluxSource.SetNamespace(c.Namespace) - log.Info("Creating or updating Flux source", "kind", kind, "sourceName", fluxSource.GetName(), "sourceNamespace", fluxSource.GetNamespace()) + log.Info("Creating or updating Flux source", "kind", rr.SourceKind, "sourceName", fluxSource.GetName(), "sourceNamespace", fluxSource.GetNamespace()) if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), fluxSource, func() error { fluxSource.SetLabels(maputils.Merge(fluxSource.GetLabels(), expectedLabels)) return setSpec(fluxSource) }); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating %s '%s/%s': %w", kind, fluxSource.GetNamespace(), fluxSource.GetName(), err), clusterconst.ReasonPlatformClusterInteractionProblem) + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating %s '%s/%s': %w", rr.SourceKind, fluxSource.GetNamespace(), fluxSource.GetName(), err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr } // delete obsolete sources @@ -374,3 +393,80 @@ func (r *ClusterReconciler) deployFluxSource(ctx context.Context, c *clustersv1a return rr } + +// deployHelmRelease deploys the HelmRelease to install external-dns onto the Cluster. +// It expects 'Config', 'AccessRequest', and 'SourceKind' to be set in the given ReconcileResult. +func (r *ClusterReconciler) deployHelmRelease(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { + log := logging.FromContextOrPanic(ctx) + + hr := &fluxhelmv2.HelmRelease{} + hr.Name = clusterBasedResourceName(c.Name) + hr.Namespace = c.Namespace + + log.Info("Creating or updating HelmRelease", "resourceName", hr.Name, "resourceNamespace", hr.Namespace) + if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), hr, func() error { + // labels + hr.Labels = maputils.Merge(hr.Labels, expectedLabels) + // chart + hr.Spec.Chart = nil + hr.Spec.ChartRef = &fluxhelmv2.CrossNamespaceSourceReference{ + APIVersion: fluxsourcev1.SchemeBuilder.GroupVersion.String(), + Kind: rr.SourceKind, + Name: hr.Name, + Namespace: hr.Namespace, + } + // release information + hr.Spec.ReleaseName = "external-dns" + hr.Spec.TargetNamespace = "external-dns" + // values + hr.Spec.Values = rr.Config.HelmValues.DeepCopy() + // install configuration + if hr.Spec.Install == nil { + hr.Spec.Install = &fluxhelmv2.Install{} + } + hr.Spec.Install.CRDs = fluxhelmv2.CreateReplace + hr.Spec.Install.CreateNamespace = true + if hr.Spec.Install.Remediation == nil { + hr.Spec.Install.Remediation = &fluxhelmv2.InstallRemediation{} + } + hr.Spec.Install.Remediation.Retries = 3 + // upgrade configuration + if hr.Spec.Upgrade == nil { + hr.Spec.Upgrade = &fluxhelmv2.Upgrade{} + } + hr.Spec.Upgrade.CRDs = fluxhelmv2.CreateReplace + if hr.Spec.Upgrade.Remediation == nil { + hr.Spec.Upgrade.Remediation = &fluxhelmv2.UpgradeRemediation{} + } + hr.Spec.Upgrade.Remediation.Retries = 3 + // reference Cluster kubeconfig + hr.Spec.KubeConfig = &fluxmeta.KubeConfigReference{ + SecretRef: fluxmeta.SecretKeyReference{ + Name: rr.AccessRequest.Status.SecretRef.Name, + Key: clustersv1alpha1.SecretKeyKubeconfig, + }, + } + // deploy interval + if rr.Config.HelmReleaseReconciliationInterval != nil { + hr.Spec.Interval = *rr.Config.HelmReleaseReconciliationInterval + } else if r.Config.Spec.HelmReleaseReconciliationInterval != nil { + hr.Spec.Interval = *r.Config.Spec.HelmReleaseReconciliationInterval + } else { + hr.Spec.Interval = metav1.Duration{Duration: 1 * time.Hour} + } + return nil + }); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating HelmRelease '%s/%s': %w", hr.Namespace, hr.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + + return rr +} + +// clusterBasedResourceName generates a name for secondary resources based on the cluster name. +// The name is guaranteed to be unique for each cluster and to not exceed the Kubernetes name length limit. +// It is deterministic, the same clusterName will always yield the same resource name. +func clusterBasedResourceName(clusterName string) string { + suffix := ".external-dns" + return ctrlutils.ShortenToXCharactersUnsafe(clusterName, ctrlutils.K8sMaxNameLength-len(suffix)) + suffix +} From 32dad78e85e321dc9352b9a17c843620174f1d31 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 17 Sep 2025 10:54:36 +0200 Subject: [PATCH 02/18] finish cluster controller implementation --- internal/controllers/cluster/controller.go | 250 +++++++++++++++++---- 1 file changed, 210 insertions(+), 40 deletions(-) diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 793ce5d..c44c98b 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "maps" + "slices" "strings" "time" @@ -11,6 +12,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -37,6 +39,7 @@ import ( ) const ControllerName = "DNSCluster" +const defaultRequeueAfterDuration = 30 * time.Second type ClusterReconciler struct { PlatformCluster *clusters.Cluster @@ -71,34 +74,41 @@ type ReconcileResult struct { SourceKind string // AccessRequest is the AccessRequest that provides access to the Cluster, if access was successfully obtained. AccessRequest *clustersv1alpha1.AccessRequest + // Message is an optional message to be printed in the generated event. + Message string } func (r *ClusterReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { log := logging.FromContextOrPanic(ctx).WithName(ControllerName) ctx = logging.NewContext(ctx, log) log.Info("Starting reconcile") - rr := r.reconcile(ctx, req) - - // no status update, because the Cluster resource doesn't have status fields for DNS configuration - // instead, output events for significant changes - // TODO - - return rr.Result, rr.ReconcileError -} - -func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request) ReconcileResult { - log := logging.FromContextOrPanic(ctx) // get Cluster resource c := &clustersv1alpha1.Cluster{} if err := r.PlatformCluster.Client().Get(ctx, req.NamespacedName, c); err != nil { if apierrors.IsNotFound(err) { log.Info("Resource not found") - return ReconcileResult{} + return reconcile.Result{}, nil } - return ReconcileResult{ReconcileError: errutils.WithReason(fmt.Errorf("unable to get resource '%s' from cluster: %w", req.String(), err), clusterconst.ReasonPlatformClusterInteractionProblem)} + return reconcile.Result{}, fmt.Errorf("unable to get resource '%s' from cluster: %w", req.String(), err) } + rr := r.reconcile(ctx, c) + + // no status update, because the Cluster resource doesn't have status fields for DNS configuration + // instead, output events for significant changes and errors + if rr.ReconcileError != nil { + r.eventRecorder.Event(c, corev1.EventTypeWarning, rr.ReconcileError.Reason(), rr.ReconcileError.Error()) + } else if rr.Message != "" { + r.eventRecorder.Event(c, corev1.EventTypeNormal, "Reconciled", rr.Message) + } + + return log.LogRequeue(rr.Result), rr.ReconcileError +} + +func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.Cluster) ReconcileResult { + log := logging.FromContextOrPanic(ctx) + // handle operation annotation if c.GetAnnotations() != nil { op, ok := c.GetAnnotations()[dnsv1alpha1.OperationAnnotation] @@ -126,7 +136,21 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request openmcpconst.ManagedPurposeLabel: c.Name, } - if c.DeletionTimestamp.IsZero() { + // iterate over configurations with purpose selectors and choose the first matching one + for i, cfg := range r.Config.Spec.ExternalDNSForPurposes { + if cfg.PurposeSelector == nil || cfg.PurposeSelector.Matches(c.Spec.Purposes) { + log.Info("Found configuration with matching purpose selector", "configName", cfg.Name, "configIndex", i) + rr.Config = &r.Config.Spec.ExternalDNSForPurposes[i] + rr.ConfigIndex = i + break + } + } + if rr.Config == nil { + log.Info("No configuration with matching purpose selector found") + rr.ConfigIndex = -1 + } + + if c.DeletionTimestamp.IsZero() && rr.Config != nil { // CREATE/UPDATE log.Info("Creating or updating DNS configuration for Cluster") @@ -140,27 +164,9 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request } } - // iterate over configurations with purpose selectors and choose the first matching one - for i, cfg := range r.Config.Spec.ExternalDNSForPurposes { - if cfg.PurposeSelector == nil || cfg.PurposeSelector.Matches(c.Spec.Purposes) { - log.Info("Found configuration with matching purpose selector", "configName", cfg.Name, "configIndex", i) - rr.Config = &r.Config.Spec.ExternalDNSForPurposes[i] - rr.ConfigIndex = i - break - } - } - if rr.Config == nil { - log.Info("No configuration with matching purpose selector found") - rr.ConfigIndex = -1 - return rr - } - // get access to the Cluster accessMgr := accesslib.NewClusterAccessManager(r.PlatformCluster.Client(), strings.ToLower(ControllerName), c.Namespace) - localName := c.Name - if len(ControllerName+"--"+localName) > 63 { - localName = ctrlutils.K8sNameUUIDUnsafe(c.Name) - } + localName := ctrlutils.ShortenToXCharactersUnsafe(c.Name, ctrlutils.K8sMaxNameLength-len(ControllerName)-2) // TODO: the StableRequestNameFromLocalName function should do this internally _, ar, err := accessMgr.WaitForClusterAccess(ctx, localName, nil, &commonapi.ObjectReference{ Name: c.Name, Namespace: c.Namespace, @@ -188,28 +194,57 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request } rr = r.deployAuthSecret(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil { + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { return rr } rr = r.deployHelmChartSource(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil { + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { return rr } rr = r.deployHelmRelease(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil { + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { return rr } - // TODO: wait for HelmRelease to be ready? + rr.Message = "Successfully triggered deployment of external-dns on Cluster" } else { // DELETE - log.Info("Cluster marked for deletion, cleaning up DNS configuration") + // check if the Cluster has a finalizer, otherwise we don't have to do anything + if !slices.Contains(c.Finalizers, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { + log.Debug("Cluster does not have finalizer, no cleanup required", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) + return rr + } - // TODO: clean up deployed resources by removing Flux resources with matching labels + log.Info("Cleaning up DNS configuration for Cluster, either because it is being deleted or no configuration matches anymore") - // TODO: clean up copied auth secret if it was copied + rr = r.undeployHelmRelease(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } + + rr = r.undeployHelmChartSource(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } + + rr = r.undeployAuthSecret(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } + + // delete AccessRequest + ar := &clustersv1alpha1.AccessRequest{} + localName := ctrlutils.ShortenToXCharactersUnsafe(c.Name, ctrlutils.K8sMaxNameLength-len(ControllerName)-2) // TODO: the StableRequestNameFromLocalName function should do this internally + ar.Name = accesslib.StableRequestNameFromLocalName(strings.ToLower(ControllerName), localName) + ar.Namespace = c.Namespace + if err := r.PlatformCluster.Client().Delete(ctx, ar); err != nil { + if !apierrors.IsNotFound(err) { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + } // remove finalizer from Cluster old := c.DeepCopy() @@ -220,6 +255,8 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request return rr } } + + rr.Message = "Successfully removed external-dns from Cluster" } return rr @@ -277,6 +314,8 @@ func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1a rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating target secret '%s/%s': %w", target.Namespace, target.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr } + + rr.Message = "Successfully copied auth secret into Cluster namespace." } return rr @@ -391,6 +430,7 @@ func (r *ClusterReconciler) deployHelmChartSource(ctx context.Context, c *cluste } } + rr.Message = fmt.Sprintf("Successfully created or updated helm chart source (%s).", rr.SourceKind) return rr } @@ -460,6 +500,136 @@ func (r *ClusterReconciler) deployHelmRelease(ctx context.Context, c *clustersv1 return rr } + rr.Message = "Successfully created or updated HelmRelease to install external-dns." + return rr +} + +// undeployHelmRelease deletes the HelmRelease. +// It requeues the Cluster until the HelmRelease is fully deleted. +func (r *ClusterReconciler) undeployHelmRelease(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { + log := logging.FromContextOrPanic(ctx) + + hr := &fluxhelmv2.HelmRelease{} + hr.Name = clusterBasedResourceName(c.Name) + hr.Namespace = c.Namespace + + if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(hr), hr); err != nil { + if apierrors.IsNotFound(err) { + log.Info("HelmRelease not found, nothing to do", "resourceName", hr.Name, "resourceNamespace", hr.Namespace) + return rr + } else { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting HelmRelease '%s/%s': %w", hr.Namespace, hr.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + } + + // check if HelmRelease is marked for deletion + if !hr.DeletionTimestamp.IsZero() { + log.Info("HelmRelease already marked for deletion, waiting for its removal", "resourceName", hr.Name, "resourceNamespace", hr.Namespace) + } else { + // verify that the HelmRelease is managed by us + for k, v := range expectedLabels { + if v2, ok := hr.Labels[k]; !ok || v2 != v { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("HelmRelease '%s/%s' exists but is missing expected label (label '%s', expected to have value '%s', actually has '%s')", hr.Namespace, hr.Name, k, v, v2), clusterconst.ReasonInternalError) + return rr + } + } + + // delete HelmRelease + log.Info("Deleting HelmRelease", "resourceName", hr.Name, "resourceNamespace", hr.Namespace) + if err := r.PlatformCluster.Client().Delete(ctx, hr); err != nil { + if !apierrors.IsNotFound(err) { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting HelmRelease '%s/%s': %w", hr.Namespace, hr.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + } + } + + // requeue to verify deletion + rr.Message = "Waiting for HelmRelease to be deleted." + rr.Result.RequeueAfter = defaultRequeueAfterDuration + return rr +} + +// undeployHelmChartSource deletes all Flux sources where the labels indicate they were created by this controller for the given Cluster. +// It does not wait for their deletion. +func (r *ClusterReconciler) undeployHelmChartSource(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { + log := logging.FromContextOrPanic(ctx) + + // list existing Flux sources to detect obsolete ones + existingHelm := &fluxsourcev1.HelmRepositoryList{} + if err := r.PlatformCluster.Client().List(ctx, existingHelm, client.InNamespace(c.Namespace), client.MatchingLabels(expectedLabels)); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error listing existing HelmRepository resources in target namespace '%s': %w", c.Namespace, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + existingGit := &fluxsourcev1.GitRepositoryList{} + if err := r.PlatformCluster.Client().List(ctx, existingGit, client.InNamespace(c.Namespace), client.MatchingLabels(expectedLabels)); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error listing existing GitRepository resources in target namespace '%s': %w", c.Namespace, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + existingOCI := &fluxsourcev1.OCIRepositoryList{} + if err := r.PlatformCluster.Client().List(ctx, existingOCI, client.InNamespace(c.Namespace), client.MatchingLabels(expectedLabels)); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error listing existing OCIRepository resources in target namespace '%s': %w", c.Namespace, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + toBeDeleted := []client.Object{} + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingHelm.Items, func(obj fluxsourcev1.HelmRepository) client.Object { + if obj.GetObjectKind().GroupVersionKind().Kind == "" { + obj.SetGroupVersionKind(schema.GroupVersionKind{Group: fluxsourcev1.GroupVersion.Group, Version: fluxsourcev1.GroupVersion.Version, Kind: "HelmRepository"}) + } + return &obj + })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingGit.Items, func(obj fluxsourcev1.GitRepository) client.Object { + if obj.GetObjectKind().GroupVersionKind().Kind == "" { + obj.SetGroupVersionKind(schema.GroupVersionKind{Group: fluxsourcev1.GroupVersion.Group, Version: fluxsourcev1.GroupVersion.Version, Kind: "GitRepository"}) + } + return &obj + })...) + toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingOCI.Items, func(obj fluxsourcev1.OCIRepository) client.Object { + if obj.GetObjectKind().GroupVersionKind().Kind == "" { + obj.SetGroupVersionKind(schema.GroupVersionKind{Group: fluxsourcev1.GroupVersion.Group, Version: fluxsourcev1.GroupVersion.Version, Kind: "OCIRepository"}) + } + return &obj + })...) + + for _, obj := range toBeDeleted { + log.Info("Deleting Flux source", "kind", obj.GetObjectKind().GroupVersionKind().Kind, "resourceName", obj.GetName(), "resourceNamespace", obj.GetNamespace()) + if err := r.PlatformCluster.Client().Delete(ctx, obj); err != nil { + if !apierrors.IsNotFound(err) { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting %s '%s/%s': %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetNamespace(), obj.GetName(), err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + } + } + + rr.Message = "Deleted all helm chart sources for Cluster." + return rr +} + +// undeployAuthSecret removes all secrets from the Cluster namespace where the labels indicate they were created by this controller for the given Cluster. +// It does not wait for their deletion. +func (r *ClusterReconciler) undeployAuthSecret(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { + log := logging.FromContextOrPanic(ctx) + + // list existing secrets to detect obsolete ones + existingSecrets := &corev1.SecretList{} + if err := r.PlatformCluster.Client().List(ctx, existingSecrets, client.InNamespace(c.Namespace), client.MatchingLabels(expectedLabels)); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error listing existing Secret resources in target namespace '%s': %w", c.Namespace, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + + for i := range existingSecrets.Items { + obj := &existingSecrets.Items[i] + log.Info("Deleting auth secret", "resourceName", obj.GetName(), "resourceNamespace", obj.GetNamespace()) + if err := r.PlatformCluster.Client().Delete(ctx, obj); err != nil { + if !apierrors.IsNotFound(err) { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting Secret '%s/%s': %w", obj.GetNamespace(), obj.GetName(), err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + } + } + + rr.Message = "Deleted all auth secrets for Cluster." return rr } From f446293150fbf0a0680a7809e111d127c2bc528f Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 17 Sep 2025 11:25:33 +0200 Subject: [PATCH 03/18] actually start cluster controller --- cmd/platform-service-dns/app/app.go | 20 ++++--- cmd/platform-service-dns/app/run.go | 7 ++- internal/controllers/cluster/controller.go | 68 ++++++++++++++++------ 3 files changed, 69 insertions(+), 26 deletions(-) diff --git a/cmd/platform-service-dns/app/app.go b/cmd/platform-service-dns/app/app.go index df60e42..6890907 100644 --- a/cmd/platform-service-dns/app/app.go +++ b/cmd/platform-service-dns/app/app.go @@ -10,6 +10,7 @@ import ( "github.com/openmcp-project/controller-utils/pkg/clusters" "github.com/openmcp-project/controller-utils/pkg/logging" + openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" ) func NewPlatformServiceDNSCommand() *cobra.Command { @@ -32,8 +33,9 @@ func NewPlatformServiceDNSCommand() *cobra.Command { } type RawSharedOptions struct { - Environment string `json:"environment"` - DryRun bool `json:"dry-run"` + Environment string `json:"environment"` + ProviderName string `json:"provider-name"` + DryRun bool `json:"dry-run"` } type SharedOptions struct { @@ -41,8 +43,8 @@ type SharedOptions struct { PlatformCluster *clusters.Cluster // fields filled in Complete() - Log logging.Logger - ProviderName string + Log logging.Logger + ProviderNamespace string } func (o *SharedOptions) AddPersistentFlags(cmd *cobra.Command) { @@ -52,6 +54,8 @@ func (o *SharedOptions) AddPersistentFlags(cmd *cobra.Command) { o.PlatformCluster.RegisterSingleConfigPathFlag(cmd.PersistentFlags()) // environment cmd.PersistentFlags().StringVar(&o.Environment, "environment", "", "Environment name. Required. This is used to distinguish between different environments that are watching the same Onboarding cluster. Must be globally unique.") + // provider name + cmd.PersistentFlags().StringVar(&o.ProviderName, "provider-name", "", "Name of the provider resource.") cmd.PersistentFlags().BoolVar(&o.DryRun, "dry-run", false, "If set, the command aborts after evaluation of the given flags.") } @@ -59,10 +63,12 @@ func (o *SharedOptions) Complete() error { if o.Environment == "" { return fmt.Errorf("environment must not be empty") } - - o.ProviderName = os.Getenv("OPENMCP_PROVIDER_NAME") if o.ProviderName == "" { - o.ProviderName = "gardener" + return fmt.Errorf("provider-name must not be empty") + } + o.ProviderNamespace = os.Getenv(openmcpconst.EnvVariablePodNamespace) + if o.ProviderNamespace == "" { + return fmt.Errorf("environment variable '%s' must be set", openmcpconst.EnvVariablePodNamespace) } // build logger diff --git a/cmd/platform-service-dns/app/run.go b/cmd/platform-service-dns/app/run.go index a6e7570..82d626b 100644 --- a/cmd/platform-service-dns/app/run.go +++ b/cmd/platform-service-dns/app/run.go @@ -19,6 +19,7 @@ import ( "github.com/openmcp-project/controller-utils/pkg/logging" providerscheme "github.com/openmcp-project/platform-service-dns/api/install" + "github.com/openmcp-project/platform-service-dns/internal/controllers/cluster" ) var setupLog logging.Logger @@ -199,7 +200,7 @@ func (o *RunOptions) Run(ctx context.Context) error { }) mgr, err := ctrl.NewManager(o.PlatformCluster.RESTConfig(), ctrl.Options{ - Scheme: runtime.NewScheme(), // TODO add scheme, e.g. providerscheme.InstallProviderAPIs(runtime.NewScheme()), + Scheme: o.PlatformCluster.Scheme(), Metrics: o.MetricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: o.ProbeAddr, @@ -222,7 +223,9 @@ func (o *RunOptions) Run(ctx context.Context) error { return fmt.Errorf("unable to create manager: %w", err) } - // TODO setup controllers + if err := cluster.NewClusterReconciler(o.PlatformCluster, mgr.GetEventRecorderFor(cluster.ControllerName), o.ProviderName, o.ProviderNamespace).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to add Cluster reconciler to manager: %w", err) + } if o.MetricsCertWatcher != nil { setupLog.Info("Adding metrics certificate watcher to manager") diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index c44c98b..c43dcde 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -14,8 +14,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" fluxhelmv2 "github.com/fluxcd/helm-controller/api/v2" @@ -43,19 +45,17 @@ const defaultRequeueAfterDuration = 30 * time.Second type ClusterReconciler struct { PlatformCluster *clusters.Cluster - Config *dnsv1alpha1.DNSServiceConfig eventRecorder record.EventRecorder ProviderName string ProviderNamespace string } -func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.EventRecorder, cfg *dnsv1alpha1.DNSServiceConfig, providerName, providerNamespace string) *ClusterReconciler { +func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.EventRecorder, providerName, providerNamespace string) *ClusterReconciler { return &ClusterReconciler{ PlatformCluster: platformCluster, eventRecorder: recorder, ProviderName: providerName, ProviderNamespace: providerNamespace, - Config: cfg, } } @@ -76,6 +76,8 @@ type ReconcileResult struct { AccessRequest *clustersv1alpha1.AccessRequest // Message is an optional message to be printed in the generated event. Message string + // ProviderConfig is the complete provider configuration. + ProviderConfig *dnsv1alpha1.DNSServiceConfig } func (r *ClusterReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { @@ -136,11 +138,24 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C openmcpconst.ManagedPurposeLabel: c.Name, } + // load DNSServiceConfig resource + rr.ProviderConfig = &dnsv1alpha1.DNSServiceConfig{} + rr.ProviderConfig.Name = r.ProviderName + rr.ProviderConfig.Namespace = r.ProviderNamespace + if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(rr.ProviderConfig), rr.ProviderConfig); err != nil { + if apierrors.IsNotFound(err) { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("DNSServiceConfig '%s/%s' not found", rr.ProviderConfig.Namespace, rr.ProviderConfig.Name), clusterconst.ReasonConfigurationProblem) + } else { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting DNSServiceConfig '%s/%s': %w", rr.ProviderConfig.Namespace, rr.ProviderConfig.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + } + return rr + } + // iterate over configurations with purpose selectors and choose the first matching one - for i, cfg := range r.Config.Spec.ExternalDNSForPurposes { + for i, cfg := range rr.ProviderConfig.Spec.ExternalDNSForPurposes { if cfg.PurposeSelector == nil || cfg.PurposeSelector.Matches(c.Spec.Purposes) { log.Info("Found configuration with matching purpose selector", "configName", cfg.Name, "configIndex", i) - rr.Config = &r.Config.Spec.ExternalDNSForPurposes[i] + rr.Config = &rr.ProviderConfig.Spec.ExternalDNSForPurposes[i] rr.ConfigIndex = i break } @@ -267,9 +282,9 @@ func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1a log := logging.FromContextOrPanic(ctx) // copy secret if configured - if r.Config.Spec.ExternalDNSSource.CopyAuthSecret != nil { + if rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret != nil { source := &corev1.Secret{} - source.Name = r.Config.Spec.ExternalDNSSource.CopyAuthSecret.Source.Name + source.Name = rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Source.Name source.Namespace = r.ProviderNamespace log.Debug("Auth secret copying configured, getting source secret", "sourceNamespace", source.Namespace, "sourceName", source.Name) if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(source), source); err != nil { @@ -280,8 +295,8 @@ func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1a // check if target secret already exists target := &corev1.Secret{} target.Name = source.Name - if r.Config.Spec.ExternalDNSSource.CopyAuthSecret.Target != nil && r.Config.Spec.ExternalDNSSource.CopyAuthSecret.Target.Name != "" { - target.Name = r.Config.Spec.ExternalDNSSource.CopyAuthSecret.Target.Name + if rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Target != nil && rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Target.Name != "" { + target.Name = rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Target.Name } target.Namespace = c.Namespace targetExists := true @@ -348,14 +363,14 @@ func (r *ClusterReconciler) deployHelmChartSource(ctx context.Context, c *cluste } toBeDeleted := []client.Object{} // determine which type of source to create and which existing sources to delete - if r.Config.Spec.ExternalDNSSource.Helm != nil { + if rr.ProviderConfig.Spec.ExternalDNSSource.Helm != nil { fluxSource = &fluxsourcev1.HelmRepository{} setSpec = func(obj client.Object) error { helmRepo, ok := obj.(*fluxsourcev1.HelmRepository) if !ok { return fmt.Errorf("expected HelmRepository object, got %T", obj) } - helmRepo.Spec = *r.Config.Spec.ExternalDNSSource.Helm.DeepCopy() + helmRepo.Spec = *rr.ProviderConfig.Spec.ExternalDNSSource.Helm.DeepCopy() return nil } rr.SourceKind = "HelmRepository" @@ -367,14 +382,14 @@ func (r *ClusterReconciler) deployHelmChartSource(ctx context.Context, c *cluste } toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingGit.Items, func(obj fluxsourcev1.GitRepository) client.Object { return &obj })...) toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingOCI.Items, func(obj fluxsourcev1.OCIRepository) client.Object { return &obj })...) - } else if r.Config.Spec.ExternalDNSSource.Git != nil { + } else if rr.ProviderConfig.Spec.ExternalDNSSource.Git != nil { fluxSource = &fluxsourcev1.GitRepository{} setSpec = func(obj client.Object) error { gitRepo, ok := obj.(*fluxsourcev1.GitRepository) if !ok { return fmt.Errorf("expected GitRepository object, got %T", obj) } - gitRepo.Spec = *r.Config.Spec.ExternalDNSSource.Git.DeepCopy() + gitRepo.Spec = *rr.ProviderConfig.Spec.ExternalDNSSource.Git.DeepCopy() return nil } rr.SourceKind = "GitRepository" @@ -386,14 +401,14 @@ func (r *ClusterReconciler) deployHelmChartSource(ctx context.Context, c *cluste } toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingHelm.Items, func(obj fluxsourcev1.HelmRepository) client.Object { return &obj })...) toBeDeleted = append(toBeDeleted, collections.ProjectSliceToSlice(existingOCI.Items, func(obj fluxsourcev1.OCIRepository) client.Object { return &obj })...) - } else if r.Config.Spec.ExternalDNSSource.OCI != nil { + } else if rr.ProviderConfig.Spec.ExternalDNSSource.OCI != nil { fluxSource = &fluxsourcev1.OCIRepository{} setSpec = func(obj client.Object) error { ociRepo, ok := obj.(*fluxsourcev1.OCIRepository) if !ok { return fmt.Errorf("expected OCIRepository object, got %T", obj) } - ociRepo.Spec = *r.Config.Spec.ExternalDNSSource.OCI.DeepCopy() + ociRepo.Spec = *rr.ProviderConfig.Spec.ExternalDNSSource.OCI.DeepCopy() return nil } rr.SourceKind = "OCIRepository" @@ -489,8 +504,8 @@ func (r *ClusterReconciler) deployHelmRelease(ctx context.Context, c *clustersv1 // deploy interval if rr.Config.HelmReleaseReconciliationInterval != nil { hr.Spec.Interval = *rr.Config.HelmReleaseReconciliationInterval - } else if r.Config.Spec.HelmReleaseReconciliationInterval != nil { - hr.Spec.Interval = *r.Config.Spec.HelmReleaseReconciliationInterval + } else if rr.ProviderConfig.Spec.HelmReleaseReconciliationInterval != nil { + hr.Spec.Interval = *rr.ProviderConfig.Spec.HelmReleaseReconciliationInterval } else { hr.Spec.Interval = metav1.Duration{Duration: 1 * time.Hour} } @@ -640,3 +655,22 @@ func clusterBasedResourceName(clusterName string) string { suffix := ".external-dns" return ctrlutils.ShortenToXCharactersUnsafe(clusterName, ctrlutils.K8sMaxNameLength-len(suffix)) + suffix } + +// SetupWithManager sets up the controller with the Manager. +func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + // watch Cluster resources + For(&clustersv1alpha1.Cluster{}). + WithEventFilter(predicate.And( + predicate.Or( + predicate.GenerationChangedPredicate{}, + ctrlutils.DeletionTimestampChangedPredicate{}, + ctrlutils.GotAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueReconcile), + ctrlutils.LostAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), + ), + predicate.Not( + ctrlutils.HasAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), + ), + )). + Complete(r) +} From bd4da8688c3774ebe13f9e8e475c7d5a0d7e5513 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 17 Sep 2025 14:26:56 +0200 Subject: [PATCH 04/18] reconcile clusters if external-dns config changes (requires controller-utils update) --- internal/controllers/cluster/controller.go | 50 +++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index c43dcde..6bbf152 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -6,6 +6,7 @@ import ( "maps" "slices" "strings" + "sync" "time" corev1 "k8s.io/api/core/v1" @@ -13,10 +14,13 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "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/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -41,13 +45,15 @@ import ( ) const ControllerName = "DNSCluster" -const defaultRequeueAfterDuration = 30 * time.Second +const defaultRequeueAfterDuration = 1 * time.Minute type ClusterReconciler struct { PlatformCluster *clusters.Cluster eventRecorder record.EventRecorder ProviderName string ProviderNamespace string + KnownClusters map[types.NamespacedName]struct{} + KnownClustersLock *sync.RWMutex } func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.EventRecorder, providerName, providerNamespace string) *ClusterReconciler { @@ -56,6 +62,8 @@ func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.Eve eventRecorder: recorder, ProviderName: providerName, ProviderNamespace: providerNamespace, + KnownClusters: map[types.NamespacedName]struct{}{}, + KnownClustersLock: &sync.RWMutex{}, } } @@ -90,6 +98,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req reconcile.Request if err := r.PlatformCluster.Client().Get(ctx, req.NamespacedName, c); err != nil { if apierrors.IsNotFound(err) { log.Info("Resource not found") + r.removeKnownClusterRaw(req.Name, req.Namespace) return reconcile.Result{}, nil } return reconcile.Result{}, fmt.Errorf("unable to get resource '%s' from cluster: %w", req.String(), err) @@ -178,6 +187,7 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C return rr } } + r.addKnownCluster(c) // get access to the Cluster accessMgr := accesslib.NewClusterAccessManager(r.PlatformCluster.Client(), strings.ToLower(ControllerName), c.Namespace) @@ -270,6 +280,7 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C return rr } } + r.removeKnownCluster(c) rr.Message = "Successfully removed external-dns from Cluster" } @@ -656,6 +667,34 @@ func clusterBasedResourceName(clusterName string) string { return ctrlutils.ShortenToXCharactersUnsafe(clusterName, ctrlutils.K8sMaxNameLength-len(suffix)) + suffix } +func (r *ClusterReconciler) addKnownCluster(c *clustersv1alpha1.Cluster) { + nn := types.NamespacedName{Namespace: c.Namespace, Name: c.Name} + r.KnownClustersLock.Lock() + defer r.KnownClustersLock.Unlock() + r.KnownClusters[nn] = struct{}{} +} + +func (r *ClusterReconciler) removeKnownCluster(c *clustersv1alpha1.Cluster) { + r.removeKnownClusterRaw(c.Name, c.Namespace) +} + +func (r *ClusterReconciler) removeKnownClusterRaw(name, namespace string) { + nn := types.NamespacedName{Namespace: namespace, Name: name} + r.KnownClustersLock.Lock() + defer r.KnownClustersLock.Unlock() + delete(r.KnownClusters, nn) +} + +func (r *ClusterReconciler) listKnownClusters() []types.NamespacedName { + r.KnownClustersLock.RLock() + defer r.KnownClustersLock.RUnlock() + result := make([]types.NamespacedName, 0, len(r.KnownClusters)) + for nn := range r.KnownClusters { + result = append(result, nn) + } + return result +} + // SetupWithManager sets up the controller with the Manager. func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). @@ -672,5 +711,14 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { ctrlutils.HasAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), ), )). + // watch DNSServiceConfig resource and reconcile all Clusters that are known to have external-dns deployed if it changes + Watches(&dnsv1alpha1.DNSServiceConfig{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, _ client.Object) []ctrl.Request { + return collections.ProjectSliceToSlice(r.listKnownClusters(), func(nn types.NamespacedName) ctrl.Request { + return ctrl.Request{NamespacedName: nn} + }) + }), builder.WithPredicates(predicate.And( + predicate.GenerationChangedPredicate{}, + ctrlutils.ExactNamePredicate(r.ProviderName, r.ProviderNamespace), + ))). Complete(r) } From c7483337498ab69a9c1b197b89a01f7d2bfcad59 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 17 Sep 2025 14:49:30 +0200 Subject: [PATCH 05/18] remove label selector from DNSServiceConfig The label selector is incompatible with the current implementation that loads the config on-demand during each reconciliation. We would either need to load it once during startup, which would mean that the controller needs to be restarted for changes to take effect, or we would need a more complicated setup with an own controller for the config. Since clusters can already be filtered by their purposes, there is probably no need for a label selector anyway, at least not for now. --- .../dns.openmcp.cloud_dnsserviceconfigs.yaml | 48 ------------------- api/dns/v1alpha1/config_types.go | 5 -- api/dns/v1alpha1/zz_generated.deepcopy.go | 5 -- 3 files changed, 58 deletions(-) diff --git a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml index dd8db87..c8b7ede 100644 --- a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml +++ b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml @@ -664,54 +664,6 @@ spec: The value can be overwritten for specific purposes using ExternalDNSForPurposes. If not set, a default of 1h is used. type: string - selector: - description: |- - Selector is a label selector. - If not nil, only Clusters that match the selector will be reconciled by the controller. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. - The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies - to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic required: - externalDNSSource type: object diff --git a/api/dns/v1alpha1/config_types.go b/api/dns/v1alpha1/config_types.go index 0e36323..4f28b0e 100644 --- a/api/dns/v1alpha1/config_types.go +++ b/api/dns/v1alpha1/config_types.go @@ -13,11 +13,6 @@ import ( // DNSServiceConfigSpec defines the desired state of DNSServiceConfig type DNSServiceConfigSpec struct { - // Selector is a label selector. - // If not nil, only Clusters that match the selector will be reconciled by the controller. - // +optional - Selector *metav1.LabelSelector `json:"selector,omitempty"` - // ExternalDNSSource is the source of the external-dns helm chart. ExternalDNSSource ExternalDNSSource `json:"externalDNSSource"` diff --git a/api/dns/v1alpha1/zz_generated.deepcopy.go b/api/dns/v1alpha1/zz_generated.deepcopy.go index 1081ef9..2d8ad3e 100644 --- a/api/dns/v1alpha1/zz_generated.deepcopy.go +++ b/api/dns/v1alpha1/zz_generated.deepcopy.go @@ -73,11 +73,6 @@ func (in *DNSServiceConfigList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNSServiceConfigSpec) DeepCopyInto(out *DNSServiceConfigSpec) { *out = *in - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } in.ExternalDNSSource.DeepCopyInto(&out.ExternalDNSSource) if in.HelmReleaseReconciliationInterval != nil { in, out := &in.HelmReleaseReconciliationInterval, &out.HelmReleaseReconciliationInterval From 4d47ac8b44743a3c5be52f9c043c2d0fb82a51e3 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Thu, 18 Sep 2025 17:06:36 +0200 Subject: [PATCH 06/18] fix bugs and add first unit tests --- .../dns.openmcp.cloud_dnsserviceconfigs.yaml | 38 ++- api/dns/v1alpha1/config_types.go | 7 +- api/dns/v1alpha1/zz_generated.deepcopy.go | 2 +- api/install/install.go | 3 + go.mod | 11 +- go.sum | 10 +- internal/controllers/cluster/controller.go | 81 ++++- .../controllers/cluster/controller_test.go | 322 ++++++++++++++++++ .../cluster/purposeselector_test.go | 66 ++++ internal/controllers/cluster/suite_test.go | 14 + .../testdata/purposeselector/test-01.yaml | 22 ++ .../testdata/purposeselector/test-02.yaml | 22 ++ .../testdata/purposeselector/test-03.yaml | 25 ++ .../testdata/purposeselector/test-04.yaml | 29 ++ .../testdata/purposeselector/test-05.yaml | 22 ++ .../testdata/purposeselector/test-06.yaml | 23 ++ .../cluster/testdata/test-01/cluster-01.yaml | 12 + .../cluster/testdata/test-01/cluster-02.yaml | 11 + .../cluster/testdata/test-01/config.yaml | 33 ++ .../cluster/testdata/test-01/secret-01.yaml | 8 + .../cluster/testdata/test-02/cluster-01.yaml | 13 + .../cluster/testdata/test-02/cluster-02.yaml | 11 + .../cluster/testdata/test-02/cluster-03.yaml | 12 + .../cluster/testdata/test-02/config.yaml | 34 ++ 24 files changed, 788 insertions(+), 43 deletions(-) create mode 100644 internal/controllers/cluster/controller_test.go create mode 100644 internal/controllers/cluster/purposeselector_test.go create mode 100644 internal/controllers/cluster/suite_test.go create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-01.yaml create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-02.yaml create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-03.yaml create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-04.yaml create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-05.yaml create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-06.yaml create mode 100644 internal/controllers/cluster/testdata/test-01/cluster-01.yaml create mode 100644 internal/controllers/cluster/testdata/test-01/cluster-02.yaml create mode 100644 internal/controllers/cluster/testdata/test-01/config.yaml create mode 100644 internal/controllers/cluster/testdata/test-01/secret-01.yaml create mode 100644 internal/controllers/cluster/testdata/test-02/cluster-01.yaml create mode 100644 internal/controllers/cluster/testdata/test-02/cluster-02.yaml create mode 100644 internal/controllers/cluster/testdata/test-02/cluster-03.yaml create mode 100644 internal/controllers/cluster/testdata/test-02/config.yaml diff --git a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml index c8b7ede..533ad02 100644 --- a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml +++ b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml @@ -100,33 +100,35 @@ spec: If target is nil or target.name is empty, the secret will be copied with the same name as the source secret. properties: source: - description: ObjectReference is a reference to an object in - any namespace. + description: LocalObjectReference is a reference to an object + in the same namespace as the resource referencing it. properties: name: - description: Name is the name of the object. - type: string - namespace: - description: Namespace is the namespace of the object. + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string - required: - - name - - namespace type: object + x-kubernetes-map-type: atomic target: - description: ObjectReference is a reference to an object in - any namespace. + description: LocalObjectReference is a reference to an object + in the same namespace as the resource referencing it. properties: name: - description: Name is the name of the object. - type: string - namespace: - description: Namespace is the namespace of the object. + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string - required: - - name - - namespace type: object + x-kubernetes-map-type: atomic required: - source - target diff --git a/api/dns/v1alpha1/config_types.go b/api/dns/v1alpha1/config_types.go index 4f28b0e..725360f 100644 --- a/api/dns/v1alpha1/config_types.go +++ b/api/dns/v1alpha1/config_types.go @@ -43,8 +43,8 @@ type ExternalDNSSource struct { // SecretCopy defines the name of the secret to copy and the name of the copied secret. // If target is nil or target.name is empty, the secret will be copied with the same name as the source secret. type SecretCopy struct { - Source commonapi.ObjectReference `json:"source"` - Target *commonapi.ObjectReference `json:"target"` + Source commonapi.LocalObjectReference `json:"source"` + Target *commonapi.LocalObjectReference `json:"target"` } // ExternalDNSPurposeConfig holds a purpose selector and the DNS configuration to apply if the selector matches. @@ -116,6 +116,9 @@ func init() { // Matches returns true if the selector matches the given list of purposes. func (ps *PurposeSelector) Matches(purposes []string) bool { + if ps == nil { + return true + } return requirementMatches(&ps.PurposeSelectorRequirement, purposes, map[*PurposeSelectorRequirement]empty{}) } diff --git a/api/dns/v1alpha1/zz_generated.deepcopy.go b/api/dns/v1alpha1/zz_generated.deepcopy.go index 2d8ad3e..a053882 100644 --- a/api/dns/v1alpha1/zz_generated.deepcopy.go +++ b/api/dns/v1alpha1/zz_generated.deepcopy.go @@ -219,7 +219,7 @@ func (in *SecretCopy) DeepCopyInto(out *SecretCopy) { out.Source = in.Source if in.Target != nil { in, out := &in.Target, &out.Target - *out = new(common.ObjectReference) + *out = new(common.LocalObjectReference) **out = **in } } diff --git a/api/install/install.go b/api/install/install.go index 64de53e..306f384 100644 --- a/api/install/install.go +++ b/api/install/install.go @@ -9,6 +9,8 @@ import ( fluxhelmv2 "github.com/fluxcd/helm-controller/api/v2" fluxsourcev1 "github.com/fluxcd/source-controller/api/v1" + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + dnsv1alpha1 "github.com/openmcp-project/platform-service-dns/api/dns/v1alpha1" ) @@ -24,6 +26,7 @@ func InstallCRDAPIs(scheme *runtime.Scheme) *runtime.Scheme { func InstallOperatorAPIsPlatform(scheme *runtime.Scheme) *runtime.Scheme { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(dnsv1alpha1.AddToScheme(scheme)) + utilruntime.Must(clustersv1alpha1.AddToScheme(scheme)) utilruntime.Must(fluxsourcev1.AddToScheme(scheme)) utilruntime.Must(fluxhelmv2.AddToScheme(scheme)) diff --git a/go.mod b/go.mod index 5bcdb87..4605770 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,9 @@ require ( github.com/fluxcd/helm-controller/api v1.3.0 github.com/fluxcd/pkg/apis/meta v1.12.0 github.com/fluxcd/source-controller/api v1.6.2 - github.com/openmcp-project/controller-utils v0.21.1-0.20250916143313-911636c58c14 + github.com/openmcp-project/controller-utils v0.22.0 github.com/openmcp-project/openmcp-operator/api v0.14.0 - github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250910074108-2166b543cee3 + github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250918113839-de9cd0290162 github.com/openmcp-project/platform-service-dns/api v0.0.0-00010101000000-000000000000 github.com/spf13/cobra v1.9.1 k8s.io/api v0.34.1 @@ -22,6 +22,7 @@ require ( require ( cel.dev/expr v0.24.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -41,11 +42,13 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -55,6 +58,8 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/ginkgo/v2 v2.25.3 + github.com/onsi/gomega v1.38.2 github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.22.0 // indirect @@ -73,6 +78,7 @@ require ( go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect @@ -85,6 +91,7 @@ require ( golang.org/x/term v0.35.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.10.0 // indirect + golang.org/x/tools v0.37.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect diff --git a/go.sum b/go.sum index 4de6a1b..1d1d35b 100644 --- a/go.sum +++ b/go.sum @@ -105,17 +105,19 @@ github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/openmcp-project/controller-utils v0.21.1-0.20250916143313-911636c58c14 h1:w08DQVsSFxCAbBgmTMGusTdm9sCfrUaaIkPlVijf5u0= -github.com/openmcp-project/controller-utils v0.21.1-0.20250916143313-911636c58c14/go.mod h1:b8VcTK6iXFgkW6pXtIEDbaiQtzqYycVMFmElc7SFBQQ= +github.com/openmcp-project/controller-utils v0.22.0 h1:kdWGds+LOyOaOuKqWZGsJUv17e78HCr5y3bJOMSkdqE= +github.com/openmcp-project/controller-utils v0.22.0/go.mod h1:aIF4lk7agc+yCNRN5Oqg4BLlzRKsGixqwsGmxPoO5ak= github.com/openmcp-project/openmcp-operator/api v0.14.0 h1:/Ogg13b/IBBmAVQEuFnOKvHuVV3o2DlrQpSQhXca0+g= github.com/openmcp-project/openmcp-operator/api v0.14.0/go.mod h1:mgckJa59TpgTjta8+BWHwbOxlY1zVasqQTDFQLCs6M8= -github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250910074108-2166b543cee3 h1:RJmtE7Iqt8LS2ruGwJed6CqEoVgO1XoPQdDrnDwcaI8= -github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250910074108-2166b543cee3/go.mod h1:q3vb/BR9idvd0akkKHsjA5WkR38Lj378F8iFgiOSWs0= +github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250918113839-de9cd0290162 h1:eNC5bjGlLBc7Jb9vb6EUmMH0lcyxrstXSX4a9hf6ynI= +github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250918113839-de9cd0290162/go.mod h1:p5ekUWhoJyolSkYxGkfqPMLG/cdeiq51G8foU4dMVyQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 6bbf152..cd61346 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -54,6 +54,7 @@ type ClusterReconciler struct { ProviderNamespace string KnownClusters map[types.NamespacedName]struct{} KnownClustersLock *sync.RWMutex + FakeClientMapping map[string]client.Client // this must be nil except for unit tests } func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.EventRecorder, providerName, providerNamespace string) *ClusterReconciler { @@ -108,10 +109,12 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req reconcile.Request // no status update, because the Cluster resource doesn't have status fields for DNS configuration // instead, output events for significant changes and errors - if rr.ReconcileError != nil { - r.eventRecorder.Event(c, corev1.EventTypeWarning, rr.ReconcileError.Reason(), rr.ReconcileError.Error()) - } else if rr.Message != "" { - r.eventRecorder.Event(c, corev1.EventTypeNormal, "Reconciled", rr.Message) + if r.eventRecorder != nil { + if rr.ReconcileError != nil { + r.eventRecorder.Event(c, corev1.EventTypeWarning, rr.ReconcileError.Reason(), rr.ReconcileError.Error()) + } else if rr.Message != "" { + r.eventRecorder.Event(c, corev1.EventTypeNormal, "Reconciled", rr.Message) + } } return log.LogRequeue(rr.Result), rr.ReconcileError @@ -143,7 +146,7 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C rr := ReconcileResult{} expectedLabels := map[string]string{ - openmcpconst.ManagedByLabel: ControllerName, + openmcpconst.ManagedByLabel: fmt.Sprintf("%s.%s", r.ProviderName, ControllerName), openmcpconst.ManagedPurposeLabel: c.Name, } @@ -162,7 +165,7 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C // iterate over configurations with purpose selectors and choose the first matching one for i, cfg := range rr.ProviderConfig.Spec.ExternalDNSForPurposes { - if cfg.PurposeSelector == nil || cfg.PurposeSelector.Matches(c.Spec.Purposes) { + if cfg.PurposeSelector.Matches(c.Spec.Purposes) { log.Info("Found configuration with matching purpose selector", "configName", cfg.Name, "configIndex", i) rr.Config = &rr.ProviderConfig.Spec.ExternalDNSForPurposes[i] rr.ConfigIndex = i @@ -190,9 +193,14 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C r.addKnownCluster(c) // get access to the Cluster - accessMgr := accesslib.NewClusterAccessManager(r.PlatformCluster.Client(), strings.ToLower(ControllerName), c.Namespace) - localName := ctrlutils.ShortenToXCharactersUnsafe(c.Name, ctrlutils.K8sMaxNameLength-len(ControllerName)-2) // TODO: the StableRequestNameFromLocalName function should do this internally - _, ar, err := accessMgr.WaitForClusterAccess(ctx, localName, nil, &commonapi.ObjectReference{ + var accessMgr accesslib.Manager + if r.FakeClientMapping != nil { + log.Info("Using fake client mapping for access manager, this message should never appear outside of unit tests") + accessMgr = accesslib.NewTestClusterAccessManager(r.PlatformCluster.Client(), ControllerName, c.Namespace, r.FakeClientMapping) + } else { + accessMgr = accesslib.NewClusterAccessManager(r.PlatformCluster.Client(), ControllerName, c.Namespace) + } + _, ar, err := accessMgr.WaitForClusterAccess(ctx, c.Name, nil, &commonapi.ObjectReference{ Name: c.Name, Namespace: c.Namespace, }, accesslib.ReferenceToCluster, []clustersv1alpha1.PermissionsRequest{ @@ -212,11 +220,37 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C } rr.AccessRequest = ar - // inject labels into AccessRequest - if err := ctrlutils.EnsureLabel(ctx, r.PlatformCluster.Client(), rr.AccessRequest, openmcpconst.ManagedByLabel, ControllerName, true, ctrlutils.OVERWRITE); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error ensuring labels on AccessRequest: %w", err), clusterconst.ReasonPlatformClusterInteractionProblem) + // inject labels and owner reference into AccessRequest + changed := false + arOld := ar.DeepCopy() + if ar.Labels == nil { + ar.Labels = map[string]string{} + } + for k, v := range expectedLabels { + if v2, ok := ar.Labels[k]; !ok || v2 != v { + changed = true + ar.Labels[k] = v + } + } + hasOwner, err := controllerutil.HasOwnerReference(ar.OwnerReferences, c, r.PlatformCluster.Scheme()) + if err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error checking owner references on AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonInternalError) return rr } + if !hasOwner { + changed = true + if err := controllerutil.SetOwnerReference(c, ar, r.PlatformCluster.Scheme()); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error setting owner reference on AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonInternalError) + return rr + } + } + if changed { + log.Debug("Patching labels and/or owner references on AccessRequest", "labels", expectedLabels) + if err := r.PlatformCluster.Client().Patch(ctx, ar, client.MergeFrom(arOld)); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error patching labels and/or owner references on AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + } rr = r.deployAuthSecret(ctx, c, expectedLabels, rr) if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { @@ -239,6 +273,7 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C // check if the Cluster has a finalizer, otherwise we don't have to do anything if !slices.Contains(c.Finalizers, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { log.Debug("Cluster does not have finalizer, no cleanup required", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) + r.removeKnownCluster(c) return rr } @@ -261,8 +296,7 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C // delete AccessRequest ar := &clustersv1alpha1.AccessRequest{} - localName := ctrlutils.ShortenToXCharactersUnsafe(c.Name, ctrlutils.K8sMaxNameLength-len(ControllerName)-2) // TODO: the StableRequestNameFromLocalName function should do this internally - ar.Name = accesslib.StableRequestNameFromLocalName(strings.ToLower(ControllerName), localName) + ar.Name = accesslib.StableRequestNameFromLocalName(strings.ToLower(ControllerName), c.Name) ar.Namespace = c.Namespace if err := r.PlatformCluster.Client().Delete(ctx, ar); err != nil { if !apierrors.IsNotFound(err) { @@ -330,6 +364,9 @@ func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1a } log.Debug("Creating or updating target secret", "targetNamespace", target.Namespace, "targetName", target.Name) if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), target, func() error { + if err := controllerutil.SetOwnerReference(c, target, r.PlatformCluster.Scheme()); err != nil { + return fmt.Errorf("error setting owner reference on target secret '%s/%s': %w", target.Namespace, target.Name, err) + } target.Labels = maputils.Merge(target.Labels, source.Labels, expectedLabels) target.Annotations = maputils.Merge(target.Annotations, source.Annotations) target.Data = make(map[string][]byte, len(source.Data)) @@ -439,6 +476,9 @@ func (r *ClusterReconciler) deployHelmChartSource(ctx context.Context, c *cluste fluxSource.SetNamespace(c.Namespace) log.Info("Creating or updating Flux source", "kind", rr.SourceKind, "sourceName", fluxSource.GetName(), "sourceNamespace", fluxSource.GetNamespace()) if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), fluxSource, func() error { + if err := controllerutil.SetOwnerReference(c, fluxSource, r.PlatformCluster.Scheme()); err != nil { + return fmt.Errorf("error setting owner reference on %s '%s/%s': %w", rr.SourceKind, fluxSource.GetNamespace(), fluxSource.GetName(), err) + } fluxSource.SetLabels(maputils.Merge(fluxSource.GetLabels(), expectedLabels)) return setSpec(fluxSource) }); err != nil { @@ -471,6 +511,10 @@ func (r *ClusterReconciler) deployHelmRelease(ctx context.Context, c *clustersv1 log.Info("Creating or updating HelmRelease", "resourceName", hr.Name, "resourceNamespace", hr.Namespace) if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), hr, func() error { + // owner reference + if err := controllerutil.SetOwnerReference(c, hr, r.PlatformCluster.Scheme()); err != nil { + return fmt.Errorf("error setting owner reference on HelmRelease '%s/%s': %w", hr.Namespace, hr.Name, err) + } // labels hr.Labels = maputils.Merge(hr.Labels, expectedLabels) // chart @@ -706,9 +750,14 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { ctrlutils.DeletionTimestampChangedPredicate{}, ctrlutils.GotAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueReconcile), ctrlutils.LostAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), + ctrlutils.GotAnnotationPredicate(dnsv1alpha1.OperationAnnotation, openmcpconst.OperationAnnotationValueReconcile), + ctrlutils.LostAnnotationPredicate(dnsv1alpha1.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), ), predicate.Not( - ctrlutils.HasAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), + predicate.Or( + ctrlutils.HasAnnotationPredicate(openmcpconst.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), + ctrlutils.HasAnnotationPredicate(dnsv1alpha1.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), + ), ), )). // watch DNSServiceConfig resource and reconcile all Clusters that are known to have external-dns deployed if it changes @@ -718,7 +767,7 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { }) }), builder.WithPredicates(predicate.And( predicate.GenerationChangedPredicate{}, - ctrlutils.ExactNamePredicate(r.ProviderName, r.ProviderNamespace), + // ctrlutils.ExactNamePredicate(r.ProviderName, r.ProviderNamespace), // TODO: add when available in controller-utils ))). Complete(r) } diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go new file mode 100644 index 0000000..c0c885d --- /dev/null +++ b/internal/controllers/cluster/controller_test.go @@ -0,0 +1,322 @@ +package cluster_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + + 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" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + fluxhelmv2 "github.com/fluxcd/helm-controller/api/v2" + fluxsourcev1 "github.com/fluxcd/source-controller/api/v1" + + "github.com/openmcp-project/controller-utils/pkg/clusters" + testutils "github.com/openmcp-project/controller-utils/pkg/testing" + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" + + dnsv1alpha1 "github.com/openmcp-project/platform-service-dns/api/dns/v1alpha1" + "github.com/openmcp-project/platform-service-dns/api/install" + "github.com/openmcp-project/platform-service-dns/internal/controllers/cluster" +) + +const ( + platformCluster = "platform" + + providerName = "dns-service" + providerNamespace = "test" + managedByValue = providerName + "." + cluster.ControllerName +) + +var platformScheme = install.InstallOperatorAPIsPlatform(runtime.NewScheme()) + +func defaultTestSetup(testDirPathSegments ...string) (*testutils.Environment, *cluster.ClusterReconciler) { + env := testutils.NewEnvironmentBuilder(). + WithFakeClient(platformScheme). + WithInitObjectPath(testDirPathSegments...). + WithDynamicObjectsWithStatus(&clustersv1alpha1.AccessRequest{}). + WithReconcilerConstructor(func(c client.Client) reconcile.Reconciler { + cRec := cluster.NewClusterReconciler(clusters.NewTestClusterFromClient(platformCluster, c), nil, providerName, providerNamespace) + cRec.FakeClientMapping = map[string]client.Client{ + "cluster-01": nil, + "cluster-02": nil, + "cluster-03": nil, + } + return cRec + }). + Build() + + cr, ok := env.Reconciler().(*cluster.ClusterReconciler) + Expect(ok).To(BeTrue(), "Reconciler is not of type ClusterReconciler") + + return env, cr +} + +var _ = Describe("ClusterReconciler", func() { + + It("should fail if no DNSServiceConfig exists", func() { + env, _ := defaultTestSetup("testdata", "test-01") + + // delete any existing DNSServiceConfig + Expect(env.Client().DeleteAllOf(env.Ctx, &dnsv1alpha1.DNSServiceConfig{}, client.InNamespace(providerNamespace))).To(Succeed()) + + c := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c)).To(Succeed()) + env.ShouldNotReconcile(testutils.RequestFromObject(c)) + }) + + It("should correctly match configs to clusters and create the flux resources", func() { + env, _ := defaultTestSetup("testdata", "test-01") + + cfg := &dnsv1alpha1.DNSServiceConfig{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + + c1 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c1)) + + // verify that the correct resources were created + expectedLabels := map[string]string{ + openmcpconst.ManagedByLabel: managedByValue, + openmcpconst.ManagedPurposeLabel: "cluster-01", + } + // flux source + srcs := &fluxsourcev1.OCIRepositoryList{} + Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(srcs.Items).To(HaveLen(1)) + Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-01"), + }))) + Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "URL": Equal("oci://example.org/repo/charts"), + })) + // flux helm release + hrs := &fluxhelmv2.HelmReleaseList{} + Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(hrs.Items).To(HaveLen(1)) + Expect(hrs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-01"), + }))) + Expect(hrs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "ReleaseName": Equal("external-dns"), + "TargetNamespace": Equal("external-dns"), + "Values": BeEquivalentTo(cfg.Spec.ExternalDNSForPurposes[0].HelmValues), + "Interval": Equal(metav1.Duration{Duration: 1 * time.Hour}), + })) + // AccessRequest + ars := &clustersv1alpha1.AccessRequestList{} + Expect(env.Client().List(env.Ctx, ars, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(ars.Items).To(HaveLen(1)) + Expect(ars.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-01"), + }))) + Expect(ars.Items[0].Spec.ClusterRef).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "Name": Equal("cluster-01"), + "Namespace": Equal("foo"), + }))) + // auth secret + ss := &corev1.SecretList{} + Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(ss.Items).To(HaveLen(1)) + Expect(ss.Items[0].Name).To(Equal("my-auth-copy")) + Expect(ss.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-01"), + }))) + Expect(ss.Items[0].Data).To(HaveKeyWithValue("key", []byte("value"))) + + c2 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-02", Namespace: "bar"}, c2)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c2)) + + // verify that the correct flux resources were created + expectedLabels[openmcpconst.ManagedPurposeLabel] = "cluster-02" + // flux source + srcs = &fluxsourcev1.OCIRepositoryList{} + Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(srcs.Items).To(HaveLen(1)) + Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-02"), + }))) + Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "URL": Equal("oci://example.org/repo/charts"), + })) + // flux helm release + hrs = &fluxhelmv2.HelmReleaseList{} + Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(hrs.Items).To(HaveLen(1)) + Expect(hrs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-02"), + }))) + Expect(hrs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "ReleaseName": Equal("external-dns"), + "TargetNamespace": Equal("external-dns"), + "Values": BeEquivalentTo(cfg.Spec.ExternalDNSForPurposes[1].HelmValues), + "Interval": Equal(metav1.Duration{Duration: 1 * time.Hour}), + })) + // AccessRequest + ars = &clustersv1alpha1.AccessRequestList{} + Expect(env.Client().List(env.Ctx, ars, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(ars.Items).To(HaveLen(1)) + Expect(ars.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-02"), + }))) + Expect(ars.Items[0].Spec.ClusterRef).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "Name": Equal("cluster-02"), + "Namespace": Equal("bar"), + }))) + // auth secret + ss = &corev1.SecretList{} + Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(ss.Items).To(HaveLen(1)) + Expect(ss.Items[0].Name).To(Equal("my-auth-copy")) + Expect(ss.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-02"), + }))) + Expect(ss.Items[0].Data).To(HaveKeyWithValue("key", []byte("value"))) + }) + + It("should correctly match complex purpose selectors and don't create resources if no purpose selector matches", func() { + env, _ := defaultTestSetup("testdata", "test-02") + + cfg := &dnsv1alpha1.DNSServiceConfig{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + + c1 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c1)) + + // verify that the correct HelmRelease was created + expectedLabels := map[string]string{ + openmcpconst.ManagedByLabel: managedByValue, + openmcpconst.ManagedPurposeLabel: "cluster-01", + } + // cluster-01 has purposes foo, bar, and foobar so it matches the second configuration + hrs := &fluxhelmv2.HelmReleaseList{} + Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(hrs.Items).To(HaveLen(1)) + Expect(hrs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "Values": BeEquivalentTo(cfg.Spec.ExternalDNSForPurposes[1].HelmValues), + })) + + c2 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-02", Namespace: "bar"}, c2)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c2)) + + // verify that the correct HelmRelease was created + expectedLabels[openmcpconst.ManagedPurposeLabel] = "cluster-02" + // cluster-02 has purpose bar, so it matches the first configuration + hrs = &fluxhelmv2.HelmReleaseList{} + Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(hrs.Items).To(HaveLen(1)) + Expect(hrs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "Values": BeEquivalentTo(cfg.Spec.ExternalDNSForPurposes[0].HelmValues), + })) + + c3 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-03", Namespace: "baz"}, c3)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c3)) + + // verify that the correct HelmRelease was created + expectedLabels[openmcpconst.ManagedPurposeLabel] = "cluster-03" + // cluster-03 has purposes foo and bar, so it does not match any configuration, + // as the first one requires either foo or bar, but not both, and the second one requires foo and foobar + // this means that no resources should be created + srcs := &fluxsourcev1.OCIRepositoryList{} + Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c3.Namespace))).To(Succeed()) + Expect(srcs.Items).To(BeEmpty()) + hrs = &fluxhelmv2.HelmReleaseList{} + Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c3.Namespace))).To(Succeed()) + Expect(hrs.Items).To(BeEmpty()) + ars := &clustersv1alpha1.AccessRequestList{} + Expect(env.Client().List(env.Ctx, ars, client.InNamespace(c3.Namespace))).To(Succeed()) + Expect(ars.Items).To(BeEmpty()) + }) + + It("should use finalizers and remove resources when the Cluster is being deleted", func() { + env, _ := defaultTestSetup("testdata", "test-01") + + cfg := &dnsv1alpha1.DNSServiceConfig{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + + c1 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c1)) + + // verify finalizer on Cluster + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(c1), c1)).To(Succeed()) + Expect(c1.Finalizers).To(ContainElement(dnsv1alpha1.ExternalDNSFinalizerOnCluster)) + + // verify that the flux resources were created + expectedLabels := map[string]string{ + openmcpconst.ManagedByLabel: managedByValue, + openmcpconst.ManagedPurposeLabel: "cluster-01", + } + // flux source + srcs := &fluxsourcev1.OCIRepositoryList{} + Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(srcs.Items).To(HaveLen(1)) + Expect(srcs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedByLabel, managedByValue)) + Expect(srcs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedPurposeLabel, "cluster-01")) + // flux helm release + hrs := &fluxhelmv2.HelmReleaseList{} + Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(hrs.Items).To(HaveLen(1)) + Expect(hrs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedByLabel, managedByValue)) + Expect(hrs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedPurposeLabel, "cluster-01")) + // AccessRequest + ars := &clustersv1alpha1.AccessRequestList{} + Expect(env.Client().List(env.Ctx, ars, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(ars.Items).To(HaveLen(1)) + Expect(ars.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedByLabel, managedByValue)) + Expect(ars.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedPurposeLabel, "cluster-01")) + + // delete Cluster + Expect(env.Client().Delete(env.Ctx, c1)).To(Succeed()) + // cluster should still exist because of finalizer + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(c1), c1)).To(Succeed()) + Expect(c1.DeletionTimestamp).ToNot(BeNil()) + + // wrapped in Eventually because it may take multiple reconciliations until all resources are deleted + Eventually(func(g Gomega) { + // reconcile again, this should remove the resources and the finalizer + env.ShouldReconcile(testutils.RequestFromObject(c1)) + + // verify that the flux resources were deleted + // flux source + srcs = &fluxsourcev1.OCIRepositoryList{} + g.Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + g.Expect(srcs.Items).To(BeEmpty()) + // flux helm release + hrs = &fluxhelmv2.HelmReleaseList{} + g.Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + g.Expect(hrs.Items).To(BeEmpty()) + + // verify that finalizer was removed and Cluster deleted + g.Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(c1), c1)).To(MatchError(apierrors.IsNotFound, "IsNotFound")) + }).Should(Succeed()) + }) + +}) diff --git a/internal/controllers/cluster/purposeselector_test.go b/internal/controllers/cluster/purposeselector_test.go new file mode 100644 index 0000000..ca2a514 --- /dev/null +++ b/internal/controllers/cluster/purposeselector_test.go @@ -0,0 +1,66 @@ +package cluster_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/util/sets" + + "sigs.k8s.io/yaml" + + dnsv1alpha1 "github.com/openmcp-project/platform-service-dns/api/dns/v1alpha1" +) + +// These are tests for the purpose selector logic. +// It is implemented in the api package, but tested here to avoid testing dependencies in the api package. + +// purposeSelectorTest is a helper struct for testing purpose selectors. +// It can be parsed from JSON/YAML and contains a PurposeSelector, a map of purpose sets, and a list of expected matches. +// Its Test method fails if not exactly the purpose sets whose names are in Expected match the PurposeSelector (the order doesn't matter). +type purposeSelectorTest struct { + PurposeSelector *dnsv1alpha1.PurposeSelector `json:"purposeSelector,omitempty"` + PurposeSets map[string][]string `json:"purposeSets,omitempty"` + Expected []string `json:"expected,omitempty"` +} + +func (pst *purposeSelectorTest) Test() { + matched := sets.New[string]() + for name, purposes := range pst.PurposeSets { + if pst.PurposeSelector.Matches(purposes) { + matched.Insert(name) + } + } + Expect(matched.UnsortedList()).To(ConsistOf(pst.Expected), "PurposeSelector did not match expected purpose sets") +} + +var _ = Describe("Purpose Selector", func() { + + It("should match purpose selectors correctly", func() { + // Read all files in testdata/purposeselector that end in '.yaml', '.yml', or '.json'. + // Nested directories are ignored. + entries, err := os.ReadDir(filepath.Join("testdata", "purposeselector")) + Expect(err).ToNot(HaveOccurred()) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + ext := filepath.Ext(name) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + continue + } + // read file + By("testing purpose selector in file " + name) + data, err := os.ReadFile(filepath.Join("testdata", "purposeselector", name)) + Expect(err).ToNot(HaveOccurred()) + // parse file + pst := &purposeSelectorTest{} + Expect(yaml.Unmarshal(data, pst)).To(Succeed()) + // test purpose selector + pst.Test() + } + }) + +}) diff --git a/internal/controllers/cluster/suite_test.go b/internal/controllers/cluster/suite_test.go new file mode 100644 index 0000000..4b1f6de --- /dev/null +++ b/internal/controllers/cluster/suite_test.go @@ -0,0 +1,14 @@ +package cluster_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestComponentUtils(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "DNS Cluster Controller Test Suite") +} diff --git a/internal/controllers/cluster/testdata/purposeselector/test-01.yaml b/internal/controllers/cluster/testdata/purposeselector/test-01.yaml new file mode 100644 index 0000000..e884c76 --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-01.yaml @@ -0,0 +1,22 @@ +purposeSelector: + name: foo + +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - baz + f: + - foobar + +expected: +- a +- b +- c diff --git a/internal/controllers/cluster/testdata/purposeselector/test-02.yaml b/internal/controllers/cluster/testdata/purposeselector/test-02.yaml new file mode 100644 index 0000000..28cd53a --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-02.yaml @@ -0,0 +1,22 @@ +purposeSelector: + and: + - name: foo + - name: bar + +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - bar + f: + - foobar + +expected: +- b diff --git a/internal/controllers/cluster/testdata/purposeselector/test-03.yaml b/internal/controllers/cluster/testdata/purposeselector/test-03.yaml new file mode 100644 index 0000000..df964c8 --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-03.yaml @@ -0,0 +1,25 @@ +purposeSelector: + or: + - name: foo + - name: bar + +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - bar + f: + - foobar + +expected: +- a +- b +- c +- e diff --git a/internal/controllers/cluster/testdata/purposeselector/test-04.yaml b/internal/controllers/cluster/testdata/purposeselector/test-04.yaml new file mode 100644 index 0000000..4b8b343 --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-04.yaml @@ -0,0 +1,29 @@ +purposeSelector: + and: + - not: + and: + - name: foo + - name: bar + - or: + - name: foo + - name: bar + +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - bar + f: + - foobar + +expected: +- a +- c +- e diff --git a/internal/controllers/cluster/testdata/purposeselector/test-05.yaml b/internal/controllers/cluster/testdata/purposeselector/test-05.yaml new file mode 100644 index 0000000..02b89cb --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-05.yaml @@ -0,0 +1,22 @@ +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - bar + f: + - foobar + +expected: +- a +- b +- c +- d +- e +- f diff --git a/internal/controllers/cluster/testdata/purposeselector/test-06.yaml b/internal/controllers/cluster/testdata/purposeselector/test-06.yaml new file mode 100644 index 0000000..e1eaaf8 --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-06.yaml @@ -0,0 +1,23 @@ +purposeSelector: + not: + name: foo + +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - bar + f: + - foobar + +expected: +- d +- e +- f diff --git a/internal/controllers/cluster/testdata/test-01/cluster-01.yaml b/internal/controllers/cluster/testdata/test-01/cluster-01.yaml new file mode 100644 index 0000000..7b9b960 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-01/cluster-01.yaml @@ -0,0 +1,12 @@ +apiVersion: clusters.openmcp.cloud/v1alpha1 +kind: Cluster +metadata: + name: cluster-01 + namespace: foo +spec: + kubernetes: {} + profile: my-profile + purposes: + - foo + - bar + tenancy: Shared diff --git a/internal/controllers/cluster/testdata/test-01/cluster-02.yaml b/internal/controllers/cluster/testdata/test-01/cluster-02.yaml new file mode 100644 index 0000000..e2d63ce --- /dev/null +++ b/internal/controllers/cluster/testdata/test-01/cluster-02.yaml @@ -0,0 +1,11 @@ +apiVersion: clusters.openmcp.cloud/v1alpha1 +kind: Cluster +metadata: + name: cluster-02 + namespace: bar +spec: + kubernetes: {} + profile: my-profile + purposes: + - foobar + tenancy: Shared diff --git a/internal/controllers/cluster/testdata/test-01/config.yaml b/internal/controllers/cluster/testdata/test-01/config.yaml new file mode 100644 index 0000000..8176456 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-01/config.yaml @@ -0,0 +1,33 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns-service + namespace: test +spec: + externalDNSSource: + oci: + url: "oci://example.org/repo/charts" + interval: 10m + copyAuthSecret: + source: + name: my-auth + target: + name: my-auth-copy + externalDNSForPurposes: + - name: foo + purposeSelector: + name: foo + helmValues: | + { + "foo": "foo" + } + - name: asdf + helmValues: | + { + "foo": "bar" + } + - name: qwer + helmValues: | + { + "foo": "baz" + } diff --git a/internal/controllers/cluster/testdata/test-01/secret-01.yaml b/internal/controllers/cluster/testdata/test-01/secret-01.yaml new file mode 100644 index 0000000..4941276 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-01/secret-01.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: my-auth + namespace: test +data: + key: dmFsdWU= +type: Opaque diff --git a/internal/controllers/cluster/testdata/test-02/cluster-01.yaml b/internal/controllers/cluster/testdata/test-02/cluster-01.yaml new file mode 100644 index 0000000..5b91ec3 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-02/cluster-01.yaml @@ -0,0 +1,13 @@ +apiVersion: clusters.openmcp.cloud/v1alpha1 +kind: Cluster +metadata: + name: cluster-01 + namespace: foo +spec: + kubernetes: {} + profile: my-profile + purposes: + - foo + - bar + - foobar + tenancy: Shared diff --git a/internal/controllers/cluster/testdata/test-02/cluster-02.yaml b/internal/controllers/cluster/testdata/test-02/cluster-02.yaml new file mode 100644 index 0000000..b296404 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-02/cluster-02.yaml @@ -0,0 +1,11 @@ +apiVersion: clusters.openmcp.cloud/v1alpha1 +kind: Cluster +metadata: + name: cluster-02 + namespace: bar +spec: + kubernetes: {} + profile: my-profile + purposes: + - bar + tenancy: Shared diff --git a/internal/controllers/cluster/testdata/test-02/cluster-03.yaml b/internal/controllers/cluster/testdata/test-02/cluster-03.yaml new file mode 100644 index 0000000..09e0ab6 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-02/cluster-03.yaml @@ -0,0 +1,12 @@ +apiVersion: clusters.openmcp.cloud/v1alpha1 +kind: Cluster +metadata: + name: cluster-03 + namespace: baz +spec: + kubernetes: {} + profile: my-profile + purposes: + - foo + - bar + tenancy: Shared diff --git a/internal/controllers/cluster/testdata/test-02/config.yaml b/internal/controllers/cluster/testdata/test-02/config.yaml new file mode 100644 index 0000000..f69c4f0 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-02/config.yaml @@ -0,0 +1,34 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns-service + namespace: test +spec: + externalDNSSource: + oci: + url: "oci://example.org/repo/charts" + interval: 10m + externalDNSForPurposes: + - name: complex + purposeSelector: + and: + - or: + - name: foo + - name: bar + - not: + and: + - name: foo + - name: bar + helmValues: | + { + "foo": "baz" + } + - name: foobar + purposeSelector: + and: + - name: foo + - name: foobar + helmValues: | + { + "foo": "foo" + } From 2535868c2cf6aa7ad19e50176d5d343024a13566 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Fri, 19 Sep 2025 13:08:25 +0200 Subject: [PATCH 07/18] add more unit tests --- go.mod | 2 +- go.sum | 4 +- internal/controllers/cluster/controller.go | 9 +- .../controllers/cluster/controller_test.go | 112 +++++++++++++++++- .../cluster/testdata/test-03/cluster-01.yaml | 12 ++ .../cluster/testdata/test-03/config.yaml | 18 +++ .../cluster/testdata/test-04/cluster-01.yaml | 12 ++ .../cluster/testdata/test-04/config.yaml | 18 +++ 8 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 internal/controllers/cluster/testdata/test-03/cluster-01.yaml create mode 100644 internal/controllers/cluster/testdata/test-03/config.yaml create mode 100644 internal/controllers/cluster/testdata/test-04/cluster-01.yaml create mode 100644 internal/controllers/cluster/testdata/test-04/config.yaml diff --git a/go.mod b/go.mod index 4605770..d489d66 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/fluxcd/helm-controller/api v1.3.0 github.com/fluxcd/pkg/apis/meta v1.12.0 github.com/fluxcd/source-controller/api v1.6.2 - github.com/openmcp-project/controller-utils v0.22.0 + github.com/openmcp-project/controller-utils v0.22.1-0.20250919094614-9981cd04836f github.com/openmcp-project/openmcp-operator/api v0.14.0 github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250918113839-de9cd0290162 github.com/openmcp-project/platform-service-dns/api v0.0.0-00010101000000-000000000000 diff --git a/go.sum b/go.sum index 1d1d35b..471bb96 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/openmcp-project/controller-utils v0.22.0 h1:kdWGds+LOyOaOuKqWZGsJUv17e78HCr5y3bJOMSkdqE= -github.com/openmcp-project/controller-utils v0.22.0/go.mod h1:aIF4lk7agc+yCNRN5Oqg4BLlzRKsGixqwsGmxPoO5ak= +github.com/openmcp-project/controller-utils v0.22.1-0.20250919094614-9981cd04836f h1:FOM9/Bcrhbp3q6OM30gJKYZ9UvN9vICbkLiBBR82Yx8= +github.com/openmcp-project/controller-utils v0.22.1-0.20250919094614-9981cd04836f/go.mod h1:aIF4lk7agc+yCNRN5Oqg4BLlzRKsGixqwsGmxPoO5ak= github.com/openmcp-project/openmcp-operator/api v0.14.0 h1:/Ogg13b/IBBmAVQEuFnOKvHuVV3o2DlrQpSQhXca0+g= github.com/openmcp-project/openmcp-operator/api v0.14.0/go.mod h1:mgckJa59TpgTjta8+BWHwbOxlY1zVasqQTDFQLCs6M8= github.com/openmcp-project/openmcp-operator/lib v0.14.1-0.20250918113839-de9cd0290162 h1:eNC5bjGlLBc7Jb9vb6EUmMH0lcyxrstXSX4a9hf6ynI= diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index cd61346..691d9e5 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -760,6 +760,13 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { ), ), )). + // watch owned resources and reconcile the owning Cluster if they are deleted + Owns(&fluxsourcev1.GitRepository{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). + Owns(&fluxsourcev1.OCIRepository{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). + Owns(&fluxsourcev1.HelmRepository{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). + Owns(&fluxhelmv2.HelmRelease{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). + Owns(&corev1.Secret{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). + Owns(&clustersv1alpha1.AccessRequest{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). // watch DNSServiceConfig resource and reconcile all Clusters that are known to have external-dns deployed if it changes Watches(&dnsv1alpha1.DNSServiceConfig{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, _ client.Object) []ctrl.Request { return collections.ProjectSliceToSlice(r.listKnownClusters(), func(nn types.NamespacedName) ctrl.Request { @@ -767,7 +774,7 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { }) }), builder.WithPredicates(predicate.And( predicate.GenerationChangedPredicate{}, - // ctrlutils.ExactNamePredicate(r.ProviderName, r.ProviderNamespace), // TODO: add when available in controller-utils + ctrlutils.ExactNamePredicate(r.ProviderName, r.ProviderNamespace), ))). Complete(r) } diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go index c0c885d..978b789 100644 --- a/internal/controllers/cluster/controller_test.go +++ b/internal/controllers/cluster/controller_test.go @@ -278,20 +278,18 @@ var _ = Describe("ClusterReconciler", func() { srcs := &fluxsourcev1.OCIRepositoryList{} Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) Expect(srcs.Items).To(HaveLen(1)) - Expect(srcs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedByLabel, managedByValue)) - Expect(srcs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedPurposeLabel, "cluster-01")) // flux helm release hrs := &fluxhelmv2.HelmReleaseList{} Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) Expect(hrs.Items).To(HaveLen(1)) - Expect(hrs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedByLabel, managedByValue)) - Expect(hrs.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedPurposeLabel, "cluster-01")) // AccessRequest ars := &clustersv1alpha1.AccessRequestList{} Expect(env.Client().List(env.Ctx, ars, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) Expect(ars.Items).To(HaveLen(1)) - Expect(ars.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedByLabel, managedByValue)) - Expect(ars.Items[0].Labels).To(HaveKeyWithValue(openmcpconst.ManagedPurposeLabel, "cluster-01")) + // auth secret + ss := &corev1.SecretList{} + Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(ss.Items).To(HaveLen(1)) // delete Cluster Expect(env.Client().Delete(env.Ctx, c1)).To(Succeed()) @@ -313,10 +311,112 @@ var _ = Describe("ClusterReconciler", func() { hrs = &fluxhelmv2.HelmReleaseList{} g.Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) g.Expect(hrs.Items).To(BeEmpty()) + // auth secret + ss := &corev1.SecretList{} + g.Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(ss.Items).To(BeEmpty()) // verify that finalizer was removed and Cluster deleted g.Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(c1), c1)).To(MatchError(apierrors.IsNotFound, "IsNotFound")) }).Should(Succeed()) }) + It("should delete obsolete flux sources", func() { + env, _ := defaultTestSetup("testdata", "test-01") + + cfg := &dnsv1alpha1.DNSServiceConfig{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + + // create dummy flux sources + expectedLabels := map[string]string{ + openmcpconst.ManagedByLabel: managedByValue, + openmcpconst.ManagedPurposeLabel: "cluster-01", + } + // helm repo + helmSource := &fluxsourcev1.HelmRepository{} + helmSource.Name = "dummy" + helmSource.Namespace = "foo" + helmSource.Labels = expectedLabels + Expect(env.Client().Create(env.Ctx, helmSource)).To(Succeed()) + // oci repo + ociSource := &fluxsourcev1.OCIRepository{} + ociSource.Name = "dummy" + ociSource.Namespace = "foo" + ociSource.Labels = expectedLabels + Expect(env.Client().Create(env.Ctx, ociSource)).To(Succeed()) + // git repo + gitSource := &fluxsourcev1.GitRepository{} + gitSource.Name = "dummy" + gitSource.Namespace = "foo" + gitSource.Labels = expectedLabels + Expect(env.Client().Create(env.Ctx, gitSource)).To(Succeed()) + + c1 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c1)) + + // all three dummy resources should be deleted + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(helmSource), helmSource)).To(MatchError(apierrors.IsNotFound, "IsNotFound")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ociSource), ociSource)).To(MatchError(apierrors.IsNotFound, "IsNotFound")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(gitSource), gitSource)).To(MatchError(apierrors.IsNotFound, "IsNotFound")) + }) + + It("should create a GitRepository if configured", func() { + env, _ := defaultTestSetup("testdata", "test-03") + + cfg := &dnsv1alpha1.DNSServiceConfig{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + + c1 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c1)) + + // verify that the correct resources were created + expectedLabels := map[string]string{ + openmcpconst.ManagedByLabel: managedByValue, + openmcpconst.ManagedPurposeLabel: "cluster-01", + } + // flux source + srcs := &fluxsourcev1.GitRepositoryList{} + Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(srcs.Items).To(HaveLen(1)) + Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-01"), + }))) + Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "URL": Equal("https://example.org/repo/charts"), + })) + }) + + It("should create a HelmRepository if configured", func() { + env, _ := defaultTestSetup("testdata", "test-04") + + cfg := &dnsv1alpha1.DNSServiceConfig{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + + c1 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) + env.ShouldReconcile(testutils.RequestFromObject(c1)) + + // verify that the correct resources were created + expectedLabels := map[string]string{ + openmcpconst.ManagedByLabel: managedByValue, + openmcpconst.ManagedPurposeLabel: "cluster-01", + } + // flux source + srcs := &fluxsourcev1.HelmRepositoryList{} + Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(srcs.Items).To(HaveLen(1)) + Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal("cluster-01"), + }))) + Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "URL": Equal("https://example.org/repo/charts"), + })) + }) + }) diff --git a/internal/controllers/cluster/testdata/test-03/cluster-01.yaml b/internal/controllers/cluster/testdata/test-03/cluster-01.yaml new file mode 100644 index 0000000..7b9b960 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-03/cluster-01.yaml @@ -0,0 +1,12 @@ +apiVersion: clusters.openmcp.cloud/v1alpha1 +kind: Cluster +metadata: + name: cluster-01 + namespace: foo +spec: + kubernetes: {} + profile: my-profile + purposes: + - foo + - bar + tenancy: Shared diff --git a/internal/controllers/cluster/testdata/test-03/config.yaml b/internal/controllers/cluster/testdata/test-03/config.yaml new file mode 100644 index 0000000..dd45770 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-03/config.yaml @@ -0,0 +1,18 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns-service + namespace: test +spec: + externalDNSSource: + git: + url: "https://example.org/repo/charts" + interval: 10m + externalDNSForPurposes: + - name: foo + purposeSelector: + name: foo + helmValues: | + { + "foo": "foo" + } diff --git a/internal/controllers/cluster/testdata/test-04/cluster-01.yaml b/internal/controllers/cluster/testdata/test-04/cluster-01.yaml new file mode 100644 index 0000000..7b9b960 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-04/cluster-01.yaml @@ -0,0 +1,12 @@ +apiVersion: clusters.openmcp.cloud/v1alpha1 +kind: Cluster +metadata: + name: cluster-01 + namespace: foo +spec: + kubernetes: {} + profile: my-profile + purposes: + - foo + - bar + tenancy: Shared diff --git a/internal/controllers/cluster/testdata/test-04/config.yaml b/internal/controllers/cluster/testdata/test-04/config.yaml new file mode 100644 index 0000000..28111e4 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-04/config.yaml @@ -0,0 +1,18 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns-service + namespace: test +spec: + externalDNSSource: + helm: + url: "https://example.org/repo/charts" + interval: 10m + externalDNSForPurposes: + - name: foo + purposeSelector: + name: foo + helmValues: | + { + "foo": "foo" + } From f2bf188fb6dbe8a5280e465fd498a7a24f95fb14 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Fri, 19 Sep 2025 16:32:16 +0200 Subject: [PATCH 08/18] remove library function which should not be used in this context --- internal/controllers/cluster/controller.go | 95 ++++++++----------- .../controllers/cluster/controller_test.go | 71 ++++++++++++-- 2 files changed, 100 insertions(+), 66 deletions(-) diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 691d9e5..3020390 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -45,7 +45,7 @@ import ( ) const ControllerName = "DNSCluster" -const defaultRequeueAfterDuration = 1 * time.Minute +const defaultRequeueAfterDuration = 30 * time.Second type ClusterReconciler struct { PlatformCluster *clusters.Cluster @@ -192,65 +192,51 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C } r.addKnownCluster(c) - // get access to the Cluster - var accessMgr accesslib.Manager - if r.FakeClientMapping != nil { - log.Info("Using fake client mapping for access manager, this message should never appear outside of unit tests") - accessMgr = accesslib.NewTestClusterAccessManager(r.PlatformCluster.Client(), ControllerName, c.Namespace, r.FakeClientMapping) - } else { - accessMgr = accesslib.NewClusterAccessManager(r.PlatformCluster.Client(), ControllerName, c.Namespace) - } - _, ar, err := accessMgr.WaitForClusterAccess(ctx, c.Name, nil, &commonapi.ObjectReference{ - Name: c.Name, - Namespace: c.Namespace, - }, accesslib.ReferenceToCluster, []clustersv1alpha1.PermissionsRequest{ - { - Rules: []rbacv1.PolicyRule{ // TODO: restrict permissions + log.Info("Creating or updating AccessRequest to get access to Cluster") + ar := &clustersv1alpha1.AccessRequest{} + ar.SetName(accesslib.StableRequestNameFromLocalName(ControllerName, c.Name)) + ar.SetNamespace(c.Namespace) + if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), ar, func() error { + if err := controllerutil.SetOwnerReference(c, ar, r.PlatformCluster.Scheme()); err != nil { + return fmt.Errorf("error setting owner reference: %w", err) + } + ar.Labels = maputils.Merge(ar.Labels, expectedLabels) + ar.Spec.ClusterRef = &commonapi.ObjectReference{ + Name: c.Name, + Namespace: c.Namespace, + } + ar.Spec.Token = &clustersv1alpha1.TokenConfig{ + Permissions: []clustersv1alpha1.PermissionsRequest{ { - APIGroups: []string{"*"}, - Resources: []string{"*"}, - Verbs: []string{"*"}, + Rules: []rbacv1.PolicyRule{ // TODO: restrict permissions + { + APIGroups: []string{"*"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }, + }, }, }, - }, - }) - if err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting access to Cluster '%s/%s': %w", c.Namespace, c.Name, err), clusterconst.ReasonInternalError) - return rr - } - rr.AccessRequest = ar - - // inject labels and owner reference into AccessRequest - changed := false - arOld := ar.DeepCopy() - if ar.Labels == nil { - ar.Labels = map[string]string{} - } - for k, v := range expectedLabels { - if v2, ok := ar.Labels[k]; !ok || v2 != v { - changed = true - ar.Labels[k] = v } + return nil + }); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr } - hasOwner, err := controllerutil.HasOwnerReference(ar.OwnerReferences, c, r.PlatformCluster.Scheme()) - if err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error checking owner references on AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonInternalError) + if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(ar), ar); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr } - if !hasOwner { - changed = true - if err := controllerutil.SetOwnerReference(c, ar, r.PlatformCluster.Scheme()); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error setting owner reference on AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonInternalError) - return rr - } + if ar.Status.IsDenied() { + rr.Message = fmt.Sprintf("AccessRequest '%s/%s' was denied, unable to proceed with deploying DNS configuration", ar.Namespace, ar.Name) + return rr } - if changed { - log.Debug("Patching labels and/or owner references on AccessRequest", "labels", expectedLabels) - if err := r.PlatformCluster.Client().Patch(ctx, ar, client.MergeFrom(arOld)); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error patching labels and/or owner references on AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr - } + if !ar.Status.IsGranted() { + rr.Message = fmt.Sprintf("AccessRequest '%s/%s' is not yet granted, waiting for access to be granted", ar.Namespace, ar.Name) + rr.Result.RequeueAfter = defaultRequeueAfterDuration + return rr } + rr.AccessRequest = ar rr = r.deployAuthSecret(ctx, c, expectedLabels, rr) if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { @@ -760,13 +746,6 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { ), ), )). - // watch owned resources and reconcile the owning Cluster if they are deleted - Owns(&fluxsourcev1.GitRepository{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). - Owns(&fluxsourcev1.OCIRepository{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). - Owns(&fluxsourcev1.HelmRepository{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). - Owns(&fluxhelmv2.HelmRelease{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). - Owns(&corev1.Secret{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). - Owns(&clustersv1alpha1.AccessRequest{}, builder.WithPredicates(ctrlutils.OnDeletePredicate())). // watch DNSServiceConfig resource and reconcile all Clusters that are known to have external-dns deployed if it changes Watches(&dnsv1alpha1.DNSServiceConfig{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, _ client.Object) []ctrl.Request { return collections.ProjectSliceToSlice(r.listKnownClusters(), func(nn types.NamespacedName) ctrl.Request { diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go index 978b789..19fbc54 100644 --- a/internal/controllers/cluster/controller_test.go +++ b/internal/controllers/cluster/controller_test.go @@ -20,7 +20,9 @@ import ( "github.com/openmcp-project/controller-utils/pkg/clusters" testutils "github.com/openmcp-project/controller-utils/pkg/testing" clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + commonapi "github.com/openmcp-project/openmcp-operator/api/common" openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" + accesslib "github.com/openmcp-project/openmcp-operator/lib/clusteraccess" dnsv1alpha1 "github.com/openmcp-project/platform-service-dns/api/dns/v1alpha1" "github.com/openmcp-project/platform-service-dns/api/install" @@ -80,7 +82,11 @@ var _ = Describe("ClusterReconciler", func() { c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c1)) + rr := env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c1) + rr = env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeZero()) // verify that the correct resources were created expectedLabels := map[string]string{ @@ -141,7 +147,11 @@ var _ = Describe("ClusterReconciler", func() { c2 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-02", Namespace: "bar"}, c2)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c2)) + rr = env.ShouldReconcile(testutils.RequestFromObject(c2)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c2) + rr = env.ShouldReconcile(testutils.RequestFromObject(c2)) + Expect(rr.RequeueAfter).To(BeZero()) // verify that the correct flux resources were created expectedLabels[openmcpconst.ManagedPurposeLabel] = "cluster-02" @@ -206,7 +216,11 @@ var _ = Describe("ClusterReconciler", func() { c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c1)) + rr := env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c1) + rr = env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeZero()) // verify that the correct HelmRelease was created expectedLabels := map[string]string{ @@ -223,7 +237,11 @@ var _ = Describe("ClusterReconciler", func() { c2 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-02", Namespace: "bar"}, c2)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c2)) + rr = env.ShouldReconcile(testutils.RequestFromObject(c2)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c2) + rr = env.ShouldReconcile(testutils.RequestFromObject(c2)) + Expect(rr.RequeueAfter).To(BeZero()) // verify that the correct HelmRelease was created expectedLabels[openmcpconst.ManagedPurposeLabel] = "cluster-02" @@ -263,7 +281,11 @@ var _ = Describe("ClusterReconciler", func() { c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c1)) + rr := env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c1) + rr = env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeZero()) // verify finalizer on Cluster Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(c1), c1)).To(Succeed()) @@ -353,7 +375,11 @@ var _ = Describe("ClusterReconciler", func() { c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c1)) + rr := env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c1) + rr = env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeZero()) // all three dummy resources should be deleted Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(helmSource), helmSource)).To(MatchError(apierrors.IsNotFound, "IsNotFound")) @@ -369,7 +395,11 @@ var _ = Describe("ClusterReconciler", func() { c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c1)) + rr := env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c1) + rr = env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeZero()) // verify that the correct resources were created expectedLabels := map[string]string{ @@ -398,7 +428,11 @@ var _ = Describe("ClusterReconciler", func() { c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) - env.ShouldReconcile(testutils.RequestFromObject(c1)) + rr := env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c1) + rr = env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeZero()) // verify that the correct resources were created expectedLabels := map[string]string{ @@ -420,3 +454,24 @@ var _ = Describe("ClusterReconciler", func() { }) }) + +func fakeAccessRequestReadiness(env *testutils.Environment, c *clustersv1alpha1.Cluster) { + ar := &clustersv1alpha1.AccessRequest{} + ar.SetName(accesslib.StableRequestNameFromLocalName(cluster.ControllerName, c.Name)) + ar.SetNamespace(c.Namespace) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ar), ar)).To(Succeed()) + old := ar.DeepCopy() + ar.Status.Phase = clustersv1alpha1.REQUEST_GRANTED + ar.Status.SecretRef = &commonapi.ObjectReference{ + Name: ar.Name, + Namespace: ar.Namespace, + } + Expect(env.Client().Status().Patch(env.Ctx, ar, client.MergeFrom(old))).To(Succeed()) + sec := &corev1.Secret{} + sec.Name = ar.Status.SecretRef.Name + sec.Namespace = ar.Status.SecretRef.Namespace + sec.Data = map[string][]byte{ + clustersv1alpha1.SecretKeyKubeconfig: []byte("fake"), + } + Expect(env.Client().Create(env.Ctx, sec)).To(Succeed()) +} From fa621d361b7682b369240fd1168483b8e28f9997 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Fri, 19 Sep 2025 16:38:25 +0200 Subject: [PATCH 09/18] fix linting issues --- internal/controllers/cluster/controller.go | 226 +++++++++--------- .../controllers/cluster/controller_test.go | 22 +- 2 files changed, 129 insertions(+), 119 deletions(-) diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 3020390..869d29a 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -179,132 +179,144 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C if c.DeletionTimestamp.IsZero() && rr.Config != nil { // CREATE/UPDATE - log.Info("Creating or updating DNS configuration for Cluster") - - // add finalizer to Cluster if not present - old := c.DeepCopy() - if controllerutil.AddFinalizer(c, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { - log.Info("Adding finalizer to Cluster", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) - if err := r.PlatformCluster.Client().Patch(ctx, c, client.MergeFrom(old)); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error adding finalizer to Cluster '%s/%s': %w", c.Namespace, c.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr - } - } - r.addKnownCluster(c) + rr = r.handleCreateOrUpdate(ctx, c, expectedLabels, rr) + } else { + // DELETE + rr = r.handleDelete(ctx, c, expectedLabels, rr) + } - log.Info("Creating or updating AccessRequest to get access to Cluster") - ar := &clustersv1alpha1.AccessRequest{} - ar.SetName(accesslib.StableRequestNameFromLocalName(ControllerName, c.Name)) - ar.SetNamespace(c.Namespace) - if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), ar, func() error { - if err := controllerutil.SetOwnerReference(c, ar, r.PlatformCluster.Scheme()); err != nil { - return fmt.Errorf("error setting owner reference: %w", err) - } - ar.Labels = maputils.Merge(ar.Labels, expectedLabels) - ar.Spec.ClusterRef = &commonapi.ObjectReference{ - Name: c.Name, - Namespace: c.Namespace, - } - ar.Spec.Token = &clustersv1alpha1.TokenConfig{ - Permissions: []clustersv1alpha1.PermissionsRequest{ - { - Rules: []rbacv1.PolicyRule{ // TODO: restrict permissions - { - APIGroups: []string{"*"}, - Resources: []string{"*"}, - Verbs: []string{"*"}, - }, + return rr +} + +func (r *ClusterReconciler) handleCreateOrUpdate(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { + log := logging.FromContextOrPanic(ctx) + log.Info("Creating or updating DNS configuration for Cluster") + + // add finalizer to Cluster if not present + old := c.DeepCopy() + if controllerutil.AddFinalizer(c, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { + log.Info("Adding finalizer to Cluster", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) + if err := r.PlatformCluster.Client().Patch(ctx, c, client.MergeFrom(old)); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error adding finalizer to Cluster '%s/%s': %w", c.Namespace, c.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + } + r.addKnownCluster(c) + + log.Info("Creating or updating AccessRequest to get access to Cluster") + ar := &clustersv1alpha1.AccessRequest{} + ar.SetName(accesslib.StableRequestNameFromLocalName(ControllerName, c.Name)) + ar.SetNamespace(c.Namespace) + if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), ar, func() error { + if err := controllerutil.SetOwnerReference(c, ar, r.PlatformCluster.Scheme()); err != nil { + return fmt.Errorf("error setting owner reference: %w", err) + } + ar.Labels = maputils.Merge(ar.Labels, expectedLabels) + ar.Spec.ClusterRef = &commonapi.ObjectReference{ + Name: c.Name, + Namespace: c.Namespace, + } + ar.Spec.Token = &clustersv1alpha1.TokenConfig{ + Permissions: []clustersv1alpha1.PermissionsRequest{ + { + Rules: []rbacv1.PolicyRule{ // TODO: restrict permissions + { + APIGroups: []string{"*"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, }, }, }, - } - return nil - }); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr - } - if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(ar), ar); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr - } - if ar.Status.IsDenied() { - rr.Message = fmt.Sprintf("AccessRequest '%s/%s' was denied, unable to proceed with deploying DNS configuration", ar.Namespace, ar.Name) - return rr - } - if !ar.Status.IsGranted() { - rr.Message = fmt.Sprintf("AccessRequest '%s/%s' is not yet granted, waiting for access to be granted", ar.Namespace, ar.Name) - rr.Result.RequeueAfter = defaultRequeueAfterDuration - return rr + }, } - rr.AccessRequest = ar + return nil + }); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(ar), ar); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } + if ar.Status.IsDenied() { + rr.Message = fmt.Sprintf("AccessRequest '%s/%s' was denied, unable to proceed with deploying DNS configuration", ar.Namespace, ar.Name) + return rr + } + if !ar.Status.IsGranted() { + rr.Message = fmt.Sprintf("AccessRequest '%s/%s' is not yet granted, waiting for access to be granted", ar.Namespace, ar.Name) + rr.Result.RequeueAfter = defaultRequeueAfterDuration + return rr + } + rr.AccessRequest = ar - rr = r.deployAuthSecret(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { - return rr - } + rr = r.deployAuthSecret(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - rr = r.deployHelmChartSource(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { - return rr - } + rr = r.deployHelmChartSource(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - rr = r.deployHelmRelease(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { - return rr - } + rr = r.deployHelmRelease(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - rr.Message = "Successfully triggered deployment of external-dns on Cluster" - } else { - // DELETE - // check if the Cluster has a finalizer, otherwise we don't have to do anything - if !slices.Contains(c.Finalizers, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { - log.Debug("Cluster does not have finalizer, no cleanup required", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) - r.removeKnownCluster(c) - return rr - } + rr.Message = "Successfully triggered deployment of external-dns on Cluster" + return rr +} - log.Info("Cleaning up DNS configuration for Cluster, either because it is being deleted or no configuration matches anymore") +func (r *ClusterReconciler) handleDelete(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { + log := logging.FromContextOrPanic(ctx) + // check if the Cluster has a finalizer, otherwise we don't have to do anything + if !slices.Contains(c.Finalizers, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { + log.Debug("Cluster does not have finalizer, no cleanup required", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) + r.removeKnownCluster(c) + return rr + } - rr = r.undeployHelmRelease(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { - return rr - } + log.Info("Cleaning up DNS configuration for Cluster, either because it is being deleted or no configuration matches anymore") - rr = r.undeployHelmChartSource(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { - return rr - } + rr = r.undeployHelmRelease(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - rr = r.undeployAuthSecret(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { - return rr - } + rr = r.undeployHelmChartSource(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - // delete AccessRequest - ar := &clustersv1alpha1.AccessRequest{} - ar.Name = accesslib.StableRequestNameFromLocalName(strings.ToLower(ControllerName), c.Name) - ar.Namespace = c.Namespace - if err := r.PlatformCluster.Client().Delete(ctx, ar); err != nil { - if !apierrors.IsNotFound(err) { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr - } - } + rr = r.undeployAuthSecret(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - // remove finalizer from Cluster - old := c.DeepCopy() - if controllerutil.RemoveFinalizer(c, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { - log.Info("Removing finalizer from Cluster", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) - if err := r.PlatformCluster.Client().Patch(ctx, c, client.MergeFrom(old)); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error removing finalizer from Cluster '%s/%s': %w", c.Namespace, c.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr - } + // delete AccessRequest + ar := &clustersv1alpha1.AccessRequest{} + ar.Name = accesslib.StableRequestNameFromLocalName(strings.ToLower(ControllerName), c.Name) + ar.Namespace = c.Namespace + if err := r.PlatformCluster.Client().Delete(ctx, ar); err != nil { + if !apierrors.IsNotFound(err) { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting AccessRequest '%s/%s': %w", ar.Namespace, ar.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr } - r.removeKnownCluster(c) + } - rr.Message = "Successfully removed external-dns from Cluster" + // remove finalizer from Cluster + old := c.DeepCopy() + if controllerutil.RemoveFinalizer(c, dnsv1alpha1.ExternalDNSFinalizerOnCluster) { + log.Info("Removing finalizer from Cluster", "finalizer", dnsv1alpha1.ExternalDNSFinalizerOnCluster) + if err := r.PlatformCluster.Client().Patch(ctx, c, client.MergeFrom(old)); err != nil { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error removing finalizer from Cluster '%s/%s': %w", c.Namespace, c.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr + } } + r.removeKnownCluster(c) + rr.Message = "Successfully removed external-dns from Cluster" return rr } diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go index 19fbc54..d07e815 100644 --- a/internal/controllers/cluster/controller_test.go +++ b/internal/controllers/cluster/controller_test.go @@ -1,3 +1,4 @@ +//nolint:goconst package cluster_test import ( @@ -39,7 +40,7 @@ const ( var platformScheme = install.InstallOperatorAPIsPlatform(runtime.NewScheme()) -func defaultTestSetup(testDirPathSegments ...string) (*testutils.Environment, *cluster.ClusterReconciler) { +func defaultTestSetup(testDirPathSegments ...string) *testutils.Environment { env := testutils.NewEnvironmentBuilder(). WithFakeClient(platformScheme). WithInitObjectPath(testDirPathSegments...). @@ -55,16 +56,13 @@ func defaultTestSetup(testDirPathSegments ...string) (*testutils.Environment, *c }). Build() - cr, ok := env.Reconciler().(*cluster.ClusterReconciler) - Expect(ok).To(BeTrue(), "Reconciler is not of type ClusterReconciler") - - return env, cr + return env } var _ = Describe("ClusterReconciler", func() { It("should fail if no DNSServiceConfig exists", func() { - env, _ := defaultTestSetup("testdata", "test-01") + env := defaultTestSetup("testdata", "test-01") // delete any existing DNSServiceConfig Expect(env.Client().DeleteAllOf(env.Ctx, &dnsv1alpha1.DNSServiceConfig{}, client.InNamespace(providerNamespace))).To(Succeed()) @@ -75,7 +73,7 @@ var _ = Describe("ClusterReconciler", func() { }) It("should correctly match configs to clusters and create the flux resources", func() { - env, _ := defaultTestSetup("testdata", "test-01") + env := defaultTestSetup("testdata", "test-01") cfg := &dnsv1alpha1.DNSServiceConfig{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) @@ -209,7 +207,7 @@ var _ = Describe("ClusterReconciler", func() { }) It("should correctly match complex purpose selectors and don't create resources if no purpose selector matches", func() { - env, _ := defaultTestSetup("testdata", "test-02") + env := defaultTestSetup("testdata", "test-02") cfg := &dnsv1alpha1.DNSServiceConfig{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) @@ -274,7 +272,7 @@ var _ = Describe("ClusterReconciler", func() { }) It("should use finalizers and remove resources when the Cluster is being deleted", func() { - env, _ := defaultTestSetup("testdata", "test-01") + env := defaultTestSetup("testdata", "test-01") cfg := &dnsv1alpha1.DNSServiceConfig{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) @@ -344,7 +342,7 @@ var _ = Describe("ClusterReconciler", func() { }) It("should delete obsolete flux sources", func() { - env, _ := defaultTestSetup("testdata", "test-01") + env := defaultTestSetup("testdata", "test-01") cfg := &dnsv1alpha1.DNSServiceConfig{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) @@ -388,7 +386,7 @@ var _ = Describe("ClusterReconciler", func() { }) It("should create a GitRepository if configured", func() { - env, _ := defaultTestSetup("testdata", "test-03") + env := defaultTestSetup("testdata", "test-03") cfg := &dnsv1alpha1.DNSServiceConfig{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) @@ -421,7 +419,7 @@ var _ = Describe("ClusterReconciler", func() { }) It("should create a HelmRepository if configured", func() { - env, _ := defaultTestSetup("testdata", "test-04") + env := defaultTestSetup("testdata", "test-04") cfg := &dnsv1alpha1.DNSServiceConfig{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) From 402799ce3c7ee714b8c2d52de6ccd5451607bdee Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Mon, 22 Sep 2025 11:36:37 +0200 Subject: [PATCH 10/18] fix helm release --- .../dns.openmcp.cloud_dnsserviceconfigs.yaml | 14 +++++++++++-- api/dns/v1alpha1/config_types.go | 7 ++++++- internal/controllers/cluster/controller.go | 20 +++++++++++++------ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml index 533ad02..4ada220 100644 --- a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml +++ b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml @@ -94,6 +94,13 @@ spec: description: ExternalDNSSource is the source of the external-dns helm chart. properties: + chartName: + description: |- + ChartName specifies the name of the external-dns chart. + Depending on the source, this can also be a relative path within the repository. + When using a source that needs a version (helm or oci), append the version to the chart name using '@', e.g. 'external-dns@1.10.0' or omit for latest version. + minLength: 1 + type: string copyAuthSecret: description: |- SecretCopy defines the name of the secret to copy and the name of the copied secret. @@ -655,11 +662,14 @@ spec: - interval - url type: object + required: + - chartName type: object x-kubernetes-validations: - message: Exactly one of 'helm', 'git', or 'oci' must be set - rule: size(self.filter(property, (property != "copyAuthSecret") - && (size(self[property]) > 0))) == 1 + rule: size(self.filter(property, (property == "helm" || property + == "git" || property == "oci") && (size(self[property]) > 0))) + == 1 helmReleaseReconciliationInterval: description: |- HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. diff --git a/api/dns/v1alpha1/config_types.go b/api/dns/v1alpha1/config_types.go index 725360f..ce3eb08 100644 --- a/api/dns/v1alpha1/config_types.go +++ b/api/dns/v1alpha1/config_types.go @@ -32,8 +32,13 @@ type DNSServiceConfigSpec struct { // ExternalDNSSource defines the source of the external-dns helm chart in form of a Flux source. // Exactly one of 'HelmRepository', 'GitRepository' or 'OCIRepository' must be set. // If 'copyAuthSecret' is set, the referenced source secret is copied into the namespace where the Flux resources are created with the specified target name. -// +kubebuilder:validation:XValidation:rule=`size(self.filter(property, (property != "copyAuthSecret") && (size(self[property]) > 0))) == 1`, message="Exactly one of 'helm', 'git', or 'oci' must be set" +// +kubebuilder:validation:XValidation:rule=`size(self.filter(property, (property == "helm" || property == "git" || property == "oci") && (size(self[property]) > 0))) == 1`, message="Exactly one of 'helm', 'git', or 'oci' must be set" type ExternalDNSSource struct { + // ChartName specifies the name of the external-dns chart. + // Depending on the source, this can also be a relative path within the repository. + // When using a source that needs a version (helm or oci), append the version to the chart name using '@', e.g. 'external-dns@1.10.0' or omit for latest version. + // +kubebuilder:validation:MinLength=1 + ChartName string `json:"chartName"` Helm *fluxv1.HelmRepositorySpec `json:"helm,omitempty"` Git *fluxv1.GitRepositorySpec `json:"git,omitempty"` OCI *fluxv1.OCIRepositorySpec `json:"oci,omitempty"` diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 869d29a..ef3eeeb 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -516,12 +516,20 @@ func (r *ClusterReconciler) deployHelmRelease(ctx context.Context, c *clustersv1 // labels hr.Labels = maputils.Merge(hr.Labels, expectedLabels) // chart - hr.Spec.Chart = nil - hr.Spec.ChartRef = &fluxhelmv2.CrossNamespaceSourceReference{ - APIVersion: fluxsourcev1.SchemeBuilder.GroupVersion.String(), - Kind: rr.SourceKind, - Name: hr.Name, - Namespace: hr.Namespace, + hr.Spec.Chart = &fluxhelmv2.HelmChartTemplate{ + Spec: fluxhelmv2.HelmChartTemplateSpec{ + SourceRef: fluxhelmv2.CrossNamespaceObjectReference{ + APIVersion: fluxsourcev1.SchemeBuilder.GroupVersion.String(), + Kind: rr.SourceKind, + Name: hr.Name, + Namespace: hr.Namespace, + }, + }, + } + chartNameVersion := strings.Split(rr.ProviderConfig.Spec.ExternalDNSSource.ChartName, "@") + hr.Spec.Chart.Spec.Chart = chartNameVersion[0] + if len(chartNameVersion) > 1 { + hr.Spec.Chart.Spec.Version = chartNameVersion[1] } // release information hr.Spec.ReleaseName = "external-dns" From b32c1c60a48f0426b3c0b5e91429f5c2772cb60f Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Tue, 23 Sep 2025 13:28:54 +0200 Subject: [PATCH 11/18] fix controller commands --- api/crds/crds.go | 15 +++ .../dns.openmcp.cloud_dnsserviceconfigs.yaml | 20 ++-- api/dns/v1alpha1/config_types.go | 27 +++-- api/go.mod | 43 +++++++- api/go.sum | 102 +++++++++++++++--- cmd/platform-service-dns/app/app.go | 8 +- cmd/platform-service-dns/app/init.go | 28 ++--- cmd/platform-service-dns/app/run.go | 23 ++++ 8 files changed, 211 insertions(+), 55 deletions(-) create mode 100644 api/crds/crds.go diff --git a/api/crds/crds.go b/api/crds/crds.go new file mode 100644 index 0000000..8f11638 --- /dev/null +++ b/api/crds/crds.go @@ -0,0 +1,15 @@ +package crds + +import ( + "embed" + + crdutil "github.com/openmcp-project/controller-utils/pkg/crds" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +//go:embed manifests +var CRDFS embed.FS + +func CRDs() ([]*apiextv1.CustomResourceDefinition, error) { + return crdutil.CRDsFromFileSystem(CRDFS, "manifests") +} diff --git a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml index 4ada220..92f77c0 100644 --- a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml +++ b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml @@ -61,6 +61,7 @@ spec: helmValues: description: HelmValues are the helm values to deploy external-dns with, if the purpose selector matches. + type: string name: description: |- Name is an optional name. @@ -72,20 +73,18 @@ spec: If not set, all Clusters are matched. properties: and: - items: {} + items: + type: object type: array name: type: string - not: {} + not: + type: object or: - items: {} + items: + type: object type: array type: object - x-kubernetes-validations: - - message: Exactly one of 'and', 'or', 'not' or 'name' must - be set - rule: size(self.filter(property, size(self[property]) > 0)) - == 1 required: - helmValues type: object @@ -665,11 +664,6 @@ spec: required: - chartName type: object - x-kubernetes-validations: - - message: Exactly one of 'helm', 'git', or 'oci' must be set - rule: size(self.filter(property, (property == "helm" || property - == "git" || property == "oci") && (size(self[property]) > 0))) - == 1 helmReleaseReconciliationInterval: description: |- HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. diff --git a/api/dns/v1alpha1/config_types.go b/api/dns/v1alpha1/config_types.go index ce3eb08..4eee2c3 100644 --- a/api/dns/v1alpha1/config_types.go +++ b/api/dns/v1alpha1/config_types.go @@ -32,7 +32,7 @@ type DNSServiceConfigSpec struct { // ExternalDNSSource defines the source of the external-dns helm chart in form of a Flux source. // Exactly one of 'HelmRepository', 'GitRepository' or 'OCIRepository' must be set. // If 'copyAuthSecret' is set, the referenced source secret is copied into the namespace where the Flux resources are created with the specified target name. -// +kubebuilder:validation:XValidation:rule=`size(self.filter(property, (property == "helm" || property == "git" || property == "oci") && (size(self[property]) > 0))) == 1`, message="Exactly one of 'helm', 'git', or 'oci' must be set" +// +kubebuilder:validation:ExactlyOneOf=helm;git;oci type ExternalDNSSource struct { // ChartName specifies the name of the external-dns chart. // Depending on the source, this can also be a relative path within the repository. @@ -70,6 +70,7 @@ type ExternalDNSPurposeConfig struct { HelmReleaseReconciliationInterval *metav1.Duration `json:"helmReleaseReconciliationInterval,omitempty"` // HelmValues are the helm values to deploy external-dns with, if the purpose selector matches. + // +kubebuilder:validation:Type=string // +kubebuilder:validation:Schemaless HelmValues *apiextensionsv1.JSON `json:"helmValues"` } @@ -81,17 +82,23 @@ type PurposeSelector struct { // PurposeSelectorRequirement is a selector to select purposes to apply the configuration to. // The struct can be combined recursively using "and", "or" and "not" to build complex selectors. -// Exactly one of the fields must be set. +// Exactly one of the fields must be set, otherwise only one of them is evaluated in the order: name, not, and, or. // If name is set, the selector matches if the Cluster's purposes contain the given name. // If and is set, the selector matches if all of the contained selectors match. // If or is set, the selector matches if any of the contained selectors match. // If not is set, the selector matches if the contained selector does not match. -// +kubebuilder:validation:XValidation:rule=`size(self.filter(property, size(self[property]) > 0)) == 1`, message="Exactly one of 'and', 'or', 'not' or 'name' must be set" type PurposeSelectorRequirement struct { - And []PurposeSelectorRequirement `json:"and,omitempty"` - Or []PurposeSelectorRequirement `json:"or,omitempty"` - Not *PurposeSelectorRequirement `json:"not,omitempty"` - Name string `json:"name,omitempty"` + // +kubebuilder:validation:items:Type=object + // +optional + And []PurposeSelectorRequirement `json:"and,omitempty"` + // +kubebuilder:validation:items:Type=object + // +optional + Or []PurposeSelectorRequirement `json:"or,omitempty"` + // +kubebuilder:validation:Type=object + // +optional + Not *PurposeSelectorRequirement `json:"not,omitempty"` + // +optional + Name string `json:"name,omitempty"` } // +kubebuilder:object:root=true @@ -142,6 +149,9 @@ func requirementMatches(r *PurposeSelectorRequirement, purposes []string, seenRe if r.Name != "" { return slices.Contains(purposes, r.Name) } + if r.Not != nil { + return !requirementMatches(r.Not, purposes, seenRequirements) + } if len(r.And) > 0 { for i := range r.And { if !requirementMatches(&r.And[i], purposes, seenRequirements) { @@ -158,8 +168,5 @@ func requirementMatches(r *PurposeSelectorRequirement, purposes []string, seenRe } return false } - if r.Not != nil { - return !requirementMatches(r.Not, purposes, seenRequirements) - } return false } diff --git a/api/go.mod b/api/go.mod index 3f4cdbc..22b0742 100644 --- a/api/go.mod +++ b/api/go.mod @@ -12,9 +12,49 @@ require ( sigs.k8s.io/controller-runtime v0.22.1 ) +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/time v0.10.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect +) + require ( github.com/fluxcd/pkg/apis/acl v0.7.0 // indirect - github.com/fluxcd/pkg/apis/kustomize v1.10.0 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.12.0 // indirect github.com/fluxcd/pkg/apis/meta v1.12.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -22,6 +62,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/openmcp-project/controller-utils v0.19.0 github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/net v0.43.0 // indirect diff --git a/api/go.sum b/api/go.sum index 841f1de..9f6ec8c 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,65 +1,127 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fluxcd/helm-controller/api v1.3.0 h1:PupXPuQbksmU0g2Lc6NjIYal2HJGL+6xohsf82eGVjo= github.com/fluxcd/helm-controller/api v1.3.0/go.mod h1:4b8PfdH0e/9Pfol2ogdMYbQ1nLjcVu9gAv27cQzIPK4= github.com/fluxcd/pkg/apis/acl v0.7.0 h1:dMhZJH+g6ZRPjs4zVOAN9vHBd1DcavFgcIFkg5ooOE0= github.com/fluxcd/pkg/apis/acl v0.7.0/go.mod h1:uv7pXXR/gydiX4MUwlQa7vS8JONEDztynnjTvY3JxKQ= -github.com/fluxcd/pkg/apis/kustomize v1.10.0 h1:47EeSzkQvlQZdH92vHMe2lK2iR8aOSEJq95avw5idts= -github.com/fluxcd/pkg/apis/kustomize v1.10.0/go.mod h1:UsqMV4sqNa1Yg0pmTsdkHRJr7bafBOENIJoAN+3ezaQ= +github.com/fluxcd/pkg/apis/kustomize v1.12.0 h1:KvZN6xwgP/dNSeckL4a/Uv715XqiN1C3xS+jGcPejtE= +github.com/fluxcd/pkg/apis/kustomize v1.12.0/go.mod h1:OojLxIdKm1JAAdh3sL4j4F+vfrLKb7kq1vr8bpyEKgg= github.com/fluxcd/pkg/apis/meta v1.12.0 h1:XW15TKZieC2b7MN8VS85stqZJOx+/b8jATQ/xTUhVYg= github.com/fluxcd/pkg/apis/meta v1.12.0/go.mod h1:+son1Va60x2eiDcTwd7lcctbI6C+K3gM7R+ULmEq1SI= github.com/fluxcd/source-controller/api v1.6.2 h1:UmodAeqLIeF29HdTqf2GiacZyO+hJydJlepDaYsMvhc= github.com/fluxcd/source-controller/api v1.6.2/go.mod h1:ZJcAi0nemsnBxjVgmJl0WQzNvB0rMETxQMTdoFosmMw= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= -github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= -github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.25.2 h1:hepmgwx1D+llZleKQDMEvy8vIlCxMGt7W5ZxDjIEhsw= +github.com/onsi/ginkgo/v2 v2.25.2/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/openmcp-project/controller-utils v0.19.0 h1:D4Ht3LI/Ue5yk2wdAnJEpChUVmB6xM7kglwhn7a2J3g= +github.com/openmcp-project/controller-utils v0.19.0/go.mod h1:zxcbcmedLdlQ//X/nwdPvq/nM3ikyR13DbOivou2I4Y= github.com/openmcp-project/openmcp-operator/api v0.14.0 h1:/Ogg13b/IBBmAVQEuFnOKvHuVV3o2DlrQpSQhXca0+g= github.com/openmcp-project/openmcp-operator/api v0.14.0/go.mod h1:mgckJa59TpgTjta8+BWHwbOxlY1zVasqQTDFQLCs6M8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -75,31 +137,45 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -114,6 +190,8 @@ k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= diff --git a/cmd/platform-service-dns/app/app.go b/cmd/platform-service-dns/app/app.go index 6890907..a62490b 100644 --- a/cmd/platform-service-dns/app/app.go +++ b/cmd/platform-service-dns/app/app.go @@ -10,7 +10,6 @@ import ( "github.com/openmcp-project/controller-utils/pkg/clusters" "github.com/openmcp-project/controller-utils/pkg/logging" - openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" ) func NewPlatformServiceDNSCommand() *cobra.Command { @@ -43,8 +42,7 @@ type SharedOptions struct { PlatformCluster *clusters.Cluster // fields filled in Complete() - Log logging.Logger - ProviderNamespace string + Log logging.Logger } func (o *SharedOptions) AddPersistentFlags(cmd *cobra.Command) { @@ -66,10 +64,6 @@ func (o *SharedOptions) Complete() error { if o.ProviderName == "" { return fmt.Errorf("provider-name must not be empty") } - o.ProviderNamespace = os.Getenv(openmcpconst.EnvVariablePodNamespace) - if o.ProviderNamespace == "" { - return fmt.Errorf("environment variable '%s' must be set", openmcpconst.EnvVariablePodNamespace) - } // build logger log, err := logging.GetLogger() diff --git a/cmd/platform-service-dns/app/init.go b/cmd/platform-service-dns/app/init.go index 6db9b98..cbd0e48 100644 --- a/cmd/platform-service-dns/app/init.go +++ b/cmd/platform-service-dns/app/init.go @@ -5,9 +5,14 @@ import ( "fmt" "github.com/spf13/cobra" - // crdutil "github.com/openmcp-project/controller-utils/pkg/crds" - // clustersv1alpha1 "github.com/openmcp-project/platform-service-dns/api/clusters/v1alpha1" - // openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" + "k8s.io/apimachinery/pkg/runtime" + + crdutil "github.com/openmcp-project/controller-utils/pkg/crds" + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" + + "github.com/openmcp-project/platform-service-dns/api/crds" + providerscheme "github.com/openmcp-project/platform-service-dns/api/install" ) func NewInitCommand(so *SharedOptions) *cobra.Command { @@ -52,21 +57,20 @@ func (o *InitOptions) Complete(ctx context.Context) error { } func (o *InitOptions) Run(ctx context.Context) error { - // if err := o.PlatformCluster.InitializeClient(providerscheme.InstallCRDAPIs(runtime.NewScheme())); err != nil { - // return err - // } + if err := o.PlatformCluster.InitializeClient(providerscheme.InstallCRDAPIs(runtime.NewScheme())); err != nil { + return err + } log := o.Log.WithName("main") log.Info("Environment", "value", o.Environment) log.Info("ProviderName", "value", o.ProviderName) // apply CRDs - // TODO: are CRDs required? - // crdManager := crdutil.NewCRDManager(openmcpconst.ClusterLabel, crds.CRDs) - // crdManager.AddCRDLabelToClusterMapping(clustersv1alpha1.PURPOSE_PLATFORM, o.PlatformCluster) - // if err := crdManager.CreateOrUpdateCRDs(ctx, &log); err != nil { - // return fmt.Errorf("error creating/updating CRDs: %w", err) - // } + crdManager := crdutil.NewCRDManager(openmcpconst.ClusterLabel, crds.CRDs) + crdManager.AddCRDLabelToClusterMapping(clustersv1alpha1.PURPOSE_PLATFORM, o.PlatformCluster) + if err := crdManager.CreateOrUpdateCRDs(ctx, &log); err != nil { + return fmt.Errorf("error creating/updating CRDs: %w", err) + } log.Info("Finished init command") return nil diff --git a/cmd/platform-service-dns/app/run.go b/cmd/platform-service-dns/app/run.go index 82d626b..58bec0b 100644 --- a/cmd/platform-service-dns/app/run.go +++ b/cmd/platform-service-dns/app/run.go @@ -4,20 +4,25 @@ import ( "context" "crypto/tls" "fmt" + "os" "path/filepath" "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" "github.com/openmcp-project/controller-utils/pkg/logging" + openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" + dnsv1alpha1 "github.com/openmcp-project/platform-service-dns/api/dns/v1alpha1" providerscheme "github.com/openmcp-project/platform-service-dns/api/install" "github.com/openmcp-project/platform-service-dns/internal/controllers/cluster" ) @@ -79,6 +84,7 @@ type RunOptions struct { MetricsServerOptions metricsserver.Options MetricsCertWatcher *certwatcher.CertWatcher WebhookCertWatcher *certwatcher.CertWatcher + ProviderNamespace string } func (o *RunOptions) AddFlags(cmd *cobra.Command) { @@ -101,6 +107,11 @@ func (o *RunOptions) Complete(ctx context.Context) error { if err := o.SharedOptions.Complete(); err != nil { return err } + o.ProviderNamespace = os.Getenv(openmcpconst.EnvVariablePodNamespace) + if o.ProviderNamespace == "" { + return fmt.Errorf("environment variable '%s' must be set", openmcpconst.EnvVariablePodNamespace) + } + setupLog = o.Log.WithName("setup") ctrl.SetLogger(o.Log.Logr()) @@ -223,6 +234,18 @@ func (o *RunOptions) Run(ctx context.Context) error { return fmt.Errorf("unable to create manager: %w", err) } + // setup Cluster reconciler + // verify DNSServiceConfig existence + // This also happens in the reconcile, but then the pod will look healthy while it is actually not able to reconcile anything. + svcCfg := &dnsv1alpha1.DNSServiceConfig{} + svcCfg.Name = o.ProviderName + svcCfg.Namespace = o.ProviderNamespace + if err := o.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(svcCfg), svcCfg); err != nil { + if apierrors.IsNotFound(err) { + return fmt.Errorf("DNSServiceConfig '%s/%s' not found: %w", svcCfg.Namespace, svcCfg.Name, err) + } + return fmt.Errorf("error getting DNSServiceConfig '%s/%s': %w", svcCfg.Namespace, svcCfg.Name, err) + } if err := cluster.NewClusterReconciler(o.PlatformCluster, mgr.GetEventRecorderFor(cluster.ControllerName), o.ProviderName, o.ProviderNamespace).SetupWithManager(mgr); err != nil { return fmt.Errorf("unable to add Cluster reconciler to manager: %w", err) } From 5ee132060e23c3b9a717f019e16b669065d8d84f Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Tue, 23 Sep 2025 13:54:51 +0200 Subject: [PATCH 12/18] fix DNSServiceConfig being cluster-scoped --- cmd/platform-service-dns/app/run.go | 5 ++--- internal/controllers/cluster/controller.go | 7 +++---- internal/controllers/cluster/controller_test.go | 14 +++++++------- .../cluster/testdata/test-01/config.yaml | 1 - .../cluster/testdata/test-02/config.yaml | 1 - .../cluster/testdata/test-03/config.yaml | 1 - .../cluster/testdata/test-04/config.yaml | 1 - 7 files changed, 12 insertions(+), 18 deletions(-) diff --git a/cmd/platform-service-dns/app/run.go b/cmd/platform-service-dns/app/run.go index 58bec0b..31a4363 100644 --- a/cmd/platform-service-dns/app/run.go +++ b/cmd/platform-service-dns/app/run.go @@ -239,12 +239,11 @@ func (o *RunOptions) Run(ctx context.Context) error { // This also happens in the reconcile, but then the pod will look healthy while it is actually not able to reconcile anything. svcCfg := &dnsv1alpha1.DNSServiceConfig{} svcCfg.Name = o.ProviderName - svcCfg.Namespace = o.ProviderNamespace if err := o.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(svcCfg), svcCfg); err != nil { if apierrors.IsNotFound(err) { - return fmt.Errorf("DNSServiceConfig '%s/%s' not found: %w", svcCfg.Namespace, svcCfg.Name, err) + return fmt.Errorf("DNSServiceConfig '%s' not found: %w", svcCfg.Name, err) } - return fmt.Errorf("error getting DNSServiceConfig '%s/%s': %w", svcCfg.Namespace, svcCfg.Name, err) + return fmt.Errorf("error getting DNSServiceConfig '%s': %w", svcCfg.Name, err) } if err := cluster.NewClusterReconciler(o.PlatformCluster, mgr.GetEventRecorderFor(cluster.ControllerName), o.ProviderName, o.ProviderNamespace).SetupWithManager(mgr); err != nil { return fmt.Errorf("unable to add Cluster reconciler to manager: %w", err) diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index ef3eeeb..783e7b6 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -153,12 +153,11 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C // load DNSServiceConfig resource rr.ProviderConfig = &dnsv1alpha1.DNSServiceConfig{} rr.ProviderConfig.Name = r.ProviderName - rr.ProviderConfig.Namespace = r.ProviderNamespace if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(rr.ProviderConfig), rr.ProviderConfig); err != nil { if apierrors.IsNotFound(err) { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("DNSServiceConfig '%s/%s' not found", rr.ProviderConfig.Namespace, rr.ProviderConfig.Name), clusterconst.ReasonConfigurationProblem) + rr.ReconcileError = errutils.WithReason(fmt.Errorf("DNSServiceConfig '%s' not found", rr.ProviderConfig.Name), clusterconst.ReasonConfigurationProblem) } else { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting DNSServiceConfig '%s/%s': %w", rr.ProviderConfig.Namespace, rr.ProviderConfig.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting DNSServiceConfig '%s': %w", rr.ProviderConfig.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) } return rr } @@ -773,7 +772,7 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { }) }), builder.WithPredicates(predicate.And( predicate.GenerationChangedPredicate{}, - ctrlutils.ExactNamePredicate(r.ProviderName, r.ProviderNamespace), + ctrlutils.ExactNamePredicate(r.ProviderName, ""), ))). Complete(r) } diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go index d07e815..cb042b4 100644 --- a/internal/controllers/cluster/controller_test.go +++ b/internal/controllers/cluster/controller_test.go @@ -65,7 +65,7 @@ var _ = Describe("ClusterReconciler", func() { env := defaultTestSetup("testdata", "test-01") // delete any existing DNSServiceConfig - Expect(env.Client().DeleteAllOf(env.Ctx, &dnsv1alpha1.DNSServiceConfig{}, client.InNamespace(providerNamespace))).To(Succeed()) + Expect(env.Client().DeleteAllOf(env.Ctx, &dnsv1alpha1.DNSServiceConfig{})).To(Succeed()) c := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c)).To(Succeed()) @@ -76,7 +76,7 @@ var _ = Describe("ClusterReconciler", func() { env := defaultTestSetup("testdata", "test-01") cfg := &dnsv1alpha1.DNSServiceConfig{} - Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName}, cfg)).To(Succeed()) c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) @@ -210,7 +210,7 @@ var _ = Describe("ClusterReconciler", func() { env := defaultTestSetup("testdata", "test-02") cfg := &dnsv1alpha1.DNSServiceConfig{} - Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName}, cfg)).To(Succeed()) c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) @@ -275,7 +275,7 @@ var _ = Describe("ClusterReconciler", func() { env := defaultTestSetup("testdata", "test-01") cfg := &dnsv1alpha1.DNSServiceConfig{} - Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName}, cfg)).To(Succeed()) c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) @@ -345,7 +345,7 @@ var _ = Describe("ClusterReconciler", func() { env := defaultTestSetup("testdata", "test-01") cfg := &dnsv1alpha1.DNSServiceConfig{} - Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName}, cfg)).To(Succeed()) // create dummy flux sources expectedLabels := map[string]string{ @@ -389,7 +389,7 @@ var _ = Describe("ClusterReconciler", func() { env := defaultTestSetup("testdata", "test-03") cfg := &dnsv1alpha1.DNSServiceConfig{} - Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName}, cfg)).To(Succeed()) c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) @@ -422,7 +422,7 @@ var _ = Describe("ClusterReconciler", func() { env := defaultTestSetup("testdata", "test-04") cfg := &dnsv1alpha1.DNSServiceConfig{} - Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName, Namespace: providerNamespace}, cfg)).To(Succeed()) + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName}, cfg)).To(Succeed()) c1 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) diff --git a/internal/controllers/cluster/testdata/test-01/config.yaml b/internal/controllers/cluster/testdata/test-01/config.yaml index 8176456..fb9a6cd 100644 --- a/internal/controllers/cluster/testdata/test-01/config.yaml +++ b/internal/controllers/cluster/testdata/test-01/config.yaml @@ -2,7 +2,6 @@ apiVersion: dns.openmcp.cloud/v1alpha1 kind: DNSServiceConfig metadata: name: dns-service - namespace: test spec: externalDNSSource: oci: diff --git a/internal/controllers/cluster/testdata/test-02/config.yaml b/internal/controllers/cluster/testdata/test-02/config.yaml index f69c4f0..184dd7d 100644 --- a/internal/controllers/cluster/testdata/test-02/config.yaml +++ b/internal/controllers/cluster/testdata/test-02/config.yaml @@ -2,7 +2,6 @@ apiVersion: dns.openmcp.cloud/v1alpha1 kind: DNSServiceConfig metadata: name: dns-service - namespace: test spec: externalDNSSource: oci: diff --git a/internal/controllers/cluster/testdata/test-03/config.yaml b/internal/controllers/cluster/testdata/test-03/config.yaml index dd45770..fc72a13 100644 --- a/internal/controllers/cluster/testdata/test-03/config.yaml +++ b/internal/controllers/cluster/testdata/test-03/config.yaml @@ -2,7 +2,6 @@ apiVersion: dns.openmcp.cloud/v1alpha1 kind: DNSServiceConfig metadata: name: dns-service - namespace: test spec: externalDNSSource: git: diff --git a/internal/controllers/cluster/testdata/test-04/config.yaml b/internal/controllers/cluster/testdata/test-04/config.yaml index 28111e4..1990001 100644 --- a/internal/controllers/cluster/testdata/test-04/config.yaml +++ b/internal/controllers/cluster/testdata/test-04/config.yaml @@ -2,7 +2,6 @@ apiVersion: dns.openmcp.cloud/v1alpha1 kind: DNSServiceConfig metadata: name: dns-service - namespace: test spec: externalDNSSource: helm: From fbbd7272817abf4467841a0dd8f4906e1ff75af2 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Tue, 23 Sep 2025 16:20:14 +0200 Subject: [PATCH 13/18] enable copying any amount of secrets --- .../dns.openmcp.cloud_dnsserviceconfigs.yaml | 93 ++++++++++-------- api/dns/v1alpha1/config_types.go | 20 +++- api/dns/v1alpha1/zz_generated.deepcopy.go | 12 ++- internal/controllers/cluster/controller.go | 73 ++++++++------ .../controllers/cluster/controller_test.go | 97 ++++++++++++------- .../cluster/testdata/test-01/config.yaml | 12 ++- .../cluster/testdata/test-01/secret-02.yaml | 8 ++ .../cluster/testdata/test-01/secret-03.yaml | 11 +++ 8 files changed, 206 insertions(+), 120 deletions(-) create mode 100644 internal/controllers/cluster/testdata/test-01/secret-02.yaml create mode 100644 internal/controllers/cluster/testdata/test-01/secret-03.yaml diff --git a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml index 92f77c0..abad5ea 100644 --- a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml +++ b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml @@ -59,8 +59,14 @@ spec: If not set, the global HelmReleaseReconciliationInterval is used. type: string helmValues: - description: HelmValues are the helm values to deploy external-dns - with, if the purpose selector matches. + description: |- + HelmValues are the helm values to deploy external-dns with, if the purpose selector matches. + There are a few special strings which will be replaced before creating the HelmRelease: + - will be replaced with the provider name resource. + - will be replaced with the namespace that hosts the platform service. + - will be replaced with the environment name of the operator. + - will be replaced with the name of the reconciled Cluster. + - will be replaced with the namespace of the reconciled Cluster. type: string name: description: |- @@ -100,45 +106,6 @@ spec: When using a source that needs a version (helm or oci), append the version to the chart name using '@', e.g. 'external-dns@1.10.0' or omit for latest version. minLength: 1 type: string - copyAuthSecret: - description: |- - SecretCopy defines the name of the secret to copy and the name of the copied secret. - If target is nil or target.name is empty, the secret will be copied with the same name as the source secret. - properties: - source: - description: LocalObjectReference is a reference to an object - in the same namespace as the resource referencing it. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - target: - description: LocalObjectReference is a reference to an object - in the same namespace as the resource referencing it. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - required: - - source - - target - type: object git: description: |- GitRepositorySpec specifies the required configuration to produce an @@ -670,6 +637,50 @@ spec: The value can be overwritten for specific purposes using ExternalDNSForPurposes. If not set, a default of 1h is used. type: string + secretsToCopy: + description: |- + SecretsToCopy specifies an optional list of secrets which will be copied from the provider namespace into the namespaces of the reconciled Clusters. + This can, for example, be used to distribute credentials for the registry holding the external-dns helm chart. + items: + description: |- + SecretCopy defines the name of the secret to copy and the name of the copied secret. + If target is nil or target.name is empty, the secret will be copied with the same name as the source secret. + properties: + source: + description: LocalObjectReference is a reference to an object + in the same namespace as the resource referencing it. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + target: + description: LocalObjectReference is a reference to an object + in the same namespace as the resource referencing it. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + required: + - source + - target + type: object + type: array required: - externalDNSSource type: object diff --git a/api/dns/v1alpha1/config_types.go b/api/dns/v1alpha1/config_types.go index 4eee2c3..8e070fc 100644 --- a/api/dns/v1alpha1/config_types.go +++ b/api/dns/v1alpha1/config_types.go @@ -16,6 +16,11 @@ type DNSServiceConfigSpec struct { // ExternalDNSSource is the source of the external-dns helm chart. ExternalDNSSource ExternalDNSSource `json:"externalDNSSource"` + // SecretsToCopy specifies an optional list of secrets which will be copied from the provider namespace into the namespaces of the reconciled Clusters. + // This can, for example, be used to distribute credentials for the registry holding the external-dns helm chart. + // +optional + SecretsToCopy []SecretCopy `json:"secretsToCopy,omitempty"` + // HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. // The value can be overwritten for specific purposes using ExternalDNSForPurposes. // If not set, a default of 1h is used. @@ -38,11 +43,10 @@ type ExternalDNSSource struct { // Depending on the source, this can also be a relative path within the repository. // When using a source that needs a version (helm or oci), append the version to the chart name using '@', e.g. 'external-dns@1.10.0' or omit for latest version. // +kubebuilder:validation:MinLength=1 - ChartName string `json:"chartName"` - Helm *fluxv1.HelmRepositorySpec `json:"helm,omitempty"` - Git *fluxv1.GitRepositorySpec `json:"git,omitempty"` - OCI *fluxv1.OCIRepositorySpec `json:"oci,omitempty"` - CopyAuthSecret *SecretCopy `json:"copyAuthSecret,omitempty"` + ChartName string `json:"chartName"` + Helm *fluxv1.HelmRepositorySpec `json:"helm,omitempty"` + Git *fluxv1.GitRepositorySpec `json:"git,omitempty"` + OCI *fluxv1.OCIRepositorySpec `json:"oci,omitempty"` } // SecretCopy defines the name of the secret to copy and the name of the copied secret. @@ -70,6 +74,12 @@ type ExternalDNSPurposeConfig struct { HelmReleaseReconciliationInterval *metav1.Duration `json:"helmReleaseReconciliationInterval,omitempty"` // HelmValues are the helm values to deploy external-dns with, if the purpose selector matches. + // There are a few special strings which will be replaced before creating the HelmRelease: + // - will be replaced with the provider name resource. + // - will be replaced with the namespace that hosts the platform service. + // - will be replaced with the environment name of the operator. + // - will be replaced with the name of the reconciled Cluster. + // - will be replaced with the namespace of the reconciled Cluster. // +kubebuilder:validation:Type=string // +kubebuilder:validation:Schemaless HelmValues *apiextensionsv1.JSON `json:"helmValues"` diff --git a/api/dns/v1alpha1/zz_generated.deepcopy.go b/api/dns/v1alpha1/zz_generated.deepcopy.go index a053882..ad90150 100644 --- a/api/dns/v1alpha1/zz_generated.deepcopy.go +++ b/api/dns/v1alpha1/zz_generated.deepcopy.go @@ -74,6 +74,13 @@ func (in *DNSServiceConfigList) DeepCopyObject() runtime.Object { func (in *DNSServiceConfigSpec) DeepCopyInto(out *DNSServiceConfigSpec) { *out = *in in.ExternalDNSSource.DeepCopyInto(&out.ExternalDNSSource) + if in.SecretsToCopy != nil { + in, out := &in.SecretsToCopy, &out.SecretsToCopy + *out = make([]SecretCopy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.HelmReleaseReconciliationInterval != nil { in, out := &in.HelmReleaseReconciliationInterval, &out.HelmReleaseReconciliationInterval *out = new(v1.Duration) @@ -146,11 +153,6 @@ func (in *ExternalDNSSource) DeepCopyInto(out *ExternalDNSSource) { *out = new(apiv1.OCIRepositorySpec) (*in).DeepCopyInto(*out) } - if in.CopyAuthSecret != nil { - in, out := &in.CopyAuthSecret, &out.CopyAuthSecret - *out = new(SecretCopy) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDNSSource. diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 783e7b6..5f8670a 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -15,6 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -248,7 +249,12 @@ func (r *ClusterReconciler) handleCreateOrUpdate(ctx context.Context, c *cluster } rr.AccessRequest = ar - rr = r.deployAuthSecret(ctx, c, expectedLabels, rr) + rr, copied := r.copySecrets(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } + // remove any secrets that were copied in a previous run but are no longer configured to be copied + rr = r.uncopySecrets(ctx, c, expectedLabels, rr, copied) if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { return rr } @@ -288,7 +294,7 @@ func (r *ClusterReconciler) handleDelete(ctx context.Context, c *clustersv1alpha return rr } - rr = r.undeployAuthSecret(ctx, c, expectedLabels, rr) + rr = r.uncopySecrets(ctx, c, expectedLabels, rr, nil) if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { return rr } @@ -319,50 +325,52 @@ func (r *ClusterReconciler) handleDelete(ctx context.Context, c *clustersv1alpha return rr } -// deployAuthSecret copies the auth secret (for access to the helm chart source) into the Cluster namespace if configured. -func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { +// copySecrets copies the configured secrets into the Cluster namespace. +// Returns a list of the names of the copied secrets. +func (r *ClusterReconciler) copySecrets(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) (ReconcileResult, sets.Set[string]) { log := logging.FromContextOrPanic(ctx) + copied := sets.New[string]() - // copy secret if configured - if rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret != nil { + // copy secrets if configured + for i, stc := range rr.ProviderConfig.Spec.SecretsToCopy { source := &corev1.Secret{} - source.Name = rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Source.Name + source.Name = stc.Source.Name source.Namespace = r.ProviderNamespace - log.Debug("Auth secret copying configured, getting source secret", "sourceNamespace", source.Namespace, "sourceName", source.Name) + log.Debug("Secret copying configured, getting source secret", "sourceNamespace", source.Namespace, "sourceName", source.Name, "index", i) if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(source), source); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting source secret '%s/%s': %w", source.Namespace, source.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting source secret '%s/%s' (index: %d): %w", source.Namespace, source.Name, i, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr, copied } // check if target secret already exists target := &corev1.Secret{} target.Name = source.Name - if rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Target != nil && rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Target.Name != "" { - target.Name = rr.ProviderConfig.Spec.ExternalDNSSource.CopyAuthSecret.Target.Name + if stc.Target != nil && stc.Target.Name != "" { + target.Name = stc.Target.Name } target.Namespace = c.Namespace targetExists := true if err := r.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(target), target); err != nil { if !apierrors.IsNotFound(err) { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting target secret '%s/%s': %w", target.Namespace, target.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting target secret '%s/%s' (index: %d): %w", target.Namespace, target.Name, i, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr, copied } targetExists = false } if targetExists { // if target secret exists, verify that it is managed by us - log.Debug("Target secret already exists", "targetNamespace", target.Namespace, "targetName", target.Name) + log.Debug("Target secret already exists", "targetNamespace", target.Namespace, "targetName", target.Name, "index", i) for k, v := range expectedLabels { if v2, ok := target.Labels[k]; !ok || v2 != v { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("target secret '%s/%s' already exists and is not managed by %s controller", target.Namespace, target.Name, ControllerName), clusterconst.ReasonConfigurationProblem) - return rr + rr.ReconcileError = errutils.WithReason(fmt.Errorf("target secret '%s/%s' (index: %d) already exists and is not managed by %s controller", target.Namespace, target.Name, i, ControllerName), clusterconst.ReasonConfigurationProblem) + return rr, copied } } } - log.Debug("Creating or updating target secret", "targetNamespace", target.Namespace, "targetName", target.Name) + log.Debug("Creating or updating target secret", "targetNamespace", target.Namespace, "targetName", target.Name, "index", i) if _, err := controllerutil.CreateOrUpdate(ctx, r.PlatformCluster.Client(), target, func() error { if err := controllerutil.SetOwnerReference(c, target, r.PlatformCluster.Scheme()); err != nil { - return fmt.Errorf("error setting owner reference on target secret '%s/%s': %w", target.Namespace, target.Name, err) + return fmt.Errorf("error setting owner reference on target secret '%s/%s' (index: %d): %w", target.Namespace, target.Name, i, err) } target.Labels = maputils.Merge(target.Labels, source.Labels, expectedLabels) target.Annotations = maputils.Merge(target.Annotations, source.Annotations) @@ -371,14 +379,14 @@ func (r *ClusterReconciler) deployAuthSecret(ctx context.Context, c *clustersv1a target.Type = source.Type return nil }); err != nil { - rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating target secret '%s/%s': %w", target.Namespace, target.Name, err), clusterconst.ReasonPlatformClusterInteractionProblem) - return rr + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error creating or updating target secret '%s/%s' (index: %d): %w", target.Namespace, target.Name, i, err), clusterconst.ReasonPlatformClusterInteractionProblem) + return rr, copied } - - rr.Message = "Successfully copied auth secret into Cluster namespace." + copied.Insert(target.Name) } + rr.Message = fmt.Sprintf("Successfully copied %d secrets into Cluster namespace", len(rr.ProviderConfig.Spec.SecretsToCopy)) - return rr + return rr, copied } // deployHelmChartSource deploys the configured Flux source (HelmRepository, GitRepository, OCIRepository) into the Cluster namespace. @@ -681,9 +689,10 @@ func (r *ClusterReconciler) undeployHelmChartSource(ctx context.Context, c *clus return rr } -// undeployAuthSecret removes all secrets from the Cluster namespace where the labels indicate they were created by this controller for the given Cluster. +// uncopySecrets removes all secrets from the Cluster namespace where the labels indicate they were created by this controller for the given Cluster. +// Secrets listed in 'keep' are not deleted. // It does not wait for their deletion. -func (r *ClusterReconciler) undeployAuthSecret(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult) ReconcileResult { +func (r *ClusterReconciler) uncopySecrets(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult, keep sets.Set[string]) ReconcileResult { log := logging.FromContextOrPanic(ctx) // list existing secrets to detect obsolete ones @@ -693,18 +702,26 @@ func (r *ClusterReconciler) undeployAuthSecret(ctx context.Context, c *clustersv return rr } + deleted := 0 + kept := 0 for i := range existingSecrets.Items { obj := &existingSecrets.Items[i] - log.Info("Deleting auth secret", "resourceName", obj.GetName(), "resourceNamespace", obj.GetNamespace()) + if keep.Has(obj.Name) { + log.Debug("Keeping copied secret", "resourceName", obj.GetName(), "resourceNamespace", obj.GetNamespace()) + kept++ + continue + } + log.Info("Deleting copied secret", "resourceName", obj.GetName(), "resourceNamespace", obj.GetNamespace()) if err := r.PlatformCluster.Client().Delete(ctx, obj); err != nil { if !apierrors.IsNotFound(err) { rr.ReconcileError = errutils.WithReason(fmt.Errorf("error deleting Secret '%s/%s': %w", obj.GetNamespace(), obj.GetName(), err), clusterconst.ReasonPlatformClusterInteractionProblem) return rr } } + deleted++ } - rr.Message = "Deleted all auth secrets for Cluster." + rr.Message = fmt.Sprintf("Deleted %d copied secrets from Cluster namespace, kept %d.", deleted, kept) return rr } diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go index cb042b4..62668e8 100644 --- a/internal/controllers/cluster/controller_test.go +++ b/internal/controllers/cluster/controller_test.go @@ -89,7 +89,7 @@ var _ = Describe("ClusterReconciler", func() { // verify that the correct resources were created expectedLabels := map[string]string{ openmcpconst.ManagedByLabel: managedByValue, - openmcpconst.ManagedPurposeLabel: "cluster-01", + openmcpconst.ManagedPurposeLabel: c1.Name, } // flux source srcs := &fluxsourcev1.OCIRepositoryList{} @@ -98,7 +98,7 @@ var _ = Describe("ClusterReconciler", func() { Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-01"), + "Name": Equal(c1.Name), }))) Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ "URL": Equal("oci://example.org/repo/charts"), @@ -110,7 +110,7 @@ var _ = Describe("ClusterReconciler", func() { Expect(hrs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-01"), + "Name": Equal(c1.Name), }))) Expect(hrs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ "ReleaseName": Equal("external-dns"), @@ -125,23 +125,36 @@ var _ = Describe("ClusterReconciler", func() { Expect(ars.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-01"), + "Name": Equal(c1.Name), }))) Expect(ars.Items[0].Spec.ClusterRef).To(PointTo(MatchFields(IgnoreExtras, Fields{ - "Name": Equal("cluster-01"), + "Name": Equal(c1.Name), "Namespace": Equal("foo"), }))) - // auth secret + // copied secrets (including deletion of the obsolete one) ss := &corev1.SecretList{} Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) - Expect(ss.Items).To(HaveLen(1)) - Expect(ss.Items[0].Name).To(Equal("my-auth-copy")) - Expect(ss.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ - "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), - "Kind": Equal("Cluster"), - "Name": Equal("cluster-01"), - }))) - Expect(ss.Items[0].Data).To(HaveKeyWithValue("key", []byte("value"))) + Expect(ss.Items).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ObjectMeta": MatchFields(IgnoreExtras, Fields{ + "Name": Equal("my-auth-copy"), + }), + "Data": Equal(map[string][]byte{"key": []byte("value")}), + }), + MatchFields(IgnoreExtras, Fields{ + "ObjectMeta": MatchFields(IgnoreExtras, Fields{ + "Name": Equal("my-other-secret"), + }), + "Data": Equal(map[string][]byte{"foo": []byte("bar")}), + }), + )) + for _, s := range ss.Items { + Expect(s.OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal(c1.Name), + })), "secret '%s/%s' does not have the expected owner reference", s.Namespace, s.Name) + } c2 := &clustersv1alpha1.Cluster{} Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-02", Namespace: "bar"}, c2)).To(Succeed()) @@ -152,7 +165,7 @@ var _ = Describe("ClusterReconciler", func() { Expect(rr.RequeueAfter).To(BeZero()) // verify that the correct flux resources were created - expectedLabels[openmcpconst.ManagedPurposeLabel] = "cluster-02" + expectedLabels[openmcpconst.ManagedPurposeLabel] = c2.Name // flux source srcs = &fluxsourcev1.OCIRepositoryList{} Expect(env.Client().List(env.Ctx, srcs, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) @@ -160,7 +173,7 @@ var _ = Describe("ClusterReconciler", func() { Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-02"), + "Name": Equal(c2.Name), }))) Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ "URL": Equal("oci://example.org/repo/charts"), @@ -172,7 +185,7 @@ var _ = Describe("ClusterReconciler", func() { Expect(hrs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-02"), + "Name": Equal(c2.Name), }))) Expect(hrs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ "ReleaseName": Equal("external-dns"), @@ -187,23 +200,35 @@ var _ = Describe("ClusterReconciler", func() { Expect(ars.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-02"), + "Name": Equal(c2.Name), }))) Expect(ars.Items[0].Spec.ClusterRef).To(PointTo(MatchFields(IgnoreExtras, Fields{ - "Name": Equal("cluster-02"), + "Name": Equal(c2.Name), "Namespace": Equal("bar"), }))) - // auth secret - ss = &corev1.SecretList{} + // copied secrets Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) - Expect(ss.Items).To(HaveLen(1)) - Expect(ss.Items[0].Name).To(Equal("my-auth-copy")) - Expect(ss.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ - "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), - "Kind": Equal("Cluster"), - "Name": Equal("cluster-02"), - }))) - Expect(ss.Items[0].Data).To(HaveKeyWithValue("key", []byte("value"))) + Expect(ss.Items).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ObjectMeta": MatchFields(IgnoreExtras, Fields{ + "Name": Equal("my-auth-copy"), + }), + "Data": Equal(map[string][]byte{"key": []byte("value")}), + }), + MatchFields(IgnoreExtras, Fields{ + "ObjectMeta": MatchFields(IgnoreExtras, Fields{ + "Name": Equal("my-other-secret"), + }), + "Data": Equal(map[string][]byte{"foo": []byte("bar")}), + }), + )) + for _, s := range ss.Items { + Expect(s.OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), + "Kind": Equal("Cluster"), + "Name": Equal(c2.Name), + })), "secret '%s/%s' does not have the expected owner reference", s.Namespace, s.Name) + } }) It("should correctly match complex purpose selectors and don't create resources if no purpose selector matches", func() { @@ -292,7 +317,7 @@ var _ = Describe("ClusterReconciler", func() { // verify that the flux resources were created expectedLabels := map[string]string{ openmcpconst.ManagedByLabel: managedByValue, - openmcpconst.ManagedPurposeLabel: "cluster-01", + openmcpconst.ManagedPurposeLabel: c1.Name, } // flux source srcs := &fluxsourcev1.OCIRepositoryList{} @@ -306,10 +331,10 @@ var _ = Describe("ClusterReconciler", func() { ars := &clustersv1alpha1.AccessRequestList{} Expect(env.Client().List(env.Ctx, ars, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) Expect(ars.Items).To(HaveLen(1)) - // auth secret + // copied secrets ss := &corev1.SecretList{} Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) - Expect(ss.Items).To(HaveLen(1)) + Expect(ss.Items).To(HaveLen(2)) // delete Cluster Expect(env.Client().Delete(env.Ctx, c1)).To(Succeed()) @@ -402,7 +427,7 @@ var _ = Describe("ClusterReconciler", func() { // verify that the correct resources were created expectedLabels := map[string]string{ openmcpconst.ManagedByLabel: managedByValue, - openmcpconst.ManagedPurposeLabel: "cluster-01", + openmcpconst.ManagedPurposeLabel: c1.Name, } // flux source srcs := &fluxsourcev1.GitRepositoryList{} @@ -411,7 +436,7 @@ var _ = Describe("ClusterReconciler", func() { Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-01"), + "Name": Equal(c1.Name), }))) Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ "URL": Equal("https://example.org/repo/charts"), @@ -435,7 +460,7 @@ var _ = Describe("ClusterReconciler", func() { // verify that the correct resources were created expectedLabels := map[string]string{ openmcpconst.ManagedByLabel: managedByValue, - openmcpconst.ManagedPurposeLabel: "cluster-01", + openmcpconst.ManagedPurposeLabel: c1.Name, } // flux source srcs := &fluxsourcev1.HelmRepositoryList{} @@ -444,7 +469,7 @@ var _ = Describe("ClusterReconciler", func() { Expect(srcs.Items[0].OwnerReferences).To(ContainElements(MatchFields(IgnoreExtras, Fields{ "APIVersion": Equal(clustersv1alpha1.GroupVersion.String()), "Kind": Equal("Cluster"), - "Name": Equal("cluster-01"), + "Name": Equal(c1.Name), }))) Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ "URL": Equal("https://example.org/repo/charts"), diff --git a/internal/controllers/cluster/testdata/test-01/config.yaml b/internal/controllers/cluster/testdata/test-01/config.yaml index fb9a6cd..5e36f08 100644 --- a/internal/controllers/cluster/testdata/test-01/config.yaml +++ b/internal/controllers/cluster/testdata/test-01/config.yaml @@ -3,15 +3,17 @@ kind: DNSServiceConfig metadata: name: dns-service spec: + secretsToCopy: + - source: + name: my-auth + target: + name: my-auth-copy + - source: + name: my-other-secret externalDNSSource: oci: url: "oci://example.org/repo/charts" interval: 10m - copyAuthSecret: - source: - name: my-auth - target: - name: my-auth-copy externalDNSForPurposes: - name: foo purposeSelector: diff --git a/internal/controllers/cluster/testdata/test-01/secret-02.yaml b/internal/controllers/cluster/testdata/test-01/secret-02.yaml new file mode 100644 index 0000000..64eb9e0 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-01/secret-02.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: my-other-secret + namespace: test +data: + foo: YmFy +type: Opaque diff --git a/internal/controllers/cluster/testdata/test-01/secret-03.yaml b/internal/controllers/cluster/testdata/test-01/secret-03.yaml new file mode 100644 index 0000000..9cacad9 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-01/secret-03.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: obsolete-secret + namespace: foo + labels: + openmcp.cloud/managed-by: dns-service.DNSCluster + openmcp.cloud/managed-purpose: cluster-01 +data: + foo: YXNkZg== +type: Opaque From faf1ea4515c48f16fff951047c619440152553eb Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 24 Sep 2025 11:48:20 +0200 Subject: [PATCH 14/18] implement helm values replace feature --- cmd/platform-service-dns/app/run.go | 2 +- go.mod | 2 +- internal/controllers/cluster/controller.go | 15 +++++-- .../controllers/cluster/controller_test.go | 41 +++++++++++++++---- .../cluster/testdata/test-01/config.yaml | 18 +++----- .../cluster/testdata/test-02/config.yaml | 12 ++---- .../cluster/testdata/test-03/config.yaml | 10 +++-- .../cluster/testdata/test-04/config.yaml | 6 +-- 8 files changed, 66 insertions(+), 40 deletions(-) diff --git a/cmd/platform-service-dns/app/run.go b/cmd/platform-service-dns/app/run.go index 31a4363..d5db0de 100644 --- a/cmd/platform-service-dns/app/run.go +++ b/cmd/platform-service-dns/app/run.go @@ -245,7 +245,7 @@ func (o *RunOptions) Run(ctx context.Context) error { } return fmt.Errorf("error getting DNSServiceConfig '%s': %w", svcCfg.Name, err) } - if err := cluster.NewClusterReconciler(o.PlatformCluster, mgr.GetEventRecorderFor(cluster.ControllerName), o.ProviderName, o.ProviderNamespace).SetupWithManager(mgr); err != nil { + if err := cluster.NewClusterReconciler(o.PlatformCluster, mgr.GetEventRecorderFor(cluster.ControllerName), o.ProviderName, o.ProviderNamespace, o.Environment).SetupWithManager(mgr); err != nil { return fmt.Errorf("unable to add Cluster reconciler to manager: %w", err) } diff --git a/go.mod b/go.mod index d489d66..4d3e628 100644 --- a/go.mod +++ b/go.mod @@ -100,7 +100,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 k8s.io/apiserver v0.34.1 // indirect k8s.io/component-base v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 5f8670a..0dfc9ec 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -11,6 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -53,17 +54,18 @@ type ClusterReconciler struct { eventRecorder record.EventRecorder ProviderName string ProviderNamespace string + Environment string KnownClusters map[types.NamespacedName]struct{} KnownClustersLock *sync.RWMutex - FakeClientMapping map[string]client.Client // this must be nil except for unit tests } -func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.EventRecorder, providerName, providerNamespace string) *ClusterReconciler { +func NewClusterReconciler(platformCluster *clusters.Cluster, recorder record.EventRecorder, providerName, providerNamespace, environment string) *ClusterReconciler { return &ClusterReconciler{ PlatformCluster: platformCluster, eventRecorder: recorder, ProviderName: providerName, ProviderNamespace: providerNamespace, + Environment: environment, KnownClusters: map[types.NamespacedName]struct{}{}, KnownClustersLock: &sync.RWMutex{}, } @@ -542,7 +544,14 @@ func (r *ClusterReconciler) deployHelmRelease(ctx context.Context, c *clustersv1 hr.Spec.ReleaseName = "external-dns" hr.Spec.TargetNamespace = "external-dns" // values - hr.Spec.Values = rr.Config.HelmValues.DeepCopy() + values := string(rr.Config.HelmValues.Raw) + // at some point '<' and '>' get escaped and we have to match the escaped version here + values = strings.ReplaceAll(values, fmt.Sprintf("%sprovider.namespace%s", "\\u003c", "\\u003e"), r.ProviderNamespace) + values = strings.ReplaceAll(values, fmt.Sprintf("%sprovider.name%s", "\\u003c", "\\u003e"), r.ProviderName) + values = strings.ReplaceAll(values, fmt.Sprintf("%senvironment%s", "\\u003c", "\\u003e"), r.Environment) + values = strings.ReplaceAll(values, fmt.Sprintf("%scluster.namespace%s", "\\u003c", "\\u003e"), c.Namespace) + values = strings.ReplaceAll(values, fmt.Sprintf("%scluster.name%s", "\\u003c", "\\u003e"), c.Name) + hr.Spec.Values = &apiextensionsv1.JSON{Raw: []byte(values)} // install configuration if hr.Spec.Install == nil { hr.Spec.Install = &fluxhelmv2.Install{} diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go index 62668e8..5e70c1b 100644 --- a/internal/controllers/cluster/controller_test.go +++ b/internal/controllers/cluster/controller_test.go @@ -14,6 +14,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/yaml" fluxhelmv2 "github.com/fluxcd/helm-controller/api/v2" fluxsourcev1 "github.com/fluxcd/source-controller/api/v1" @@ -35,6 +36,7 @@ const ( providerName = "dns-service" providerNamespace = "test" + environment = "default" managedByValue = providerName + "." + cluster.ControllerName ) @@ -46,13 +48,7 @@ func defaultTestSetup(testDirPathSegments ...string) *testutils.Environment { WithInitObjectPath(testDirPathSegments...). WithDynamicObjectsWithStatus(&clustersv1alpha1.AccessRequest{}). WithReconcilerConstructor(func(c client.Client) reconcile.Reconciler { - cRec := cluster.NewClusterReconciler(clusters.NewTestClusterFromClient(platformCluster, c), nil, providerName, providerNamespace) - cRec.FakeClientMapping = map[string]client.Client{ - "cluster-01": nil, - "cluster-02": nil, - "cluster-03": nil, - } - return cRec + return cluster.NewClusterReconciler(clusters.NewTestClusterFromClient(platformCluster, c), nil, providerName, providerNamespace, environment) }). Build() @@ -476,6 +472,37 @@ var _ = Describe("ClusterReconciler", func() { })) }) + It("should replace the special keywords in the values correctly", func() { + env := defaultTestSetup("testdata", "test-03") + + cfg := &dnsv1alpha1.DNSServiceConfig{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: providerName}, cfg)).To(Succeed()) + + c1 := &clustersv1alpha1.Cluster{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "cluster-01", Namespace: "foo"}, c1)).To(Succeed()) + rr := env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeNumerically(">", 0)) + fakeAccessRequestReadiness(env, c1) + rr = env.ShouldReconcile(testutils.RequestFromObject(c1)) + Expect(rr.RequeueAfter).To(BeZero()) + + // verify that the correct resources were created + expectedLabels := map[string]string{ + openmcpconst.ManagedByLabel: managedByValue, + openmcpconst.ManagedPurposeLabel: c1.Name, + } + hrs := &fluxhelmv2.HelmReleaseList{} + Expect(env.Client().List(env.Ctx, hrs, client.InNamespace(c1.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + Expect(hrs.Items).To(HaveLen(1)) + valueData := map[string]any{} + Expect(yaml.Unmarshal(hrs.Items[0].Spec.Values.Raw, &valueData)).To(Succeed()) + Expect(valueData).To(HaveKeyWithValue("clusterName", c1.Name)) + Expect(valueData).To(HaveKeyWithValue("clusterNamespace", c1.Namespace)) + Expect(valueData).To(HaveKeyWithValue("environment", environment)) + Expect(valueData).To(HaveKeyWithValue("providerName", providerName)) + Expect(valueData).To(HaveKeyWithValue("providerNamespace", providerNamespace)) + }) + }) func fakeAccessRequestReadiness(env *testutils.Environment, c *clustersv1alpha1.Cluster) { diff --git a/internal/controllers/cluster/testdata/test-01/config.yaml b/internal/controllers/cluster/testdata/test-01/config.yaml index 5e36f08..3dba821 100644 --- a/internal/controllers/cluster/testdata/test-01/config.yaml +++ b/internal/controllers/cluster/testdata/test-01/config.yaml @@ -18,17 +18,11 @@ spec: - name: foo purposeSelector: name: foo - helmValues: | - { - "foo": "foo" - } + helmValues: + foo: foo - name: asdf - helmValues: | - { - "foo": "bar" - } + helmValues: + foo: bar - name: qwer - helmValues: | - { - "foo": "baz" - } + helmValues: + foo: baz diff --git a/internal/controllers/cluster/testdata/test-02/config.yaml b/internal/controllers/cluster/testdata/test-02/config.yaml index 184dd7d..0f740f0 100644 --- a/internal/controllers/cluster/testdata/test-02/config.yaml +++ b/internal/controllers/cluster/testdata/test-02/config.yaml @@ -18,16 +18,12 @@ spec: and: - name: foo - name: bar - helmValues: | - { - "foo": "baz" - } + helmValues: + foo: baz - name: foobar purposeSelector: and: - name: foo - name: foobar - helmValues: | - { - "foo": "foo" - } + helmValues: + foo: foo diff --git a/internal/controllers/cluster/testdata/test-03/config.yaml b/internal/controllers/cluster/testdata/test-03/config.yaml index fc72a13..51c0a3f 100644 --- a/internal/controllers/cluster/testdata/test-03/config.yaml +++ b/internal/controllers/cluster/testdata/test-03/config.yaml @@ -11,7 +11,9 @@ spec: - name: foo purposeSelector: name: foo - helmValues: | - { - "foo": "foo" - } + helmValues: + providerNamespace: + providerName: + environment: + clusterNamespace: + clusterName: diff --git a/internal/controllers/cluster/testdata/test-04/config.yaml b/internal/controllers/cluster/testdata/test-04/config.yaml index 1990001..8c737d4 100644 --- a/internal/controllers/cluster/testdata/test-04/config.yaml +++ b/internal/controllers/cluster/testdata/test-04/config.yaml @@ -11,7 +11,5 @@ spec: - name: foo purposeSelector: name: foo - helmValues: | - { - "foo": "foo" - } + helmValues: + foo: foo From e5aeaf3267d484c061ad8cafe50a3c8ac8286285 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 24 Sep 2025 13:29:30 +0200 Subject: [PATCH 15/18] make purposeSelector behavior more consistent --- api/dns/v1alpha1/config_types.go | 2 +- .../testdata/purposeselector/test-07.yaml | 24 +++++++++++++++++++ .../testdata/purposeselector/test-08.yaml | 24 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-07.yaml create mode 100644 internal/controllers/cluster/testdata/purposeselector/test-08.yaml diff --git a/api/dns/v1alpha1/config_types.go b/api/dns/v1alpha1/config_types.go index 8e070fc..c51f8f7 100644 --- a/api/dns/v1alpha1/config_types.go +++ b/api/dns/v1alpha1/config_types.go @@ -178,5 +178,5 @@ func requirementMatches(r *PurposeSelectorRequirement, purposes []string, seenRe } return false } - return false + return true } diff --git a/internal/controllers/cluster/testdata/purposeselector/test-07.yaml b/internal/controllers/cluster/testdata/purposeselector/test-07.yaml new file mode 100644 index 0000000..d3305a4 --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-07.yaml @@ -0,0 +1,24 @@ +purposeSelector: {} + +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - bar + f: + - foobar + +expected: +- a +- b +- c +- d +- e +- f \ No newline at end of file diff --git a/internal/controllers/cluster/testdata/purposeselector/test-08.yaml b/internal/controllers/cluster/testdata/purposeselector/test-08.yaml new file mode 100644 index 0000000..dc934af --- /dev/null +++ b/internal/controllers/cluster/testdata/purposeselector/test-08.yaml @@ -0,0 +1,24 @@ +purposeSelector: + and: + - name: foo + - {} + +purposeSets: + a: + - foo + b: + - bar + - foo + c: + - foo + - foo + d: [] + e: + - bar + f: + - foobar + +expected: +- a +- b +- c From e5edaf06ef9ac4756e2ecbe9dbc901b3a9ace4dc Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 24 Sep 2025 14:02:36 +0200 Subject: [PATCH 16/18] add documentation --- README.md | 25 +++++++- Taskfile.yaml | 2 +- docs/README.md | 7 +++ docs/config/.docnames | 3 + docs/config/dns-service-config.md | 99 +++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/config/.docnames create mode 100644 docs/config/dns-service-config.md diff --git a/README.md b/README.md index b616e5a..40485b4 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,32 @@ ## About this project -Platform Service DNS discovers endpoints of remote services +PlatformService DNS is a `PlatformService` as described in [the OpenMCP Architecture Docs](https://github.com/openmcp-project/docs/blob/main/architecture/general/open-mcp-landscape-overview.md). + +It is a k8s controller that reconciles `Cluster` resources (from our [Cluster API](https://github.com/openmcp-project/docs/blob/main/adrs/cluster-api.md)) and deploys the [external-dns](https://github.com/kubernetes-sigs/external-dns) operator with a configuration depending on the `Cluster`'s purpose(s). The main goal of the service is to help setting up cross-cluster DNS routing for dynamically managed clusters, especially to enable `ValidatingWebhookConfiguration` resources pointing to webhooks served on other clusters. ## Requirements and Setup -*Insert a short description what is required to get your project running...* +In combination with the [openMCP Operator](https://github.com/openmcp-project/openmcp-operator), this controller can be deployed via a simple k8s resource: +```yaml +apiVersion: openmcp.cloud/v1alpha1 +kind: PlatformService +metadata: + name: dns +spec: + image: "ghcr.io/openmcp-project/images/platform-service-dns:v0.1.0" +``` + +To run it locally, run +```shell +go run ./cmd/platform-service-dns/main.go init --environment default --provider-name dns --kubeconfig path/to/kubeconfig +``` +to deploy the CRDs that are required for the controller and then +```shell +go run ./cmd/platform-service-dns/main.go run --environment default --provider-name dns --kubeconfig path/to/kubeconfig +``` + +Note that a `DNSServiceConfig` resources is required for the platform service. See the [documentation](docs/README.md) for further details regarding resources and configuration. ## Support, Feedback, Contributing diff --git a/Taskfile.yaml b/Taskfile.yaml index 6916a2f..a6cfb5d 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -12,7 +12,7 @@ includes: CODE_DIRS: '{{.ROOT_DIR}}/cmd/... {{.ROOT_DIR}}/internal/... {{.ROOT_DIR}}/api/...' COMPONENTS: platform-service-dns REPO_URL: 'https://github.com/openmcp-project/platform-service-dns' - GENERATE_DOCS_INDEX: "false" + GENERATE_DOCS_INDEX: "true" CHART_COMPONENTS: "[]" CRDS_COMPONENTS: platform-service-dns CRDS_PATH: '{{.ROOT_DIR}}/api/crds/manifests' diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2dbeb3b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ + +# Documentation Index + +## Configuration + +- [DNS Service Configuration](config/dns-service-config.md) + diff --git a/docs/config/.docnames b/docs/config/.docnames new file mode 100644 index 0000000..7ba1d2d --- /dev/null +++ b/docs/config/.docnames @@ -0,0 +1,3 @@ +{ + "header": "Configuration" +} \ No newline at end of file diff --git a/docs/config/dns-service-config.md b/docs/config/dns-service-config.md new file mode 100644 index 0000000..af12d33 --- /dev/null +++ b/docs/config/dns-service-config.md @@ -0,0 +1,99 @@ +# DNS Service Configuration + +The _PlatformService DNS_ requires configuration in form of a custom resource that is registered during the `init` step of the operator. +The `DNSServiceConfig` resource is cluster-scoped and must have the same name as the PlatformService it is meant for. + +```yaml +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns + namespace: openmcp-system +spec: + externalDNSSource: + chartName: charts/external-dns # path to the external-dns helm chart within the chosen repository + git: + url: https://github.com/kubernetes-sigs/external-dns + interval: 1h + ref: + tag: v0.18.0 + + externalDNSForPurposes: + - name: my-identifier # optional, for logging purposes only + purposeSelector: + and: + - or: + - name: foo + - name: bar + - name: asdf + helmValues: + foo: bar + asdf: qwer +``` + +#### Helm Chart Source + +`spec.externalDNSSource` is required and describes where to find the helm chart for the [external-dns](https://github.com/kubernetes-sigs/external-dns) controller. Next to `chartName`, it has to contain exactly one of either `git`, `helm` or `oci`. The content of this field will then be used as `spec` for a Flux [`GitRepository`](https://fluxcd.io/flux/components/source/gitrepositories/), [`HelmRepository`](https://fluxcd.io/flux/components/source/helmrepositories/), or [`OCIRepository`](https://fluxcd.io/flux/components/source/ocirepositories/), respectively. + +While the `HelmRepository` spec already contains a reference to a branch or tag, for `helm` and `oci`, the desired version can be specified by appending it to `chartName` with an `@` as separator, e.g. `chartName: charts/external-dns@v1.2.3`. The version is otherwise assumed to be `latest`. + +#### Purpose Mapping + +`spec.externalDNSForPurposes` maps purpose selectors to helm values for the external-dns deployment. Its `name` is optional and just used for better log and error messages. + +`helmValues` contains the values to be passed into the helm release. They are simply forwarded, but a few keywords are replaced with specific values: +- `` resolves to the name of the `PlatformService`. +- `` resolves to the namespace the operator pod is running in. +- `` resolves to the PlatformService's environment. +- `` resolves to the name of the `Cluster` resource the deployment belongs to. +- `` resolves to the namespace of the `Cluster` resource the deployment belongs to. + +The `purposeSelector` defines which `Cluster` resources should get the `external-dns` deployment. Because a `Cluster` can have multiple purposes, a simple mapping from a purpose to the configuration would not suffice. Therefore, the `purposeSelector` field is a recursive struct that allows to specify complex purpose selectors. Exactly one of the allowed fields `name`, `and`, `or`, and `not` must be set: +- The `name` field takes a string. If set, the selector matches if the shoot purposes contain the purpose with the given name. +- `not` takes a purpose selector and negates its result. +- `and` takes a list of purpose selectors and matches only if all of them match. +- Similarly, `or` also takes a list of purpose selectors, but matches if at least one of them matches. +- An empty purpose selector always matches. + +> It is not validated that only one of the mentioned fields is set. If multiple ones are set, only one of them will be evaluated and the rest will be ignored. + +Whenever a `Cluster` is reconciled, the selectors are applied in the order they are specified in. The first mapping where the selector matches decides the configuration with which `external-dns` is deployed. If no selector matches, `external-dns` is not deployed on the respective `Cluster`. + +##### Examples + +Here are a few examples for purpose selectors and what they match: + +###### Example 1 +```yaml +purposeSelector: + name: foo +``` +Probably the most common use cases: Matches every `Cluster` where `spec.purposes` contains `foo`. + +###### Example 2 +```yaml +purposeSelector: {} +``` +Matches all `Cluster` resources. + +###### Example 3 +```yaml +purposeSelector: + and: + - or: + - name: foo + - name: bar + - not: + and: + - name: foo + - name: bar +``` +Basically an `XOR`, matches all `Cluster`s that have either `foo` or `bar` among their purposes, but not both of them. + +###### Example 4 +```yaml +purposeSelector: + not: + name: foo +``` +Matches all `Cluster` resources that do not have `foo` in their purpose list. From 99951157c712cea841b523d618c5547c1d39ddec Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 24 Sep 2025 14:03:32 +0200 Subject: [PATCH 17/18] bump build submodule --- api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml | 6 +++++- hack/common | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml index abad5ea..bab388d 100644 --- a/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml +++ b/api/crds/manifests/dns.openmcp.cloud_dnsserviceconfigs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 + controller-gen.kubebuilder.io/version: v0.19.0 labels: openmcp.cloud/cluster: platform name: dnsserviceconfigs.dns.openmcp.cloud @@ -631,6 +631,10 @@ spec: required: - chartName type: object + x-kubernetes-validations: + - message: exactly one of the fields in [helm git oci] must be set + rule: '[has(self.helm),has(self.git),has(self.oci)].filter(x,x==true).size() + == 1' helmReleaseReconciliationInterval: description: |- HelmReleaseReconciliationInterval is the interval at which the HelmRelease for external-dns is reconciled. diff --git a/hack/common b/hack/common index acbe270..31450a1 160000 --- a/hack/common +++ b/hack/common @@ -1 +1 @@ -Subproject commit acbe27054cdc9d5ef16b443ff9d910fd31863f32 +Subproject commit 31450a115bbe0e08d51a5d41d769174d260238d2 From ca89e73cf4afa22c68c716d3f510693fa4da6509 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 24 Sep 2025 16:08:45 +0200 Subject: [PATCH 18/18] implement review feedback --- internal/controllers/cluster/controller.go | 24 +++++++--------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 0dfc9ec..1703856 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -10,7 +10,6 @@ import ( "time" corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -80,8 +79,6 @@ type ReconcileResult struct { ReconcileError errutils.ReasonableError // Config is the selected configuration that was applied to the Cluster, if it could be determined. Config *dnsv1alpha1.ExternalDNSPurposeConfig - // ConfigIndex is the index of the selected configuration in the DNSServiceConfig, or -1 if no configuration was selected. - ConfigIndex int // SourceKind is the kind of Flux source that was deployed (HelmRepository, GitRepository, OCIRepository), if any. SourceKind string // AccessRequest is the AccessRequest that provides access to the Cluster, if access was successfully obtained. @@ -170,13 +167,11 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, c *clustersv1alpha1.C if cfg.PurposeSelector.Matches(c.Spec.Purposes) { log.Info("Found configuration with matching purpose selector", "configName", cfg.Name, "configIndex", i) rr.Config = &rr.ProviderConfig.Spec.ExternalDNSForPurposes[i] - rr.ConfigIndex = i break } } if rr.Config == nil { log.Info("No configuration with matching purpose selector found") - rr.ConfigIndex = -1 } if c.DeletionTimestamp.IsZero() && rr.Config != nil { @@ -219,15 +214,10 @@ func (r *ClusterReconciler) handleCreateOrUpdate(ctx context.Context, c *cluster Namespace: c.Namespace, } ar.Spec.Token = &clustersv1alpha1.TokenConfig{ - Permissions: []clustersv1alpha1.PermissionsRequest{ + RoleRefs: []commonapi.RoleRef{ { - Rules: []rbacv1.PolicyRule{ // TODO: restrict permissions - { - APIGroups: []string{"*"}, - Resources: []string{"*"}, - Verbs: []string{"*"}, - }, - }, + Kind: "ClusterRole", + Name: "cluster-admin", }, }, } @@ -256,7 +246,7 @@ func (r *ClusterReconciler) handleCreateOrUpdate(ctx context.Context, c *cluster return rr } // remove any secrets that were copied in a previous run but are no longer configured to be copied - rr = r.uncopySecrets(ctx, c, expectedLabels, rr, copied) + rr = r.removeSecrets(ctx, c, expectedLabels, rr, copied) if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { return rr } @@ -296,7 +286,7 @@ func (r *ClusterReconciler) handleDelete(ctx context.Context, c *clustersv1alpha return rr } - rr = r.uncopySecrets(ctx, c, expectedLabels, rr, nil) + rr = r.removeSecrets(ctx, c, expectedLabels, rr, nil) if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { return rr } @@ -698,10 +688,10 @@ func (r *ClusterReconciler) undeployHelmChartSource(ctx context.Context, c *clus return rr } -// uncopySecrets removes all secrets from the Cluster namespace where the labels indicate they were created by this controller for the given Cluster. +// removeSecrets removes all secrets from the Cluster namespace where the labels indicate they were created by this controller for the given Cluster. // Secrets listed in 'keep' are not deleted. // It does not wait for their deletion. -func (r *ClusterReconciler) uncopySecrets(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult, keep sets.Set[string]) ReconcileResult { +func (r *ClusterReconciler) removeSecrets(ctx context.Context, c *clustersv1alpha1.Cluster, expectedLabels map[string]string, rr ReconcileResult, keep sets.Set[string]) ReconcileResult { log := logging.FromContextOrPanic(ctx) // list existing secrets to detect obsolete ones