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/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 46cd644..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 @@ -53,9 +53,21 @@ spec: description: ExternalDNSPurposeConfig holds a purpose selector and the DNS configuration to apply if the selector matches. properties: - config: - description: HelmValues are the helm values to deploy external-dns - with, if the purpose selector matches. + 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. + 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: |- Name is an optional name. @@ -67,65 +79,33 @@ 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: - - config + - helmValues type: object type: array externalDNSSource: description: ExternalDNSSource is the source of the external-dns helm chart. properties: - copyAuthSecret: + chartName: 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: ObjectReference is a reference to an object in - any namespace. - properties: - name: - description: Name is the name of the object. - type: string - namespace: - description: Namespace is the namespace of the object. - type: string - required: - - name - - namespace - type: object - target: - description: ObjectReference is a reference to an object in - any namespace. - properties: - name: - description: Name is the name of the object. - type: string - namespace: - description: Namespace is the namespace of the object. - type: string - required: - - name - - namespace - type: object - required: - - source - - target - type: object + 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 git: description: |- GitRepositorySpec specifies the required configuration to produce an @@ -648,59 +628,63 @@ 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 - selector: + - 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: |- - 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. + 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 + 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: - key: - description: key is the label key that the selector applies - to. - type: string - operator: + name: + default: "" description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. + 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 - values: + 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: |- - 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 + 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 - 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 + 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 abc5433..c51f8f7 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" @@ -13,14 +13,20 @@ 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"` + // 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. + // +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. @@ -31,19 +37,23 @@ 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:ExactlyOneOf=helm;git;oci type ExternalDNSSource struct { - Helm *fluxv1.HelmRepositorySpec `json:"helm,omitempty"` - Git *fluxv1.GitRepositorySpec `json:"git,omitempty"` - OCI *fluxv1.OCIRepositorySpec `json:"oci,omitempty"` - CopyAuthSecret *SecretCopy `json:"copyAuthSecret,omitempty"` + // 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"` } // 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. @@ -52,13 +62,27 @@ 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. + // 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 runtime.RawExtension `json:"config"` + HelmValues *apiextensionsv1.JSON `json:"helmValues"` } // PurposeSelector is a selector to match against the list of purposes of a Cluster. @@ -68,17 +92,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 @@ -108,6 +138,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{}) } @@ -126,6 +159,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) { @@ -142,8 +178,5 @@ func requirementMatches(r *PurposeSelectorRequirement, purposes []string, seenRe } return false } - if r.Not != nil { - return !requirementMatches(r.Not, purposes, seenRequirements) - } - return false + return true } diff --git a/api/dns/v1alpha1/zz_generated.deepcopy.go b/api/dns/v1alpha1/zz_generated.deepcopy.go index 21079ef..ad90150 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. @@ -72,12 +73,19 @@ 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.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) + **out = **in + } if in.ExternalDNSForPurposes != nil { in, out := &in.ExternalDNSForPurposes, &out.ExternalDNSForPurposes *out = make([]ExternalDNSPurposeConfig, len(*in)) @@ -105,7 +113,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. @@ -136,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. @@ -209,7 +221,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/go.mod b/api/go.mod index 190bd13..22b0742 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 @@ -11,16 +12,57 @@ 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.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 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/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 827b2cc..9f6ec8c 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,62 +1,127 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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.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= @@ -72,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= @@ -111,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/api/install/install.go b/api/install/install.go index eb4cfe4..306f384 100644 --- a/api/install/install.go +++ b/api/install/install.go @@ -6,6 +6,11 @@ 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" + + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + dnsv1alpha1 "github.com/openmcp-project/platform-service-dns/api/dns/v1alpha1" ) @@ -21,6 +26,9 @@ 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)) return scheme } diff --git a/cmd/platform-service-dns/app/app.go b/cmd/platform-service-dns/app/app.go index df60e42..a62490b 100644 --- a/cmd/platform-service-dns/app/app.go +++ b/cmd/platform-service-dns/app/app.go @@ -32,8 +32,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 +42,7 @@ type SharedOptions struct { PlatformCluster *clusters.Cluster // fields filled in Complete() - Log logging.Logger - ProviderName string + Log logging.Logger } func (o *SharedOptions) AddPersistentFlags(cmd *cobra.Command) { @@ -52,6 +52,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 +61,8 @@ 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") } // build logger 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 cb818a1..d5db0de 100644 --- a/cmd/platform-service-dns/app/run.go +++ b/cmd/platform-service-dns/app/run.go @@ -4,19 +4,27 @@ 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" ) var setupLog logging.Logger @@ -76,6 +84,7 @@ type RunOptions struct { MetricsServerOptions metricsserver.Options MetricsCertWatcher *certwatcher.CertWatcher WebhookCertWatcher *certwatcher.CertWatcher + ProviderNamespace string } func (o *RunOptions) AddFlags(cmd *cobra.Command) { @@ -98,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()) @@ -184,10 +198,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) @@ -198,7 +211,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, @@ -221,7 +234,20 @@ func (o *RunOptions) Run(ctx context.Context) error { return fmt.Errorf("unable to create manager: %w", err) } - // TODO setup controllers + // 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 + if err := o.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(svcCfg), svcCfg); err != nil { + if apierrors.IsNotFound(err) { + return fmt.Errorf("DNSServiceConfig '%s' not found: %w", 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, o.Environment).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/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. diff --git a/go.mod b/go.mod index aade720..4d3e628 100644 --- a/go.mod +++ b/go.mod @@ -5,21 +5,24 @@ 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.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.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.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 ) 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 @@ -30,7 +33,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 @@ -39,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 @@ -53,13 +58,15 @@ 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 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 @@ -71,18 +78,20 @@ 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 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 + 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 @@ -91,9 +100,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 + 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..471bb96 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,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.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.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.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= @@ -126,8 +132,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 +186,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 +246,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/hack/common b/hack/common index acbe270..31450a1 160000 --- a/hack/common +++ b/hack/common @@ -1 +1 @@ -Subproject commit acbe27054cdc9d5ef16b443ff9d910fd31863f32 +Subproject commit 31450a115bbe0e08d51a5d41d769174d260238d2 diff --git a/internal/controllers/cluster/controller.go b/internal/controllers/cluster/controller.go index 8fb50a1..1703856 100644 --- a/internal/controllers/cluster/controller.go +++ b/internal/controllers/cluster/controller.go @@ -4,17 +4,30 @@ import ( "context" "fmt" "maps" + "slices" "strings" + "sync" + "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" + "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" "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" - 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" @@ -33,60 +46,83 @@ import ( ) const ControllerName = "DNSCluster" +const defaultRequeueAfterDuration = 30 * time.Second type ClusterReconciler struct { PlatformCluster *clusters.Cluster - Config *dnsv1alpha1.DNSServiceConfig eventRecorder record.EventRecorder ProviderName string ProviderNamespace string + Environment string + KnownClusters map[types.NamespacedName]struct{} + KnownClustersLock *sync.RWMutex } -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, environment string) *ClusterReconciler { return &ClusterReconciler{ PlatformCluster: platformCluster, eventRecorder: recorder, ProviderName: providerName, ProviderNamespace: providerNamespace, - Config: cfg, + Environment: environment, + KnownClusters: map[types.NamespacedName]struct{}{}, + KnownClustersLock: &sync.RWMutex{}, } } 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 + // 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 + // 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) { 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{} + r.removeKnownClusterRaw(req.Name, req.Namespace) + 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 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 +} + +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] @@ -110,145 +146,224 @@ func (r *ClusterReconciler) reconcile(ctx context.Context, req reconcile.Request rr := ReconcileResult{} expectedLabels := map[string]string{ - openmcpconst.ManagedByLabel: ControllerName, + openmcpconst.ManagedByLabel: fmt.Sprintf("%s.%s", r.ProviderName, ControllerName), openmcpconst.ManagedPurposeLabel: c.Name, } - if c.DeletionTimestamp.IsZero() { - // 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 - } + // load DNSServiceConfig resource + rr.ProviderConfig = &dnsv1alpha1.DNSServiceConfig{} + rr.ProviderConfig.Name = r.ProviderName + 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' not found", rr.ProviderConfig.Name), clusterconst.ReasonConfigurationProblem) + } else { + rr.ReconcileError = errutils.WithReason(fmt.Errorf("error getting DNSServiceConfig '%s': %w", 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 { - 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 - } + // iterate over configurations with purpose selectors and choose the first matching one + for i, cfg := range rr.ProviderConfig.Spec.ExternalDNSForPurposes { + 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] + break } - if rr.Config == nil { - log.Info("No configuration with matching purpose selector found") - rr.ConfigIndex = -1 + } + if rr.Config == nil { + log.Info("No configuration with matching purpose selector found") + } + + if c.DeletionTimestamp.IsZero() && rr.Config != nil { + // CREATE/UPDATE + rr = r.handleCreateOrUpdate(ctx, c, expectedLabels, rr) + } else { + // DELETE + rr = r.handleDelete(ctx, c, expectedLabels, rr) + } + + 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 } - - // 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) + } + 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) } - // TODO: use access - _, ar, err := accessMgr.WaitForClusterAccess(ctx, localName, nil, &commonapi.ObjectReference{ + ar.Labels = maputils.Merge(ar.Labels, expectedLabels) + ar.Spec.ClusterRef = &commonapi.ObjectReference{ Name: c.Name, Namespace: c.Namespace, - }, accesslib.ReferenceToCluster, []clustersv1alpha1.PermissionsRequest{ - { - Rules: []rbacv1.PolicyRule{ // TODO: restrict permissions - { - APIGroups: []string{"*"}, - Resources: []string{"*"}, - Verbs: []string{"*"}, - }, + } + ar.Spec.Token = &clustersv1alpha1.TokenConfig{ + RoleRefs: []commonapi.RoleRef{ + { + Kind: "ClusterRole", + Name: "cluster-admin", }, }, - }) - 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 } + 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 - // inject labels into AccessRequest - if err := ctrlutils.EnsureLabel(ctx, r.PlatformCluster.Client(), ar, 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 - } + 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.removeSecrets(ctx, c, expectedLabels, rr, copied) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - rr = r.deployAuthSecret(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil { - return rr - } + rr = r.deployHelmChartSource(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - rr = r.deployFluxSource(ctx, c, expectedLabels, rr) - if rr.ReconcileError != nil { - return rr - } + rr = r.deployHelmRelease(ctx, c, expectedLabels, rr) + if rr.ReconcileError != nil || rr.Result.RequeueAfter > 0 { + return rr + } - // TODO: deploy Flux Kustomization to deploy external-dns with selected config onto Cluster - } else { - // DELETE - log.Info("Cluster marked for deletion, cleaning up DNS configuration") + rr.Message = "Successfully triggered deployment of external-dns on Cluster" + return rr +} - // TODO: clean up deployed resources by removing Flux resources with matching labels +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 + } - // TODO: clean up copied auth secret if it was copied + log.Info("Cleaning up DNS configuration for Cluster, either because it is being deleted or no configuration matches anymore") - // 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 - } + 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.removeSecrets(ctx, c, expectedLabels, rr, nil) + 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 + } + } + + // 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 } -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 r.Config.Spec.ExternalDNSSource.CopyAuthSecret != nil { + // copy secrets if configured + for i, stc := range rr.ProviderConfig.Spec.SecretsToCopy { source := &corev1.Secret{} - source.Name = r.Config.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 r.Config.Spec.ExternalDNSSource.CopyAuthSecret.Target != nil && r.Config.Spec.ExternalDNSSource.CopyAuthSecret.Target.Name != "" { - target.Name = r.Config.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' (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) target.Data = make(map[string][]byte, len(source.Data)) @@ -256,109 +371,115 @@ 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 } + 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 } -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 } 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{} + if rr.ProviderConfig.Spec.ExternalDNSSource.Helm != nil { + 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() + helmRepo.Spec = *rr.ProviderConfig.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 })...) - } else if r.Config.Spec.ExternalDNSSource.Git != nil { - fluxSource = &fluxv1.GitRepository{} + 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 rr.ProviderConfig.Spec.ExternalDNSSource.Git != nil { + 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() + gitRepo.Spec = *rr.ProviderConfig.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 })...) - } else if r.Config.Spec.ExternalDNSSource.OCI != nil { - fluxSource = &fluxv1.OCIRepository{} + 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 rr.ProviderConfig.Spec.ExternalDNSSource.OCI != nil { + 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() + ociRepo.Spec = *rr.ProviderConfig.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 { + 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 { - 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 @@ -372,5 +493,302 @@ func (r *ClusterReconciler) deployFluxSource(ctx context.Context, c *clustersv1a } } + rr.Message = fmt.Sprintf("Successfully created or updated helm chart source (%s).", rr.SourceKind) + 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 { + // 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 + 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" + hr.Spec.TargetNamespace = "external-dns" + // values + 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{} + } + 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 rr.ProviderConfig.Spec.HelmReleaseReconciliationInterval != nil { + hr.Spec.Interval = *rr.ProviderConfig.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 + } + + 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 +} + +// 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) 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 + 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 + } + + deleted := 0 + kept := 0 + for i := range existingSecrets.Items { + obj := &existingSecrets.Items[i] + 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 = fmt.Sprintf("Deleted %d copied secrets from Cluster namespace, kept %d.", deleted, kept) + 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 +} + +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). + // 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), + ctrlutils.GotAnnotationPredicate(dnsv1alpha1.OperationAnnotation, openmcpconst.OperationAnnotationValueReconcile), + ctrlutils.LostAnnotationPredicate(dnsv1alpha1.OperationAnnotation, openmcpconst.OperationAnnotationValueIgnore), + ), + predicate.Not( + 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 + 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, ""), + ))). + Complete(r) +} diff --git a/internal/controllers/cluster/controller_test.go b/internal/controllers/cluster/controller_test.go new file mode 100644 index 0000000..5e70c1b --- /dev/null +++ b/internal/controllers/cluster/controller_test.go @@ -0,0 +1,527 @@ +//nolint:goconst +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" + "sigs.k8s.io/yaml" + + 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" + 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" + "github.com/openmcp-project/platform-service-dns/internal/controllers/cluster" +) + +const ( + platformCluster = "platform" + + providerName = "dns-service" + providerNamespace = "test" + environment = "default" + managedByValue = providerName + "." + cluster.ControllerName +) + +var platformScheme = install.InstallOperatorAPIsPlatform(runtime.NewScheme()) + +func defaultTestSetup(testDirPathSegments ...string) *testutils.Environment { + env := testutils.NewEnvironmentBuilder(). + WithFakeClient(platformScheme). + WithInitObjectPath(testDirPathSegments...). + WithDynamicObjectsWithStatus(&clustersv1alpha1.AccessRequest{}). + WithReconcilerConstructor(func(c client.Client) reconcile.Reconciler { + return cluster.NewClusterReconciler(clusters.NewTestClusterFromClient(platformCluster, c), nil, providerName, providerNamespace, environment) + }). + Build() + + return env +} + +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{})).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}, 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, + } + // 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(c1.Name), + }))) + 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(c1.Name), + }))) + 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(c1.Name), + }))) + Expect(ars.Items[0].Spec.ClusterRef).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "Name": Equal(c1.Name), + "Namespace": Equal("foo"), + }))) + // 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(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()) + 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] = c2.Name + // 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(c2.Name), + }))) + 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(c2.Name), + }))) + 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(c2.Name), + }))) + Expect(ars.Items[0].Spec.ClusterRef).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "Name": Equal(c2.Name), + "Namespace": Equal("bar"), + }))) + // copied secrets + Expect(env.Client().List(env.Ctx, ss, client.InNamespace(c2.Namespace), client.MatchingLabels(expectedLabels))).To(Succeed()) + 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() { + env := defaultTestSetup("testdata", "test-02") + + 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 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()) + 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" + // 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}, 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 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: c1.Name, + } + // 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)) + // 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)) + // 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)) + // 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(2)) + + // 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()) + // 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}, 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()) + 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")) + 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}, 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, + } + // 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(c1.Name), + }))) + 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}, 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, + } + // 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(c1.Name), + }))) + Expect(srcs.Items[0].Spec).To(MatchFields(IgnoreExtras, Fields{ + "URL": Equal("https://example.org/repo/charts"), + })) + }) + + 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) { + 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()) +} 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/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 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..3dba821 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-01/config.yaml @@ -0,0 +1,28 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +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 + 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-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 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..0f740f0 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-02/config.yaml @@ -0,0 +1,29 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns-service +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 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..51c0a3f --- /dev/null +++ b/internal/controllers/cluster/testdata/test-03/config.yaml @@ -0,0 +1,19 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns-service +spec: + externalDNSSource: + git: + url: "https://example.org/repo/charts" + interval: 10m + externalDNSForPurposes: + - name: foo + purposeSelector: + name: foo + helmValues: + providerNamespace: + providerName: + environment: + clusterNamespace: + clusterName: 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..8c737d4 --- /dev/null +++ b/internal/controllers/cluster/testdata/test-04/config.yaml @@ -0,0 +1,15 @@ +apiVersion: dns.openmcp.cloud/v1alpha1 +kind: DNSServiceConfig +metadata: + name: dns-service +spec: + externalDNSSource: + helm: + url: "https://example.org/repo/charts" + interval: 10m + externalDNSForPurposes: + - name: foo + purposeSelector: + name: foo + helmValues: + foo: foo