From 3d4bf500afb2697cd75ce098a40f4f0f24d755fc Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 31 Jul 2026 14:12:45 +0200 Subject: [PATCH 01/20] feat: initial commit for manager pkg - copy paste Signed-off-by: Jakob Bergmeier --- pkg/manager/flux.go | 161 +++++++++++++++ pkg/manager/flux_test.go | 104 ++++++++++ pkg/manager/helm.go | 33 +++ pkg/manager/helm_test.go | 77 +++++++ pkg/manager/labels.go | 27 +++ pkg/manager/managedcluster.go | 99 +++++++++ pkg/manager/managedobject.go | 137 ++++++++++++ pkg/manager/manager.go | 212 +++++++++++++++++++ pkg/manager/objectutils.go | 14 ++ pkg/manager/orphancleaner.go | 135 ++++++++++++ pkg/manager/orphancleaner_test.go | 332 ++++++++++++++++++++++++++++++ pkg/manager/permissions.go | 186 +++++++++++++++++ pkg/manager/permissions_test.go | 148 +++++++++++++ pkg/manager/secret.go | 76 +++++++ pkg/manager/secret_test.go | 112 ++++++++++ pkg/manager/utils_test.go | 65 ++++++ 16 files changed, 1918 insertions(+) create mode 100644 pkg/manager/flux.go create mode 100644 pkg/manager/flux_test.go create mode 100644 pkg/manager/helm.go create mode 100644 pkg/manager/helm_test.go create mode 100644 pkg/manager/labels.go create mode 100644 pkg/manager/managedcluster.go create mode 100644 pkg/manager/managedobject.go create mode 100644 pkg/manager/manager.go create mode 100644 pkg/manager/objectutils.go create mode 100644 pkg/manager/orphancleaner.go create mode 100644 pkg/manager/orphancleaner_test.go create mode 100644 pkg/manager/permissions.go create mode 100644 pkg/manager/permissions_test.go create mode 100644 pkg/manager/secret.go create mode 100644 pkg/manager/secret_test.go create mode 100644 pkg/manager/utils_test.go diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go new file mode 100644 index 0000000..022a384 --- /dev/null +++ b/pkg/manager/flux.go @@ -0,0 +1,161 @@ +package externalsecrets + +import ( + "context" + "fmt" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/fluxcd/pkg/runtime/conditions" + + "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" + + apiv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" +) + +const ( + // DefaultNamespace is the default namespace where External Secrets Operator components are deployed on the ManagedControlPlane + DefaultNamespace = "external-secrets" + // OCIRepositoryName is the name of the External Secrets Operator OCIRepository resource + OCIRepositoryName = "external-secrets" + // HelmReleaseName is the name of the External Secrets Operator HelmRelease resource + HelmReleaseName = "external-secrets" +) + +// ManageFluxResourcesParams groups all parameters to create the required manage flux resources +type ManageFluxResourcesParams struct { + // Cluster defines where the resources will be created + Cluster ManagedCluster + // MCPNamespace defines the namespace name that deploy ESO + MCPNamespace string + // ChartPullSecretName defines the name of the secret copy that will be placed in the Cluster namespace + ChartPullSecretName string + // Obj is the tenant API object that is being reconciled + Obj *apiv1alpha1.ExternalSecretsOperator + // Interval defines OCIRepository and HelmRelease reconcile intervals + Interval time.Duration + // ClusterContext of the current reconciliation context + ClusterContext clusteraccess.ClusterContext + // RequestedVersion is the version of External Secrets Operator that a user requested through the onboarding API + RequestedVersion apiv1alpha1.ExternalSecretsVersion +} + +// ManageFluxResources configures OCIRepo and HelmRelease +func ManageFluxResources(p ManageFluxResourcesParams) { + ociRepo := NewManagedObject(&sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: OCIRepositoryName, + Namespace: p.Cluster.GetDefaultNamespace(), + }, + }, ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, o client.Object) error { + ociRepo, ok := o.(*sourcev1.OCIRepository) + if !ok { + return fmt.Errorf("expected *sourcev1.OCIRepository, got %T", o) + } + if p.RequestedVersion.ChartURL == nil { + // this should never happen as long as defaulting works properly + return fmt.Errorf("missing ChartURL definition for Flux version %s", p.RequestedVersion.Version) + } + ociRepo.Spec = sourcev1.OCIRepositorySpec{ + Interval: metav1.Duration{Duration: p.Interval}, + URL: *p.RequestedVersion.ChartURL, + Reference: &sourcev1.OCIRepositoryRef{ + Tag: p.RequestedVersion.ChartVersion, + }, + // required to always select the correct OCI layer + // this mitigates non-deterministic layer ordering across different eso versions + // that prevented the OCIRepository from getting ready for some eso versions + // https://fluxcd.io/flux/components/source/ocirepositories/#layer-selector + LayerSelector: &sourcev1.OCILayerSelector{ + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + Operation: "extract", + }, + } + if p.ChartPullSecretName != "" { + ociRepo.Spec.SecretRef = &meta.LocalObjectReference{ + Name: p.ChartPullSecretName, + } + } + return nil + }, + DependsOn: []ManagedObject{}, + DeletionPolicy: Delete, + StatusFunc: FluxStatus, + }) + p.Cluster.AddObject(ociRepo) + + helmRelease := NewManagedObject(&helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: HelmReleaseName, + Namespace: p.Cluster.GetDefaultNamespace(), + }, + }, ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, o client.Object) error { + helmRelease, ok := o.(*helmv2.HelmRelease) + if !ok { + return fmt.Errorf("expected *helmv2.HelmRelease, got %T", o) + } + helmRelease.Spec = helmv2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: p.Interval}, + ChartRef: &helmv2.CrossNamespaceSourceReference{ + Kind: "OCIRepository", + Name: OCIRepositoryName, + Namespace: p.Cluster.GetDefaultNamespace(), + }, + KubeConfig: &meta.KubeConfigReference{ + SecretRef: &meta.SecretKeyReference{ + Name: p.ClusterContext.MCPAccessSecretKey.Name, + Key: "kubeconfig", + }, + }, + Install: &helmv2.Install{ + Remediation: &helmv2.InstallRemediation{ + Retries: 3, + }, + CreateNamespace: true, + }, + DriftDetection: &helmv2.DriftDetection{ + Mode: helmv2.DriftDetectionEnabled, + }, + Values: p.RequestedVersion.HelmValues, + TargetNamespace: p.MCPNamespace, + StorageNamespace: p.MCPNamespace, + } + return nil + }, + DependsOn: []ManagedObject{ociRepo}, + DeletionPolicy: Delete, + StatusFunc: FluxStatus, + }) + p.Cluster.AddObject(helmRelease) +} + +// FluxStatus indicates whether the given object is in phase terminating, pending or ready. +func FluxStatus(o client.Object, rl apiv1alpha1.ResourceLocation) Status { + fluxObject := o.(conditions.Getter) + if !o.GetDeletionTimestamp().IsZero() { + return Status{ + Phase: apiv1alpha1.Terminating, + Message: "Resource is terminating.", + Location: rl, + } + } + if conditions.IsTrue(fluxObject, meta.ReadyCondition) { + return Status{ + Phase: apiv1alpha1.Ready, + Message: "Resource is ready", + Location: rl, + } + } + return Status{ + Phase: apiv1alpha1.Pending, + Message: "Resource is not ready", + Location: rl, + } +} diff --git a/pkg/manager/flux_test.go b/pkg/manager/flux_test.go new file mode 100644 index 0000000..dd09e82 --- /dev/null +++ b/pkg/manager/flux_test.go @@ -0,0 +1,104 @@ +package externalsecrets + +import ( + "context" + "testing" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiextensionv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" + + apiv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" +) + +const ( + testNamespace = "test" + testCharURL = "chart-url" + testChartPullSecret = "chart-pull-secret" + testKubeconfigKey = "kubeconfig-key" +) + +func TestManageFluxResources(t *testing.T) { + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + params ManageFluxResourcesParams + }{ + { + name: "APIObject and ProviderConfig values mapping", + params: ManageFluxResourcesParams{ + Cluster: NewManagedCluster(CreateFakeCluster(t, "platform"), &rest.Config{}, testNamespace, PlatformCluster), + MCPNamespace: "external-secrets", + Obj: &apiv1alpha1.ExternalSecretsOperator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: testNamespace, + }, + Spec: apiv1alpha1.ExternalSecretsOperatorSpec{ + Version: "v2.2.0", + }, + }, + Interval: time.Hour, + ClusterContext: clusteraccess.ClusterContext{ + MCPAccessSecretKey: client.ObjectKey{ + Namespace: testNamespace, + Name: testKubeconfigKey, + }, + }, + ChartPullSecretName: "secret-copy", + RequestedVersion: apiv1alpha1.ExternalSecretsVersion{ + Version: "v2.2.0", + ChartVersion: "v2.2.0", + ChartURL: new(testCharURL), + ChartPullSecret: testChartPullSecret, + HelmValues: &apiextensionv1.JSON{ + Raw: []byte(`{"foo":"bar"}`), + }, + }, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ManageFluxResources(tt.params) + // expect oci repo and helm release without errors + ExecApply(t, []ManagedCluster{tt.params.Cluster}, 2, []string{}) + + // assert oci repo + ociRepo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: OCIRepositoryName, + Namespace: testNamespace, + }, + } + require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(ociRepo), ociRepo)) + assert.Equal(t, tt.params.RequestedVersion.ChartURL, ptr.To(ociRepo.Spec.URL)) + assert.Equal(t, tt.params.ChartPullSecretName, ociRepo.Spec.SecretRef.Name) + assert.Equal(t, tt.params.Obj.Spec.Version, ociRepo.Spec.Reference.Tag) + assert.Equal(t, tt.params.Interval, ociRepo.Spec.Interval.Duration) + + // assert helm release + helmRelease := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: HelmReleaseName, + Namespace: testNamespace, + }, + } + require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(helmRelease), helmRelease)) + assert.Equal(t, tt.params.RequestedVersion.HelmValues, helmRelease.Spec.Values) + assert.Equal(t, tt.params.Interval, helmRelease.Spec.Interval.Duration) + assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.StorageNamespace) + assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.TargetNamespace) + assert.Equal(t, tt.params.ClusterContext.MCPAccessSecretKey.Name, helmRelease.Spec.KubeConfig.SecretRef.Name) + assert.Equal(t, "kubeconfig", helmRelease.Spec.KubeConfig.SecretRef.Key) + }) + } +} diff --git a/pkg/manager/helm.go b/pkg/manager/helm.go new file mode 100644 index 0000000..cc965e1 --- /dev/null +++ b/pkg/manager/helm.go @@ -0,0 +1,33 @@ +package externalsecrets + +import ( + "encoding/json" + + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// HelmValues define the helm values that are explicitly processed during reconciliation +type HelmValues struct { + NamespaceOverride string `json:"namespaceOverride,omitempty"` + Global Global `json:"global,omitempty"` +} + +// Global define the global settings that are explicitly process during reconciliation +type Global struct { + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` +} + +// ExtractHelmValues extract helm values required for processing +func ExtractHelmValues(values *apiextensionsv1.JSON) (*HelmValues, error) { + if values == nil || len(values.Raw) == 0 { + return &HelmValues{}, nil + } + + vals := &HelmValues{} + if err := json.Unmarshal(values.Raw, vals); err != nil { + return nil, err + } + + return vals, nil +} diff --git a/pkg/manager/helm_test.go b/pkg/manager/helm_test.go new file mode 100644 index 0000000..0adaf73 --- /dev/null +++ b/pkg/manager/helm_test.go @@ -0,0 +1,77 @@ +package externalsecrets + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestExtractHelmValues(t *testing.T) { + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + values *apiextensionsv1.JSON + want *HelmValues + wantErr bool + }{ + { + name: "Extract Namespace", + values: &apiextensionsv1.JSON{ + Raw: []byte(`{"namespaceOverride": "eso-system"}`), + }, + want: &HelmValues{ + NamespaceOverride: "eso-system", + }, + wantErr: false, + }, + { + name: "Extract ImagePullSecrets", + values: &apiextensionsv1.JSON{ + Raw: []byte(`{"global": {"imagePullSecrets": [{"name": "test"}]}}`), + }, + want: &HelmValues{ + Global: Global{ + ImagePullSecrets: []corev1.LocalObjectReference{ + { + Name: "test", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "Ignore unknown values", + values: &apiextensionsv1.JSON{ + Raw: []byte(`{"replicaCount": 1}`), + }, + want: &HelmValues{}, + wantErr: false, + }, + { + name: "Error on invalid JSON", + values: &apiextensionsv1.JSON{ + Raw: []byte("invalid json"), + }, + want: &HelmValues{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotErr := ExtractHelmValues(tt.values) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("ExtractHelmValues() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("ExtractHelmValues() succeeded unexpectedly") + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/manager/labels.go b/pkg/manager/labels.go new file mode 100644 index 0000000..209b526 --- /dev/null +++ b/pkg/manager/labels.go @@ -0,0 +1,27 @@ +package externalsecrets + +import "sigs.k8s.io/controller-runtime/pkg/client" + +const ( + // LabelManagedBy defines the managed-by label that is added to every managed object. + LabelManagedBy = "app.kubernetes.io/managed-by" + // LabelManagedByValue defines the managed-by value that is added to every managed object. + LabelManagedByValue = "service-provider-external-secrets" +) + +// SetManagedBy sets the managed-by label of the given client.Object. +func SetManagedBy(o client.Object) { + labels := o.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[LabelManagedBy] = LabelManagedByValue + o.SetLabels(labels) +} + +// ManagedBy returns the managed-by label of the given client.Object. +func ManagedBy() client.ListOption { + return client.MatchingLabels{ + LabelManagedBy: LabelManagedByValue, + } +} diff --git a/pkg/manager/managedcluster.go b/pkg/manager/managedcluster.go new file mode 100644 index 0000000..3d186b8 --- /dev/null +++ b/pkg/manager/managedcluster.go @@ -0,0 +1,99 @@ +package externalsecrets + +import ( + "strings" + + "github.com/openmcp-project/controller-utils/pkg/clusters" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // ManagedControlPlane indicates that a cluster is a managed control plane. + ManagedControlPlane ClusterType = "ManagedControlPlane" + // PlatformCluster indicates that a cluster is a platform cluster. + PlatformCluster ClusterType = "PlatformCluster" + // WorkloadCluster indicates that a cluster is a workload cluster. + WorkloadCluster ClusterType = "WorkloadCluster" +) + +// ClusterType distinguishes between managed control plane and workload clusters. +type ClusterType string + +// NewManagedCluster creates a new ManagedCluster instance. +func NewManagedCluster(c *clusters.Cluster, cfg *rest.Config, ns string, ct ClusterType) ManagedCluster { + return &managedCluster{ + cluster: c, + cfg: cfg, + objects: []ManagedObject{}, + defaultNamespace: ns, + clusterType: ct, + } +} + +// ManagedCluster holds a set of ManagedObjects. +type ManagedCluster interface { + AddObject(o ManagedObject) + GetObjects() []ManagedObject + GetDefaultNamespace() string + GetHostAndPort() (string, string) + GetConfig() *rest.Config + GetClient() client.Client + GetCluster() *clusters.Cluster + GetClusterType() ClusterType +} + +var _ ManagedCluster = &managedCluster{} + +type managedCluster struct { + cluster *clusters.Cluster + cfg *rest.Config + objects []ManagedObject + defaultNamespace string + clusterType ClusterType +} + +// GetClient implements ManagedCluster. +func (m *managedCluster) GetClient() client.Client { + return m.cluster.Client() +} + +// GetConfig implements ManagedCluster. +func (m *managedCluster) GetConfig() *rest.Config { + return m.cfg +} + +// GetHostAndPort implements ManagedCluster. +func (m *managedCluster) GetHostAndPort() (string, string) { + input := strings.TrimPrefix(m.cfg.Host, "https://") + host, port, found := strings.Cut(input, ":") + if !found { + port = "443" + } + return host, port +} + +// GetDefaultNamespace implements ManagedCluster. +func (m *managedCluster) GetDefaultNamespace() string { + return m.defaultNamespace +} + +// AddObject implements ManagedCluster. +func (m *managedCluster) AddObject(o ManagedObject) { + m.objects = append(m.objects, o) +} + +// GetObjects implements ManagedCluster. +func (m *managedCluster) GetObjects() []ManagedObject { + return m.objects +} + +// GetCluster implements ManagedCluster. +func (m *managedCluster) GetCluster() *clusters.Cluster { + return m.cluster +} + +// GetClusterType implements ManagedCluster. +func (m *managedCluster) GetClusterType() ClusterType { + return m.clusterType +} diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go new file mode 100644 index 0000000..8647454 --- /dev/null +++ b/pkg/manager/managedobject.go @@ -0,0 +1,137 @@ +package externalsecrets + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" +) + +// DeletionPolicy distinguishes between normal deletion and orphaning an object. +type DeletionPolicy string + +const ( + // Orphan indicates that an object will be orphaned when deletion is requested + Orphan DeletionPolicy = "orphan" + // Delete indicates that an object will be deleted when deletion is requested + Delete DeletionPolicy = "delete" +) + +// ReconcileFunc reconciles the given client.Object. +type ReconcileFunc func(ctx context.Context, o client.Object) error + +// NoOp does not do anything with the provided object and returns nil. +func NoOp(context.Context, client.Object) error { + return nil +} + +// StatusFunc provides Status information for the given client.Object. +type StatusFunc func(o client.Object, rl v1alpha1.ResourceLocation) Status + +// SimpleStatus indicates whether the given object is in phase terminating, pending or ready. +func SimpleStatus(o client.Object, rl v1alpha1.ResourceLocation) Status { + if !o.GetDeletionTimestamp().IsZero() { + return Status{ + Phase: v1alpha1.Terminating, + Message: "Resource is terminating.", + Location: rl, + } + } + if o.GetUID() == "" { + return Status{ + Phase: v1alpha1.Pending, + Message: "Resource has not been created yet.", + Location: rl, + } + } + return Status{ + Phase: v1alpha1.Ready, + Message: "Resource exists.", + Location: rl, + } +} + +// Status defines the status attributes of a ManagedObject. +type Status struct { + Phase v1alpha1.InstancePhase + Message string + Location v1alpha1.ResourceLocation +} + +// NewManagedObject creates a new ManagedObject instances to manage the given client.Object. +func NewManagedObject(o client.Object, moc ManagedObjectContext) ManagedObject { + if moc.DeletionPolicy == "" { + moc.DeletionPolicy = Delete + } + + return &managedObject{ + object: o, + reconcileFunc: moc.ReconcileFunc, + dependencies: moc.DependsOn, + deletionPolicy: moc.DeletionPolicy, + statusFunc: moc.StatusFunc, + } +} + +// ManagedObjectContext holds the data to manage a client.Object. +type ManagedObjectContext struct { + ReconcileFunc ReconcileFunc + DependsOn []ManagedObject + DeletionPolicy DeletionPolicy + StatusFunc StatusFunc +} + +// ManagedObject represents an object managed by a Manager. +type ManagedObject interface { + GetObject() client.Object + Reconcile(ctx context.Context) error + GetDependencies() []ManagedObject + GetDeletionPolicy() DeletionPolicy + GetStatus(v1alpha1.ResourceLocation) Status +} + +var _ ManagedObject = &managedObject{} + +type managedObject struct { + object client.Object + reconcileFunc ReconcileFunc + statusFunc StatusFunc + dependencies []ManagedObject + deletionPolicy DeletionPolicy +} + +// GetStatus implements ManagedObject. +func (m *managedObject) GetStatus(rl v1alpha1.ResourceLocation) Status { + if m.statusFunc != nil { + return m.statusFunc(m.object, rl) + } + return Status{ + Phase: v1alpha1.Unknown, + Message: "No status function defined.", + Location: rl, + } +} + +// GetDeletionPolicy implements ManagedObject. +func (m *managedObject) GetDeletionPolicy() DeletionPolicy { + return m.deletionPolicy +} + +// GetDependencies implements ManagedObject. +func (m *managedObject) GetDependencies() []ManagedObject { + return m.dependencies +} + +// Reconcile implements ManagedObject. +func (m *managedObject) Reconcile(ctx context.Context) error { + if m.reconcileFunc != nil { + return m.reconcileFunc(ctx, m.object) + } + return nil +} + +// GetObject implements ManagedObject. +func (m *managedObject) GetObject() client.Object { + return m.object +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go new file mode 100644 index 0000000..bd9bd93 --- /dev/null +++ b/pkg/manager/manager.go @@ -0,0 +1,212 @@ +package externalsecrets + +import ( + "context" + "errors" + "fmt" + "slices" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + // OperationResultDeletionFailed indicates failed to be deleted + OperationResultDeletionFailed controllerutil.OperationResult = "deletionFailed" + // OperationResultDeletionRequested indicates that an object has been marked for deletion + OperationResultDeletionRequested controllerutil.OperationResult = "deletionRequested" + // OperationResultDeleted indicates that an object has been deleted + OperationResultDeleted controllerutil.OperationResult = "deleted" + // OperationResultOrphaned indicates that an object has been orphaned + OperationResultOrphaned controllerutil.OperationResult = OperationResultDeleted +) + +type dependents map[ManagedObject][]dependency + +// Manager manages the objects of an arbitrary number of clusters +type Manager interface { + AddCluster(mc ManagedCluster) + AddCleaner(oc OrphanCleaner) + Apply(context.Context) (_ []Result, cleanup error) + Delete(context.Context) (_ []Result, cleanup error) +} + +// OrphanCleaner removes any previously managed objects that are no longer part of the desired state. +type OrphanCleaner interface { + // []Result contains cleanup errors that can be mapped to a managed object. + // error represents cleanup errors that cannot be mapped to a managed object. + Cleanup(ctx context.Context) ([]Result, error) +} + +// NewManager creates a new Manager instance. +func NewManager() Manager { + return &managerImpl{ + clusters: []ManagedCluster{}, + cleaners: []OrphanCleaner{}, + } +} + +// managerImpl manages clusters and invokes reconciliation of ManagedObjects. +type managerImpl struct { + clusters []ManagedCluster + cleaners []OrphanCleaner +} + +// AddCluster adds a cluster to a Manager. +func (m *managerImpl) AddCluster(mc ManagedCluster) { + m.clusters = append(m.clusters, mc) +} + +// Apply invokes reconciliation of all ManagedObjects. +func (m *managerImpl) Apply(ctx context.Context) ([]Result, error) { + return m.reconcileObjects(ctx, false) +} + +// Delete invokes deletion of all ManagedObjects. +func (m *managerImpl) Delete(ctx context.Context) ([]Result, error) { + return m.reconcileObjects(ctx, true) +} + +// AddCleaner adds a cleaner to a Manager. +func (m *managerImpl) AddCleaner(cleaner OrphanCleaner) { + m.cleaners = append(m.cleaners, cleaner) +} + +func (m *managerImpl) reconcileObjects(ctx context.Context, isDeletion bool) ([]Result, error) { + dependents := m.getDependents() + + // Apply or delete objects from each cluster. + results := []Result{} + for _, mc := range m.clusters { + for _, mo := range mc.GetObjects() { + result := m.reconcileObject(ctx, mc, mo, dependents, isDeletion) + results = append(results, result) + } + } + + // remove any redundant resources like secret copies that are no longer part of the desired state. + for _, c := range m.cleaners { + result, err := c.Cleanup(ctx) + if err != nil { + return results, err + } + results = slices.Concat(results, result) + } + + return results, nil +} + +func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo ManagedObject, dependents dependents, isDeletion bool) Result { + client := mc.GetClient() + obj := mo.GetObject() + + if isDeletion { + if err := m.checkForDependents(ctx, dependents[mo]); err != nil { + return Result{ + Object: mo, + Cluster: mc, + OperationResult: controllerutil.OperationResultNone, + Error: err, + } + } + + if mo.GetDeletionPolicy() == Orphan { + return Result{ + Object: mo, + Cluster: mc, + OperationResult: OperationResultOrphaned, + Error: nil, + } + } + + err := client.Delete(ctx, obj) + if apierrors.IsNotFound(err) { + return Result{ + Object: mo, + Cluster: mc, + OperationResult: OperationResultDeleted, + Error: nil, + } + } + return Result{ + Object: mo, + Cluster: mc, + OperationResult: OperationResultDeletionRequested, + Error: err, + } + } + + opResult, err := controllerutil.CreateOrUpdate(ctx, client, obj, func() error { + SetManagedBy(obj) + return mo.Reconcile(ctx) + }) + return Result{ + Object: mo, + Cluster: mc, + OperationResult: opResult, + Error: err, + } +} + +func (m *managerImpl) checkForDependents(ctx context.Context, deps []dependency) error { + errs := []error{} + for _, dep := range deps { + obj := dep.Object.GetObject() + err := dep.Cluster.GetClient().Get(ctx, client.ObjectKeyFromObject(obj), obj) + if apierrors.IsNotFound(err) { + // "Not found" is the success case: The object which depends on us does not exist anymore. + continue + } + if err != nil { + // Some unexpected error occurred. + errs = append(errs, err) + continue + } + // No error occurred, the GET request has been successful. + // The object still exists and depends on us. + errs = append(errs, fmt.Errorf("dependent object still exists: %s", ObjectID(obj))) + } + return errors.Join(errs...) +} + +func (m *managerImpl) getDependents() dependents { + deps := dependents{} + for _, mc := range m.clusters { + for _, mo := range mc.GetObjects() { + for _, dep := range mo.GetDependencies() { + if deps[dep] == nil { + deps[dep] = []dependency{} + } + deps[dep] = append(deps[dep], dependency{ + Object: mo, + Cluster: mc, + }) + } + } + } + return deps +} + +// Result summarizes a reconciliation result. +type Result struct { + Object ManagedObject + Cluster ManagedCluster + OperationResult controllerutil.OperationResult + Error error +} + +type dependency struct { + Object ManagedObject + Cluster ManagedCluster +} + +// AllDeleted returns true if every item's operation result is OperationResultDeleted. +func AllDeleted(results []Result) bool { + for _, r := range results { + if r.OperationResult != OperationResultDeleted { + return false + } + } + return true +} diff --git a/pkg/manager/objectutils.go b/pkg/manager/objectutils.go new file mode 100644 index 0000000..8b9a596 --- /dev/null +++ b/pkg/manager/objectutils.go @@ -0,0 +1,14 @@ +package externalsecrets + +import ( + "fmt" + "reflect" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ObjectID returns a human-readable identifier string for a Kubernetes object. +func ObjectID(obj client.Object) string { + typeName := reflect.TypeOf(obj).Elem().Name() + return fmt.Sprintf(`Kind=%s, Name=%s, Namespace=%s`, typeName, obj.GetName(), obj.GetNamespace()) +} diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go new file mode 100644 index 0000000..4ec28ed --- /dev/null +++ b/pkg/manager/orphancleaner.go @@ -0,0 +1,135 @@ +package externalsecrets + +import ( + "context" + "errors" + "fmt" + "slices" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + apiv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" +) + +// ErrOrphanCleanup is an user-facing error that indicates orphan cleanup failures +var ErrOrphanCleanup = errors.New("orphan cleanup failed") + +var _ OrphanCleaner = &orphanCleaner[*corev1.SecretList]{} + +type orphanCleaner[T client.ObjectList] struct { + cluster ManagedCluster + namespace string + cleanerType cleanerType[T] +} + +type cleanerType[T client.ObjectList] struct { + ObjectsToKeep []corev1.LocalObjectReference + EmptyList func() T + // PreDeletionSteps is an optional hook that is invoked before OrphanCleaner.Cleanup deletes an object. + // It returns: + // - proceedWithDeletion: true if the object is ready to be deleted. + // Returning false prevents deletion for this reconciliation cycle. + // - err: an error if the preparation step failed. + PreDeletionSteps func(context.Context, client.Object) (proceedWithDeletion bool, _ error) +} + +// NewOrphanCleaner removes redundant objects in the given target namespace. +func NewOrphanCleaner[T client.ObjectList](cluster ManagedCluster, namespace string, clType cleanerType[T]) OrphanCleaner { + return &orphanCleaner[T]{ + cluster: cluster, + namespace: namespace, + cleanerType: clType, + } +} + +func (c *orphanCleaner[T]) items(list T) []client.Object { + items, _ := meta.ExtractList(list) + objList := make([]client.Object, 0, len(items)) + for _, item := range items { + if obj, ok := item.(client.Object); ok { + objList = append(objList, obj) + } + } + return objList +} + +func (c *orphanCleaner[T]) Cleanup(ctx context.Context) ([]Result, error) { + results := []Result{} + if c.cleanerType.EmptyList == nil { + return nil, fmt.Errorf("%w: orphan cleaner is missing empty list definition", ErrOrphanCleanup) + } + objList := c.cleanerType.EmptyList() + cl := c.cluster.GetClient() + if err := cl.List(ctx, objList, + client.InNamespace(c.namespace), + client.MatchingLabels{LabelManagedBy: LabelManagedByValue}, + ); err != nil { + log.FromContext(ctx).Error(err, "failed to list objects for orphan cleanup") + return nil, ErrOrphanCleanup + } + for _, obj := range c.items(objList) { + if !slices.ContainsFunc(c.cleanerType.ObjectsToKeep, func(ref corev1.LocalObjectReference) bool { return obj.GetName() == ref.Name }) { + // exec delete preparation steps + if c.cleanerType.PreDeletionSteps != nil { + proceedWithDeletion, err := c.cleanerType.PreDeletionSteps(ctx, obj) + if err != nil { + results = append(results, c.deletionError(obj, err)) + continue + } + if !proceedWithDeletion { + results = append(results, c.deletionPrepared(obj)) + continue + } + } + // exec delete + if err := cl.Delete(ctx, obj); client.IgnoreNotFound(err) != nil { + results = append(results, c.deletionError(obj, err)) + } + } + } + return results, nil +} + +func (c *orphanCleaner[T]) deletionPrepared(obj client.Object) Result { + return Result{ + Object: &managedObject{ + object: obj, + statusFunc: cleanupPreparedStatus, + deletionPolicy: Delete, + }, + Cluster: c.cluster, + OperationResult: OperationResultDeletionRequested, + } +} + +func cleanupPreparedStatus(_ client.Object, rl apiv1alpha1.ResourceLocation) Status { + return Status{ + Phase: apiv1alpha1.Terminating, + Message: "Orphan cleanup prepared", + Location: rl, + } +} + +func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { + return Result{ + Object: &managedObject{ + object: obj, + statusFunc: cleanupErrorStatus, + deletionPolicy: Delete, + }, + Cluster: c.cluster, + OperationResult: OperationResultDeletionFailed, + Error: err, + } +} + +func cleanupErrorStatus(_ client.Object, rl apiv1alpha1.ResourceLocation) Status { + return Status{ + Phase: apiv1alpha1.Terminating, + Message: "Orphan cleanup failed", + Location: rl, + } +} diff --git a/pkg/manager/orphancleaner_test.go b/pkg/manager/orphancleaner_test.go new file mode 100644 index 0000000..087b638 --- /dev/null +++ b/pkg/manager/orphancleaner_test.go @@ -0,0 +1,332 @@ +package externalsecrets + +import ( + "context" + "errors" + "slices" + "testing" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func Test_orphanCleaner_Cleanup(t *testing.T) { + tests := []struct { + name string // description of this test case + targetNamespace string + cluster ManagedCluster + cleanerType cleanerType[*sourcev1.OCIRepositoryList] + want []sourcev1.OCIRepository + wantErr bool + wantResultErr bool + }{ + { + name: "only OCIRepository managed by sp-eso are deleted", + targetNamespace: DefaultNamespace, + cluster: createFakeCluster(createFakeClient([]client.Object{ + createOCIRepo("managed", DefaultNamespace, true), + createOCIRepo("unmanaged", DefaultNamespace, false), + })), + cleanerType: createOCIRepoCleanerType(), + want: []sourcev1.OCIRepository{ + *createOCIRepo("unmanaged", DefaultNamespace, false), + }, + wantErr: false, + }, + { + name: "OCIRepository in other namespaces are not deleted", + targetNamespace: "openmcp-system", + cluster: createFakeCluster(createFakeClient([]client.Object{ + createOCIRepo("managed", DefaultNamespace, true), + createOCIRepo("unmanaged", DefaultNamespace, false), + })), + cleanerType: createOCIRepoCleanerType(), + want: []sourcev1.OCIRepository{ + *createOCIRepo("managed", DefaultNamespace, true), + *createOCIRepo("unmanaged", DefaultNamespace, false), + }, + wantErr: false, + }, + { + name: "objects to keep are not deleted", + targetNamespace: "openmcp-system", + cluster: createFakeCluster(createFakeClient([]client.Object{ + createOCIRepo("managed", DefaultNamespace, true), + createOCIRepo("unmanaged", DefaultNamespace, false), + })), + cleanerType: createOCIRepoCleanerType(corev1.LocalObjectReference{Name: "managed"}), + want: []sourcev1.OCIRepository{ + *createOCIRepo("managed", DefaultNamespace, true), + *createOCIRepo("unmanaged", DefaultNamespace, false), + }, + wantErr: false, + }, + { + name: "error is returned when list fails", + targetNamespace: DefaultNamespace, + cluster: createFakeCluster(listErrorClient{}), + cleanerType: createOCIRepoCleanerType(), + want: []sourcev1.OCIRepository{}, + wantErr: true, + }, + { + name: "if a single delete fails, the single result contains an error but overall cleanup succeeds", + targetNamespace: DefaultNamespace, + cluster: createFakeCluster(deleteErrorClient{ + fakeOCIRepo: *createOCIRepo("managed", DefaultNamespace, true), + }), + cleanerType: createOCIRepoCleanerType(), + want: []sourcev1.OCIRepository{}, + wantErr: false, + wantResultErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewOrphanCleaner(tt.cluster, tt.targetNamespace, tt.cleanerType) + result, gotErr := c.Cleanup(context.Background()) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("Cleanup() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("Cleanup() succeeded unexpectedly") + } + if tt.wantResultErr { + assert.True(t, slices.ContainsFunc(result, containsResultError)) + return + } + repoList := &sourcev1.OCIRepositoryList{} + require.NoError(t, tt.cluster.GetClient().List(context.Background(), repoList)) + assert.Len(t, tt.want, len(repoList.Items)) + for _, repo := range repoList.Items { + assert.True(t, slices.ContainsFunc(tt.want, func(r sourcev1.OCIRepository) bool { + return r.Name == repo.Name && r.Namespace == repo.Namespace + })) + } + assert.False(t, slices.ContainsFunc(result, containsResultError)) + }) + } +} + +func Test_orphanCleaner_Cleanup_prepareDeletion(t *testing.T) { + tests := []struct { + name string // description of this test case + targetNamespace string + cluster ManagedCluster + cleanerType cleanerType[*sourcev1.OCIRepositoryList] + option preparationOptions + want []sourcev1.OCIRepository + wantErr bool // expects orphan cleanup to return an error + wantResultErr bool // expects an error in one or more cleanup results + }{ + { + name: "preparation succeeds and proceeds with deletion", + targetNamespace: DefaultNamespace, + cluster: createFakeCluster(createFakeClient([]client.Object{ + createOCIRepo("managed", DefaultNamespace, true), + createOCIRepo("unmanaged", DefaultNamespace, false), + })), + cleanerType: createOCIRepoCleanerType(), + option: succeedAndDelete, + want: []sourcev1.OCIRepository{ + *createOCIRepo("unmanaged", DefaultNamespace, false), + }, + wantErr: false, + wantResultErr: false, + }, + { + name: "if preparation succeeds but deletion is blocked, no repo is removed", + targetNamespace: DefaultNamespace, + cluster: createFakeCluster(createFakeClient([]client.Object{ + createOCIRepo("managed", DefaultNamespace, true), + createOCIRepo("unmanaged", DefaultNamespace, false), + })), + cleanerType: createOCIRepoCleanerType(), + option: succeedAndWait, + want: []sourcev1.OCIRepository{ + *createOCIRepo("managed", DefaultNamespace, true), + *createOCIRepo("unmanaged", DefaultNamespace, false), + }, + wantErr: false, + wantResultErr: false, + }, + { + name: "if preparation fails, the single result contains an error but overall cleanup succeeds", + targetNamespace: DefaultNamespace, + cluster: createFakeCluster(createFakeClient([]client.Object{ + createOCIRepo("managed", DefaultNamespace, true), + createOCIRepo("unmanaged", DefaultNamespace, false), + })), + cleanerType: createOCIRepoCleanerType(), + option: failWithError, + want: []sourcev1.OCIRepository{ + *createOCIRepo("managed", DefaultNamespace, true), + *createOCIRepo("unmanaged", DefaultNamespace, false), + }, + wantErr: false, + wantResultErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prepDeletionMock := prepareDeletionMock{} + var cleanerType cleanerType[*sourcev1.OCIRepositoryList] + switch tt.option { + case succeedAndDelete: + cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.successfulDelete) + case succeedAndWait: + cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.successfulAndWaitWithDelete) + case failWithError: + cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.failure) + } + c := NewOrphanCleaner(tt.cluster, tt.targetNamespace, cleanerType) + result, gotErr := c.Cleanup(context.Background()) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("Cleanup() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("Cleanup() succeeded unexpectedly") + } + if prepDeletionMock.execution > 0 { + assert.Equal(t, tt.wantResultErr, prepDeletionMock.failed) + } + if tt.wantResultErr { + assert.True(t, slices.ContainsFunc(result, containsResultError)) + return + } + repoList := &sourcev1.OCIRepositoryList{} + require.NoError(t, tt.cluster.GetClient().List(context.Background(), repoList)) + assert.Len(t, tt.want, len(repoList.Items)) + for _, repo := range repoList.Items { + assert.True(t, slices.ContainsFunc(tt.want, func(r sourcev1.OCIRepository) bool { + return r.Name == repo.Name && r.Namespace == repo.Namespace + })) + } + assert.False(t, slices.ContainsFunc(result, containsResultError)) + }) + } +} + +var containsResultError = func(r Result) bool { return r.Error != nil } + +func createOCIRepoCleanerType(objectsToKeep ...corev1.LocalObjectReference) cleanerType[*sourcev1.OCIRepositoryList] { + return cleanerType[*sourcev1.OCIRepositoryList]{ + EmptyList: func() *sourcev1.OCIRepositoryList { + return &sourcev1.OCIRepositoryList{} + }, + ObjectsToKeep: objectsToKeep, + } +} + +type preparationOptions int + +const ( + succeedAndDelete preparationOptions = iota + succeedAndWait + failWithError +) + +// prepareDeletionMock is a simple mock to demonstrate how you can execute preparation steps and control when an object is ready to be deleted +type prepareDeletionMock struct { + failed bool + execution int +} + +func (p *prepareDeletionMock) successfulDelete(_ context.Context, _ client.Object) (bool, error) { + p.failed = false + p.execution++ + return true, nil +} + +func (p *prepareDeletionMock) successfulAndWaitWithDelete(_ context.Context, _ client.Object) (bool, error) { + p.failed = false + p.execution++ + return false, nil +} + +func (p *prepareDeletionMock) failure(_ context.Context, _ client.Object) (bool, error) { + p.failed = true + p.execution++ + return false, errors.New("delete prep failed") +} + +func (r cleanerType[T]) withPreSteps(preSteps func(context.Context, client.Object) (bool, error)) cleanerType[T] { + r.PreDeletionSteps = preSteps + return r +} + +func createOCIRepo(name, namespace string, managedByEso bool) *sourcev1.OCIRepository { + cm := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + if managedByEso { + cm.Labels = map[string]string{ + LabelManagedBy: LabelManagedByValue, + } + } + return cm +} + +var _ ManagedCluster = &fakeCluster{} + +type fakeCluster struct { + managedCluster + fakeClient client.Client +} + +// GetClient implements [ManagedCluster]. +func (f *fakeCluster) GetClient() client.Client { + return f.fakeClient +} + +func createFakeCluster(client client.Client) ManagedCluster { + return &fakeCluster{ + fakeClient: client, + } +} + +func createFakeClient(clusterObjects []client.Object) client.Client { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = sourcev1.AddToScheme(scheme) + return fake.NewClientBuilder().WithObjects(clusterObjects...).WithScheme(scheme).Build() +} + +type listErrorClient struct { + client.Client +} + +func (l listErrorClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("list failed") +} + +type deleteErrorClient struct { + client.Client + fakeOCIRepo sourcev1.OCIRepository +} + +func (d deleteErrorClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + repoList := list.(*sourcev1.OCIRepositoryList) + repoList.Items = []sourcev1.OCIRepository{d.fakeOCIRepo} + return nil +} + +func (d deleteErrorClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + return errors.New("delete failed") +} diff --git a/pkg/manager/permissions.go b/pkg/manager/permissions.go new file mode 100644 index 0000000..7223a96 --- /dev/null +++ b/pkg/manager/permissions.go @@ -0,0 +1,186 @@ +package externalsecrets + +import ( + "context" + "fmt" + + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + + externalsecretsoperatorsv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" + + rbacv1 "k8s.io/api/rbac/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// ESONamespace carries the resolved ESO installation namespace between ResolveEsoNamespace and TokenAccessGenerator. +type ESONamespace string + +const ( + verbCreate = "create" + verbDelete = "delete" + verbGet = "get" + verbList = "list" + verbPatch = "patch" + verbUpdate = "update" + verbWatch = "watch" +) + +// ResolveEsoNamespace is an AdditionalDataResolver that returns the ESO installation namespace. +func ResolveEsoNamespace(_ context.Context, obj *externalsecretsoperatorsv1alpha1.ExternalSecretsOperator, providerConfig *externalsecretsoperatorsv1alpha1.ProviderConfig) (any, error) { + if obj == nil { + return nil, fmt.Errorf("obj must not be nil") + } + if providerConfig == nil { + return nil, fmt.Errorf("providerConfig must not be nil") + } + for _, version := range providerConfig.Spec.Versions { + if version.Version != obj.Spec.Version { + continue + } + helmValues, err := ExtractHelmValues(version.HelmValues) + if err != nil { + return nil, fmt.Errorf("failed to extract helm values: %w", err) + } + namespace := DefaultNamespace + if helmValues.NamespaceOverride != "" { + namespace = helmValues.NamespaceOverride + } + return ESONamespace(namespace), nil + } + return nil, fmt.Errorf("version %s not found in ProviderConfig", obj.Spec.Version) +} + +// TokenAccesGenerator returns a TokenConfig with the RBAC permissions required to install ESO. +func TokenAccesGenerator(_ reconcile.Request, additionalData ...any) (*clustersv1alpha1.TokenConfig, error) { + namespace := DefaultNamespace + for _, data := range additionalData { + ns, ok := data.(ESONamespace) + if ok && ns != "" { + namespace = string(ns) + break + } + } + return &clustersv1alpha1.TokenConfig{ + Permissions: []clustersv1alpha1.PermissionsRequest{ + { + Namespace: namespace, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"serviceaccounts", "services"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{"apps"}, + Resources: []string{"deployments", "replicasets"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"roles", "rolebindings"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{"policy"}, + Resources: []string{"poddisruptionbudgets"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{"cert-manager.io"}, + Resources: []string{"certificates"}, + Verbs: []string{rbacv1.VerbAll}, + }, + }, + }, + { + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterroles", "clusterrolebindings"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{"admissionregistration.k8s.io"}, + Resources: []string{"validatingwebhookconfigurations"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{""}, + Resources: []string{"endpoints"}, + Verbs: []string{verbGet, verbList, verbWatch}, + }, + { + APIGroups: []string{"discovery.k8s.io"}, + Resources: []string{"endpointslices"}, + Verbs: []string{verbGet, verbList, verbWatch}, + }, + { + APIGroups: []string{"coordination.k8s.io"}, + Resources: []string{"leases"}, + Verbs: []string{verbGet, verbCreate, verbPatch, verbUpdate}, + }, + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{verbGet, verbList, verbWatch, verbCreate, verbPatch, verbUpdate}, + }, + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{verbCreate, verbPatch}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{""}, + Resources: []string{"serviceaccounts"}, + Verbs: []string{verbGet, verbList, verbWatch}, + }, + { + APIGroups: []string{""}, + Resources: []string{"serviceaccounts/token"}, + Verbs: []string{verbCreate}, + }, + { + APIGroups: []string{"authentication.k8s.io"}, + Resources: []string{"tokenreviews"}, + Verbs: []string{verbCreate}, + }, + { + APIGroups: []string{"authorization.k8s.io"}, + Resources: []string{"subjectaccessreviews"}, + Verbs: []string{verbCreate}, + }, + { + APIGroups: []string{"monitoring.coreos.com"}, + Resources: []string{"servicemonitors"}, + Verbs: []string{rbacv1.VerbAll}, + }, + { + APIGroups: []string{"external-secrets.io"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }, + { + APIGroups: []string{"generators.external-secrets.io"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }, + }, + }, + }, + }, nil +} diff --git a/pkg/manager/permissions_test.go b/pkg/manager/permissions_test.go new file mode 100644 index 0000000..5aed7ae --- /dev/null +++ b/pkg/manager/permissions_test.go @@ -0,0 +1,148 @@ +package externalsecrets + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + externalsecretsoperatorsv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" +) + +func TestResolveEsoNamespace(t *testing.T) { + tests := []struct { + name string + obj *externalsecretsoperatorsv1alpha1.ExternalSecretsOperator + pc *externalsecretsoperatorsv1alpha1.ProviderConfig + want any + wantErr bool + }{ + { + name: "No Namespace Override", + obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ + Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ + Version: "v1.0.0", + }, + }, + pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ + Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ + Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ + { + Version: "v1.0.0", + }, + }, + }, + }, + want: ESONamespace(DefaultNamespace), + }, + { + name: "Namespace Override", + obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ + Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ + Version: "v1.0.0", + }, + }, + pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ + Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ + Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ + { + Version: "v1.0.0", + HelmValues: &apiextensionsv1.JSON{ + Raw: []byte(`{"namespaceOverride": "eso-system"}`), + }, + }, + }, + }, + }, + want: ESONamespace("eso-system"), + }, + { + name: "Empty String Namespace Override", + obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ + Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ + Version: "v1.0.0", + }, + }, + pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ + Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ + Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ + { + Version: "v1.0.0", + HelmValues: &apiextensionsv1.JSON{ + Raw: []byte(`{"namespaceOverride": ""}`), + }, + }, + }, + }, + }, + want: ESONamespace(DefaultNamespace), + }, + { + name: "ProviderConfig nil", + obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{}, + pc: nil, + wantErr: true, + }, + { + name: "Obj nil", + obj: nil, + pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{}, + wantErr: true, + }, + { + name: "Version Not Found", + obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ + Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ + Version: "v2.0.0", + }, + }, + pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ + Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ + Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ + { + Version: "v1.0.0", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "Invalid Helm Values", + obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ + Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ + Version: "v1.0.0", + }, + }, + pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ + Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ + Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ + { + Version: "v1.0.0", + HelmValues: &apiextensionsv1.JSON{ + Raw: []byte("invalid json"), + }, + }, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotErr := ResolveEsoNamespace(context.Background(), tt.obj, tt.pc) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("TestResolveEsoNamespace() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("TestResolveEsoNamespace() succeeded unexpectedly") + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/manager/secret.go b/pkg/manager/secret.go new file mode 100644 index 0000000..ac25537 --- /dev/null +++ b/pkg/manager/secret.go @@ -0,0 +1,76 @@ +package externalsecrets + +import ( + "context" + "fmt" + + ctrlutils "github.com/openmcp-project/controller-utils/pkg/controller" + openmcpresources "github.com/openmcp-project/controller-utils/pkg/resources" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// SecretCopyConfig holds the configuration for copying a secret. +type SecretCopyConfig struct { + // SourceClient is the client to read the source secret from. + SourceClient client.Client + // SourceNamespace is the namespace of the source secret. + SourceNamespace string + // TargetNamespace is the namespace of the target secret. + TargetNamespace string + // TargetName is the name of the target secret. + TargetName string +} + +const secretNamePrefix = "sp-eso-" + +// ManagePullSecret syncs every image pull secret the to cluster +func ManagePullSecret(targetCluster ManagedCluster, pullSecret corev1.LocalObjectReference, config SecretCopyConfig) { + secret := NewManagedObject(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: config.TargetName, + Namespace: config.TargetNamespace, + }, + }, ManagedObjectContext{ + ReconcileFunc: func(ctx context.Context, o client.Object) error { + oSecret, ok := o.(*corev1.Secret) + if !ok { + return fmt.Errorf("expected *corev1.Secret, got %T", o) + } + sourceSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: pullSecret.Name, + Namespace: config.SourceNamespace, + }, + } + // retrieve source secret from platform cluster + if err := config.SourceClient.Get(ctx, client.ObjectKeyFromObject(sourceSecret), sourceSecret); err != nil { + return err + } + mutator := openmcpresources.NewSecretMutator(config.TargetName, config.TargetNamespace, sourceSecret.Data, corev1.SecretTypeDockerConfigJson) + return mutator.Mutate(oSecret) + }, + StatusFunc: SimpleStatus, + }) + targetCluster.AddObject(secret) +} + +// PrefixSecretName adds the "sp-eso-" prefix to the given secret name +// to prevent name collisions in namespaces where multiple service providers operate. +// If the resulting name exceeds 63 characters (K8s limit), it will be truncated +// and a hash suffix appended for uniqueness via ShortenToXCharacters. +func PrefixSecretName(secretName string) (string, error) { + return ctrlutils.ShortenToXCharacters(fmt.Sprintf("%s%s", secretNamePrefix, secretName), ctrlutils.K8sMaxNameLength) +} + +// NewSecretCleaner removes redundant pull secrets in the given target namespace +// by removing any secret labeled as managed by sp-external-secrets that is not in secretsToKeep. +func NewSecretCleaner(cluster ManagedCluster, namespace string, secretsToKeep []corev1.LocalObjectReference) OrphanCleaner { + return NewOrphanCleaner(cluster, namespace, cleanerType[*corev1.SecretList]{ + EmptyList: func() *corev1.SecretList { + return &corev1.SecretList{} + }, + ObjectsToKeep: secretsToKeep, + }) +} diff --git a/pkg/manager/secret_test.go b/pkg/manager/secret_test.go new file mode 100644 index 0000000..b1ed4f3 --- /dev/null +++ b/pkg/manager/secret_test.go @@ -0,0 +1,112 @@ +package externalsecrets + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + secretName = "privateregcred" + sourceNamespace = "source" + targetNamespace = "target" +) + +func TestManagePullSecrets(t *testing.T) { + fakeCluster := CreateFakeCluster(t, "platform", &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: sourceNamespace, + }, + Data: map[string][]byte{ + "test": []byte("testdata"), + }, + Type: corev1.SecretTypeDockerConfigJson, + }, &corev1.Secret{ + // existing secret with the same source secret name in the target namespace + // this secret needs to be untouched by the service provider secret copy functionality + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: targetNamespace, + }, + Data: map[string][]byte{ + "existing-secret-data": []byte("must-not-be-altered"), + }, + Type: corev1.SecretTypeDockerConfigJson, + }) + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + targetCluster ManagedCluster + pullSecret corev1.LocalObjectReference + config SecretCopyConfig + }{ + { + name: "copy secret privateregcred from source to target namespace and adjust its name", + targetCluster: NewManagedCluster(fakeCluster, &rest.Config{}, sourceNamespace, PlatformCluster), + pullSecret: corev1.LocalObjectReference{Name: secretName}, + config: SecretCopyConfig{ + SourceClient: fakeCluster.Client(), + SourceNamespace: sourceNamespace, + TargetNamespace: targetNamespace, + TargetName: fmt.Sprintf("%s%s", secretNamePrefix, secretName), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ManagePullSecret(tt.targetCluster, tt.pullSecret, tt.config) + ExecApply(t, []ManagedCluster{tt.targetCluster}, 1, []string{}) + sourceSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.pullSecret.Name, + Namespace: tt.config.SourceNamespace, + }, + } + targetSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.config.TargetName, + Namespace: tt.config.TargetNamespace, + }, + } + existingSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: tt.config.TargetNamespace, + }, + } + require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(sourceSecret), sourceSecret)) + require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(targetSecret), targetSecret)) + require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(existingSecret), existingSecret)) + assert.Equal(t, sourceSecret.Data, targetSecret.Data) + assert.Equal(t, map[string][]byte{"existing-secret-data": []byte("must-not-be-altered")}, existingSecret.Data) + assert.Equal(t, corev1.SecretTypeDockerConfigJson, targetSecret.Type, "target secret should have the correct type") + }) + } +} + +func TestPrefixSecretName(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"short name", "privateregcred"}, + {"long name truncated", strings.Repeat("a", 60)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := PrefixSecretName(tt.input) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(got, secretNamePrefix)) + assert.LessOrEqual(t, len(got), 63) + }) + } +} diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go new file mode 100644 index 0000000..b7ad0d9 --- /dev/null +++ b/pkg/manager/utils_test.go @@ -0,0 +1,65 @@ +package externalsecrets + +import ( + "context" + "testing" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/controller-utils/pkg/clusters" + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" +) + +// CreateFakeCluster sets up a cluster with a fake client +func CreateFakeCluster(t *testing.T, id string, clusterObjects ...client.Object) *clusters.Cluster { + t.Helper() + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = apiextv1.AddToScheme(scheme) + _ = v1alpha1.AddToScheme(scheme) + _ = clustersv1alpha1.AddToScheme(scheme) + _ = sourcev1.AddToScheme(scheme) + _ = helmv2.AddToScheme(scheme) + + // init cluster with objects + fakeClient := fake.NewClientBuilder().WithObjects(clusterObjects...).WithScheme(scheme).Build() + return clusters.NewTestClusterFromClient(id, fakeClient) +} + +// ExecApply sets up a manager for the provided clusters and invokes reconciliation of all managed objects +func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects int, wantErrors []string) []Result { + t.Helper() + // invoke apply with manager + mgr := NewManager() + for _, cluster := range clusters { + mgr.AddCluster(cluster) + } + results, err := mgr.Apply(context.TODO()) + require.NoError(t, err) + return assertResult(t, results, expectedManagedObjects, wantErrors) +} + +func assertResult(t *testing.T, results []Result, expectedManagedObjects int, wantErrors []string) []Result { + t.Helper() + assert.Len(t, results, expectedManagedObjects, "expected %d managed object(s), got %d managed object(s)") + errcount := 0 + for _, r := range results { + if r.Error != nil { + // assert that an error is expected + assert.Contains(t, wantErrors, r.Object.GetObject().GetName(), "unexpected reconcile error of managed object %s", r.Object.GetObject().GetName()) + errcount++ + } + } + // assert that the overall number of errors is expected + assert.Equal(t, len(wantErrors), errcount, "expected %d reconcile error(s), got %d reconcile error(s)", len(wantErrors), errcount) + return results +} From a4e594c4e54b7de419aea5e90a88f29afee2ce9d Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Mon, 3 Aug 2026 06:31:39 +0200 Subject: [PATCH 02/20] feat: Change package name, modify label handling Signed-off-by: Jakob Bergmeier --- go.mod | 14 ++- go.sum | 28 ++++- pkg/manager/flux.go | 37 +++--- pkg/manager/flux_test.go | 2 +- pkg/manager/helm.go | 2 +- pkg/manager/helm_test.go | 2 +- pkg/manager/labels.go | 14 +-- pkg/manager/managedcluster.go | 2 +- pkg/manager/managedobject.go | 47 +++++--- pkg/manager/manager.go | 12 +- pkg/manager/objectutils.go | 2 +- pkg/manager/orphancleaner.go | 34 +++--- pkg/manager/orphancleaner_test.go | 2 +- pkg/manager/permissions.go | 186 ------------------------------ pkg/manager/permissions_test.go | 148 ------------------------ pkg/manager/utils_test.go | 5 +- 16 files changed, 125 insertions(+), 412 deletions(-) delete mode 100644 pkg/manager/permissions.go delete mode 100644 pkg/manager/permissions_test.go diff --git a/go.mod b/go.mod index fcacb5b..a455eff 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,19 @@ replace github.com/openmcp-project/controller-utils/api => ./api require ( github.com/evanphx/json-patch/v5 v5.9.11 + github.com/fluxcd/helm-controller/api v1.6.3 + github.com/fluxcd/pkg/apis/meta v1.31.0 + github.com/fluxcd/pkg/runtime v0.111.0 + github.com/fluxcd/source-controller/api v1.9.3 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 github.com/openmcp-project/controller-utils/api v0.31.0 + github.com/openmcp-project/opencontrolplane-runtime v1.2.0 + github.com/openmcp-project/openmcp-operator/api v1.3.0 + github.com/openmcp-project/service-provider-external-secrets v1.1.1 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 @@ -28,12 +35,13 @@ require ( ) require ( - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect 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.13.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/fluxcd/pkg/apis/acl v0.10.0 // indirect github.com/fluxcd/pkg/apis/kustomize v1.20.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect @@ -60,6 +68,8 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openmcp-project/openmcp-operator/lib v1.3.0 // 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.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -83,7 +93,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102 // indirect + k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect diff --git a/go.sum b/go.sum index 8a890b8..e1a077c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -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/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.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= @@ -14,8 +16,18 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH 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.6.3 h1:8EvQASKKFDEj8Ub3xhMSjyNbGTEjNqlHZYm5WhRxJvg= +github.com/fluxcd/helm-controller/api v1.6.3/go.mod h1:CaI5bHedusLcXYj1+pkd4RkSE8TtiEHI3ReHNsUySbg= +github.com/fluxcd/pkg/apis/acl v0.10.0 h1:KPfAmELNvtvaz8wixnm/MYXqa+MJf7ntVVMUU93Aenk= +github.com/fluxcd/pkg/apis/acl v0.10.0/go.mod h1:a87i2A7AlFO5N2J8CxtzaUCCDmuLLWOHwkKu3eJF5fY= github.com/fluxcd/pkg/apis/kustomize v1.20.0 h1:Aur2337TwSYGUffDQVlawOR3SJfRvxH7ikEKD6cJSxs= github.com/fluxcd/pkg/apis/kustomize v1.20.0/go.mod h1:9FUs77fd/Rh5/mDgZbGBUCL0UqmXiGj8rYywG3T3x+s= +github.com/fluxcd/pkg/apis/meta v1.31.0 h1:5niQvTirK0wTE0TfRjnUSdmu6GTSbAFzrdnovtZ9rJ8= +github.com/fluxcd/pkg/apis/meta v1.31.0/go.mod h1:Gx+YRq26a+mTbCjotSXC7/6kSSyo0zXQ8JnsEXf2vVk= +github.com/fluxcd/pkg/runtime v0.111.0 h1:nbmFuYstyN2a1SDW0SZU5dG9+9x+yDP4A39kKQKjj2A= +github.com/fluxcd/pkg/runtime v0.111.0/go.mod h1:YUljTAXVaeWG+GLnXNZopFFMZstUoTxJs7+vl91OV58= +github.com/fluxcd/source-controller/api v1.9.3 h1:Z5Id6iPV+n1OY/14dpC18PaHRATzAyZkjAzDNYxSl4E= +github.com/fluxcd/source-controller/api v1.9.3/go.mod h1:qO98L20gw0MvLpSPapJKypyxHP7PejmJN5KxWXxDjCA= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= @@ -107,6 +119,14 @@ github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/openmcp-project/opencontrolplane-runtime v1.2.0 h1:TYqIwEz93mZmA6mp7LSXXy0VbU/tND9vUTVo/IpzS90= +github.com/openmcp-project/opencontrolplane-runtime v1.2.0/go.mod h1:TA7vVVm8NcT6tIchu4mFeHSdCCm08lIMzFT1mF4HkBA= +github.com/openmcp-project/openmcp-operator/api v1.3.0 h1:RgetVDqslJPD7/nb65AbqWYtwM1qJf0ePgf0uJiEcQQ= +github.com/openmcp-project/openmcp-operator/api v1.3.0/go.mod h1:GMCkaGUND5Qu3zTNjpYtR44her8gcOksHkrJu5isL6I= +github.com/openmcp-project/openmcp-operator/lib v1.3.0 h1:Icx40/Qx/85+E8vWTMi1jW1WrX/MFN+FaZTDt7JWcRU= +github.com/openmcp-project/openmcp-operator/lib v1.3.0/go.mod h1:+Ptr+38neYaoGzEdY5zhR3uhfKqNZ2J9tyOcc15S7EY= +github.com/openmcp-project/service-provider-external-secrets v1.1.1 h1:JrpFRdCfkIdWzULeN3xgg4mTAanirUia2LxxXAwMtp4= +github.com/openmcp-project/service-provider-external-secrets v1.1.1/go.mod h1:YO/oHfzWQYxm6eR1ZsMvkmjegpJOI995+Qjy/V7U290= 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= @@ -193,8 +213,8 @@ k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102 h1:xs2ux1MvyrOdfKwS3vuFWrGuLgDOHk6id975Twx2Jss= -k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 h1:mPMaPMpBij2V1Wv/fR+HW124vVGXXvOSS9ver/9yjWs= +k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go index 022a384..2951082 100644 --- a/pkg/manager/flux.go +++ b/pkg/manager/flux.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" @@ -8,14 +8,14 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/fluxcd/pkg/runtime/conditions" "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" - - apiv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" + commonapi "github.com/openmcp-project/openmcp-operator/api/common" ) const ( @@ -36,13 +36,20 @@ type ManageFluxResourcesParams struct { // ChartPullSecretName defines the name of the secret copy that will be placed in the Cluster namespace ChartPullSecretName string // Obj is the tenant API object that is being reconciled - Obj *apiv1alpha1.ExternalSecretsOperator + Obj *client.Object // Interval defines OCIRepository and HelmRelease reconcile intervals Interval time.Duration // ClusterContext of the current reconciliation context ClusterContext clusteraccess.ClusterContext // RequestedVersion is the version of External Secrets Operator that a user requested through the onboarding API - RequestedVersion apiv1alpha1.ExternalSecretsVersion + RequestedVersion RequestedVersion +} + +type RequestedVersion struct { + Version string + ChartURL string + ChartVersion string + HelmValues *apiextensionsv1.JSON } // ManageFluxResources configures OCIRepo and HelmRelease @@ -58,13 +65,13 @@ func ManageFluxResources(p ManageFluxResourcesParams) { if !ok { return fmt.Errorf("expected *sourcev1.OCIRepository, got %T", o) } - if p.RequestedVersion.ChartURL == nil { + if p.RequestedVersion.ChartURL == "" { // this should never happen as long as defaulting works properly return fmt.Errorf("missing ChartURL definition for Flux version %s", p.RequestedVersion.Version) } ociRepo.Spec = sourcev1.OCIRepositorySpec{ Interval: metav1.Duration{Duration: p.Interval}, - URL: *p.RequestedVersion.ChartURL, + URL: p.RequestedVersion.ChartURL, Reference: &sourcev1.OCIRepositoryRef{ Tag: p.RequestedVersion.ChartVersion, }, @@ -137,25 +144,25 @@ func ManageFluxResources(p ManageFluxResourcesParams) { } // FluxStatus indicates whether the given object is in phase terminating, pending or ready. -func FluxStatus(o client.Object, rl apiv1alpha1.ResourceLocation) Status { +func FluxStatus(o client.Object, resourceLocation string) Status { fluxObject := o.(conditions.Getter) if !o.GetDeletionTimestamp().IsZero() { return Status{ - Phase: apiv1alpha1.Terminating, + Phase: commonapi.StatusPhaseTerminating, Message: "Resource is terminating.", - Location: rl, + Location: resourceLocation, } } - if conditions.IsTrue(fluxObject, meta.ReadyCondition) { + if conditions.IsReady(fluxObject) { return Status{ - Phase: apiv1alpha1.Ready, + Phase: commonapi.StatusPhaseReady, Message: "Resource is ready", - Location: rl, + Location: resourceLocation, } } return Status{ - Phase: apiv1alpha1.Pending, + Phase: commonapi.StatusPhaseProgressing, Message: "Resource is not ready", - Location: rl, + Location: resourceLocation, } } diff --git a/pkg/manager/flux_test.go b/pkg/manager/flux_test.go index dd09e82..631759a 100644 --- a/pkg/manager/flux_test.go +++ b/pkg/manager/flux_test.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" diff --git a/pkg/manager/helm.go b/pkg/manager/helm.go index cc965e1..12b4d31 100644 --- a/pkg/manager/helm.go +++ b/pkg/manager/helm.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "encoding/json" diff --git a/pkg/manager/helm_test.go b/pkg/manager/helm_test.go index 0adaf73..66fa872 100644 --- a/pkg/manager/helm_test.go +++ b/pkg/manager/helm_test.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "testing" diff --git a/pkg/manager/labels.go b/pkg/manager/labels.go index 209b526..bca854f 100644 --- a/pkg/manager/labels.go +++ b/pkg/manager/labels.go @@ -1,27 +1,25 @@ -package externalsecrets +package manager import "sigs.k8s.io/controller-runtime/pkg/client" const ( // LabelManagedBy defines the managed-by label that is added to every managed object. LabelManagedBy = "app.kubernetes.io/managed-by" - // LabelManagedByValue defines the managed-by value that is added to every managed object. - LabelManagedByValue = "service-provider-external-secrets" ) // SetManagedBy sets the managed-by label of the given client.Object. -func SetManagedBy(o client.Object) { +func SetManagedBy(o client.Object, managedBy string) { labels := o.GetLabels() if labels == nil { labels = map[string]string{} } - labels[LabelManagedBy] = LabelManagedByValue + labels[LabelManagedBy] = managedBy o.SetLabels(labels) } -// ManagedBy returns the managed-by label of the given client.Object. -func ManagedBy() client.ListOption { +// ManagedBy returns a list option filtering objects by managed-by label value. +func ManagedBy(managedBy string) client.ListOption { return client.MatchingLabels{ - LabelManagedBy: LabelManagedByValue, + LabelManagedBy: managedBy, } } diff --git a/pkg/manager/managedcluster.go b/pkg/manager/managedcluster.go index 3d186b8..2aee341 100644 --- a/pkg/manager/managedcluster.go +++ b/pkg/manager/managedcluster.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "strings" diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go index 8647454..70406b4 100644 --- a/pkg/manager/managedobject.go +++ b/pkg/manager/managedobject.go @@ -1,11 +1,11 @@ -package externalsecrets +package manager import ( "context" - "sigs.k8s.io/controller-runtime/pkg/client" + commonapi "github.com/openmcp-project/openmcp-operator/api/common" - "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" ) // DeletionPolicy distinguishes between normal deletion and orphaning an object. @@ -27,36 +27,36 @@ func NoOp(context.Context, client.Object) error { } // StatusFunc provides Status information for the given client.Object. -type StatusFunc func(o client.Object, rl v1alpha1.ResourceLocation) Status +type StatusFunc func(o client.Object, resourceLocation string) Status // SimpleStatus indicates whether the given object is in phase terminating, pending or ready. -func SimpleStatus(o client.Object, rl v1alpha1.ResourceLocation) Status { +func SimpleStatus(o client.Object, resourceLocation string) Status { if !o.GetDeletionTimestamp().IsZero() { return Status{ - Phase: v1alpha1.Terminating, + Phase: commonapi.StatusPhaseTerminating, Message: "Resource is terminating.", - Location: rl, + Location: resourceLocation, } } if o.GetUID() == "" { return Status{ - Phase: v1alpha1.Pending, + Phase: commonapi.StatusPhaseProgressing, Message: "Resource has not been created yet.", - Location: rl, + Location: resourceLocation, } } return Status{ - Phase: v1alpha1.Ready, + Phase: commonapi.StatusPhaseReady, Message: "Resource exists.", - Location: rl, + Location: resourceLocation, } } // Status defines the status attributes of a ManagedObject. type Status struct { - Phase v1alpha1.InstancePhase + Phase string Message string - Location v1alpha1.ResourceLocation + Location string } // NewManagedObject creates a new ManagedObject instances to manage the given client.Object. @@ -71,6 +71,7 @@ func NewManagedObject(o client.Object, moc ManagedObjectContext) ManagedObject { dependencies: moc.DependsOn, deletionPolicy: moc.DeletionPolicy, statusFunc: moc.StatusFunc, + managedBy: moc.ManagedBy, } } @@ -80,15 +81,17 @@ type ManagedObjectContext struct { DependsOn []ManagedObject DeletionPolicy DeletionPolicy StatusFunc StatusFunc + ManagedBy string } -// ManagedObject represents an object managed by a Manager. +// ManagedObject represents an object managed by a Mana^ger. type ManagedObject interface { GetObject() client.Object Reconcile(ctx context.Context) error GetDependencies() []ManagedObject GetDeletionPolicy() DeletionPolicy - GetStatus(v1alpha1.ResourceLocation) Status + GetStatus(resourceLocation string) Status + Label() string } var _ ManagedObject = &managedObject{} @@ -99,17 +102,18 @@ type managedObject struct { statusFunc StatusFunc dependencies []ManagedObject deletionPolicy DeletionPolicy + managedBy string } // GetStatus implements ManagedObject. -func (m *managedObject) GetStatus(rl v1alpha1.ResourceLocation) Status { +func (m *managedObject) GetStatus(resourceLocation string) Status { if m.statusFunc != nil { - return m.statusFunc(m.object, rl) + return m.statusFunc(m.object, resourceLocation) } return Status{ - Phase: v1alpha1.Unknown, + Phase: "Unknown", Message: "No status function defined.", - Location: rl, + Location: resourceLocation, } } @@ -135,3 +139,8 @@ func (m *managedObject) Reconcile(ctx context.Context) error { func (m *managedObject) GetObject() client.Object { return m.object } + +// Label implements ManagedObject. +func (m *managedObject) Label() string { + return m.managedBy +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index bd9bd93..2b411f4 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" @@ -40,15 +40,17 @@ type OrphanCleaner interface { } // NewManager creates a new Manager instance. -func NewManager() Manager { +func NewManager(serviceProvider string) Manager { return &managerImpl{ - clusters: []ManagedCluster{}, - cleaners: []OrphanCleaner{}, + serviceProvider: serviceProvider, + clusters: []ManagedCluster{}, + cleaners: []OrphanCleaner{}, } } // managerImpl manages clusters and invokes reconciliation of ManagedObjects. type managerImpl struct { + serviceProvider string clusters []ManagedCluster cleaners []OrphanCleaner } @@ -138,7 +140,7 @@ func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo } opResult, err := controllerutil.CreateOrUpdate(ctx, client, obj, func() error { - SetManagedBy(obj) + SetManagedBy(obj , m.serviceProvider) return mo.Reconcile(ctx) }) return Result{ diff --git a/pkg/manager/objectutils.go b/pkg/manager/objectutils.go index 8b9a596..34f8d89 100644 --- a/pkg/manager/objectutils.go +++ b/pkg/manager/objectutils.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "fmt" diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go index 4ec28ed..584ada6 100644 --- a/pkg/manager/orphancleaner.go +++ b/pkg/manager/orphancleaner.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" @@ -11,7 +11,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - apiv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" + commonapi "github.com/openmcp-project/openmcp-operator/api/common" ) // ErrOrphanCleanup is an user-facing error that indicates orphan cleanup failures @@ -20,9 +20,10 @@ var ErrOrphanCleanup = errors.New("orphan cleanup failed") var _ OrphanCleaner = &orphanCleaner[*corev1.SecretList]{} type orphanCleaner[T client.ObjectList] struct { - cluster ManagedCluster - namespace string - cleanerType cleanerType[T] + cluster ManagedCluster + serviceProvider string + namespace string + cleanerType cleanerType[T] } type cleanerType[T client.ObjectList] struct { @@ -37,11 +38,12 @@ type cleanerType[T client.ObjectList] struct { } // NewOrphanCleaner removes redundant objects in the given target namespace. -func NewOrphanCleaner[T client.ObjectList](cluster ManagedCluster, namespace string, clType cleanerType[T]) OrphanCleaner { +func NewOrphanCleaner[T client.ObjectList](cluster ManagedCluster, serviceProvider string, namespace string, clType cleanerType[T]) OrphanCleaner { return &orphanCleaner[T]{ - cluster: cluster, - namespace: namespace, - cleanerType: clType, + cluster: cluster, + serviceProvider: serviceProvider, + namespace: namespace, + cleanerType: clType, } } @@ -65,7 +67,7 @@ func (c *orphanCleaner[T]) Cleanup(ctx context.Context) ([]Result, error) { cl := c.cluster.GetClient() if err := cl.List(ctx, objList, client.InNamespace(c.namespace), - client.MatchingLabels{LabelManagedBy: LabelManagedByValue}, + client.MatchingLabels{LabelManagedBy: c.serviceProvider}, ); err != nil { log.FromContext(ctx).Error(err, "failed to list objects for orphan cleanup") return nil, ErrOrphanCleanup @@ -105,11 +107,11 @@ func (c *orphanCleaner[T]) deletionPrepared(obj client.Object) Result { } } -func cleanupPreparedStatus(_ client.Object, rl apiv1alpha1.ResourceLocation) Status { +func cleanupPreparedStatus(_ client.Object, resourceLocation string) Status { return Status{ - Phase: apiv1alpha1.Terminating, + Phase: commonapi.StatusPhaseTerminating, Message: "Orphan cleanup prepared", - Location: rl, + Location: resourceLocation, } } @@ -126,10 +128,10 @@ func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { } } -func cleanupErrorStatus(_ client.Object, rl apiv1alpha1.ResourceLocation) Status { +func cleanupErrorStatus(_ client.Object, resourceLocation string) Status { return Status{ - Phase: apiv1alpha1.Terminating, + Phase: "Failed", Message: "Orphan cleanup failed", - Location: rl, + Location: resourceLocation, } } diff --git a/pkg/manager/orphancleaner_test.go b/pkg/manager/orphancleaner_test.go index 087b638..7b7d394 100644 --- a/pkg/manager/orphancleaner_test.go +++ b/pkg/manager/orphancleaner_test.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" diff --git a/pkg/manager/permissions.go b/pkg/manager/permissions.go deleted file mode 100644 index 7223a96..0000000 --- a/pkg/manager/permissions.go +++ /dev/null @@ -1,186 +0,0 @@ -package externalsecrets - -import ( - "context" - "fmt" - - clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" - - externalsecretsoperatorsv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" - - rbacv1 "k8s.io/api/rbac/v1" - "sigs.k8s.io/controller-runtime/pkg/reconcile" -) - -// ESONamespace carries the resolved ESO installation namespace between ResolveEsoNamespace and TokenAccessGenerator. -type ESONamespace string - -const ( - verbCreate = "create" - verbDelete = "delete" - verbGet = "get" - verbList = "list" - verbPatch = "patch" - verbUpdate = "update" - verbWatch = "watch" -) - -// ResolveEsoNamespace is an AdditionalDataResolver that returns the ESO installation namespace. -func ResolveEsoNamespace(_ context.Context, obj *externalsecretsoperatorsv1alpha1.ExternalSecretsOperator, providerConfig *externalsecretsoperatorsv1alpha1.ProviderConfig) (any, error) { - if obj == nil { - return nil, fmt.Errorf("obj must not be nil") - } - if providerConfig == nil { - return nil, fmt.Errorf("providerConfig must not be nil") - } - for _, version := range providerConfig.Spec.Versions { - if version.Version != obj.Spec.Version { - continue - } - helmValues, err := ExtractHelmValues(version.HelmValues) - if err != nil { - return nil, fmt.Errorf("failed to extract helm values: %w", err) - } - namespace := DefaultNamespace - if helmValues.NamespaceOverride != "" { - namespace = helmValues.NamespaceOverride - } - return ESONamespace(namespace), nil - } - return nil, fmt.Errorf("version %s not found in ProviderConfig", obj.Spec.Version) -} - -// TokenAccesGenerator returns a TokenConfig with the RBAC permissions required to install ESO. -func TokenAccesGenerator(_ reconcile.Request, additionalData ...any) (*clustersv1alpha1.TokenConfig, error) { - namespace := DefaultNamespace - for _, data := range additionalData { - ns, ok := data.(ESONamespace) - if ok && ns != "" { - namespace = string(ns) - break - } - } - return &clustersv1alpha1.TokenConfig{ - Permissions: []clustersv1alpha1.PermissionsRequest{ - { - Namespace: namespace, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"serviceaccounts", "services"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{"apps"}, - Resources: []string{"deployments", "replicasets"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{"rbac.authorization.k8s.io"}, - Resources: []string{"roles", "rolebindings"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{"policy"}, - Resources: []string{"poddisruptionbudgets"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{"cert-manager.io"}, - Resources: []string{"certificates"}, - Verbs: []string{rbacv1.VerbAll}, - }, - }, - }, - { - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{"apiextensions.k8s.io"}, - Resources: []string{"customresourcedefinitions"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{"rbac.authorization.k8s.io"}, - Resources: []string{"clusterroles", "clusterrolebindings"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{"admissionregistration.k8s.io"}, - Resources: []string{"validatingwebhookconfigurations"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{""}, - Resources: []string{"endpoints"}, - Verbs: []string{verbGet, verbList, verbWatch}, - }, - { - APIGroups: []string{"discovery.k8s.io"}, - Resources: []string{"endpointslices"}, - Verbs: []string{verbGet, verbList, verbWatch}, - }, - { - APIGroups: []string{"coordination.k8s.io"}, - Resources: []string{"leases"}, - Verbs: []string{verbGet, verbCreate, verbPatch, verbUpdate}, - }, - { - APIGroups: []string{""}, - Resources: []string{"namespaces"}, - Verbs: []string{verbGet, verbList, verbWatch, verbCreate, verbPatch, verbUpdate}, - }, - { - APIGroups: []string{""}, - Resources: []string{"configmaps"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{""}, - Resources: []string{"events"}, - Verbs: []string{verbCreate, verbPatch}, - }, - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{""}, - Resources: []string{"serviceaccounts"}, - Verbs: []string{verbGet, verbList, verbWatch}, - }, - { - APIGroups: []string{""}, - Resources: []string{"serviceaccounts/token"}, - Verbs: []string{verbCreate}, - }, - { - APIGroups: []string{"authentication.k8s.io"}, - Resources: []string{"tokenreviews"}, - Verbs: []string{verbCreate}, - }, - { - APIGroups: []string{"authorization.k8s.io"}, - Resources: []string{"subjectaccessreviews"}, - Verbs: []string{verbCreate}, - }, - { - APIGroups: []string{"monitoring.coreos.com"}, - Resources: []string{"servicemonitors"}, - Verbs: []string{rbacv1.VerbAll}, - }, - { - APIGroups: []string{"external-secrets.io"}, - Resources: []string{"*"}, - Verbs: []string{"*"}, - }, - { - APIGroups: []string{"generators.external-secrets.io"}, - Resources: []string{"*"}, - Verbs: []string{"*"}, - }, - }, - }, - }, - }, nil -} diff --git a/pkg/manager/permissions_test.go b/pkg/manager/permissions_test.go deleted file mode 100644 index 5aed7ae..0000000 --- a/pkg/manager/permissions_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package externalsecrets - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - - externalsecretsoperatorsv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" -) - -func TestResolveEsoNamespace(t *testing.T) { - tests := []struct { - name string - obj *externalsecretsoperatorsv1alpha1.ExternalSecretsOperator - pc *externalsecretsoperatorsv1alpha1.ProviderConfig - want any - wantErr bool - }{ - { - name: "No Namespace Override", - obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ - Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ - Version: "v1.0.0", - }, - }, - pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ - Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ - Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ - { - Version: "v1.0.0", - }, - }, - }, - }, - want: ESONamespace(DefaultNamespace), - }, - { - name: "Namespace Override", - obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ - Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ - Version: "v1.0.0", - }, - }, - pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ - Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ - Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ - { - Version: "v1.0.0", - HelmValues: &apiextensionsv1.JSON{ - Raw: []byte(`{"namespaceOverride": "eso-system"}`), - }, - }, - }, - }, - }, - want: ESONamespace("eso-system"), - }, - { - name: "Empty String Namespace Override", - obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ - Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ - Version: "v1.0.0", - }, - }, - pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ - Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ - Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ - { - Version: "v1.0.0", - HelmValues: &apiextensionsv1.JSON{ - Raw: []byte(`{"namespaceOverride": ""}`), - }, - }, - }, - }, - }, - want: ESONamespace(DefaultNamespace), - }, - { - name: "ProviderConfig nil", - obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{}, - pc: nil, - wantErr: true, - }, - { - name: "Obj nil", - obj: nil, - pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{}, - wantErr: true, - }, - { - name: "Version Not Found", - obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ - Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ - Version: "v2.0.0", - }, - }, - pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ - Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ - Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ - { - Version: "v1.0.0", - }, - }, - }, - }, - wantErr: true, - }, - { - name: "Invalid Helm Values", - obj: &externalsecretsoperatorsv1alpha1.ExternalSecretsOperator{ - Spec: externalsecretsoperatorsv1alpha1.ExternalSecretsOperatorSpec{ - Version: "v1.0.0", - }, - }, - pc: &externalsecretsoperatorsv1alpha1.ProviderConfig{ - Spec: externalsecretsoperatorsv1alpha1.ProviderConfigSpec{ - Versions: []externalsecretsoperatorsv1alpha1.ExternalSecretsVersion{ - { - Version: "v1.0.0", - HelmValues: &apiextensionsv1.JSON{ - Raw: []byte("invalid json"), - }, - }, - }, - }, - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, gotErr := ResolveEsoNamespace(context.Background(), tt.obj, tt.pc) - if gotErr != nil { - if !tt.wantErr { - t.Errorf("TestResolveEsoNamespace() failed: %v", gotErr) - } - return - } - if tt.wantErr { - t.Fatal("TestResolveEsoNamespace() succeeded unexpectedly") - } - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go index b7ad0d9..2ebb579 100644 --- a/pkg/manager/utils_test.go +++ b/pkg/manager/utils_test.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" @@ -7,6 +7,7 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/openmcp-project/opencontrolplane-runtime/testdata/api/v1alpha1" clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -15,8 +16,6 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - - "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" ) // CreateFakeCluster sets up a cluster with a fake client From 9afc0fa6c81a6cb161e258a05b738a739298658f Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Mon, 3 Aug 2026 06:32:19 +0200 Subject: [PATCH 03/20] feat(secret): Modify secret prefix handling Signed-off-by: Jakob Bergmeier --- pkg/manager/secret.go | 14 +++++++------- pkg/manager/secret_test.go | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/manager/secret.go b/pkg/manager/secret.go index ac25537..33d4e53 100644 --- a/pkg/manager/secret.go +++ b/pkg/manager/secret.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" @@ -21,10 +21,10 @@ type SecretCopyConfig struct { TargetNamespace string // TargetName is the name of the target secret. TargetName string + // ServiceProviderPrefix is the prefix for the secret in the tenant namespace + ServiceProviderPrefix string } -const secretNamePrefix = "sp-eso-" - // ManagePullSecret syncs every image pull secret the to cluster func ManagePullSecret(targetCluster ManagedCluster, pullSecret corev1.LocalObjectReference, config SecretCopyConfig) { secret := NewManagedObject(&corev1.Secret{ @@ -60,14 +60,14 @@ func ManagePullSecret(targetCluster ManagedCluster, pullSecret corev1.LocalObjec // to prevent name collisions in namespaces where multiple service providers operate. // If the resulting name exceeds 63 characters (K8s limit), it will be truncated // and a hash suffix appended for uniqueness via ShortenToXCharacters. -func PrefixSecretName(secretName string) (string, error) { - return ctrlutils.ShortenToXCharacters(fmt.Sprintf("%s%s", secretNamePrefix, secretName), ctrlutils.K8sMaxNameLength) +func PrefixSecretName(secretName string, config SecretCopyConfig) (string, error) { + return ctrlutils.ShortenToXCharacters(fmt.Sprintf("%s%s", config.ServiceProviderPrefix, secretName), ctrlutils.K8sMaxNameLength) } // NewSecretCleaner removes redundant pull secrets in the given target namespace // by removing any secret labeled as managed by sp-external-secrets that is not in secretsToKeep. -func NewSecretCleaner(cluster ManagedCluster, namespace string, secretsToKeep []corev1.LocalObjectReference) OrphanCleaner { - return NewOrphanCleaner(cluster, namespace, cleanerType[*corev1.SecretList]{ +func NewSecretCleaner(cluster ManagedCluster, serviceProvider string, namespace string, secretsToKeep []corev1.LocalObjectReference) OrphanCleaner { + return NewOrphanCleaner(cluster, serviceProvider, namespace, cleanerType[*corev1.SecretList]{ EmptyList: func() *corev1.SecretList { return &corev1.SecretList{} }, diff --git a/pkg/manager/secret_test.go b/pkg/manager/secret_test.go index b1ed4f3..f7efb1f 100644 --- a/pkg/manager/secret_test.go +++ b/pkg/manager/secret_test.go @@ -1,4 +1,4 @@ -package externalsecrets +package manager import ( "context" From 0fbb8c4ffe5c859d80b4f1410070a9e9537591fb Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Mon, 3 Aug 2026 07:23:06 +0200 Subject: [PATCH 04/20] feat: Replace ResourceLocation with ClusterType and Replace Flux Constants Signed-off-by: Jakob Bergmeier --- pkg/manager/flux.go | 21 ++++++++------------- pkg/manager/managedobject.go | 13 +++++++------ pkg/manager/orphancleaner.go | 4 ++-- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go index 2951082..00cc5ef 100644 --- a/pkg/manager/flux.go +++ b/pkg/manager/flux.go @@ -18,15 +18,6 @@ import ( commonapi "github.com/openmcp-project/openmcp-operator/api/common" ) -const ( - // DefaultNamespace is the default namespace where External Secrets Operator components are deployed on the ManagedControlPlane - DefaultNamespace = "external-secrets" - // OCIRepositoryName is the name of the External Secrets Operator OCIRepository resource - OCIRepositoryName = "external-secrets" - // HelmReleaseName is the name of the External Secrets Operator HelmRelease resource - HelmReleaseName = "external-secrets" -) - // ManageFluxResourcesParams groups all parameters to create the required manage flux resources type ManageFluxResourcesParams struct { // Cluster defines where the resources will be created @@ -43,6 +34,10 @@ type ManageFluxResourcesParams struct { ClusterContext clusteraccess.ClusterContext // RequestedVersion is the version of External Secrets Operator that a user requested through the onboarding API RequestedVersion RequestedVersion + // OCIRepositoryName is the name of the OCIRepository resource + OCIRepositoryName string + // HelmReleaseName is the name of the External Secrets Operator HelmRelease resource + HelmReleaseName string } type RequestedVersion struct { @@ -56,7 +51,7 @@ type RequestedVersion struct { func ManageFluxResources(p ManageFluxResourcesParams) { ociRepo := NewManagedObject(&sourcev1.OCIRepository{ ObjectMeta: metav1.ObjectMeta{ - Name: OCIRepositoryName, + Name: p.OCIRepositoryName, Namespace: p.Cluster.GetDefaultNamespace(), }, }, ManagedObjectContext{ @@ -99,7 +94,7 @@ func ManageFluxResources(p ManageFluxResourcesParams) { helmRelease := NewManagedObject(&helmv2.HelmRelease{ ObjectMeta: metav1.ObjectMeta{ - Name: HelmReleaseName, + Name: p.HelmReleaseName, Namespace: p.Cluster.GetDefaultNamespace(), }, }, ManagedObjectContext{ @@ -112,7 +107,7 @@ func ManageFluxResources(p ManageFluxResourcesParams) { Interval: metav1.Duration{Duration: p.Interval}, ChartRef: &helmv2.CrossNamespaceSourceReference{ Kind: "OCIRepository", - Name: OCIRepositoryName, + Name: p.OCIRepositoryName, Namespace: p.Cluster.GetDefaultNamespace(), }, KubeConfig: &meta.KubeConfigReference{ @@ -144,7 +139,7 @@ func ManageFluxResources(p ManageFluxResourcesParams) { } // FluxStatus indicates whether the given object is in phase terminating, pending or ready. -func FluxStatus(o client.Object, resourceLocation string) Status { +func FluxStatus(o client.Object, resourceLocation ClusterType) Status { fluxObject := o.(conditions.Getter) if !o.GetDeletionTimestamp().IsZero() { return Status{ diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go index 70406b4..2fd8e5a 100644 --- a/pkg/manager/managedobject.go +++ b/pkg/manager/managedobject.go @@ -10,6 +10,7 @@ import ( // DeletionPolicy distinguishes between normal deletion and orphaning an object. type DeletionPolicy string +type InstancePhase string const ( // Orphan indicates that an object will be orphaned when deletion is requested @@ -27,10 +28,10 @@ func NoOp(context.Context, client.Object) error { } // StatusFunc provides Status information for the given client.Object. -type StatusFunc func(o client.Object, resourceLocation string) Status +type StatusFunc func(o client.Object, resourceLocation ClusterType) Status // SimpleStatus indicates whether the given object is in phase terminating, pending or ready. -func SimpleStatus(o client.Object, resourceLocation string) Status { +func SimpleStatus(o client.Object, resourceLocation ClusterType) Status { if !o.GetDeletionTimestamp().IsZero() { return Status{ Phase: commonapi.StatusPhaseTerminating, @@ -54,9 +55,9 @@ func SimpleStatus(o client.Object, resourceLocation string) Status { // Status defines the status attributes of a ManagedObject. type Status struct { - Phase string + Phase InstancePhase Message string - Location string + Location ClusterType } // NewManagedObject creates a new ManagedObject instances to manage the given client.Object. @@ -90,7 +91,7 @@ type ManagedObject interface { Reconcile(ctx context.Context) error GetDependencies() []ManagedObject GetDeletionPolicy() DeletionPolicy - GetStatus(resourceLocation string) Status + GetStatus(resourceLocation ClusterType) Status Label() string } @@ -106,7 +107,7 @@ type managedObject struct { } // GetStatus implements ManagedObject. -func (m *managedObject) GetStatus(resourceLocation string) Status { +func (m *managedObject) GetStatus(resourceLocation ClusterType) Status { if m.statusFunc != nil { return m.statusFunc(m.object, resourceLocation) } diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go index 584ada6..e352f01 100644 --- a/pkg/manager/orphancleaner.go +++ b/pkg/manager/orphancleaner.go @@ -107,7 +107,7 @@ func (c *orphanCleaner[T]) deletionPrepared(obj client.Object) Result { } } -func cleanupPreparedStatus(_ client.Object, resourceLocation string) Status { +func cleanupPreparedStatus(_ client.Object, resourceLocation ClusterType) Status { return Status{ Phase: commonapi.StatusPhaseTerminating, Message: "Orphan cleanup prepared", @@ -128,7 +128,7 @@ func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { } } -func cleanupErrorStatus(_ client.Object, resourceLocation string) Status { +func cleanupErrorStatus(_ client.Object, resourceLocation ClusterType) Status { return Status{ Phase: "Failed", Message: "Orphan cleanup failed", From d51c938cb43790dd599f09ee604f604edf278074 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Mon, 3 Aug 2026 09:47:27 +0200 Subject: [PATCH 05/20] fix: Secret prefixing Signed-off-by: Jakob Bergmeier --- pkg/manager/secret.go | 8 +++----- pkg/manager/secret_test.go | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/manager/secret.go b/pkg/manager/secret.go index 33d4e53..af589f7 100644 --- a/pkg/manager/secret.go +++ b/pkg/manager/secret.go @@ -21,8 +21,6 @@ type SecretCopyConfig struct { TargetNamespace string // TargetName is the name of the target secret. TargetName string - // ServiceProviderPrefix is the prefix for the secret in the tenant namespace - ServiceProviderPrefix string } // ManagePullSecret syncs every image pull secret the to cluster @@ -56,12 +54,12 @@ func ManagePullSecret(targetCluster ManagedCluster, pullSecret corev1.LocalObjec targetCluster.AddObject(secret) } -// PrefixSecretName adds the "sp-eso-" prefix to the given secret name +// PrefixSecretName adds the prefix to the given secret name // to prevent name collisions in namespaces where multiple service providers operate. // If the resulting name exceeds 63 characters (K8s limit), it will be truncated // and a hash suffix appended for uniqueness via ShortenToXCharacters. -func PrefixSecretName(secretName string, config SecretCopyConfig) (string, error) { - return ctrlutils.ShortenToXCharacters(fmt.Sprintf("%s%s", config.ServiceProviderPrefix, secretName), ctrlutils.K8sMaxNameLength) +func PrefixSecretName(secretName string, prefix string) (string, error) { + return ctrlutils.ShortenToXCharacters(fmt.Sprintf("%s%s", prefix, secretName), ctrlutils.K8sMaxNameLength) } // NewSecretCleaner removes redundant pull secrets in the given target namespace diff --git a/pkg/manager/secret_test.go b/pkg/manager/secret_test.go index f7efb1f..8b394a6 100644 --- a/pkg/manager/secret_test.go +++ b/pkg/manager/secret_test.go @@ -57,7 +57,7 @@ func TestManagePullSecrets(t *testing.T) { SourceClient: fakeCluster.Client(), SourceNamespace: sourceNamespace, TargetNamespace: targetNamespace, - TargetName: fmt.Sprintf("%s%s", secretNamePrefix, secretName), + TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), }, }, } @@ -103,9 +103,9 @@ func TestPrefixSecretName(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := PrefixSecretName(tt.input) + got, err := PrefixSecretName(tt.input, "secretNamePrefix") require.NoError(t, err) - assert.True(t, strings.HasPrefix(got, secretNamePrefix)) + assert.True(t, strings.HasPrefix(got, "secretNamePrefix")) assert.LessOrEqual(t, len(got), 63) }) } From 4508d36ae8644f884b62dac846d20046d84de755 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Mon, 3 Aug 2026 09:48:05 +0200 Subject: [PATCH 06/20] fix: Unit tests Signed-off-by: Jakob Bergmeier --- pkg/manager/flux.go | 39 ++++++++-------- pkg/manager/flux_test.go | 46 ++++++++---------- pkg/manager/helm.go | 33 ------------- pkg/manager/helm_test.go | 77 ------------------------------- pkg/manager/managedobject.go | 3 +- pkg/manager/manager.go | 4 +- pkg/manager/orphancleaner_test.go | 67 ++++++++++++++------------- pkg/manager/utils_test.go | 2 +- 8 files changed, 80 insertions(+), 191 deletions(-) delete mode 100644 pkg/manager/helm.go delete mode 100644 pkg/manager/helm_test.go diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go index 00cc5ef..9fc7200 100644 --- a/pkg/manager/flux.go +++ b/pkg/manager/flux.go @@ -7,47 +7,51 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" sourcev1 "github.com/fluxcd/source-controller/api/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/fluxcd/pkg/runtime/conditions" - "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" commonapi "github.com/openmcp-project/openmcp-operator/api/common" ) -// ManageFluxResourcesParams groups all parameters to create the required manage flux resources +// ManageFluxResourcesParams groups all parameters to create the required Flux resources. type ManageFluxResourcesParams struct { - // Cluster defines where the resources will be created + // Cluster defines where the resources will be created. Cluster ManagedCluster - // MCPNamespace defines the namespace name that deploy ESO + // MCPNamespace is the target namespace for the Helm release. MCPNamespace string - // ChartPullSecretName defines the name of the secret copy that will be placed in the Cluster namespace + // ChartPullSecretName is the name of the image-pull secret placed in the cluster namespace. ChartPullSecretName string - // Obj is the tenant API object that is being reconciled - Obj *client.Object - // Interval defines OCIRepository and HelmRelease reconcile intervals + // Interval defines the OCIRepository and HelmRelease reconcile intervals. Interval time.Duration // ClusterContext of the current reconciliation context ClusterContext clusteraccess.ClusterContext // RequestedVersion is the version of External Secrets Operator that a user requested through the onboarding API RequestedVersion RequestedVersion - // OCIRepositoryName is the name of the OCIRepository resource + // OCIRepositoryName is the name of the OCIRepository resource. OCIRepositoryName string - // HelmReleaseName is the name of the External Secrets Operator HelmRelease resource + // HelmReleaseName is the name of the HelmRelease resource. HelmReleaseName string } +// RequestedVersion describes the chart artifact that should be installed. type RequestedVersion struct { + // Version is a human-readable version identifier (e.g. "v1.2.0"). Version string - ChartURL string + // ChartURL is the OCI URL of the Helm chart repository. + ChartURL string + // ChartVersion is the tag used to select the chart from the OCI repository. ChartVersion string - HelmValues *apiextensionsv1.JSON + // HelmValues are the values to pass to the Helm release. + HelmValues *apiextensionsv1.JSON } -// ManageFluxResources configures OCIRepo and HelmRelease +// ManageFluxResources registers an OCIRepository and a HelmRelease as managed objects on the +// cluster defined in p.Cluster. The objects are not reconciled until the caller invokes +// Manager.Apply. func ManageFluxResources(p ManageFluxResourcesParams) { ociRepo := NewManagedObject(&sourcev1.OCIRepository{ ObjectMeta: metav1.ObjectMeta{ @@ -62,7 +66,7 @@ func ManageFluxResources(p ManageFluxResourcesParams) { } if p.RequestedVersion.ChartURL == "" { // this should never happen as long as defaulting works properly - return fmt.Errorf("missing ChartURL definition for Flux version %s", p.RequestedVersion.Version) + return fmt.Errorf("missing ChartURL definition for version %s", p.RequestedVersion.Version) } ociRepo.Spec = sourcev1.OCIRepositorySpec{ Interval: metav1.Duration{Duration: p.Interval}, @@ -71,8 +75,7 @@ func ManageFluxResources(p ManageFluxResourcesParams) { Tag: p.RequestedVersion.ChartVersion, }, // required to always select the correct OCI layer - // this mitigates non-deterministic layer ordering across different eso versions - // that prevented the OCIRepository from getting ready for some eso versions + // this mitigates non-deterministic layer ordering across chart versions // https://fluxcd.io/flux/components/source/ocirepositories/#layer-selector LayerSelector: &sourcev1.OCILayerSelector{ MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", @@ -138,7 +141,7 @@ func ManageFluxResources(p ManageFluxResourcesParams) { p.Cluster.AddObject(helmRelease) } -// FluxStatus indicates whether the given object is in phase terminating, pending or ready. +// FluxStatus returns the Status of a Flux-managed object by inspecting its Ready condition. func FluxStatus(o client.Object, resourceLocation ClusterType) Status { fluxObject := o.(conditions.Getter) if !o.GetDeletionTimestamp().IsZero() { diff --git a/pkg/manager/flux_test.go b/pkg/manager/flux_test.go index 631759a..f8d99ad 100644 --- a/pkg/manager/flux_test.go +++ b/pkg/manager/flux_test.go @@ -12,17 +12,14 @@ import ( apiextensionv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" - - apiv1alpha1 "github.com/openmcp-project/service-provider-external-secrets/api/v1alpha1" ) const ( testNamespace = "test" - testCharURL = "chart-url" + testChartURL = "oci://registry.example.com/charts/my-app" testChartPullSecret = "chart-pull-secret" testKubeconfigKey = "kubeconfig-key" ) @@ -34,37 +31,30 @@ func TestManageFluxResources(t *testing.T) { params ManageFluxResourcesParams }{ { - name: "APIObject and ProviderConfig values mapping", + name: "OCIRepository and HelmRelease are created with the correct values", params: ManageFluxResourcesParams{ - Cluster: NewManagedCluster(CreateFakeCluster(t, "platform"), &rest.Config{}, testNamespace, PlatformCluster), - MCPNamespace: "external-secrets", - Obj: &apiv1alpha1.ExternalSecretsOperator{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: testNamespace, - }, - Spec: apiv1alpha1.ExternalSecretsOperatorSpec{ - Version: "v2.2.0", - }, - }, - Interval: time.Hour, + Cluster: NewManagedCluster(CreateFakeCluster(t, "platform"), &rest.Config{}, testNamespace, PlatformCluster), + MCPNamespace: "my-service", + ChartPullSecretName: "secret-copy", + Interval: time.Hour, ClusterContext: clusteraccess.ClusterContext{ MCPAccessSecretKey: client.ObjectKey{ Namespace: testNamespace, Name: testKubeconfigKey, }, }, - ChartPullSecretName: "secret-copy", - RequestedVersion: apiv1alpha1.ExternalSecretsVersion{ - Version: "v2.2.0", - ChartVersion: "v2.2.0", - ChartURL: new(testCharURL), - ChartPullSecret: testChartPullSecret, + RequestedVersion: RequestedVersion{ + Version: "v1.0.0", + ChartURL: testChartURL, + ChartVersion: "1.0.0", HelmValues: &apiextensionv1.JSON{ Raw: []byte(`{"foo":"bar"}`), }, }, - }}, + OCIRepositoryName: "test-repo", + HelmReleaseName: "test-release", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -75,20 +65,20 @@ func TestManageFluxResources(t *testing.T) { // assert oci repo ociRepo := &sourcev1.OCIRepository{ ObjectMeta: metav1.ObjectMeta{ - Name: OCIRepositoryName, + Name: "test-repo", Namespace: testNamespace, }, } require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(ociRepo), ociRepo)) - assert.Equal(t, tt.params.RequestedVersion.ChartURL, ptr.To(ociRepo.Spec.URL)) + assert.Equal(t, tt.params.RequestedVersion.ChartURL, ociRepo.Spec.URL) assert.Equal(t, tt.params.ChartPullSecretName, ociRepo.Spec.SecretRef.Name) - assert.Equal(t, tt.params.Obj.Spec.Version, ociRepo.Spec.Reference.Tag) + assert.Equal(t, tt.params.RequestedVersion.ChartVersion, ociRepo.Spec.Reference.Tag) assert.Equal(t, tt.params.Interval, ociRepo.Spec.Interval.Duration) // assert helm release helmRelease := &helmv2.HelmRelease{ ObjectMeta: metav1.ObjectMeta{ - Name: HelmReleaseName, + Name: "test-release", Namespace: testNamespace, }, } diff --git a/pkg/manager/helm.go b/pkg/manager/helm.go deleted file mode 100644 index 12b4d31..0000000 --- a/pkg/manager/helm.go +++ /dev/null @@ -1,33 +0,0 @@ -package manager - -import ( - "encoding/json" - - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -// HelmValues define the helm values that are explicitly processed during reconciliation -type HelmValues struct { - NamespaceOverride string `json:"namespaceOverride,omitempty"` - Global Global `json:"global,omitempty"` -} - -// Global define the global settings that are explicitly process during reconciliation -type Global struct { - ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` -} - -// ExtractHelmValues extract helm values required for processing -func ExtractHelmValues(values *apiextensionsv1.JSON) (*HelmValues, error) { - if values == nil || len(values.Raw) == 0 { - return &HelmValues{}, nil - } - - vals := &HelmValues{} - if err := json.Unmarshal(values.Raw, vals); err != nil { - return nil, err - } - - return vals, nil -} diff --git a/pkg/manager/helm_test.go b/pkg/manager/helm_test.go deleted file mode 100644 index 66fa872..0000000 --- a/pkg/manager/helm_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package manager - -import ( - "testing" - - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -func TestExtractHelmValues(t *testing.T) { - tests := []struct { - name string // description of this test case - // Named input parameters for target function. - values *apiextensionsv1.JSON - want *HelmValues - wantErr bool - }{ - { - name: "Extract Namespace", - values: &apiextensionsv1.JSON{ - Raw: []byte(`{"namespaceOverride": "eso-system"}`), - }, - want: &HelmValues{ - NamespaceOverride: "eso-system", - }, - wantErr: false, - }, - { - name: "Extract ImagePullSecrets", - values: &apiextensionsv1.JSON{ - Raw: []byte(`{"global": {"imagePullSecrets": [{"name": "test"}]}}`), - }, - want: &HelmValues{ - Global: Global{ - ImagePullSecrets: []corev1.LocalObjectReference{ - { - Name: "test", - }, - }, - }, - }, - wantErr: false, - }, - { - name: "Ignore unknown values", - values: &apiextensionsv1.JSON{ - Raw: []byte(`{"replicaCount": 1}`), - }, - want: &HelmValues{}, - wantErr: false, - }, - { - name: "Error on invalid JSON", - values: &apiextensionsv1.JSON{ - Raw: []byte("invalid json"), - }, - want: &HelmValues{}, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, gotErr := ExtractHelmValues(tt.values) - if gotErr != nil { - if !tt.wantErr { - t.Errorf("ExtractHelmValues() failed: %v", gotErr) - } - return - } - if tt.wantErr { - t.Fatal("ExtractHelmValues() succeeded unexpectedly") - } - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go index 2fd8e5a..b135319 100644 --- a/pkg/manager/managedobject.go +++ b/pkg/manager/managedobject.go @@ -11,6 +11,7 @@ import ( // DeletionPolicy distinguishes between normal deletion and orphaning an object. type DeletionPolicy string type InstancePhase string +type ResourceLocation = ClusterType const ( // Orphan indicates that an object will be orphaned when deletion is requested @@ -57,7 +58,7 @@ func SimpleStatus(o client.Object, resourceLocation ClusterType) Status { type Status struct { Phase InstancePhase Message string - Location ClusterType + Location ResourceLocation } // NewManagedObject creates a new ManagedObject instances to manage the given client.Object. diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 2b411f4..e0f7462 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -60,7 +60,9 @@ func (m *managerImpl) AddCluster(mc ManagedCluster) { m.clusters = append(m.clusters, mc) } -// Apply invokes reconciliation of all ManagedObjects. +// Apply reconciles all managed objects in registration order. +// DependsOn is NOT used for apply ordering; it only affects deletion sequencing. +// Callers are responsible for registering objects in dependency order. func (m *managerImpl) Apply(ctx context.Context) ([]Result, error) { return m.reconcileObjects(ctx, false) } diff --git a/pkg/manager/orphancleaner_test.go b/pkg/manager/orphancleaner_test.go index 7b7d394..4f107e5 100644 --- a/pkg/manager/orphancleaner_test.go +++ b/pkg/manager/orphancleaner_test.go @@ -17,6 +17,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ) +const serviceProvider = "serviceprovider-test" +const namespace = "test-namespace" + func Test_orphanCleaner_Cleanup(t *testing.T) { tests := []struct { name string // description of this test case @@ -29,14 +32,14 @@ func Test_orphanCleaner_Cleanup(t *testing.T) { }{ { name: "only OCIRepository managed by sp-eso are deleted", - targetNamespace: DefaultNamespace, + targetNamespace: namespace, cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", DefaultNamespace, true), - createOCIRepo("unmanaged", DefaultNamespace, false), + createOCIRepo("managed", namespace, true), + createOCIRepo("unmanaged", namespace, false), })), cleanerType: createOCIRepoCleanerType(), want: []sourcev1.OCIRepository{ - *createOCIRepo("unmanaged", DefaultNamespace, false), + *createOCIRepo("unmanaged", namespace, false), }, wantErr: false, }, @@ -44,13 +47,13 @@ func Test_orphanCleaner_Cleanup(t *testing.T) { name: "OCIRepository in other namespaces are not deleted", targetNamespace: "openmcp-system", cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", DefaultNamespace, true), - createOCIRepo("unmanaged", DefaultNamespace, false), + createOCIRepo("managed", namespace, true), + createOCIRepo("unmanaged", namespace, false), })), cleanerType: createOCIRepoCleanerType(), want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", DefaultNamespace, true), - *createOCIRepo("unmanaged", DefaultNamespace, false), + *createOCIRepo("managed", namespace, true), + *createOCIRepo("unmanaged", namespace, false), }, wantErr: false, }, @@ -58,19 +61,19 @@ func Test_orphanCleaner_Cleanup(t *testing.T) { name: "objects to keep are not deleted", targetNamespace: "openmcp-system", cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", DefaultNamespace, true), - createOCIRepo("unmanaged", DefaultNamespace, false), + createOCIRepo("managed", namespace, true), + createOCIRepo("unmanaged", namespace, false), })), cleanerType: createOCIRepoCleanerType(corev1.LocalObjectReference{Name: "managed"}), want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", DefaultNamespace, true), - *createOCIRepo("unmanaged", DefaultNamespace, false), + *createOCIRepo("managed", namespace, true), + *createOCIRepo("unmanaged", namespace, false), }, wantErr: false, }, { name: "error is returned when list fails", - targetNamespace: DefaultNamespace, + targetNamespace: namespace, cluster: createFakeCluster(listErrorClient{}), cleanerType: createOCIRepoCleanerType(), want: []sourcev1.OCIRepository{}, @@ -78,9 +81,9 @@ func Test_orphanCleaner_Cleanup(t *testing.T) { }, { name: "if a single delete fails, the single result contains an error but overall cleanup succeeds", - targetNamespace: DefaultNamespace, + targetNamespace: namespace, cluster: createFakeCluster(deleteErrorClient{ - fakeOCIRepo: *createOCIRepo("managed", DefaultNamespace, true), + fakeOCIRepo: *createOCIRepo("managed", namespace, true), }), cleanerType: createOCIRepoCleanerType(), want: []sourcev1.OCIRepository{}, @@ -90,7 +93,7 @@ func Test_orphanCleaner_Cleanup(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewOrphanCleaner(tt.cluster, tt.targetNamespace, tt.cleanerType) + c := NewOrphanCleaner(tt.cluster, serviceProvider, tt.targetNamespace, tt.cleanerType) result, gotErr := c.Cleanup(context.Background()) if gotErr != nil { if !tt.wantErr { @@ -131,47 +134,47 @@ func Test_orphanCleaner_Cleanup_prepareDeletion(t *testing.T) { }{ { name: "preparation succeeds and proceeds with deletion", - targetNamespace: DefaultNamespace, + targetNamespace: namespace, cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", DefaultNamespace, true), - createOCIRepo("unmanaged", DefaultNamespace, false), + createOCIRepo("managed", namespace, true), + createOCIRepo("unmanaged", namespace, false), })), cleanerType: createOCIRepoCleanerType(), option: succeedAndDelete, want: []sourcev1.OCIRepository{ - *createOCIRepo("unmanaged", DefaultNamespace, false), + *createOCIRepo("unmanaged", namespace, false), }, wantErr: false, wantResultErr: false, }, { name: "if preparation succeeds but deletion is blocked, no repo is removed", - targetNamespace: DefaultNamespace, + targetNamespace: namespace, cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", DefaultNamespace, true), - createOCIRepo("unmanaged", DefaultNamespace, false), + createOCIRepo("managed", namespace, true), + createOCIRepo("unmanaged", namespace, false), })), cleanerType: createOCIRepoCleanerType(), option: succeedAndWait, want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", DefaultNamespace, true), - *createOCIRepo("unmanaged", DefaultNamespace, false), + *createOCIRepo("managed", namespace, true), + *createOCIRepo("unmanaged", namespace, false), }, wantErr: false, wantResultErr: false, }, { name: "if preparation fails, the single result contains an error but overall cleanup succeeds", - targetNamespace: DefaultNamespace, + targetNamespace: namespace, cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", DefaultNamespace, true), - createOCIRepo("unmanaged", DefaultNamespace, false), + createOCIRepo("managed", namespace, true), + createOCIRepo("unmanaged", namespace, false), })), cleanerType: createOCIRepoCleanerType(), option: failWithError, want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", DefaultNamespace, true), - *createOCIRepo("unmanaged", DefaultNamespace, false), + *createOCIRepo("managed", namespace, true), + *createOCIRepo("unmanaged", namespace, false), }, wantErr: false, wantResultErr: true, @@ -189,7 +192,7 @@ func Test_orphanCleaner_Cleanup_prepareDeletion(t *testing.T) { case failWithError: cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.failure) } - c := NewOrphanCleaner(tt.cluster, tt.targetNamespace, cleanerType) + c := NewOrphanCleaner(tt.cluster, serviceProvider, tt.targetNamespace, cleanerType) result, gotErr := c.Cleanup(context.Background()) if gotErr != nil { if !tt.wantErr { @@ -277,7 +280,7 @@ func createOCIRepo(name, namespace string, managedByEso bool) *sourcev1.OCIRepos } if managedByEso { cm.Labels = map[string]string{ - LabelManagedBy: LabelManagedByValue, + LabelManagedBy: serviceProvider, } } return cm diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go index 2ebb579..d3d1f7d 100644 --- a/pkg/manager/utils_test.go +++ b/pkg/manager/utils_test.go @@ -38,7 +38,7 @@ func CreateFakeCluster(t *testing.T, id string, clusterObjects ...client.Object) func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects int, wantErrors []string) []Result { t.Helper() // invoke apply with manager - mgr := NewManager() + mgr := NewManager("serviceprovider-test") for _, cluster := range clusters { mgr.AddCluster(cluster) } From 0af4c241c1e6f72ac3a529e6248fb644611dd2bc Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 6 Aug 2026 13:31:34 +0200 Subject: [PATCH 07/20] fix: Status handling Signed-off-by: Jakob Bergmeier --- pkg/manager/managedResource.go | 33 ++++++++++++++ pkg/manager/managedobject.go | 83 +++++++++++++--------------------- pkg/manager/manager.go | 79 +++++++++++++++++++++++++++++--- pkg/manager/status.go | 42 +++++++++++++++++ 4 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 pkg/manager/managedResource.go create mode 100644 pkg/manager/status.go diff --git a/pkg/manager/managedResource.go b/pkg/manager/managedResource.go new file mode 100644 index 0000000..61124f1 --- /dev/null +++ b/pkg/manager/managedResource.go @@ -0,0 +1,33 @@ +package manager + +import ( + corev1 "k8s.io/api/core/v1" +) + +type InstancePhase string + +// Constants representing the phases of an instance lifecycle. +const ( + Pending InstancePhase = "Pending" + Progressing InstancePhase = "Progressing" + Ready InstancePhase = "Ready" + Failed InstancePhase = "Failed" + Terminating InstancePhase = "Terminating" + Unknown InstancePhase = "Unknown" +) + +// ManagedResource defines a kubernetes object with its lifecycle phase +type ManagedResource struct { + corev1.TypedObjectReference `json:",inline"` + + // +required + // Phase InstancePhase `json:"phase"` + // +optional + // Message string `json:"message,omitempty"` + + // +required + Status Status + + // +optional + Location ClusterType `json:"location,omitempty"` +} diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go index b135319..40469f2 100644 --- a/pkg/manager/managedobject.go +++ b/pkg/manager/managedobject.go @@ -3,15 +3,11 @@ package manager import ( "context" - commonapi "github.com/openmcp-project/openmcp-operator/api/common" - "sigs.k8s.io/controller-runtime/pkg/client" ) // DeletionPolicy distinguishes between normal deletion and orphaning an object. type DeletionPolicy string -type InstancePhase string -type ResourceLocation = ClusterType const ( // Orphan indicates that an object will be orphaned when deletion is requested @@ -23,44 +19,14 @@ const ( // ReconcileFunc reconciles the given client.Object. type ReconcileFunc func(ctx context.Context, o client.Object) error +// StatusFunc provides Status information for the given client.Object. +type StatusFunc func(client.Object) Status + // NoOp does not do anything with the provided object and returns nil. func NoOp(context.Context, client.Object) error { return nil } -// StatusFunc provides Status information for the given client.Object. -type StatusFunc func(o client.Object, resourceLocation ClusterType) Status - -// SimpleStatus indicates whether the given object is in phase terminating, pending or ready. -func SimpleStatus(o client.Object, resourceLocation ClusterType) Status { - if !o.GetDeletionTimestamp().IsZero() { - return Status{ - Phase: commonapi.StatusPhaseTerminating, - Message: "Resource is terminating.", - Location: resourceLocation, - } - } - if o.GetUID() == "" { - return Status{ - Phase: commonapi.StatusPhaseProgressing, - Message: "Resource has not been created yet.", - Location: resourceLocation, - } - } - return Status{ - Phase: commonapi.StatusPhaseReady, - Message: "Resource exists.", - Location: resourceLocation, - } -} - -// Status defines the status attributes of a ManagedObject. -type Status struct { - Phase InstancePhase - Message string - Location ResourceLocation -} - // NewManagedObject creates a new ManagedObject instances to manage the given client.Object. func NewManagedObject(o client.Object, moc ManagedObjectContext) ManagedObject { if moc.DeletionPolicy == "" { @@ -82,8 +48,10 @@ type ManagedObjectContext struct { ReconcileFunc ReconcileFunc DependsOn []ManagedObject DeletionPolicy DeletionPolicy + Status Status StatusFunc StatusFunc - ManagedBy string + Location ClusterType + ManagedBy string } // ManagedObject represents an object managed by a Mana^ger. @@ -92,7 +60,8 @@ type ManagedObject interface { Reconcile(ctx context.Context) error GetDependencies() []ManagedObject GetDeletionPolicy() DeletionPolicy - GetStatus(resourceLocation ClusterType) Status + GetLocation() ClusterType + GetStatus() Status Label() string } @@ -101,22 +70,12 @@ var _ ManagedObject = &managedObject{} type managedObject struct { object client.Object reconcileFunc ReconcileFunc - statusFunc StatusFunc dependencies []ManagedObject deletionPolicy DeletionPolicy + location ClusterType managedBy string -} - -// GetStatus implements ManagedObject. -func (m *managedObject) GetStatus(resourceLocation ClusterType) Status { - if m.statusFunc != nil { - return m.statusFunc(m.object, resourceLocation) - } - return Status{ - Phase: "Unknown", - Message: "No status function defined.", - Location: resourceLocation, - } + statusFunc StatusFunc + status Status } // GetDeletionPolicy implements ManagedObject. @@ -124,6 +83,10 @@ func (m *managedObject) GetDeletionPolicy() DeletionPolicy { return m.deletionPolicy } +func (m *managedObject) GetLocation() ClusterType { + return m.location +} + // GetDependencies implements ManagedObject. func (m *managedObject) GetDependencies() []ManagedObject { return m.dependencies @@ -146,3 +109,19 @@ func (m *managedObject) GetObject() client.Object { func (m *managedObject) Label() string { return m.managedBy } + +// GetStatus implements ManagedObject. +func (m *managedObject) GetStatus() Status { + if m.statusFunc != nil { + return m.statusFunc(m.object) + } + return Status{ + Phase: StatusPhaseUnkown, + Message: "No status function defined.", + } +} + +// SetPhase sets the phase of the managedObject resource status +func (m *managedObject) SetPhase(phase string) { + m.status.Phase = phase +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index e0f7462..2384834 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -4,11 +4,15 @@ import ( "context" "errors" "fmt" + "reflect" "slices" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" ) const ( @@ -28,8 +32,8 @@ type dependents map[ManagedObject][]dependency type Manager interface { AddCluster(mc ManagedCluster) AddCleaner(oc OrphanCleaner) - Apply(context.Context) (_ []Result, cleanup error) - Delete(context.Context) (_ []Result, cleanup error) + Apply(context.Context) (_ []ManagedResource, cleanup error) + Delete(context.Context) (_ []ManagedResource, cleanup error) } // OrphanCleaner removes any previously managed objects that are no longer part of the desired state. @@ -63,13 +67,35 @@ func (m *managerImpl) AddCluster(mc ManagedCluster) { // Apply reconciles all managed objects in registration order. // DependsOn is NOT used for apply ordering; it only affects deletion sequencing. // Callers are responsible for registering objects in dependency order. -func (m *managerImpl) Apply(ctx context.Context) ([]Result, error) { - return m.reconcileObjects(ctx, false) +func (m *managerImpl) Apply(ctx context.Context) ([]ManagedResource, error) { + results, err := m.reconcileObjects(ctx, true) + if err != nil { + return []ManagedResource{}, err + } + managedResources, resultContainsError := resultsToResources(ctx, results) + if resultContainsError { + return managedResources, err + } + if allResourcesReady(results) { + return managedResources, nil + } + return managedResources, nil } // Delete invokes deletion of all ManagedObjects. -func (m *managerImpl) Delete(ctx context.Context) ([]Result, error) { - return m.reconcileObjects(ctx, true) +func (m *managerImpl) Delete(ctx context.Context) ([]ManagedResource, error) { + results, err := m.reconcileObjects(ctx, true) + if err != nil { + return []ManagedResource{}, err + } + managedResources, resultContainsError := resultsToResources(ctx, results) + if resultContainsError { + return managedResources, err + } + if allDeleted(results) { + return managedResources, nil + } + return managedResources, nil } // AddCleaner adds a cleaner to a Manager. @@ -206,7 +232,7 @@ type dependency struct { } // AllDeleted returns true if every item's operation result is OperationResultDeleted. -func AllDeleted(results []Result) bool { +func allDeleted(results []Result) bool { for _, r := range results { if r.OperationResult != OperationResultDeleted { return false @@ -214,3 +240,42 @@ func AllDeleted(results []Result) bool { } return true } + +func allResourcesReady(results []Result) bool { + for _, r := range results { + if r.OperationResult != StatusPhaseReady { + return false + } + } + return true +} + +func resultsToResources(ctx context.Context, results []Result) ([]ManagedResource, bool) { + l := log.FromContext(ctx) + containsError := false + resources := make([]ManagedResource, 0, len(results)) + for _, res := range results { + obj := res.Object.GetObject() + resources = append(resources, ManagedResource{ + TypedObjectReference: corev1.TypedObjectReference{ + Kind: reflect.TypeOf(obj).Elem().Name(), + Name: obj.GetName(), + Namespace: nilIfEmptyString(obj.GetNamespace()), + }, + Status: res.Object.GetStatus(), + Location: res.Object.GetLocation(), + }) + if res.Error != nil { + containsError = true + l.Error(res.Error, "objectID", ObjectID(obj)) + } + } + return resources, containsError +} + +func nilIfEmptyString(str string) *string { + if str == "" { + return nil + } + return ptr.To(str) +} diff --git a/pkg/manager/status.go b/pkg/manager/status.go new file mode 100644 index 0000000..a59d5fc --- /dev/null +++ b/pkg/manager/status.go @@ -0,0 +1,42 @@ +package manager + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // StatusPhaseReady indicates that the resource is ready. All conditions are met and are in status "True". + StatusPhaseReady = "Ready" + // StatusPhaseProgressing indicates that the resource is not ready and being created or updated. + StatusPhaseProgressing = "Progressing" + // StatusPhaseTerminating indicates that the resource is not ready and in deletion. + StatusPhaseTerminating = "Terminating" + // StatusUnknown indicates that no StatusFunc is defined + StatusPhaseUnkown = "Unkown" +) + +// Status defines the status attributes of a ManagedObject. +type Status struct { + Phase string + Message string +} + +// SimpleStatus indicates whether the given object is in phase terminating, pending or ready. +func SimpleStatus(o client.Object) Status { + if !o.GetDeletionTimestamp().IsZero() { + return Status{ + Phase: StatusPhaseTerminating, + Message: "Resource is terminating.", + } + } + if o.GetUID() == "" { + return Status{ + Phase: StatusPhaseProgressing, + Message: "Resource has not been created yet.", + } + } + return Status{ + Phase: StatusPhaseReady, + Message: "Resource exists.", + } +} \ No newline at end of file From f389b2c79ecb6d3a2ae7cbc3f9e4a92029614f23 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 6 Aug 2026 14:44:57 +0200 Subject: [PATCH 08/20] fix: Version handling Signed-off-by: Jakob Bergmeier --- pkg/manager/flux.go | 44 ++++++++++++++++++++---------------- pkg/manager/orphancleaner.go | 40 ++++++++++---------------------- pkg/manager/utils_test.go | 22 +++++------------- 3 files changed, 42 insertions(+), 64 deletions(-) diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go index 9fc7200..177a003 100644 --- a/pkg/manager/flux.go +++ b/pkg/manager/flux.go @@ -14,7 +14,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" - commonapi "github.com/openmcp-project/openmcp-operator/api/common" ) // ManageFluxResourcesParams groups all parameters to create the required Flux resources. @@ -37,15 +36,11 @@ type ManageFluxResourcesParams struct { HelmReleaseName string } -// RequestedVersion describes the chart artifact that should be installed. type RequestedVersion struct { - // Version is a human-readable version identifier (e.g. "v1.2.0"). Version string - // ChartURL is the OCI URL of the Helm chart repository. - ChartURL string - // ChartVersion is the tag used to select the chart from the OCI repository. ChartVersion string - // HelmValues are the values to pass to the Helm release. + ChartURL string + ChartPullSecret string HelmValues *apiextensionsv1.JSON } @@ -53,6 +48,16 @@ type RequestedVersion struct { // cluster defined in p.Cluster. The objects are not reconciled until the caller invokes // Manager.Apply. func ManageFluxResources(p ManageFluxResourcesParams) { + ociDependants := []ManagedObject{} + ociRepo := manageOCIRepo(p, ociDependants) + p.Cluster.AddObject(ociRepo) + + helmDependants := []ManagedObject{ociRepo} + helmRelease := manageHelmRelease(p, helmDependants) + p.Cluster.AddObject(helmRelease) +} + +func manageOCIRepo(p ManageFluxResourcesParams, dependants []ManagedObject) ManagedObject { ociRepo := NewManagedObject(&sourcev1.OCIRepository{ ObjectMeta: metav1.ObjectMeta{ Name: p.OCIRepositoryName, @@ -89,12 +94,14 @@ func ManageFluxResources(p ManageFluxResourcesParams) { } return nil }, - DependsOn: []ManagedObject{}, + DependsOn: dependants, DeletionPolicy: Delete, StatusFunc: FluxStatus, }) - p.Cluster.AddObject(ociRepo) + return ociRepo +} +func manageHelmRelease(p ManageFluxResourcesParams, dependants []ManagedObject) ManagedObject { helmRelease := NewManagedObject(&helmv2.HelmRelease{ ObjectMeta: metav1.ObjectMeta{ Name: p.HelmReleaseName, @@ -134,33 +141,30 @@ func ManageFluxResources(p ManageFluxResourcesParams) { } return nil }, - DependsOn: []ManagedObject{ociRepo}, + DependsOn: dependants, DeletionPolicy: Delete, StatusFunc: FluxStatus, }) - p.Cluster.AddObject(helmRelease) + return helmRelease } -// FluxStatus returns the Status of a Flux-managed object by inspecting its Ready condition. -func FluxStatus(o client.Object, resourceLocation ClusterType) Status { +// FluxStatus indicates whether the given object is in phase terminating, pending or ready. +func FluxStatus(o client.Object) Status { fluxObject := o.(conditions.Getter) if !o.GetDeletionTimestamp().IsZero() { return Status{ - Phase: commonapi.StatusPhaseTerminating, + Phase: StatusPhaseTerminating, Message: "Resource is terminating.", - Location: resourceLocation, } } - if conditions.IsReady(fluxObject) { + if conditions.IsTrue(fluxObject, meta.ReadyCondition) { return Status{ - Phase: commonapi.StatusPhaseReady, + Phase: StatusPhaseReady, Message: "Resource is ready", - Location: resourceLocation, } } return Status{ - Phase: commonapi.StatusPhaseProgressing, + Phase: StatusPhaseProgressing, Message: "Resource is not ready", - Location: resourceLocation, } } diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go index e352f01..25ccf28 100644 --- a/pkg/manager/orphancleaner.go +++ b/pkg/manager/orphancleaner.go @@ -10,8 +10,6 @@ import ( "k8s.io/apimachinery/pkg/api/meta" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - - commonapi "github.com/openmcp-project/openmcp-operator/api/common" ) // ErrOrphanCleanup is an user-facing error that indicates orphan cleanup failures @@ -96,42 +94,28 @@ func (c *orphanCleaner[T]) Cleanup(ctx context.Context) ([]Result, error) { } func (c *orphanCleaner[T]) deletionPrepared(obj client.Object) Result { + mo := &managedObject{ + object: obj, + deletionPolicy: Delete, + } + return Result{ - Object: &managedObject{ - object: obj, - statusFunc: cleanupPreparedStatus, - deletionPolicy: Delete, - }, + Object: mo, Cluster: c.cluster, OperationResult: OperationResultDeletionRequested, } } -func cleanupPreparedStatus(_ client.Object, resourceLocation ClusterType) Status { - return Status{ - Phase: commonapi.StatusPhaseTerminating, - Message: "Orphan cleanup prepared", - Location: resourceLocation, - } -} - func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { + mo := &managedObject{ + object: obj, + deletionPolicy: Delete, + } + return Result{ - Object: &managedObject{ - object: obj, - statusFunc: cleanupErrorStatus, - deletionPolicy: Delete, - }, + Object: mo, Cluster: c.cluster, OperationResult: OperationResultDeletionFailed, Error: err, } } - -func cleanupErrorStatus(_ client.Object, resourceLocation ClusterType) Status { - return Status{ - Phase: "Failed", - Message: "Orphan cleanup failed", - Location: resourceLocation, - } -} diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go index d3d1f7d..5dba4c5 100644 --- a/pkg/manager/utils_test.go +++ b/pkg/manager/utils_test.go @@ -35,30 +35,20 @@ func CreateFakeCluster(t *testing.T, id string, clusterObjects ...client.Object) } // ExecApply sets up a manager for the provided clusters and invokes reconciliation of all managed objects -func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects int, wantErrors []string) []Result { +func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects int, wantErrors []string) []ManagedResource { t.Helper() // invoke apply with manager mgr := NewManager("serviceprovider-test") for _, cluster := range clusters { mgr.AddCluster(cluster) } - results, err := mgr.Apply(context.TODO()) + managedResources, err := mgr.Apply(context.TODO()) require.NoError(t, err) - return assertResult(t, results, expectedManagedObjects, wantErrors) + return assertResult(t, managedResources, expectedManagedObjects, wantErrors) } -func assertResult(t *testing.T, results []Result, expectedManagedObjects int, wantErrors []string) []Result { +func assertResult(t *testing.T, managedResources []ManagedResource, expectedManagedObjects int, wantErrors []string) []ManagedResource { t.Helper() - assert.Len(t, results, expectedManagedObjects, "expected %d managed object(s), got %d managed object(s)") - errcount := 0 - for _, r := range results { - if r.Error != nil { - // assert that an error is expected - assert.Contains(t, wantErrors, r.Object.GetObject().GetName(), "unexpected reconcile error of managed object %s", r.Object.GetObject().GetName()) - errcount++ - } - } - // assert that the overall number of errors is expected - assert.Equal(t, len(wantErrors), errcount, "expected %d reconcile error(s), got %d reconcile error(s)", len(wantErrors), errcount) - return results + assert.Len(t, managedResources, expectedManagedObjects, "expected %d managed object(s), got %d managed object(s)") + return managedResources } From ce85932d034a04db2b04e69c97668168bebdf573 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 6 Aug 2026 16:05:57 +0200 Subject: [PATCH 09/20] fix: Kubebuilder issues Signed-off-by: Jakob Bergmeier --- pkg/manager/flux.go | 31 +++++++++++++++++++++++++------ pkg/manager/managedResource.go | 2 +- pkg/manager/status.go | 7 +++++-- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go index 177a003..f6bce79 100644 --- a/pkg/manager/flux.go +++ b/pkg/manager/flux.go @@ -28,7 +28,7 @@ type ManageFluxResourcesParams struct { Interval time.Duration // ClusterContext of the current reconciliation context ClusterContext clusteraccess.ClusterContext - // RequestedVersion is the version of External Secrets Operator that a user requested through the onboarding API + // RequestedVersion is the version of Service Operator that a user requested through the onboarding API RequestedVersion RequestedVersion // OCIRepositoryName is the name of the OCIRepository resource. OCIRepositoryName string @@ -36,12 +36,31 @@ type ManageFluxResourcesParams struct { HelmReleaseName string } +// RequestedVersion defines a version of Service Operator that can be installed type RequestedVersion struct { - Version string - ChartVersion string - ChartURL string - ChartPullSecret string - HelmValues *apiextensionsv1.JSON + // Version is the Service Operation version to install. + // This version is compared with ExternalSecretsOperator.Spec.Version to define available versions + // and the deployment artifacts of a version. + // +required + Version string `json:"version"` + + // ChartVersion is the version of the Helm chart to install + // +required + ChartVersion string `json:"chartVersion"` + + // ChartURL is a reference to an OCI artifact repository that hosts the external-secrets Helm chart. + // +optional + // +kubebuilder:default="oci://ghcr.io/external-secrets/charts/external-secrets" + ChartURL string `json:"chartURL,omitempty"` + + // ChartPullSecret is a reference to the secret containing the credentials to pull the Helm chart. + // The secret must be of type kubernetes.io/dockerconfigjson. + // +optional + ChartPullSecret string `json:"chartPullSecret,omitempty"` + + // HelmValues are arbitrary Helm values passed directly to the managed HelmRelease. + // +optional + HelmValues *apiextensionsv1.JSON `json:"helmValues,omitempty"` } // ManageFluxResources registers an OCIRepository and a HelmRelease as managed objects on the diff --git a/pkg/manager/managedResource.go b/pkg/manager/managedResource.go index 61124f1..8dfd436 100644 --- a/pkg/manager/managedResource.go +++ b/pkg/manager/managedResource.go @@ -26,7 +26,7 @@ type ManagedResource struct { // Message string `json:"message,omitempty"` // +required - Status Status + Status Status `json:"status,omitempty"` // +optional Location ClusterType `json:"location,omitempty"` diff --git a/pkg/manager/status.go b/pkg/manager/status.go index a59d5fc..efb488e 100644 --- a/pkg/manager/status.go +++ b/pkg/manager/status.go @@ -17,8 +17,11 @@ const ( // Status defines the status attributes of a ManagedObject. type Status struct { - Phase string - Message string + // +required + Phase string `json:"phase,omitempty"` + + // +required + Message string `json:"message,omitempty"` } // SimpleStatus indicates whether the given object is in phase terminating, pending or ready. From 1167d30ab116842e28fc9354e36ce994897ab138 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 6 Aug 2026 16:23:24 +0200 Subject: [PATCH 10/20] fix: Do not delete in apply Signed-off-by: Jakob Bergmeier --- pkg/manager/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 2384834..2b4aaf0 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -68,7 +68,7 @@ func (m *managerImpl) AddCluster(mc ManagedCluster) { // DependsOn is NOT used for apply ordering; it only affects deletion sequencing. // Callers are responsible for registering objects in dependency order. func (m *managerImpl) Apply(ctx context.Context) ([]ManagedResource, error) { - results, err := m.reconcileObjects(ctx, true) + results, err := m.reconcileObjects(ctx, false) if err != nil { return []ManagedResource{}, err } From 6de4b2942a75136df11947bbd6be37819b0fc4e4 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 7 Aug 2026 11:34:49 +0200 Subject: [PATCH 11/20] fix: Implement interface for ManagedResource Signed-off-by: Jakob Bergmeier --- pkg/manager/flux.go | 20 +++--- pkg/manager/managedResource.go | 56 ++++++++------- pkg/manager/managedobject.go | 35 ++-------- pkg/manager/manager.go | 120 +++++++++++++++++---------------- pkg/manager/orphancleaner.go | 8 ++- pkg/manager/status.go | 29 ++++---- pkg/manager/utils_test.go | 2 +- 7 files changed, 125 insertions(+), 145 deletions(-) diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go index f6bce79..a749b4e 100644 --- a/pkg/manager/flux.go +++ b/pkg/manager/flux.go @@ -36,10 +36,11 @@ type ManageFluxResourcesParams struct { HelmReleaseName string } -// RequestedVersion defines a version of Service Operator that can be installed +// RequestedVersion defines a version of a Service Operator that can be installed. +// +kubebuilder:object:generate=true type RequestedVersion struct { // Version is the Service Operation version to install. - // This version is compared with ExternalSecretsOperator.Spec.Version to define available versions + // This version is compared with OperatorName.Spec.Version to define available versions // and the deployment artifacts of a version. // +required Version string `json:"version"` @@ -48,9 +49,8 @@ type RequestedVersion struct { // +required ChartVersion string `json:"chartVersion"` - // ChartURL is a reference to an OCI artifact repository that hosts the external-secrets Helm chart. + // ChartURL is a reference to an OCI artifact repository that hosts the Helm chart. // +optional - // +kubebuilder:default="oci://ghcr.io/external-secrets/charts/external-secrets" ChartURL string `json:"chartURL,omitempty"` // ChartPullSecret is a reference to the secret containing the credentials to pull the Helm chart. @@ -172,18 +172,18 @@ func FluxStatus(o client.Object) Status { fluxObject := o.(conditions.Getter) if !o.GetDeletionTimestamp().IsZero() { return Status{ - Phase: StatusPhaseTerminating, - Message: "Resource is terminating.", + Phase: StatusPhaseTerminating, + Message: "Resource is terminating.", } } if conditions.IsTrue(fluxObject, meta.ReadyCondition) { return Status{ - Phase: StatusPhaseReady, - Message: "Resource is ready", + Phase: StatusPhaseReady, + Message: "Resource is ready", } } return Status{ - Phase: StatusPhaseProgressing, - Message: "Resource is not ready", + Phase: StatusPhaseProgressing, + Message: "Resource is not ready", } } diff --git a/pkg/manager/managedResource.go b/pkg/manager/managedResource.go index 8dfd436..d4d7214 100644 --- a/pkg/manager/managedResource.go +++ b/pkg/manager/managedResource.go @@ -1,33 +1,31 @@ package manager -import ( - corev1 "k8s.io/api/core/v1" -) - -type InstancePhase string - -// Constants representing the phases of an instance lifecycle. -const ( - Pending InstancePhase = "Pending" - Progressing InstancePhase = "Progressing" - Ready InstancePhase = "Ready" - Failed InstancePhase = "Failed" - Terminating InstancePhase = "Terminating" - Unknown InstancePhase = "Unknown" -) - -// ManagedResource defines a kubernetes object with its lifecycle phase -type ManagedResource struct { - corev1.TypedObjectReference `json:",inline"` - - // +required - // Phase InstancePhase `json:"phase"` - // +optional - // Message string `json:"message,omitempty"` +// ManagedResource is the read-only view of a reconciled object returned by Apply and Delete. +// It is an interface so pkg/manager stays decoupled from any CRD schema: consumers define +// their own concrete CRD-embedded type and populate it from this interface. +type ManagedResource interface { + GetAPIVersion() string + GetKind() string + GetName() string + GetNamespace() *string // nil when the namespace is empty + GetLocation() ClusterType + GetStatus() Status +} - // +required - Status Status `json:"status,omitempty"` - - // +optional - Location ClusterType `json:"location,omitempty"` +// managedResourceResult is the package-private concrete implementation of ManagedResource +// built inside resultsToResources. It never escapes pkg/manager. +type managedResourceResult struct { + apiVersion string + kind string + name string + namespace *string + location ClusterType + status Status } + +func (r *managedResourceResult) GetAPIVersion() string { return r.apiVersion } +func (r *managedResourceResult) GetKind() string { return r.kind } +func (r *managedResourceResult) GetName() string { return r.name } +func (r *managedResourceResult) GetNamespace() *string { return r.namespace } +func (r *managedResourceResult) GetLocation() ClusterType { return r.location } +func (r *managedResourceResult) GetStatus() Status { return r.status } diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go index 40469f2..f7afd4d 100644 --- a/pkg/manager/managedobject.go +++ b/pkg/manager/managedobject.go @@ -10,9 +10,9 @@ import ( type DeletionPolicy string const ( - // Orphan indicates that an object will be orphaned when deletion is requested + // Orphan indicates that an object will be orphaned when deletion is requested. Orphan DeletionPolicy = "orphan" - // Delete indicates that an object will be deleted when deletion is requested + // Delete indicates that an object will be deleted when deletion is requested. Delete DeletionPolicy = "delete" ) @@ -27,7 +27,7 @@ func NoOp(context.Context, client.Object) error { return nil } -// NewManagedObject creates a new ManagedObject instances to manage the given client.Object. +// NewManagedObject creates a new ManagedObject instance to manage the given client.Object. func NewManagedObject(o client.Object, moc ManagedObjectContext) ManagedObject { if moc.DeletionPolicy == "" { moc.DeletionPolicy = Delete @@ -39,7 +39,6 @@ func NewManagedObject(o client.Object, moc ManagedObjectContext) ManagedObject { dependencies: moc.DependsOn, deletionPolicy: moc.DeletionPolicy, statusFunc: moc.StatusFunc, - managedBy: moc.ManagedBy, } } @@ -48,21 +47,16 @@ type ManagedObjectContext struct { ReconcileFunc ReconcileFunc DependsOn []ManagedObject DeletionPolicy DeletionPolicy - Status Status StatusFunc StatusFunc - Location ClusterType - ManagedBy string } -// ManagedObject represents an object managed by a Mana^ger. +// ManagedObject represents an object managed by a Manager. type ManagedObject interface { GetObject() client.Object Reconcile(ctx context.Context) error GetDependencies() []ManagedObject GetDeletionPolicy() DeletionPolicy - GetLocation() ClusterType GetStatus() Status - Label() string } var _ ManagedObject = &managedObject{} @@ -72,10 +66,7 @@ type managedObject struct { reconcileFunc ReconcileFunc dependencies []ManagedObject deletionPolicy DeletionPolicy - location ClusterType - managedBy string statusFunc StatusFunc - status Status } // GetDeletionPolicy implements ManagedObject. @@ -83,10 +74,6 @@ func (m *managedObject) GetDeletionPolicy() DeletionPolicy { return m.deletionPolicy } -func (m *managedObject) GetLocation() ClusterType { - return m.location -} - // GetDependencies implements ManagedObject. func (m *managedObject) GetDependencies() []ManagedObject { return m.dependencies @@ -105,23 +92,13 @@ func (m *managedObject) GetObject() client.Object { return m.object } -// Label implements ManagedObject. -func (m *managedObject) Label() string { - return m.managedBy -} - // GetStatus implements ManagedObject. func (m *managedObject) GetStatus() Status { if m.statusFunc != nil { return m.statusFunc(m.object) } return Status{ - Phase: StatusPhaseUnkown, - Message: "No status function defined.", + Phase: StatusPhaseUnknown, + Message: "No status function defined.", } } - -// SetPhase sets the phase of the managedObject resource status -func (m *managedObject) SetPhase(phase string) { - m.status.Phase = phase -} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 2b4aaf0..f6f516d 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -7,7 +7,6 @@ import ( "reflect" "slices" - corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -26,14 +25,26 @@ const ( OperationResultOrphaned controllerutil.OperationResult = OperationResultDeleted ) +// ErrManagedResourcesFailed is returned by Apply/Delete when one or more +// managed resources encountered a reconcile error. +var ErrManagedResourcesFailed = errors.New("one or more managed resources failed") + type dependents map[ManagedObject][]dependency -// Manager manages the objects of an arbitrary number of clusters +// Manager manages the objects of an arbitrary number of clusters. type Manager interface { AddCluster(mc ManagedCluster) AddCleaner(oc OrphanCleaner) - Apply(context.Context) (_ []ManagedResource, cleanup error) - Delete(context.Context) (_ []ManagedResource, cleanup error) + // Apply reconciles all managed objects in registration order. + // DependsOn is NOT used for apply ordering; it only affects deletion sequencing. + // Callers are responsible for registering objects in dependency order. + // done=true means every resource reached StatusPhaseReady. + // err wraps ErrManagedResourcesFailed when any individual reconcile failed. + Apply(ctx context.Context) (resources []ManagedResource, done bool, err error) + // Delete deletes all managed objects. + // done=true means every resource has been deleted or orphaned. + // err wraps ErrManagedResourcesFailed when any individual deletion failed. + Delete(ctx context.Context) (resources []ManagedResource, done bool, err error) } // OrphanCleaner removes any previously managed objects that are no longer part of the desired state. @@ -47,16 +58,16 @@ type OrphanCleaner interface { func NewManager(serviceProvider string) Manager { return &managerImpl{ serviceProvider: serviceProvider, - clusters: []ManagedCluster{}, - cleaners: []OrphanCleaner{}, + clusters: []ManagedCluster{}, + cleaners: []OrphanCleaner{}, } } // managerImpl manages clusters and invokes reconciliation of ManagedObjects. type managerImpl struct { serviceProvider string - clusters []ManagedCluster - cleaners []OrphanCleaner + clusters []ManagedCluster + cleaners []OrphanCleaner } // AddCluster adds a cluster to a Manager. @@ -64,38 +75,30 @@ func (m *managerImpl) AddCluster(mc ManagedCluster) { m.clusters = append(m.clusters, mc) } -// Apply reconciles all managed objects in registration order. -// DependsOn is NOT used for apply ordering; it only affects deletion sequencing. -// Callers are responsible for registering objects in dependency order. -func (m *managerImpl) Apply(ctx context.Context) ([]ManagedResource, error) { +// Apply implements Manager. +func (m *managerImpl) Apply(ctx context.Context) ([]ManagedResource, bool, error) { results, err := m.reconcileObjects(ctx, false) if err != nil { - return []ManagedResource{}, err - } - managedResources, resultContainsError := resultsToResources(ctx, results) - if resultContainsError { - return managedResources, err + return nil, false, err } - if allResourcesReady(results) { - return managedResources, nil + resources, errs := resultsToResources(ctx, results) + if len(errs) > 0 { + return resources, false, fmt.Errorf("%w: %w", ErrManagedResourcesFailed, errors.Join(errs...)) } - return managedResources, nil + return resources, allResourcesReady(results), nil } -// Delete invokes deletion of all ManagedObjects. -func (m *managerImpl) Delete(ctx context.Context) ([]ManagedResource, error) { +// Delete implements Manager. +func (m *managerImpl) Delete(ctx context.Context) ([]ManagedResource, bool, error) { results, err := m.reconcileObjects(ctx, true) if err != nil { - return []ManagedResource{}, err - } - managedResources, resultContainsError := resultsToResources(ctx, results) - if resultContainsError { - return managedResources, err + return nil, false, err } - if allDeleted(results) { - return managedResources, nil + resources, errs := resultsToResources(ctx, results) + if len(errs) > 0 { + return resources, false, fmt.Errorf("%w: %w", ErrManagedResourcesFailed, errors.Join(errs...)) } - return managedResources, nil + return resources, allDeleted(results), nil } // AddCleaner adds a cleaner to a Manager. @@ -115,7 +118,7 @@ func (m *managerImpl) reconcileObjects(ctx context.Context, isDeletion bool) ([] } } - // remove any redundant resources like secret copies that are no longer part of the desired state. + // Remove any redundant resources like secret copies that are no longer part of the desired state. for _, c := range m.cleaners { result, err := c.Cleanup(ctx) if err != nil { @@ -128,7 +131,7 @@ func (m *managerImpl) reconcileObjects(ctx context.Context, isDeletion bool) ([] } func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo ManagedObject, dependents dependents, isDeletion bool) Result { - client := mc.GetClient() + cl := mc.GetClient() obj := mo.GetObject() if isDeletion { @@ -146,17 +149,15 @@ func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo Object: mo, Cluster: mc, OperationResult: OperationResultOrphaned, - Error: nil, } } - err := client.Delete(ctx, obj) + err := cl.Delete(ctx, obj) if apierrors.IsNotFound(err) { return Result{ Object: mo, Cluster: mc, OperationResult: OperationResultDeleted, - Error: nil, } } return Result{ @@ -167,8 +168,8 @@ func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo } } - opResult, err := controllerutil.CreateOrUpdate(ctx, client, obj, func() error { - SetManagedBy(obj , m.serviceProvider) + opResult, err := controllerutil.CreateOrUpdate(ctx, cl, obj, func() error { + SetManagedBy(obj, m.serviceProvider) return mo.Reconcile(ctx) }) return Result{ @@ -180,21 +181,19 @@ func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo } func (m *managerImpl) checkForDependents(ctx context.Context, deps []dependency) error { - errs := []error{} + var errs []error for _, dep := range deps { obj := dep.Object.GetObject() err := dep.Cluster.GetClient().Get(ctx, client.ObjectKeyFromObject(obj), obj) if apierrors.IsNotFound(err) { - // "Not found" is the success case: The object which depends on us does not exist anymore. + // "Not found" is the success case: the dependent no longer exists. continue } if err != nil { - // Some unexpected error occurred. errs = append(errs, err) continue } - // No error occurred, the GET request has been successful. - // The object still exists and depends on us. + // No error: the GET succeeded, meaning the dependent still exists. errs = append(errs, fmt.Errorf("dependent object still exists: %s", ObjectID(obj))) } return errors.Join(errs...) @@ -231,46 +230,51 @@ type dependency struct { Cluster ManagedCluster } -// AllDeleted returns true if every item's operation result is OperationResultDeleted. +// allDeleted returns true if every result is OperationResultDeleted or OperationResultOrphaned. +// It uses OperationResult because deletion completion is an immediate API fact, +// not a condition that needs to be polled from the object's status. func allDeleted(results []Result) bool { for _, r := range results { - if r.OperationResult != OperationResultDeleted { + if r.OperationResult != OperationResultDeleted && r.OperationResult != OperationResultOrphaned { return false } } return true } +// allResourcesReady returns true if every managed object has reached StatusPhaseReady. +// It uses the object's StatusFunc output rather than OperationResult because convergence +// (e.g. a Flux HelmRelease becoming ready) is asynchronous: OperationResult reflects +// what the API server did ("created"/"updated"), not whether the controller has reconciled. func allResourcesReady(results []Result) bool { for _, r := range results { - if r.OperationResult != StatusPhaseReady { + if r.Object.GetStatus().Phase != StatusPhaseReady { return false } } return true } -func resultsToResources(ctx context.Context, results []Result) ([]ManagedResource, bool) { +func resultsToResources(ctx context.Context, results []Result) ([]ManagedResource, []error) { l := log.FromContext(ctx) - containsError := false + var errs []error resources := make([]ManagedResource, 0, len(results)) for _, res := range results { obj := res.Object.GetObject() - resources = append(resources, ManagedResource{ - TypedObjectReference: corev1.TypedObjectReference{ - Kind: reflect.TypeOf(obj).Elem().Name(), - Name: obj.GetName(), - Namespace: nilIfEmptyString(obj.GetNamespace()), - }, - Status: res.Object.GetStatus(), - Location: res.Object.GetLocation(), + resources = append(resources, &managedResourceResult{ + apiVersion: obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), + kind: reflect.TypeOf(obj).Elem().Name(), + name: obj.GetName(), + namespace: nilIfEmptyString(obj.GetNamespace()), + status: res.Object.GetStatus(), + location: res.Cluster.GetClusterType(), }) if res.Error != nil { - containsError = true - l.Error(res.Error, "objectID", ObjectID(obj)) + l.Error(res.Error, "reconcile error", "objectID", ObjectID(obj)) + errs = append(errs, res.Error) } } - return resources, containsError + return resources, errs } func nilIfEmptyString(str string) *string { diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go index 25ccf28..17737b2 100644 --- a/pkg/manager/orphancleaner.go +++ b/pkg/manager/orphancleaner.go @@ -97,8 +97,10 @@ func (c *orphanCleaner[T]) deletionPrepared(obj client.Object) Result { mo := &managedObject{ object: obj, deletionPolicy: Delete, + statusFunc: func(client.Object) Status { + return Status{Phase: StatusPhaseTerminating, Message: "Deletion prepared."} + }, } - return Result{ Object: mo, Cluster: c.cluster, @@ -110,8 +112,10 @@ func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { mo := &managedObject{ object: obj, deletionPolicy: Delete, + statusFunc: func(client.Object) Status { + return Status{Phase: StatusPhaseTerminating, Message: "Deletion failed."} + }, } - return Result{ Object: mo, Cluster: c.cluster, diff --git a/pkg/manager/status.go b/pkg/manager/status.go index efb488e..4fdba22 100644 --- a/pkg/manager/status.go +++ b/pkg/manager/status.go @@ -5,41 +5,38 @@ import ( ) const ( - // StatusPhaseReady indicates that the resource is ready. All conditions are met and are in status "True". + // StatusPhaseReady indicates that the resource is ready. StatusPhaseReady = "Ready" // StatusPhaseProgressing indicates that the resource is not ready and being created or updated. StatusPhaseProgressing = "Progressing" // StatusPhaseTerminating indicates that the resource is not ready and in deletion. StatusPhaseTerminating = "Terminating" - // StatusUnknown indicates that no StatusFunc is defined - StatusPhaseUnkown = "Unkown" + // StatusPhaseUnknown indicates that no StatusFunc is defined. + StatusPhaseUnknown = "Unknown" ) // Status defines the status attributes of a ManagedObject. type Status struct { - // +required - Phase string `json:"phase,omitempty"` - - // +required - Message string `json:"message,omitempty"` + Phase string + Message string } -// SimpleStatus indicates whether the given object is in phase terminating, pending or ready. +// SimpleStatus indicates whether the given object is in phase terminating, progressing, or ready. func SimpleStatus(o client.Object) Status { if !o.GetDeletionTimestamp().IsZero() { return Status{ - Phase: StatusPhaseTerminating, - Message: "Resource is terminating.", + Phase: StatusPhaseTerminating, + Message: "Resource is terminating.", } } if o.GetUID() == "" { return Status{ - Phase: StatusPhaseProgressing, - Message: "Resource has not been created yet.", + Phase: StatusPhaseProgressing, + Message: "Resource has not been created yet.", } } return Status{ - Phase: StatusPhaseReady, - Message: "Resource exists.", + Phase: StatusPhaseReady, + Message: "Resource exists.", } -} \ No newline at end of file +} diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go index 5dba4c5..9becf60 100644 --- a/pkg/manager/utils_test.go +++ b/pkg/manager/utils_test.go @@ -42,7 +42,7 @@ func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects i for _, cluster := range clusters { mgr.AddCluster(cluster) } - managedResources, err := mgr.Apply(context.TODO()) + managedResources, _, err := mgr.Apply(context.TODO()) require.NoError(t, err) return assertResult(t, managedResources, expectedManagedObjects, wantErrors) } From b8b937af83d3be3a944dc21d8f7c811616773422 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 7 Aug 2026 12:08:28 +0200 Subject: [PATCH 12/20] chore: go mod sum Signed-off-by: Jakob Bergmeier --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index a455eff..7e701ca 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/openmcp-project/controller-utils/api v0.31.0 github.com/openmcp-project/opencontrolplane-runtime v1.2.0 github.com/openmcp-project/openmcp-operator/api v1.3.0 - github.com/openmcp-project/service-provider-external-secrets v1.1.1 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 diff --git a/go.sum b/go.sum index e1a077c..7ce16bd 100644 --- a/go.sum +++ b/go.sum @@ -125,8 +125,6 @@ github.com/openmcp-project/openmcp-operator/api v1.3.0 h1:RgetVDqslJPD7/nb65AbqW github.com/openmcp-project/openmcp-operator/api v1.3.0/go.mod h1:GMCkaGUND5Qu3zTNjpYtR44her8gcOksHkrJu5isL6I= github.com/openmcp-project/openmcp-operator/lib v1.3.0 h1:Icx40/Qx/85+E8vWTMi1jW1WrX/MFN+FaZTDt7JWcRU= github.com/openmcp-project/openmcp-operator/lib v1.3.0/go.mod h1:+Ptr+38neYaoGzEdY5zhR3uhfKqNZ2J9tyOcc15S7EY= -github.com/openmcp-project/service-provider-external-secrets v1.1.1 h1:JrpFRdCfkIdWzULeN3xgg4mTAanirUia2LxxXAwMtp4= -github.com/openmcp-project/service-provider-external-secrets v1.1.1/go.mod h1:YO/oHfzWQYxm6eR1ZsMvkmjegpJOI995+Qjy/V7U290= 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= From c29bace82c8d945931bd01152ebf4c3e561e44fb Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 7 Aug 2026 15:10:13 +0200 Subject: [PATCH 13/20] refactor: Subpackage for flux and interface for FluxResourceVersion Signed-off-by: Jakob Bergmeier --- pkg/manager/flux.go | 189 ------------------------------ pkg/manager/flux/flux.go | 208 ++++++++++++++++++++++++++++++++++ pkg/manager/flux/flux_test.go | 121 ++++++++++++++++++++ pkg/manager/flux_test.go | 94 --------------- 4 files changed, 329 insertions(+), 283 deletions(-) delete mode 100644 pkg/manager/flux.go create mode 100644 pkg/manager/flux/flux.go create mode 100644 pkg/manager/flux/flux_test.go delete mode 100644 pkg/manager/flux_test.go diff --git a/pkg/manager/flux.go b/pkg/manager/flux.go deleted file mode 100644 index a749b4e..0000000 --- a/pkg/manager/flux.go +++ /dev/null @@ -1,189 +0,0 @@ -package manager - -import ( - "context" - "fmt" - "time" - - helmv2 "github.com/fluxcd/helm-controller/api/v2" - "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/runtime/conditions" - sourcev1 "github.com/fluxcd/source-controller/api/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" -) - -// ManageFluxResourcesParams groups all parameters to create the required Flux resources. -type ManageFluxResourcesParams struct { - // Cluster defines where the resources will be created. - Cluster ManagedCluster - // MCPNamespace is the target namespace for the Helm release. - MCPNamespace string - // ChartPullSecretName is the name of the image-pull secret placed in the cluster namespace. - ChartPullSecretName string - // Interval defines the OCIRepository and HelmRelease reconcile intervals. - Interval time.Duration - // ClusterContext of the current reconciliation context - ClusterContext clusteraccess.ClusterContext - // RequestedVersion is the version of Service Operator that a user requested through the onboarding API - RequestedVersion RequestedVersion - // OCIRepositoryName is the name of the OCIRepository resource. - OCIRepositoryName string - // HelmReleaseName is the name of the HelmRelease resource. - HelmReleaseName string -} - -// RequestedVersion defines a version of a Service Operator that can be installed. -// +kubebuilder:object:generate=true -type RequestedVersion struct { - // Version is the Service Operation version to install. - // This version is compared with OperatorName.Spec.Version to define available versions - // and the deployment artifacts of a version. - // +required - Version string `json:"version"` - - // ChartVersion is the version of the Helm chart to install - // +required - ChartVersion string `json:"chartVersion"` - - // ChartURL is a reference to an OCI artifact repository that hosts the Helm chart. - // +optional - ChartURL string `json:"chartURL,omitempty"` - - // ChartPullSecret is a reference to the secret containing the credentials to pull the Helm chart. - // The secret must be of type kubernetes.io/dockerconfigjson. - // +optional - ChartPullSecret string `json:"chartPullSecret,omitempty"` - - // HelmValues are arbitrary Helm values passed directly to the managed HelmRelease. - // +optional - HelmValues *apiextensionsv1.JSON `json:"helmValues,omitempty"` -} - -// ManageFluxResources registers an OCIRepository and a HelmRelease as managed objects on the -// cluster defined in p.Cluster. The objects are not reconciled until the caller invokes -// Manager.Apply. -func ManageFluxResources(p ManageFluxResourcesParams) { - ociDependants := []ManagedObject{} - ociRepo := manageOCIRepo(p, ociDependants) - p.Cluster.AddObject(ociRepo) - - helmDependants := []ManagedObject{ociRepo} - helmRelease := manageHelmRelease(p, helmDependants) - p.Cluster.AddObject(helmRelease) -} - -func manageOCIRepo(p ManageFluxResourcesParams, dependants []ManagedObject) ManagedObject { - ociRepo := NewManagedObject(&sourcev1.OCIRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: p.OCIRepositoryName, - Namespace: p.Cluster.GetDefaultNamespace(), - }, - }, ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, o client.Object) error { - ociRepo, ok := o.(*sourcev1.OCIRepository) - if !ok { - return fmt.Errorf("expected *sourcev1.OCIRepository, got %T", o) - } - if p.RequestedVersion.ChartURL == "" { - // this should never happen as long as defaulting works properly - return fmt.Errorf("missing ChartURL definition for version %s", p.RequestedVersion.Version) - } - ociRepo.Spec = sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: p.Interval}, - URL: p.RequestedVersion.ChartURL, - Reference: &sourcev1.OCIRepositoryRef{ - Tag: p.RequestedVersion.ChartVersion, - }, - // required to always select the correct OCI layer - // this mitigates non-deterministic layer ordering across chart versions - // https://fluxcd.io/flux/components/source/ocirepositories/#layer-selector - LayerSelector: &sourcev1.OCILayerSelector{ - MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", - Operation: "extract", - }, - } - if p.ChartPullSecretName != "" { - ociRepo.Spec.SecretRef = &meta.LocalObjectReference{ - Name: p.ChartPullSecretName, - } - } - return nil - }, - DependsOn: dependants, - DeletionPolicy: Delete, - StatusFunc: FluxStatus, - }) - return ociRepo -} - -func manageHelmRelease(p ManageFluxResourcesParams, dependants []ManagedObject) ManagedObject { - helmRelease := NewManagedObject(&helmv2.HelmRelease{ - ObjectMeta: metav1.ObjectMeta{ - Name: p.HelmReleaseName, - Namespace: p.Cluster.GetDefaultNamespace(), - }, - }, ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, o client.Object) error { - helmRelease, ok := o.(*helmv2.HelmRelease) - if !ok { - return fmt.Errorf("expected *helmv2.HelmRelease, got %T", o) - } - helmRelease.Spec = helmv2.HelmReleaseSpec{ - Interval: metav1.Duration{Duration: p.Interval}, - ChartRef: &helmv2.CrossNamespaceSourceReference{ - Kind: "OCIRepository", - Name: p.OCIRepositoryName, - Namespace: p.Cluster.GetDefaultNamespace(), - }, - KubeConfig: &meta.KubeConfigReference{ - SecretRef: &meta.SecretKeyReference{ - Name: p.ClusterContext.MCPAccessSecretKey.Name, - Key: "kubeconfig", - }, - }, - Install: &helmv2.Install{ - Remediation: &helmv2.InstallRemediation{ - Retries: 3, - }, - CreateNamespace: true, - }, - DriftDetection: &helmv2.DriftDetection{ - Mode: helmv2.DriftDetectionEnabled, - }, - Values: p.RequestedVersion.HelmValues, - TargetNamespace: p.MCPNamespace, - StorageNamespace: p.MCPNamespace, - } - return nil - }, - DependsOn: dependants, - DeletionPolicy: Delete, - StatusFunc: FluxStatus, - }) - return helmRelease -} - -// FluxStatus indicates whether the given object is in phase terminating, pending or ready. -func FluxStatus(o client.Object) Status { - fluxObject := o.(conditions.Getter) - if !o.GetDeletionTimestamp().IsZero() { - return Status{ - Phase: StatusPhaseTerminating, - Message: "Resource is terminating.", - } - } - if conditions.IsTrue(fluxObject, meta.ReadyCondition) { - return Status{ - Phase: StatusPhaseReady, - Message: "Resource is ready", - } - } - return Status{ - Phase: StatusPhaseProgressing, - Message: "Resource is not ready", - } -} diff --git a/pkg/manager/flux/flux.go b/pkg/manager/flux/flux.go new file mode 100644 index 0000000..87e21b6 --- /dev/null +++ b/pkg/manager/flux/flux.go @@ -0,0 +1,208 @@ +// Package flux provides generic helpers for deploying Helm charts via Flux +// (OCIRepository + HelmRelease). Service providers implement FluxResourceVersion +// with their own CRD-embedded concrete type; the framework never owns a public +// concrete struct for version data. +package flux + +import ( + "context" + "fmt" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmcp-project/controller-utils/pkg/manager" + "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" +) + +// FluxResourceVersion defines the version information needed to deploy a +// Flux-managed Helm chart. Service providers implement this interface with +// their own CRD-embedded concrete type; the framework never owns a public +// concrete struct for version data. +type FluxResourceVersion interface { + GetVersion() string + GetChartVersion() string + GetChartURL() string + // GetChartPullSecret returns the name of the secret (already placed in the + // target namespace) used to pull the Helm chart OCI artifact. + GetChartPullSecret() string + GetHelmValues() *apiextensionsv1.JSON +} + +// NewFluxResourceVersion creates a FluxResourceVersion backed by a private +// implementation. Useful for tests and for service providers that do not +// define their own CRD-backed version type. +func NewFluxResourceVersion( + version, chartVersion, chartURL, chartPullSecret string, + helmValues *apiextensionsv1.JSON, +) FluxResourceVersion { + return &fluxResourceVersion{ + version: version, + chartVersion: chartVersion, + chartURL: chartURL, + chartPullSecret: chartPullSecret, + helmValues: helmValues, + } +} + +// fluxResourceVersion is the package-private concrete implementation of +// FluxResourceVersion, analogous to managedResourceResult in pkg/manager. +type fluxResourceVersion struct { + version string + chartVersion string + chartURL string + chartPullSecret string + helmValues *apiextensionsv1.JSON +} + +func (r *fluxResourceVersion) GetVersion() string { return r.version } +func (r *fluxResourceVersion) GetChartVersion() string { return r.chartVersion } +func (r *fluxResourceVersion) GetChartURL() string { return r.chartURL } +func (r *fluxResourceVersion) GetChartPullSecret() string { return r.chartPullSecret } +func (r *fluxResourceVersion) GetHelmValues() *apiextensionsv1.JSON { return r.helmValues } + +// ManageFluxResourcesParams groups all parameters needed to register the +// Flux resources (OCIRepository + HelmRelease) on a cluster. +type ManageFluxResourcesParams struct { + // Cluster defines where the resources will be created. + Cluster manager.ManagedCluster + // MCPNamespace is the target namespace for the Helm release. + MCPNamespace string + // Interval defines the OCIRepository and HelmRelease reconcile intervals. + Interval time.Duration + // ClusterContext of the current reconciliation context. + ClusterContext clusteraccess.ClusterContext + // RequestedVersion carries version and chart metadata. The chart pull + // secret name is sourced from RequestedVersion.GetChartPullSecret(). + RequestedVersion FluxResourceVersion + // OCIRepositoryName is the name of the OCIRepository resource. + OCIRepositoryName string + // HelmReleaseName is the name of the HelmRelease resource. + HelmReleaseName string +} + +// ManageFluxResources registers an OCIRepository and a HelmRelease as managed +// objects on the cluster defined in p.Cluster. The objects are not reconciled +// until the caller invokes Manager.Apply. +func ManageFluxResources(p ManageFluxResourcesParams) { + ociRepo := manageOCIRepo(p, []manager.ManagedObject{}) + p.Cluster.AddObject(ociRepo) + + helmRelease := manageHelmRelease(p, []manager.ManagedObject{ociRepo}) + p.Cluster.AddObject(helmRelease) +} + +func manageOCIRepo(p ManageFluxResourcesParams, dependants []manager.ManagedObject) manager.ManagedObject { + return manager.NewManagedObject(&sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: p.OCIRepositoryName, + Namespace: p.Cluster.GetDefaultNamespace(), + }, + }, manager.ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, o client.Object) error { + ociRepo, ok := o.(*sourcev1.OCIRepository) + if !ok { + return fmt.Errorf("expected *sourcev1.OCIRepository, got %T", o) + } + if p.RequestedVersion.GetChartURL() == "" { + return fmt.Errorf("missing ChartURL definition for version %s", p.RequestedVersion.GetVersion()) + } + ociRepo.Spec = sourcev1.OCIRepositorySpec{ + Interval: metav1.Duration{Duration: p.Interval}, + URL: p.RequestedVersion.GetChartURL(), + Reference: &sourcev1.OCIRepositoryRef{ + Tag: p.RequestedVersion.GetChartVersion(), + }, + // required to always select the correct OCI layer + // this mitigates non-deterministic layer ordering across chart versions + // https://fluxcd.io/flux/components/source/ocirepositories/#layer-selector + LayerSelector: &sourcev1.OCILayerSelector{ + MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + Operation: "extract", + }, + } + if secret := p.RequestedVersion.GetChartPullSecret(); secret != "" { + ociRepo.Spec.SecretRef = &meta.LocalObjectReference{ + Name: secret, + } + } + return nil + }, + DependsOn: dependants, + DeletionPolicy: manager.Delete, + StatusFunc: FluxStatus, + }) +} + +func manageHelmRelease(p ManageFluxResourcesParams, dependants []manager.ManagedObject) manager.ManagedObject { + return manager.NewManagedObject(&helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: p.HelmReleaseName, + Namespace: p.Cluster.GetDefaultNamespace(), + }, + }, manager.ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, o client.Object) error { + helmRelease, ok := o.(*helmv2.HelmRelease) + if !ok { + return fmt.Errorf("expected *helmv2.HelmRelease, got %T", o) + } + helmRelease.Spec = helmv2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: p.Interval}, + ChartRef: &helmv2.CrossNamespaceSourceReference{ + Kind: "OCIRepository", + Name: p.OCIRepositoryName, + Namespace: p.Cluster.GetDefaultNamespace(), + }, + KubeConfig: &meta.KubeConfigReference{ + SecretRef: &meta.SecretKeyReference{ + Name: p.ClusterContext.MCPAccessSecretKey.Name, + Key: "kubeconfig", + }, + }, + Install: &helmv2.Install{ + Remediation: &helmv2.InstallRemediation{ + Retries: 3, + }, + CreateNamespace: true, + }, + DriftDetection: &helmv2.DriftDetection{ + Mode: helmv2.DriftDetectionEnabled, + }, + Values: p.RequestedVersion.GetHelmValues(), + TargetNamespace: p.MCPNamespace, + StorageNamespace: p.MCPNamespace, + } + return nil + }, + DependsOn: dependants, + DeletionPolicy: manager.Delete, + StatusFunc: FluxStatus, + }) +} + +// FluxStatus indicates whether the given object is in phase terminating, pending or ready. +func FluxStatus(o client.Object) manager.Status { + fluxObject := o.(conditions.Getter) + if !o.GetDeletionTimestamp().IsZero() { + return manager.Status{ + Phase: manager.StatusPhaseTerminating, + Message: "Resource is terminating.", + } + } + if conditions.IsTrue(fluxObject, meta.ReadyCondition) { + return manager.Status{ + Phase: manager.StatusPhaseReady, + Message: "Resource is ready", + } + } + return manager.Status{ + Phase: manager.StatusPhaseProgressing, + Message: "Resource is not ready", + } +} diff --git a/pkg/manager/flux/flux_test.go b/pkg/manager/flux/flux_test.go new file mode 100644 index 0000000..b9c8127 --- /dev/null +++ b/pkg/manager/flux/flux_test.go @@ -0,0 +1,121 @@ +package flux_test + +import ( + "context" + "testing" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiextensionv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/openmcp-project/controller-utils/pkg/manager" + "github.com/openmcp-project/controller-utils/pkg/manager/flux" + "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" + ocprtv1alpha1 "github.com/openmcp-project/opencontrolplane-runtime/testdata/api/v1alpha1" + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" +) + +const ( + testNamespace = "test" + testChartURL = "oci://registry.example.com/charts/my-app" + testChartPullSecret = "chart-pull-secret" + testKubeconfigKey = "kubeconfig-key" +) + +// createFakeCluster sets up a ManagedCluster backed by a fake client. +// It mirrors the CreateFakeCluster helper in pkg/manager/utils_test.go but is +// inlined here because that helper is only available inside package manager. +func createFakeCluster(t *testing.T, id string) manager.ManagedCluster { + t.Helper() + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = apiextensionv1.AddToScheme(scheme) + _ = ocprtv1alpha1.AddToScheme(scheme) + _ = clustersv1alpha1.AddToScheme(scheme) + _ = sourcev1.AddToScheme(scheme) + _ = helmv2.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + c := clusters.NewTestClusterFromClient(id, fakeClient) + return manager.NewManagedCluster(c, &rest.Config{}, testNamespace, manager.PlatformCluster) +} + +func TestManageFluxResources(t *testing.T) { + tests := []struct { + name string + params flux.ManageFluxResourcesParams + }{ + { + name: "OCIRepository and HelmRelease are created with the correct values", + params: flux.ManageFluxResourcesParams{ + Cluster: createFakeCluster(t, "platform"), + MCPNamespace: "my-service", + Interval: time.Hour, + ClusterContext: clusteraccess.ClusterContext{ + MCPAccessSecretKey: client.ObjectKey{ + Namespace: testNamespace, + Name: testKubeconfigKey, + }, + }, + RequestedVersion: flux.NewFluxResourceVersion( + "v1.0.0", + "1.0.0", + testChartURL, + testChartPullSecret, + &apiextensionv1.JSON{Raw: []byte(`{"foo":"bar"}`)}, + ), + OCIRepositoryName: "test-repo", + HelmReleaseName: "test-release", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flux.ManageFluxResources(tt.params) + + // apply via a manager + mgr := manager.NewManager("serviceprovider-test") + mgr.AddCluster(tt.params.Cluster) + _, _, err := mgr.Apply(context.TODO()) + require.NoError(t, err) + + // assert OCIRepository + ociRepo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: testNamespace, + }, + } + require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(ociRepo), ociRepo)) + assert.Equal(t, tt.params.RequestedVersion.GetChartURL(), ociRepo.Spec.URL) + assert.Equal(t, tt.params.RequestedVersion.GetChartPullSecret(), ociRepo.Spec.SecretRef.Name) + assert.Equal(t, tt.params.RequestedVersion.GetChartVersion(), ociRepo.Spec.Reference.Tag) + assert.Equal(t, tt.params.Interval, ociRepo.Spec.Interval.Duration) + + // assert HelmRelease + helmRelease := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-release", + Namespace: testNamespace, + }, + } + require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(helmRelease), helmRelease)) + assert.Equal(t, tt.params.RequestedVersion.GetHelmValues(), helmRelease.Spec.Values) + assert.Equal(t, tt.params.Interval, helmRelease.Spec.Interval.Duration) + assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.StorageNamespace) + assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.TargetNamespace) + assert.Equal(t, tt.params.ClusterContext.MCPAccessSecretKey.Name, helmRelease.Spec.KubeConfig.SecretRef.Name) + assert.Equal(t, "kubeconfig", helmRelease.Spec.KubeConfig.SecretRef.Key) + }) + } +} diff --git a/pkg/manager/flux_test.go b/pkg/manager/flux_test.go deleted file mode 100644 index f8d99ad..0000000 --- a/pkg/manager/flux_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package manager - -import ( - "context" - "testing" - "time" - - helmv2 "github.com/fluxcd/helm-controller/api/v2" - sourcev1 "github.com/fluxcd/source-controller/api/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - apiextensionv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" -) - -const ( - testNamespace = "test" - testChartURL = "oci://registry.example.com/charts/my-app" - testChartPullSecret = "chart-pull-secret" - testKubeconfigKey = "kubeconfig-key" -) - -func TestManageFluxResources(t *testing.T) { - tests := []struct { - name string // description of this test case - // Named input parameters for target function. - params ManageFluxResourcesParams - }{ - { - name: "OCIRepository and HelmRelease are created with the correct values", - params: ManageFluxResourcesParams{ - Cluster: NewManagedCluster(CreateFakeCluster(t, "platform"), &rest.Config{}, testNamespace, PlatformCluster), - MCPNamespace: "my-service", - ChartPullSecretName: "secret-copy", - Interval: time.Hour, - ClusterContext: clusteraccess.ClusterContext{ - MCPAccessSecretKey: client.ObjectKey{ - Namespace: testNamespace, - Name: testKubeconfigKey, - }, - }, - RequestedVersion: RequestedVersion{ - Version: "v1.0.0", - ChartURL: testChartURL, - ChartVersion: "1.0.0", - HelmValues: &apiextensionv1.JSON{ - Raw: []byte(`{"foo":"bar"}`), - }, - }, - OCIRepositoryName: "test-repo", - HelmReleaseName: "test-release", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ManageFluxResources(tt.params) - // expect oci repo and helm release without errors - ExecApply(t, []ManagedCluster{tt.params.Cluster}, 2, []string{}) - - // assert oci repo - ociRepo := &sourcev1.OCIRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-repo", - Namespace: testNamespace, - }, - } - require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(ociRepo), ociRepo)) - assert.Equal(t, tt.params.RequestedVersion.ChartURL, ociRepo.Spec.URL) - assert.Equal(t, tt.params.ChartPullSecretName, ociRepo.Spec.SecretRef.Name) - assert.Equal(t, tt.params.RequestedVersion.ChartVersion, ociRepo.Spec.Reference.Tag) - assert.Equal(t, tt.params.Interval, ociRepo.Spec.Interval.Duration) - - // assert helm release - helmRelease := &helmv2.HelmRelease{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-release", - Namespace: testNamespace, - }, - } - require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(helmRelease), helmRelease)) - assert.Equal(t, tt.params.RequestedVersion.HelmValues, helmRelease.Spec.Values) - assert.Equal(t, tt.params.Interval, helmRelease.Spec.Interval.Duration) - assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.StorageNamespace) - assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.TargetNamespace) - assert.Equal(t, tt.params.ClusterContext.MCPAccessSecretKey.Name, helmRelease.Spec.KubeConfig.SecretRef.Name) - assert.Equal(t, "kubeconfig", helmRelease.Spec.KubeConfig.SecretRef.Key) - }) - } -} From e8621ec44451832b5bc25350196c064444a22f12 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 7 Aug 2026 15:11:29 +0200 Subject: [PATCH 14/20] refactor: SecretCopyConfig secretname string instead of localObjectRef Signed-off-by: Jakob Bergmeier --- pkg/manager/secret.go | 6 ++++-- pkg/manager/secret_test.go | 7 +++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/manager/secret.go b/pkg/manager/secret.go index af589f7..5842efd 100644 --- a/pkg/manager/secret.go +++ b/pkg/manager/secret.go @@ -15,6 +15,8 @@ import ( type SecretCopyConfig struct { // SourceClient is the client to read the source secret from. SourceClient client.Client + // SourceName is the name of the source secret. + SourceName string // SourceNamespace is the namespace of the source secret. SourceNamespace string // TargetNamespace is the namespace of the target secret. @@ -24,7 +26,7 @@ type SecretCopyConfig struct { } // ManagePullSecret syncs every image pull secret the to cluster -func ManagePullSecret(targetCluster ManagedCluster, pullSecret corev1.LocalObjectReference, config SecretCopyConfig) { +func ManagePullSecret(targetCluster ManagedCluster, config SecretCopyConfig) { secret := NewManagedObject(&corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: config.TargetName, @@ -38,7 +40,7 @@ func ManagePullSecret(targetCluster ManagedCluster, pullSecret corev1.LocalObjec } sourceSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: pullSecret.Name, + Name: config.SourceName, Namespace: config.SourceNamespace, }, } diff --git a/pkg/manager/secret_test.go b/pkg/manager/secret_test.go index 8b394a6..9f01032 100644 --- a/pkg/manager/secret_test.go +++ b/pkg/manager/secret_test.go @@ -46,15 +46,14 @@ func TestManagePullSecrets(t *testing.T) { name string // description of this test case // Named input parameters for target function. targetCluster ManagedCluster - pullSecret corev1.LocalObjectReference config SecretCopyConfig }{ { name: "copy secret privateregcred from source to target namespace and adjust its name", targetCluster: NewManagedCluster(fakeCluster, &rest.Config{}, sourceNamespace, PlatformCluster), - pullSecret: corev1.LocalObjectReference{Name: secretName}, config: SecretCopyConfig{ SourceClient: fakeCluster.Client(), + SourceName: secretName, SourceNamespace: sourceNamespace, TargetNamespace: targetNamespace, TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), @@ -63,11 +62,11 @@ func TestManagePullSecrets(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ManagePullSecret(tt.targetCluster, tt.pullSecret, tt.config) + ManagePullSecret(tt.targetCluster, tt.config) ExecApply(t, []ManagedCluster{tt.targetCluster}, 1, []string{}) sourceSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: tt.pullSecret.Name, + Name: tt.config.SourceName, Namespace: tt.config.SourceNamespace, }, } From de0dcdd05f3d689f574f72f594101863db593a71 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 7 Aug 2026 16:18:02 +0200 Subject: [PATCH 15/20] refactor(status): Rename Status and add condition to flux resources Signed-off-by: Jakob Bergmeier --- pkg/manager/flux/flux.go | 13 +++++++------ pkg/manager/managedResource.go | 6 +++--- pkg/manager/managedobject.go | 10 +++++----- pkg/manager/orphancleaner.go | 8 ++++---- pkg/manager/status.go | 12 ++++++------ 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/pkg/manager/flux/flux.go b/pkg/manager/flux/flux.go index 87e21b6..eafa561 100644 --- a/pkg/manager/flux/flux.go +++ b/pkg/manager/flux/flux.go @@ -187,22 +187,23 @@ func manageHelmRelease(p ManageFluxResourcesParams, dependants []manager.Managed } // FluxStatus indicates whether the given object is in phase terminating, pending or ready. -func FluxStatus(o client.Object) manager.Status { +func FluxStatus(o client.Object) manager.ManagedResourceStatus { fluxObject := o.(conditions.Getter) if !o.GetDeletionTimestamp().IsZero() { - return manager.Status{ + return manager.ManagedResourceStatus{ Phase: manager.StatusPhaseTerminating, Message: "Resource is terminating.", } } if conditions.IsTrue(fluxObject, meta.ReadyCondition) { - return manager.Status{ + return manager.ManagedResourceStatus{ Phase: manager.StatusPhaseReady, Message: "Resource is ready", } } - return manager.Status{ - Phase: manager.StatusPhaseProgressing, - Message: "Resource is not ready", + msg := "Resource is not ready" + if cond := conditions.Get(fluxObject, meta.ReadyCondition); cond != nil && cond.Message != "" { + msg = cond.Message } + return manager.ManagedResourceStatus{Phase: manager.StatusPhaseProgressing, Message: msg} } diff --git a/pkg/manager/managedResource.go b/pkg/manager/managedResource.go index d4d7214..1be0eb6 100644 --- a/pkg/manager/managedResource.go +++ b/pkg/manager/managedResource.go @@ -9,7 +9,7 @@ type ManagedResource interface { GetName() string GetNamespace() *string // nil when the namespace is empty GetLocation() ClusterType - GetStatus() Status + GetStatus() ManagedResourceStatus } // managedResourceResult is the package-private concrete implementation of ManagedResource @@ -20,7 +20,7 @@ type managedResourceResult struct { name string namespace *string location ClusterType - status Status + status ManagedResourceStatus } func (r *managedResourceResult) GetAPIVersion() string { return r.apiVersion } @@ -28,4 +28,4 @@ func (r *managedResourceResult) GetKind() string { return r.kind } func (r *managedResourceResult) GetName() string { return r.name } func (r *managedResourceResult) GetNamespace() *string { return r.namespace } func (r *managedResourceResult) GetLocation() ClusterType { return r.location } -func (r *managedResourceResult) GetStatus() Status { return r.status } +func (r *managedResourceResult) GetStatus() ManagedResourceStatus { return r.status } diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go index f7afd4d..5d97745 100644 --- a/pkg/manager/managedobject.go +++ b/pkg/manager/managedobject.go @@ -19,8 +19,8 @@ const ( // ReconcileFunc reconciles the given client.Object. type ReconcileFunc func(ctx context.Context, o client.Object) error -// StatusFunc provides Status information for the given client.Object. -type StatusFunc func(client.Object) Status +// StatusFunc provides ManagedResourceStatus information for the given client.Object. +type StatusFunc func(client.Object) ManagedResourceStatus // NoOp does not do anything with the provided object and returns nil. func NoOp(context.Context, client.Object) error { @@ -56,7 +56,7 @@ type ManagedObject interface { Reconcile(ctx context.Context) error GetDependencies() []ManagedObject GetDeletionPolicy() DeletionPolicy - GetStatus() Status + GetStatus() ManagedResourceStatus } var _ ManagedObject = &managedObject{} @@ -93,11 +93,11 @@ func (m *managedObject) GetObject() client.Object { } // GetStatus implements ManagedObject. -func (m *managedObject) GetStatus() Status { +func (m *managedObject) GetStatus() ManagedResourceStatus { if m.statusFunc != nil { return m.statusFunc(m.object) } - return Status{ + return ManagedResourceStatus{ Phase: StatusPhaseUnknown, Message: "No status function defined.", } diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go index 17737b2..c430576 100644 --- a/pkg/manager/orphancleaner.go +++ b/pkg/manager/orphancleaner.go @@ -97,8 +97,8 @@ func (c *orphanCleaner[T]) deletionPrepared(obj client.Object) Result { mo := &managedObject{ object: obj, deletionPolicy: Delete, - statusFunc: func(client.Object) Status { - return Status{Phase: StatusPhaseTerminating, Message: "Deletion prepared."} + statusFunc: func(client.Object) ManagedResourceStatus { + return ManagedResourceStatus{Phase: StatusPhaseTerminating, Message: "Deletion prepared."} }, } return Result{ @@ -112,8 +112,8 @@ func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { mo := &managedObject{ object: obj, deletionPolicy: Delete, - statusFunc: func(client.Object) Status { - return Status{Phase: StatusPhaseTerminating, Message: "Deletion failed."} + statusFunc: func(client.Object) ManagedResourceStatus { + return ManagedResourceStatus{Phase: StatusPhaseTerminating, Message: "Deletion failed."} }, } return Result{ diff --git a/pkg/manager/status.go b/pkg/manager/status.go index 4fdba22..50b07a3 100644 --- a/pkg/manager/status.go +++ b/pkg/manager/status.go @@ -15,27 +15,27 @@ const ( StatusPhaseUnknown = "Unknown" ) -// Status defines the status attributes of a ManagedObject. -type Status struct { +// ManagedResourceStatus defines the status attributes of a ManagedObject. +type ManagedResourceStatus struct { Phase string Message string } // SimpleStatus indicates whether the given object is in phase terminating, progressing, or ready. -func SimpleStatus(o client.Object) Status { +func SimpleStatus(o client.Object) ManagedResourceStatus { if !o.GetDeletionTimestamp().IsZero() { - return Status{ + return ManagedResourceStatus{ Phase: StatusPhaseTerminating, Message: "Resource is terminating.", } } if o.GetUID() == "" { - return Status{ + return ManagedResourceStatus{ Phase: StatusPhaseProgressing, Message: "Resource has not been created yet.", } } - return Status{ + return ManagedResourceStatus{ Phase: StatusPhaseReady, Message: "Resource exists.", } From 9b53b81b6225c6962061b2e5c51e6356168956e8 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Tue, 11 Aug 2026 17:44:09 +0200 Subject: [PATCH 16/20] fix: AI feedback Signed-off-by: Jakob Bergmeier --- pkg/manager/flux/flux.go | 59 ++++++-- pkg/manager/flux/flux_test.go | 182 ++++++++++++++++++++++++- pkg/manager/managedResource.go | 21 +-- pkg/manager/managedcluster.go | 2 +- pkg/manager/manager.go | 38 ++++-- pkg/manager/orphancleaner.go | 11 +- pkg/manager/orphancleaner_test.go | 12 +- pkg/manager/secret.go | 4 +- pkg/manager/secret_test.go | 45 +++++- pkg/manager/status.go | 23 ++++ pkg/manager/utils_test.go | 218 +++++++++++++++++++++++++++++- 11 files changed, 561 insertions(+), 54 deletions(-) diff --git a/pkg/manager/flux/flux.go b/pkg/manager/flux/flux.go index eafa561..a326116 100644 --- a/pkg/manager/flux/flux.go +++ b/pkg/manager/flux/flux.go @@ -90,15 +90,47 @@ type ManageFluxResourcesParams struct { // ManageFluxResources registers an OCIRepository and a HelmRelease as managed // objects on the cluster defined in p.Cluster. The objects are not reconciled // until the caller invokes Manager.Apply. -func ManageFluxResources(p ManageFluxResourcesParams) { - ociRepo := manageOCIRepo(p, []manager.ManagedObject{}) +func ManageFluxResources(p ManageFluxResourcesParams) error { + if err := validateFluxResourcesParams(p); err != nil { + return err + } + ociRepo := manageOCIRepo(p) p.Cluster.AddObject(ociRepo) helmRelease := manageHelmRelease(p, []manager.ManagedObject{ociRepo}) p.Cluster.AddObject(helmRelease) + return nil +} + +func validateFluxResourcesParams(p ManageFluxResourcesParams) error { + if p.Cluster == nil { + return fmt.Errorf("ManageFluxResourcesParams.Cluster must not be nil") + } + if p.RequestedVersion == nil { + return fmt.Errorf("ManageFluxResourcesParams.RequestedVersion must not be nil") + } + if p.RequestedVersion.GetChartURL() == "" { + return fmt.Errorf("ManageFluxResourcesParams.RequestedVersion.GetChartURL() must not be empty") + } + if p.ClusterContext.MCPAccessSecretKey.Name == "" { + return fmt.Errorf("ClusterContext.MCPAccessSecretKey.Name must not be empty") + } + if p.Interval <= 0 { + return fmt.Errorf("Interval must be greater than zero, got %v", p.Interval) + } + if p.OCIRepositoryName == "" { + return fmt.Errorf("ManageFluxResourcesParams.OCIRepositoryName must not be empty") + } + if p.HelmReleaseName == "" { + return fmt.Errorf("ManageFluxResourcesParams.HelmReleaseName must not be empty") + } + if p.MCPNamespace == "" { + return fmt.Errorf("ManageFluxResourcesParams.MCPNamespace must not be empty") + } + return nil } -func manageOCIRepo(p ManageFluxResourcesParams, dependants []manager.ManagedObject) manager.ManagedObject { +func manageOCIRepo(p ManageFluxResourcesParams) manager.ManagedObject { return manager.NewManagedObject(&sourcev1.OCIRepository{ ObjectMeta: metav1.ObjectMeta{ Name: p.OCIRepositoryName, @@ -110,9 +142,6 @@ func manageOCIRepo(p ManageFluxResourcesParams, dependants []manager.ManagedObje if !ok { return fmt.Errorf("expected *sourcev1.OCIRepository, got %T", o) } - if p.RequestedVersion.GetChartURL() == "" { - return fmt.Errorf("missing ChartURL definition for version %s", p.RequestedVersion.GetVersion()) - } ociRepo.Spec = sourcev1.OCIRepositorySpec{ Interval: metav1.Duration{Duration: p.Interval}, URL: p.RequestedVersion.GetChartURL(), @@ -134,13 +163,12 @@ func manageOCIRepo(p ManageFluxResourcesParams, dependants []manager.ManagedObje } return nil }, - DependsOn: dependants, DeletionPolicy: manager.Delete, StatusFunc: FluxStatus, }) } -func manageHelmRelease(p ManageFluxResourcesParams, dependants []manager.ManagedObject) manager.ManagedObject { +func manageHelmRelease(p ManageFluxResourcesParams, dependencies []manager.ManagedObject) manager.ManagedObject { return manager.NewManagedObject(&helmv2.HelmRelease{ ObjectMeta: metav1.ObjectMeta{ Name: p.HelmReleaseName, @@ -171,6 +199,11 @@ func manageHelmRelease(p ManageFluxResourcesParams, dependants []manager.Managed }, CreateNamespace: true, }, + Upgrade: &helmv2.Upgrade{ + Remediation: &helmv2.UpgradeRemediation{ + Retries: 3, + }, + }, DriftDetection: &helmv2.DriftDetection{ Mode: helmv2.DriftDetectionEnabled, }, @@ -180,7 +213,7 @@ func manageHelmRelease(p ManageFluxResourcesParams, dependants []manager.Managed } return nil }, - DependsOn: dependants, + DependsOn: dependencies, DeletionPolicy: manager.Delete, StatusFunc: FluxStatus, }) @@ -188,7 +221,13 @@ func manageHelmRelease(p ManageFluxResourcesParams, dependants []manager.Managed // FluxStatus indicates whether the given object is in phase terminating, pending or ready. func FluxStatus(o client.Object) manager.ManagedResourceStatus { - fluxObject := o.(conditions.Getter) + fluxObject, ok := o.(conditions.Getter) + if !ok { + return manager.ManagedResourceStatus{ + Phase: manager.StatusPhaseUnknown, + Message: fmt.Sprintf("object %T does not implement conditions.Getter", o), + } + } if !o.GetDeletionTimestamp().IsZero() { return manager.ManagedResourceStatus{ Phase: manager.StatusPhaseTerminating, diff --git a/pkg/manager/flux/flux_test.go b/pkg/manager/flux/flux_test.go index b9c8127..d2f048e 100644 --- a/pkg/manager/flux/flux_test.go +++ b/pkg/manager/flux/flux_test.go @@ -6,9 +6,11 @@ import ( "time" helmv2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/pkg/runtime/conditions" sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" apiextensionv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -50,6 +52,26 @@ func createFakeCluster(t *testing.T, id string) manager.ManagedCluster { return manager.NewManagedCluster(c, &rest.Config{}, testNamespace, manager.PlatformCluster) } +// validParams returns a fully populated ManageFluxResourcesParams. Individual +// test cases mutate one field to exercise a specific validation branch. +func validParams(t *testing.T) flux.ManageFluxResourcesParams { + t.Helper() + return flux.ManageFluxResourcesParams{ + Cluster: createFakeCluster(t, "platform"), + MCPNamespace: "my-ns", + Interval: time.Hour, + ClusterContext: clusteraccess.ClusterContext{ + MCPAccessSecretKey: client.ObjectKey{ + Namespace: testNamespace, + Name: testKubeconfigKey, + }, + }, + RequestedVersion: flux.NewFluxResourceVersion("v1", "1.0", testChartURL, "", nil), + OCIRepositoryName: "my-repo", + HelmReleaseName: "my-release", + } +} + func TestManageFluxResources(t *testing.T) { tests := []struct { name string @@ -81,13 +103,16 @@ func TestManageFluxResources(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - flux.ManageFluxResources(tt.params) + require.NoError(t, flux.ManageFluxResources(tt.params)) // apply via a manager mgr := manager.NewManager("serviceprovider-test") mgr.AddCluster(tt.params.Cluster) - _, _, err := mgr.Apply(context.TODO()) + _, done, err := mgr.Apply(context.TODO()) require.NoError(t, err) + // The fake client never sets a Flux ReadyCondition, so FluxStatus always returns + // Progressing. done must be false until Flux itself reconciles the resources. + assert.False(t, done, "expected done=false: Flux resources are not yet Ready (no ReadyCondition set by fake client)") // assert OCIRepository ociRepo := &sourcev1.OCIRepository{ @@ -102,6 +127,11 @@ func TestManageFluxResources(t *testing.T) { assert.Equal(t, tt.params.RequestedVersion.GetChartVersion(), ociRepo.Spec.Reference.Tag) assert.Equal(t, tt.params.Interval, ociRepo.Spec.Interval.Duration) + // LayerSelector + require.NotNil(t, ociRepo.Spec.LayerSelector) + assert.Equal(t, "application/vnd.cncf.helm.chart.content.v1.tar+gzip", ociRepo.Spec.LayerSelector.MediaType) + assert.Equal(t, "extract", ociRepo.Spec.LayerSelector.Operation) + // assert HelmRelease helmRelease := &helmv2.HelmRelease{ ObjectMeta: metav1.ObjectMeta{ @@ -110,6 +140,21 @@ func TestManageFluxResources(t *testing.T) { }, } require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(helmRelease), helmRelease)) + // ChartRef + assert.Equal(t, "OCIRepository", helmRelease.Spec.ChartRef.Kind) + assert.Equal(t, tt.params.OCIRepositoryName, helmRelease.Spec.ChartRef.Name) + assert.Equal(t, tt.params.Cluster.GetDefaultNamespace(), helmRelease.Spec.ChartRef.Namespace) + + // Install + assert.Equal(t, 3, helmRelease.Spec.Install.Remediation.Retries) + assert.Equal(t, true, helmRelease.Spec.Install.CreateNamespace) + + // Update + assert.Equal(t, 3, helmRelease.Spec.Upgrade.Remediation.Retries) + + // DriftDetection + assert.Equal(t, helmv2.DriftDetectionEnabled, helmRelease.Spec.DriftDetection.Mode) + assert.Equal(t, tt.params.RequestedVersion.GetHelmValues(), helmRelease.Spec.Values) assert.Equal(t, tt.params.Interval, helmRelease.Spec.Interval.Duration) assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.StorageNamespace) @@ -119,3 +164,136 @@ func TestManageFluxResources(t *testing.T) { }) } } + +// TestManageFluxResources_Validation exercises every guard in validateFluxResourcesParams. +// Each case starts from a fully valid params struct and breaks exactly one field. +func TestManageFluxResources_Validation(t *testing.T) { + tests := []struct { + name string + mutate func(p *flux.ManageFluxResourcesParams) + wantErrFrag string + }{ + { + name: "nil Cluster", + mutate: func(p *flux.ManageFluxResourcesParams) { p.Cluster = nil }, + wantErrFrag: "Cluster must not be nil", + }, + { + name: "nil RequestedVersion", + mutate: func(p *flux.ManageFluxResourcesParams) { p.RequestedVersion = nil }, + wantErrFrag: "RequestedVersion must not be nil", + }, + { + name: "empty ChartURL", + mutate: func(p *flux.ManageFluxResourcesParams) { + p.RequestedVersion = flux.NewFluxResourceVersion("v1", "1.0", "", "", nil) + }, + wantErrFrag: "GetChartURL() must not be empty", + }, + { + name: "empty MCPAccessSecretKey.Name", + mutate: func(p *flux.ManageFluxResourcesParams) { + p.ClusterContext.MCPAccessSecretKey.Name = "" + }, + wantErrFrag: "MCPAccessSecretKey.Name must not be empty", + }, + { + name: "zero Interval", + mutate: func(p *flux.ManageFluxResourcesParams) { p.Interval = 0 }, + wantErrFrag: "Interval must be greater than zero", + }, + { + name: "negative Interval", + mutate: func(p *flux.ManageFluxResourcesParams) { p.Interval = -time.Second }, + wantErrFrag: "Interval must be greater than zero", + }, + { + name: "empty OCIRepositoryName", + mutate: func(p *flux.ManageFluxResourcesParams) { p.OCIRepositoryName = "" }, + wantErrFrag: "OCIRepositoryName must not be empty", + }, + { + name: "empty HelmReleaseName", + mutate: func(p *flux.ManageFluxResourcesParams) { p.HelmReleaseName = "" }, + wantErrFrag: "HelmReleaseName must not be empty", + }, + { + name: "empty MCPNamespace", + mutate: func(p *flux.ManageFluxResourcesParams) { p.MCPNamespace = "" }, + wantErrFrag: "MCPNamespace must not be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := validParams(t) + tt.mutate(&p) + err := flux.ManageFluxResources(p) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErrFrag) + }) + } +} + +// TestFluxStatus exercises all four branches of FluxStatus: +// Unknown (non-Flux object), Terminating, Ready, and Progressing (with and without a message). +func TestFluxStatus(t *testing.T) { + tests := []struct { + name string + object client.Object + wantPhase string + wantMessage string + }{ + { + name: "Unknown — object does not implement conditions.Getter", + object: &corev1.Secret{}, + wantPhase: manager.StatusPhaseUnknown, + }, + { + name: "Terminating — DeletionTimestamp is set", + object: func() client.Object { + now := metav1.Now() + o := &sourcev1.OCIRepository{} + o.DeletionTimestamp = &now + return o + }(), + wantPhase: manager.StatusPhaseTerminating, + wantMessage: "Resource is terminating.", + }, + { + name: "Ready — ReadyCondition is True", + object: func() client.Object { + o := &sourcev1.OCIRepository{} + conditions.MarkTrue(o, "Ready", "Reconciled", "chart applied successfully") + return o + }(), + wantPhase: manager.StatusPhaseReady, + }, + { + name: "Progressing — ReadyCondition is False with a message", + object: func() client.Object { + o := &sourcev1.OCIRepository{} + conditions.MarkFalse(o, "Ready", "ChartNotFound", "chart not found in registry") + return o + }(), + wantPhase: manager.StatusPhaseProgressing, + wantMessage: "chart not found in registry", + }, + { + name: "Progressing — no conditions set", + object: &sourcev1.OCIRepository{}, + wantPhase: manager.StatusPhaseProgressing, + wantMessage: "Resource is not ready", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := flux.FluxStatus(tt.object) + assert.Equal(t, tt.wantPhase, status.Phase) + if tt.wantMessage != "" { + assert.Equal(t, tt.wantMessage, status.Message) + } + }) + } +} diff --git a/pkg/manager/managedResource.go b/pkg/manager/managedResource.go index 1be0eb6..0bec9e2 100644 --- a/pkg/manager/managedResource.go +++ b/pkg/manager/managedResource.go @@ -4,10 +4,11 @@ package manager // It is an interface so pkg/manager stays decoupled from any CRD schema: consumers define // their own concrete CRD-embedded type and populate it from this interface. type ManagedResource interface { - GetAPIVersion() string + GetAPIGroup() string GetKind() string GetName() string - GetNamespace() *string // nil when the namespace is empty + GetNamespace() string + GetGeneration() int64 GetLocation() ClusterType GetStatus() ManagedResourceStatus } @@ -15,17 +16,19 @@ type ManagedResource interface { // managedResourceResult is the package-private concrete implementation of ManagedResource // built inside resultsToResources. It never escapes pkg/manager. type managedResourceResult struct { - apiVersion string + apiGroup string kind string name string - namespace *string + namespace string + generation int64 location ClusterType status ManagedResourceStatus } -func (r *managedResourceResult) GetAPIVersion() string { return r.apiVersion } -func (r *managedResourceResult) GetKind() string { return r.kind } -func (r *managedResourceResult) GetName() string { return r.name } -func (r *managedResourceResult) GetNamespace() *string { return r.namespace } -func (r *managedResourceResult) GetLocation() ClusterType { return r.location } +func (r *managedResourceResult) GetAPIGroup() string { return r.apiGroup } +func (r *managedResourceResult) GetKind() string { return r.kind } +func (r *managedResourceResult) GetName() string { return r.name } +func (r *managedResourceResult) GetNamespace() string { return r.namespace } +func (r *managedResourceResult) GetGeneration() int64 { return r.generation } +func (r *managedResourceResult) GetLocation() ClusterType { return r.location } func (r *managedResourceResult) GetStatus() ManagedResourceStatus { return r.status } diff --git a/pkg/manager/managedcluster.go b/pkg/manager/managedcluster.go index 2aee341..ebee63d 100644 --- a/pkg/manager/managedcluster.go +++ b/pkg/manager/managedcluster.go @@ -65,7 +65,7 @@ func (m *managedCluster) GetConfig() *rest.Config { // GetHostAndPort implements ManagedCluster. func (m *managedCluster) GetHostAndPort() (string, string) { - input := strings.TrimPrefix(m.cfg.Host, "https://") + input := strings.TrimPrefix(strings.TrimPrefix(m.cfg.Host, "https://"), "http://") host, port, found := strings.Cut(input, ":") if !found { port = "443" diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index f6f516d..728bcc6 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -8,7 +8,6 @@ import ( "slices" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" @@ -127,6 +126,10 @@ func (m *managerImpl) reconcileObjects(ctx context.Context, isDeletion bool) ([] results = slices.Concat(results, result) } + if len(results) == 0 { + log.FromContext(ctx).V(1).Info("manager reconciled zero objects; no clusters or managed objects have been registered") + } + return results, nil } @@ -160,11 +163,18 @@ func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo OperationResult: OperationResultDeleted, } } + if err != nil { + return Result{ + Object: mo, + Cluster: mc, + OperationResult: OperationResultDeletionFailed, + Error: err, + } + } return Result{ Object: mo, Cluster: mc, OperationResult: OperationResultDeletionRequested, - Error: err, } } @@ -261,11 +271,22 @@ func resultsToResources(ctx context.Context, results []Result) ([]ManagedResourc resources := make([]ManagedResource, 0, len(results)) for _, res := range results { obj := res.Object.GetObject() + + apiGroup := "" + kind := reflect.TypeOf(obj).Elem().Name() // fallback: Go type name == Kind by convention + if gvk, err := res.Cluster.GetClient().GroupVersionKindFor(obj); err == nil { + apiGroup = gvk.Group + kind = gvk.Kind + } else { + l.Error(err, "cannot determine GVK for managed object; apiGroup will be empty in status", "objectID", ObjectID(obj)) + } + resources = append(resources, &managedResourceResult{ - apiVersion: obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), - kind: reflect.TypeOf(obj).Elem().Name(), + apiGroup: apiGroup, + kind: kind, name: obj.GetName(), - namespace: nilIfEmptyString(obj.GetNamespace()), + namespace: obj.GetNamespace(), + generation: obj.GetGeneration(), status: res.Object.GetStatus(), location: res.Cluster.GetClusterType(), }) @@ -276,10 +297,3 @@ func resultsToResources(ctx context.Context, results []Result) ([]ManagedResourc } return resources, errs } - -func nilIfEmptyString(str string) *string { - if str == "" { - return nil - } - return ptr.To(str) -} diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go index c430576..340b1f4 100644 --- a/pkg/manager/orphancleaner.go +++ b/pkg/manager/orphancleaner.go @@ -21,10 +21,10 @@ type orphanCleaner[T client.ObjectList] struct { cluster ManagedCluster serviceProvider string namespace string - cleanerType cleanerType[T] + cleanerType CleanerType[T] } -type cleanerType[T client.ObjectList] struct { +type CleanerType[T client.ObjectList] struct { ObjectsToKeep []corev1.LocalObjectReference EmptyList func() T // PreDeletionSteps is an optional hook that is invoked before OrphanCleaner.Cleanup deletes an object. @@ -36,7 +36,7 @@ type cleanerType[T client.ObjectList] struct { } // NewOrphanCleaner removes redundant objects in the given target namespace. -func NewOrphanCleaner[T client.ObjectList](cluster ManagedCluster, serviceProvider string, namespace string, clType cleanerType[T]) OrphanCleaner { +func NewOrphanCleaner[T client.ObjectList](cluster ManagedCluster, serviceProvider string, namespace string, clType CleanerType[T]) OrphanCleaner { return &orphanCleaner[T]{ cluster: cluster, serviceProvider: serviceProvider, @@ -113,7 +113,10 @@ func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { object: obj, deletionPolicy: Delete, statusFunc: func(client.Object) ManagedResourceStatus { - return ManagedResourceStatus{Phase: StatusPhaseTerminating, Message: "Deletion failed."} + return ManagedResourceStatus{ + Phase: StatusPhaseProgressing, + Message: fmt.Sprintf("Deletion failed, retrying: %s", err.Error()), + } }, } return Result{ diff --git a/pkg/manager/orphancleaner_test.go b/pkg/manager/orphancleaner_test.go index 4f107e5..49c2f6e 100644 --- a/pkg/manager/orphancleaner_test.go +++ b/pkg/manager/orphancleaner_test.go @@ -25,7 +25,7 @@ func Test_orphanCleaner_Cleanup(t *testing.T) { name string // description of this test case targetNamespace string cluster ManagedCluster - cleanerType cleanerType[*sourcev1.OCIRepositoryList] + cleanerType CleanerType[*sourcev1.OCIRepositoryList] want []sourcev1.OCIRepository wantErr bool wantResultErr bool @@ -126,7 +126,7 @@ func Test_orphanCleaner_Cleanup_prepareDeletion(t *testing.T) { name string // description of this test case targetNamespace string cluster ManagedCluster - cleanerType cleanerType[*sourcev1.OCIRepositoryList] + cleanerType CleanerType[*sourcev1.OCIRepositoryList] option preparationOptions want []sourcev1.OCIRepository wantErr bool // expects orphan cleanup to return an error @@ -183,7 +183,7 @@ func Test_orphanCleaner_Cleanup_prepareDeletion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prepDeletionMock := prepareDeletionMock{} - var cleanerType cleanerType[*sourcev1.OCIRepositoryList] + var cleanerType CleanerType[*sourcev1.OCIRepositoryList] switch tt.option { case succeedAndDelete: cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.successfulDelete) @@ -225,8 +225,8 @@ func Test_orphanCleaner_Cleanup_prepareDeletion(t *testing.T) { var containsResultError = func(r Result) bool { return r.Error != nil } -func createOCIRepoCleanerType(objectsToKeep ...corev1.LocalObjectReference) cleanerType[*sourcev1.OCIRepositoryList] { - return cleanerType[*sourcev1.OCIRepositoryList]{ +func createOCIRepoCleanerType(objectsToKeep ...corev1.LocalObjectReference) CleanerType[*sourcev1.OCIRepositoryList] { + return CleanerType[*sourcev1.OCIRepositoryList]{ EmptyList: func() *sourcev1.OCIRepositoryList { return &sourcev1.OCIRepositoryList{} }, @@ -266,7 +266,7 @@ func (p *prepareDeletionMock) failure(_ context.Context, _ client.Object) (bool, return false, errors.New("delete prep failed") } -func (r cleanerType[T]) withPreSteps(preSteps func(context.Context, client.Object) (bool, error)) cleanerType[T] { +func (r CleanerType[T]) withPreSteps(preSteps func(context.Context, client.Object) (bool, error)) CleanerType[T] { r.PreDeletionSteps = preSteps return r } diff --git a/pkg/manager/secret.go b/pkg/manager/secret.go index 5842efd..f71f61e 100644 --- a/pkg/manager/secret.go +++ b/pkg/manager/secret.go @@ -46,7 +46,7 @@ func ManagePullSecret(targetCluster ManagedCluster, config SecretCopyConfig) { } // retrieve source secret from platform cluster if err := config.SourceClient.Get(ctx, client.ObjectKeyFromObject(sourceSecret), sourceSecret); err != nil { - return err + return fmt.Errorf("manage pull secret - source secret not found: %w", err) } mutator := openmcpresources.NewSecretMutator(config.TargetName, config.TargetNamespace, sourceSecret.Data, corev1.SecretTypeDockerConfigJson) return mutator.Mutate(oSecret) @@ -67,7 +67,7 @@ func PrefixSecretName(secretName string, prefix string) (string, error) { // NewSecretCleaner removes redundant pull secrets in the given target namespace // by removing any secret labeled as managed by sp-external-secrets that is not in secretsToKeep. func NewSecretCleaner(cluster ManagedCluster, serviceProvider string, namespace string, secretsToKeep []corev1.LocalObjectReference) OrphanCleaner { - return NewOrphanCleaner(cluster, serviceProvider, namespace, cleanerType[*corev1.SecretList]{ + return NewOrphanCleaner(cluster, serviceProvider, namespace, CleanerType[*corev1.SecretList]{ EmptyList: func() *corev1.SecretList { return &corev1.SecretList{} }, diff --git a/pkg/manager/secret_test.go b/pkg/manager/secret_test.go index 9f01032..304c13b 100644 --- a/pkg/manager/secret_test.go +++ b/pkg/manager/secret_test.go @@ -29,7 +29,9 @@ func TestManagePullSecrets(t *testing.T) { Data: map[string][]byte{ "test": []byte("testdata"), }, - Type: corev1.SecretTypeDockerConfigJson, + // Deliberately Opaque — NOT DockerConfigJson — to verify the mutator always + // sets the target type to DockerConfigJson regardless of the source type. + Type: corev1.SecretTypeOpaque, }, &corev1.Secret{ // existing secret with the same source secret name in the target namespace // this secret needs to be untouched by the service provider secret copy functionality @@ -47,6 +49,7 @@ func TestManagePullSecrets(t *testing.T) { // Named input parameters for target function. targetCluster ManagedCluster config SecretCopyConfig + wantErr bool }{ { name: "copy secret privateregcred from source to target namespace and adjust its name", @@ -58,12 +61,49 @@ func TestManagePullSecrets(t *testing.T) { TargetNamespace: targetNamespace, TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), }, + wantErr: false, + }, + { + name: "source secret not found — wrong name", + targetCluster: NewManagedCluster(fakeCluster, &rest.Config{}, sourceNamespace, PlatformCluster), + config: SecretCopyConfig{ + SourceClient: fakeCluster.Client(), + SourceName: "wrongSecretName", + SourceNamespace: sourceNamespace, + TargetNamespace: targetNamespace, + TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), + }, + wantErr: true, + }, + { + name: "source secret not found — wrong namespace", + targetCluster: NewManagedCluster(fakeCluster, &rest.Config{}, sourceNamespace, PlatformCluster), + config: SecretCopyConfig{ + SourceClient: fakeCluster.Client(), + SourceName: secretName, + SourceNamespace: "wrongSourceNamespace", + TargetNamespace: targetNamespace, + TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), + }, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ManagePullSecret(tt.targetCluster, tt.config) - ExecApply(t, []ManagedCluster{tt.targetCluster}, 1, []string{}) + + mgr := NewManager("serviceprovider-test") + mgr.AddCluster(tt.targetCluster) + _, _, err := mgr.Apply(context.TODO()) + + if tt.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrManagedResourcesFailed) + return + } + + require.NoError(t, err) + sourceSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: tt.config.SourceName, @@ -82,6 +122,7 @@ func TestManagePullSecrets(t *testing.T) { Namespace: tt.config.TargetNamespace, }, } + require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(sourceSecret), sourceSecret)) require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(targetSecret), targetSecret)) require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(existingSecret), existingSecret)) diff --git a/pkg/manager/status.go b/pkg/manager/status.go index 50b07a3..2dea67a 100644 --- a/pkg/manager/status.go +++ b/pkg/manager/status.go @@ -1,6 +1,7 @@ package manager import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -40,3 +41,25 @@ func SimpleStatus(o client.Object) ManagedResourceStatus { Message: "Resource exists.", } } + +// GetCondition synthesizes a Ready condition from the manager status. +// observedGeneration must be set to the current metadata.generation of the +// object being reconciled so consumers can determine whether the condition is stale. +func (s *ManagedResourceStatus) GetCondition(observedGeneration int64) metav1.Condition { + condStatus := metav1.ConditionFalse + if s.Phase == StatusPhaseReady { + condStatus = metav1.ConditionTrue + } + reason := s.Phase + if reason == "" { + reason = "Unknown" + } + + return metav1.Condition{ + Type: "Ready", + Status: condStatus, + Reason: reason, + Message: s.Message, + ObservedGeneration: observedGeneration, + } +} diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go index 9becf60..e70b529 100644 --- a/pkg/manager/utils_test.go +++ b/pkg/manager/utils_test.go @@ -11,9 +11,12 @@ import ( clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) @@ -34,21 +37,224 @@ func CreateFakeCluster(t *testing.T, id string, clusterObjects ...client.Object) return clusters.NewTestClusterFromClient(id, fakeClient) } -// ExecApply sets up a manager for the provided clusters and invokes reconciliation of all managed objects -func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects int, wantErrors []string) []ManagedResource { +// ExecApply sets up a manager for the provided clusters and invokes reconciliation of all managed objects. +// wantDone asserts the expected value of the done return value from Apply. +func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects int, wantDone bool) []ManagedResource { t.Helper() - // invoke apply with manager mgr := NewManager("serviceprovider-test") for _, cluster := range clusters { mgr.AddCluster(cluster) } - managedResources, _, err := mgr.Apply(context.TODO()) + managedResources, done, err := mgr.Apply(context.TODO()) require.NoError(t, err) - return assertResult(t, managedResources, expectedManagedObjects, wantErrors) + assert.Equal(t, wantDone, done, "unexpected done value from Apply") + return assertResult(t, managedResources, expectedManagedObjects) } -func assertResult(t *testing.T, managedResources []ManagedResource, expectedManagedObjects int, wantErrors []string) []ManagedResource { +func assertResult(t *testing.T, managedResources []ManagedResource, expectedManagedObjects int) []ManagedResource { t.Helper() assert.Len(t, managedResources, expectedManagedObjects, "expected %d managed object(s), got %d managed object(s)") return managedResources } + +func TestApply_Done(t *testing.T) { + const ns = "default" + + t.Run("done=false when StatusFunc returns Progressing", func(t *testing.T) { + cluster := CreateFakeCluster(t, "platform") + mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) + obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns}} + mo := NewManagedObject(obj, ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, + StatusFunc: func(_ client.Object) ManagedResourceStatus { + return ManagedResourceStatus{Phase: StatusPhaseProgressing, Message: "always progressing"} + }, + }) + mc.AddObject(mo) + mgr := NewManager("test-sp") + mgr.AddCluster(mc) + + _, done, err := mgr.Apply(context.TODO()) + require.NoError(t, err) + assert.False(t, done, "expected done=false when resource reports Progressing") + }) + + t.Run("done=true when all resources report Ready via SimpleStatus", func(t *testing.T) { + // Pre-populate the fake store with the object including a UID. + // CreateOrUpdate will call Get (succeeds, UID propagated to obj), then Update. + // SimpleStatus checks GetUID() != "" → returns Ready → done=true. + preExisting := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns, UID: "test-uid-1234"}, + } + cluster := CreateFakeCluster(t, "platform", preExisting) + mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) + obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns}} + mo := NewManagedObject(obj, ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, + StatusFunc: SimpleStatus, + }) + mc.AddObject(mo) + mgr := NewManager("test-sp") + mgr.AddCluster(mc) + + _, done, err := mgr.Apply(context.TODO()) + require.NoError(t, err) + assert.True(t, done, "expected done=true when all resources report Ready") + }) + + t.Run("done=false when resource is Terminating via SimpleStatus", func(t *testing.T) { + // DeletionTimestamp must be on the server-side object (preExisting), not on the + // local obj. controllerutil.CreateOrUpdate calls cl.Get which overwrites obj with + // the server state, so any DeletionTimestamp set only on obj is lost before + // SimpleStatus is called. A finalizer is required to keep the object in the store + // while it has a DeletionTimestamp. + now := metav1.Now() + preExisting := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: ns, + UID: "test-uid-1234", + DeletionTimestamp: &now, + Finalizers: []string{"test-finalizer"}, + }, + } + cluster := CreateFakeCluster(t, "platform", preExisting) + mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) + obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns}} + mo := NewManagedObject(obj, ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, + StatusFunc: SimpleStatus, + }) + mc.AddObject(mo) + mgr := NewManager("test-sp") + mgr.AddCluster(mc) + + resources, done, err := mgr.Apply(context.TODO()) + require.NoError(t, err) + require.Len(t, resources, 1) + assert.False(t, done, "expected done=false when resource is Terminating") + assert.Equal(t, StatusPhaseTerminating, resources[0].GetStatus().Phase) + }) +} + +func TestDelete_Done(t *testing.T) { + const ns = "default" + const secretName = "managed-secret" + + existingSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}, + } + cluster := CreateFakeCluster(t, "platform", existingSecret) + mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) + + obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}} + mo := NewManagedObject(obj, ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, + StatusFunc: SimpleStatus, + DeletionPolicy: Delete, + }) + mc.AddObject(mo) + mgr := NewManager("test-sp") + mgr.AddCluster(mc) + + // First Delete: fake client removes the object (returns nil, not NotFound). + // The manager receives DeletionRequested → done=false. + _, done, err := mgr.Delete(context.TODO()) + require.NoError(t, err) + assert.False(t, done, "expected done=false: deletion was requested but object not yet confirmed gone") + + // Second Delete: object is already gone, fake client returns NotFound. + // The manager receives Deleted → done=true. + _, done, err = mgr.Delete(context.TODO()) + require.NoError(t, err) + assert.True(t, done, "expected done=true: object confirmed deleted (NotFound)") +} + +func TestDelete_OrphanPolicy(t *testing.T) { + const ns = "default" + const secretName = "orphaned-secret" + + existingSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}, + } + cluster := CreateFakeCluster(t, "platform", existingSecret) + mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) + + obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}} + mo := NewManagedObject(obj, ManagedObjectContext{ + ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, + StatusFunc: SimpleStatus, + DeletionPolicy: Orphan, + }) + mc.AddObject(mo) + mgr := NewManager("test-sp") + mgr.AddCluster(mc) + + stored := &corev1.Secret{} + require.NoError(t, cluster.Client().Get( + context.TODO(), + client.ObjectKey{Name: secretName, Namespace: ns}, + stored, + ), "Secret was created") + + // Orphaned objects are considered done immediately — cl.Delete is never called. + _, done, err := mgr.Delete(context.TODO()) + require.NoError(t, err) + assert.True(t, done, "expected done=true: orphaned object counts as deleted") + + // The object must still be in the store — it was left alive intentionally. + require.NoError(t, cluster.Client().Get( + context.TODO(), + client.ObjectKey{Name: secretName, Namespace: ns}, + stored, + ), "orphaned object should still exist in the store") +} + +func TestDelete_DependsOn(t *testing.T) { + const ns = "default" + // secretA is a dependency; secretB depends on A. + // A cannot be deleted while B still exists. + const nameA, nameB = "secret-a", "secret-b" + + cluster := CreateFakeCluster(t, "platform", + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameA, Namespace: ns}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameB, Namespace: ns}}, + ) + mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) + + objA := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameA, Namespace: ns}} + moA := NewManagedObject(objA, ManagedObjectContext{ + ReconcileFunc: NoOp, StatusFunc: SimpleStatus, DeletionPolicy: Delete, + }) + + objB := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameB, Namespace: ns}} + moB := NewManagedObject(objB, ManagedObjectContext{ + ReconcileFunc: NoOp, + StatusFunc: SimpleStatus, + DeletionPolicy: Delete, + // B depends on A: the framework blocks A's deletion until B is gone. + DependsOn: []ManagedObject{moA}, + }) + + mc.AddObject(moA) + mc.AddObject(moB) + mgr := NewManager("test-sp") + mgr.AddCluster(mc) + + // Iteration 1: A is blocked (B still exists); B deletion is requested. + _, done, err := mgr.Delete(context.TODO()) + assert.False(t, done, "iteration 1: done should be false — A is blocked") + require.Error(t, err, "iteration 1: expected error — A cannot be deleted while B exists") + assert.ErrorIs(t, err, ErrManagedResourcesFailed) + + // Iteration 2: B is confirmed gone (fake client removes immediately on Delete); + // A deletion is now requested. done is still false — A is DeletionRequested, not Deleted. + _, done, err = mgr.Delete(context.TODO()) + require.NoError(t, err) + assert.False(t, done, "iteration 2: done should be false — A deletion pending") + + // Iteration 3: both A and B return NotFound → done=true. + _, done, err = mgr.Delete(context.TODO()) + require.NoError(t, err) + assert.True(t, done, "iteration 3: expected done=true — all objects confirmed deleted") +} From 78d18887b2963ead3c3ba34d62885c811878b426 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 13 Aug 2026 09:43:19 +0200 Subject: [PATCH 17/20] refactor: ManagedResources Signed-off-by: Jakob Bergmeier --- pkg/manager/flux/flux.go | 2 +- pkg/manager/managedResource.go | 83 +++++++++++++++++++---------- pkg/manager/managedResource_test.go | 71 ++++++++++++++++++++++++ pkg/manager/manager.go | 15 +++--- pkg/manager/status.go | 23 -------- pkg/manager/utils_test.go | 2 +- 6 files changed, 136 insertions(+), 60 deletions(-) create mode 100644 pkg/manager/managedResource_test.go diff --git a/pkg/manager/flux/flux.go b/pkg/manager/flux/flux.go index a326116..6a7bcd7 100644 --- a/pkg/manager/flux/flux.go +++ b/pkg/manager/flux/flux.go @@ -52,7 +52,7 @@ func NewFluxResourceVersion( } // fluxResourceVersion is the package-private concrete implementation of -// FluxResourceVersion, analogous to managedResourceResult in pkg/manager. +// FluxResourceVersion. type fluxResourceVersion struct { version string chartVersion string diff --git a/pkg/manager/managedResource.go b/pkg/manager/managedResource.go index 0bec9e2..c06bcbc 100644 --- a/pkg/manager/managedResource.go +++ b/pkg/manager/managedResource.go @@ -1,34 +1,63 @@ package manager // ManagedResource is the read-only view of a reconciled object returned by Apply and Delete. -// It is an interface so pkg/manager stays decoupled from any CRD schema: consumers define -// their own concrete CRD-embedded type and populate it from this interface. -type ManagedResource interface { - GetAPIGroup() string - GetKind() string - GetName() string - GetNamespace() string - GetGeneration() int64 - GetLocation() ClusterType - GetStatus() ManagedResourceStatus +// It is a plain value struct: Apply/Delete return it by value, so callers receive a copy and +// cannot affect the manager's internal state. It stays decoupled from any CRD schema; consumers +// project it into their own CRD-embedded type via ProjectResources and ResourceStatusWriter. +type ManagedResource struct { + APIGroup string + Kind string + Name string + Namespace string + Location ClusterType + Status ManagedResourceStatus } -// managedResourceResult is the package-private concrete implementation of ManagedResource -// built inside resultsToResources. It never escapes pkg/manager. -type managedResourceResult struct { - apiGroup string - kind string - name string - namespace string - generation int64 - location ClusterType - status ManagedResourceStatus +// ResourceRef identifies a managed resource. It is passed to a consumer's +// ResourceStatusWriter so the consumer can decide how to store the fields +// (e.g. whether to represent an empty Namespace or APIGroup as nil). +type ResourceRef struct { + APIGroup string + Kind string + Name string + Namespace string + Location string } -func (r *managedResourceResult) GetAPIGroup() string { return r.apiGroup } -func (r *managedResourceResult) GetKind() string { return r.kind } -func (r *managedResourceResult) GetName() string { return r.name } -func (r *managedResourceResult) GetNamespace() string { return r.namespace } -func (r *managedResourceResult) GetGeneration() int64 { return r.generation } -func (r *managedResourceResult) GetLocation() ClusterType { return r.location } -func (r *managedResourceResult) GetStatus() ManagedResourceStatus { return r.status } +// ResourceStatusWriter is implemented by a consumer's CRD-embedded resource type. +// ProjectResources drives it to populate a consumer-owned status entry from a +// framework ManagedResource, keeping pkg/manager decoupled from any CRD schema. +type ResourceStatusWriter interface { + // SetReference stores the identity of the managed resource. + SetReference(ResourceRef) + // SetPhase stores the lifecycle phase and human-readable message. + SetPhase(phase, message string) +} + +// ProjectResources converts framework results into a slice of a consumer-provided +// type W. The framework owns the loop and allocation; the consumer owns only the +// field mapping via the ResourceStatusWriter setters. newWriter constructs a fresh, +// writable W for each result. +// +// Example: +// +// obj.Status.Resources = manager.ProjectResources( +// resources, +// func() *apiv1alpha1.ManagedResource { return &apiv1alpha1.ManagedResource{} }, +// ) +func ProjectResources[W ResourceStatusWriter](results []ManagedResource, newWriter func() W) []W { + out := make([]W, len(results)) + for i, r := range results { + w := newWriter() + w.SetReference(ResourceRef{ + APIGroup: r.APIGroup, + Kind: r.Kind, + Name: r.Name, + Namespace: r.Namespace, + Location: string(r.Location), + }) + w.SetPhase(r.Status.Phase, r.Status.Message) + out[i] = w + } + return out +} diff --git a/pkg/manager/managedResource_test.go b/pkg/manager/managedResource_test.go new file mode 100644 index 0000000..c2df708 --- /dev/null +++ b/pkg/manager/managedResource_test.go @@ -0,0 +1,71 @@ +package manager + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeWriter is a test implementation of ResourceStatusWriter that records +// what ProjectResources sets on it. +type fakeWriter struct { + ref ResourceRef + phase string + message string +} + +func (w *fakeWriter) SetReference(ref ResourceRef) { w.ref = ref } +func (w *fakeWriter) SetPhase(phase, message string) { + w.phase = phase + w.message = message +} + +func TestProjectResources(t *testing.T) { + t.Run("returns one writer per result with reference and phase/message applied", func(t *testing.T) { + results := []ManagedResource{ + { + APIGroup: "source.toolkit.fluxcd.io", + Kind: "OCIRepository", + Name: "eso", + Namespace: "tenant-ns", + Location: PlatformCluster, + Status: ManagedResourceStatus{Phase: StatusPhaseReady, Message: "Resource exists."}, + }, + { + Kind: "Secret", + Name: "pull-secret", + Location: ManagedControlPlane, + Status: ManagedResourceStatus{Phase: StatusPhaseProgressing, Message: "creating"}, + }, + } + + out := ProjectResources(results, func() *fakeWriter { return &fakeWriter{} }) + + require.Len(t, out, 2) + + assert.Equal(t, ResourceRef{ + APIGroup: "source.toolkit.fluxcd.io", + Kind: "OCIRepository", + Name: "eso", + Namespace: "tenant-ns", + Location: string(PlatformCluster), + }, out[0].ref) + assert.Equal(t, StatusPhaseReady, out[0].phase) + assert.Equal(t, "Resource exists.", out[0].message) + + assert.Equal(t, ResourceRef{ + Kind: "Secret", + Name: "pull-secret", + Location: string(ManagedControlPlane), + }, out[1].ref) + assert.Equal(t, StatusPhaseProgressing, out[1].phase) + assert.Equal(t, "creating", out[1].message) + }) + + t.Run("returns empty non-nil slice for no results", func(t *testing.T) { + out := ProjectResources(nil, func() *fakeWriter { return &fakeWriter{} }) + assert.NotNil(t, out) + assert.Empty(t, out) + }) +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 728bcc6..505f48a 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -281,14 +281,13 @@ func resultsToResources(ctx context.Context, results []Result) ([]ManagedResourc l.Error(err, "cannot determine GVK for managed object; apiGroup will be empty in status", "objectID", ObjectID(obj)) } - resources = append(resources, &managedResourceResult{ - apiGroup: apiGroup, - kind: kind, - name: obj.GetName(), - namespace: obj.GetNamespace(), - generation: obj.GetGeneration(), - status: res.Object.GetStatus(), - location: res.Cluster.GetClusterType(), + resources = append(resources, ManagedResource{ + APIGroup: apiGroup, + Kind: kind, + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + Status: res.Object.GetStatus(), + Location: res.Cluster.GetClusterType(), }) if res.Error != nil { l.Error(res.Error, "reconcile error", "objectID", ObjectID(obj)) diff --git a/pkg/manager/status.go b/pkg/manager/status.go index 2dea67a..50b07a3 100644 --- a/pkg/manager/status.go +++ b/pkg/manager/status.go @@ -1,7 +1,6 @@ package manager import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -41,25 +40,3 @@ func SimpleStatus(o client.Object) ManagedResourceStatus { Message: "Resource exists.", } } - -// GetCondition synthesizes a Ready condition from the manager status. -// observedGeneration must be set to the current metadata.generation of the -// object being reconciled so consumers can determine whether the condition is stale. -func (s *ManagedResourceStatus) GetCondition(observedGeneration int64) metav1.Condition { - condStatus := metav1.ConditionFalse - if s.Phase == StatusPhaseReady { - condStatus = metav1.ConditionTrue - } - reason := s.Phase - if reason == "" { - reason = "Unknown" - } - - return metav1.Condition{ - Type: "Ready", - Status: condStatus, - Reason: reason, - Message: s.Message, - ObservedGeneration: observedGeneration, - } -} diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go index e70b529..b59b646 100644 --- a/pkg/manager/utils_test.go +++ b/pkg/manager/utils_test.go @@ -133,7 +133,7 @@ func TestApply_Done(t *testing.T) { require.NoError(t, err) require.Len(t, resources, 1) assert.False(t, done, "expected done=false when resource is Terminating") - assert.Equal(t, StatusPhaseTerminating, resources[0].GetStatus().Phase) + assert.Equal(t, StatusPhaseTerminating, resources[0].Status.Phase) }) } From 7ca893f2ee93148667379cd1f10a4859c851e691 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 4 Sep 2026 11:09:04 +0200 Subject: [PATCH 18/20] refactor: remove pkg/manager/flux Signed-off-by: Jakob Bergmeier --- pkg/manager/flux/flux.go | 248 ---------------------------- pkg/manager/flux/flux_test.go | 299 ---------------------------------- 2 files changed, 547 deletions(-) delete mode 100644 pkg/manager/flux/flux.go delete mode 100644 pkg/manager/flux/flux_test.go diff --git a/pkg/manager/flux/flux.go b/pkg/manager/flux/flux.go deleted file mode 100644 index 6a7bcd7..0000000 --- a/pkg/manager/flux/flux.go +++ /dev/null @@ -1,248 +0,0 @@ -// Package flux provides generic helpers for deploying Helm charts via Flux -// (OCIRepository + HelmRelease). Service providers implement FluxResourceVersion -// with their own CRD-embedded concrete type; the framework never owns a public -// concrete struct for version data. -package flux - -import ( - "context" - "fmt" - "time" - - helmv2 "github.com/fluxcd/helm-controller/api/v2" - "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/runtime/conditions" - sourcev1 "github.com/fluxcd/source-controller/api/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/openmcp-project/controller-utils/pkg/manager" - "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" -) - -// FluxResourceVersion defines the version information needed to deploy a -// Flux-managed Helm chart. Service providers implement this interface with -// their own CRD-embedded concrete type; the framework never owns a public -// concrete struct for version data. -type FluxResourceVersion interface { - GetVersion() string - GetChartVersion() string - GetChartURL() string - // GetChartPullSecret returns the name of the secret (already placed in the - // target namespace) used to pull the Helm chart OCI artifact. - GetChartPullSecret() string - GetHelmValues() *apiextensionsv1.JSON -} - -// NewFluxResourceVersion creates a FluxResourceVersion backed by a private -// implementation. Useful for tests and for service providers that do not -// define their own CRD-backed version type. -func NewFluxResourceVersion( - version, chartVersion, chartURL, chartPullSecret string, - helmValues *apiextensionsv1.JSON, -) FluxResourceVersion { - return &fluxResourceVersion{ - version: version, - chartVersion: chartVersion, - chartURL: chartURL, - chartPullSecret: chartPullSecret, - helmValues: helmValues, - } -} - -// fluxResourceVersion is the package-private concrete implementation of -// FluxResourceVersion. -type fluxResourceVersion struct { - version string - chartVersion string - chartURL string - chartPullSecret string - helmValues *apiextensionsv1.JSON -} - -func (r *fluxResourceVersion) GetVersion() string { return r.version } -func (r *fluxResourceVersion) GetChartVersion() string { return r.chartVersion } -func (r *fluxResourceVersion) GetChartURL() string { return r.chartURL } -func (r *fluxResourceVersion) GetChartPullSecret() string { return r.chartPullSecret } -func (r *fluxResourceVersion) GetHelmValues() *apiextensionsv1.JSON { return r.helmValues } - -// ManageFluxResourcesParams groups all parameters needed to register the -// Flux resources (OCIRepository + HelmRelease) on a cluster. -type ManageFluxResourcesParams struct { - // Cluster defines where the resources will be created. - Cluster manager.ManagedCluster - // MCPNamespace is the target namespace for the Helm release. - MCPNamespace string - // Interval defines the OCIRepository and HelmRelease reconcile intervals. - Interval time.Duration - // ClusterContext of the current reconciliation context. - ClusterContext clusteraccess.ClusterContext - // RequestedVersion carries version and chart metadata. The chart pull - // secret name is sourced from RequestedVersion.GetChartPullSecret(). - RequestedVersion FluxResourceVersion - // OCIRepositoryName is the name of the OCIRepository resource. - OCIRepositoryName string - // HelmReleaseName is the name of the HelmRelease resource. - HelmReleaseName string -} - -// ManageFluxResources registers an OCIRepository and a HelmRelease as managed -// objects on the cluster defined in p.Cluster. The objects are not reconciled -// until the caller invokes Manager.Apply. -func ManageFluxResources(p ManageFluxResourcesParams) error { - if err := validateFluxResourcesParams(p); err != nil { - return err - } - ociRepo := manageOCIRepo(p) - p.Cluster.AddObject(ociRepo) - - helmRelease := manageHelmRelease(p, []manager.ManagedObject{ociRepo}) - p.Cluster.AddObject(helmRelease) - return nil -} - -func validateFluxResourcesParams(p ManageFluxResourcesParams) error { - if p.Cluster == nil { - return fmt.Errorf("ManageFluxResourcesParams.Cluster must not be nil") - } - if p.RequestedVersion == nil { - return fmt.Errorf("ManageFluxResourcesParams.RequestedVersion must not be nil") - } - if p.RequestedVersion.GetChartURL() == "" { - return fmt.Errorf("ManageFluxResourcesParams.RequestedVersion.GetChartURL() must not be empty") - } - if p.ClusterContext.MCPAccessSecretKey.Name == "" { - return fmt.Errorf("ClusterContext.MCPAccessSecretKey.Name must not be empty") - } - if p.Interval <= 0 { - return fmt.Errorf("Interval must be greater than zero, got %v", p.Interval) - } - if p.OCIRepositoryName == "" { - return fmt.Errorf("ManageFluxResourcesParams.OCIRepositoryName must not be empty") - } - if p.HelmReleaseName == "" { - return fmt.Errorf("ManageFluxResourcesParams.HelmReleaseName must not be empty") - } - if p.MCPNamespace == "" { - return fmt.Errorf("ManageFluxResourcesParams.MCPNamespace must not be empty") - } - return nil -} - -func manageOCIRepo(p ManageFluxResourcesParams) manager.ManagedObject { - return manager.NewManagedObject(&sourcev1.OCIRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: p.OCIRepositoryName, - Namespace: p.Cluster.GetDefaultNamespace(), - }, - }, manager.ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, o client.Object) error { - ociRepo, ok := o.(*sourcev1.OCIRepository) - if !ok { - return fmt.Errorf("expected *sourcev1.OCIRepository, got %T", o) - } - ociRepo.Spec = sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: p.Interval}, - URL: p.RequestedVersion.GetChartURL(), - Reference: &sourcev1.OCIRepositoryRef{ - Tag: p.RequestedVersion.GetChartVersion(), - }, - // required to always select the correct OCI layer - // this mitigates non-deterministic layer ordering across chart versions - // https://fluxcd.io/flux/components/source/ocirepositories/#layer-selector - LayerSelector: &sourcev1.OCILayerSelector{ - MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip", - Operation: "extract", - }, - } - if secret := p.RequestedVersion.GetChartPullSecret(); secret != "" { - ociRepo.Spec.SecretRef = &meta.LocalObjectReference{ - Name: secret, - } - } - return nil - }, - DeletionPolicy: manager.Delete, - StatusFunc: FluxStatus, - }) -} - -func manageHelmRelease(p ManageFluxResourcesParams, dependencies []manager.ManagedObject) manager.ManagedObject { - return manager.NewManagedObject(&helmv2.HelmRelease{ - ObjectMeta: metav1.ObjectMeta{ - Name: p.HelmReleaseName, - Namespace: p.Cluster.GetDefaultNamespace(), - }, - }, manager.ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, o client.Object) error { - helmRelease, ok := o.(*helmv2.HelmRelease) - if !ok { - return fmt.Errorf("expected *helmv2.HelmRelease, got %T", o) - } - helmRelease.Spec = helmv2.HelmReleaseSpec{ - Interval: metav1.Duration{Duration: p.Interval}, - ChartRef: &helmv2.CrossNamespaceSourceReference{ - Kind: "OCIRepository", - Name: p.OCIRepositoryName, - Namespace: p.Cluster.GetDefaultNamespace(), - }, - KubeConfig: &meta.KubeConfigReference{ - SecretRef: &meta.SecretKeyReference{ - Name: p.ClusterContext.MCPAccessSecretKey.Name, - Key: "kubeconfig", - }, - }, - Install: &helmv2.Install{ - Remediation: &helmv2.InstallRemediation{ - Retries: 3, - }, - CreateNamespace: true, - }, - Upgrade: &helmv2.Upgrade{ - Remediation: &helmv2.UpgradeRemediation{ - Retries: 3, - }, - }, - DriftDetection: &helmv2.DriftDetection{ - Mode: helmv2.DriftDetectionEnabled, - }, - Values: p.RequestedVersion.GetHelmValues(), - TargetNamespace: p.MCPNamespace, - StorageNamespace: p.MCPNamespace, - } - return nil - }, - DependsOn: dependencies, - DeletionPolicy: manager.Delete, - StatusFunc: FluxStatus, - }) -} - -// FluxStatus indicates whether the given object is in phase terminating, pending or ready. -func FluxStatus(o client.Object) manager.ManagedResourceStatus { - fluxObject, ok := o.(conditions.Getter) - if !ok { - return manager.ManagedResourceStatus{ - Phase: manager.StatusPhaseUnknown, - Message: fmt.Sprintf("object %T does not implement conditions.Getter", o), - } - } - if !o.GetDeletionTimestamp().IsZero() { - return manager.ManagedResourceStatus{ - Phase: manager.StatusPhaseTerminating, - Message: "Resource is terminating.", - } - } - if conditions.IsTrue(fluxObject, meta.ReadyCondition) { - return manager.ManagedResourceStatus{ - Phase: manager.StatusPhaseReady, - Message: "Resource is ready", - } - } - msg := "Resource is not ready" - if cond := conditions.Get(fluxObject, meta.ReadyCondition); cond != nil && cond.Message != "" { - msg = cond.Message - } - return manager.ManagedResourceStatus{Phase: manager.StatusPhaseProgressing, Message: msg} -} diff --git a/pkg/manager/flux/flux_test.go b/pkg/manager/flux/flux_test.go deleted file mode 100644 index d2f048e..0000000 --- a/pkg/manager/flux/flux_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package flux_test - -import ( - "context" - "testing" - "time" - - helmv2 "github.com/fluxcd/helm-controller/api/v2" - "github.com/fluxcd/pkg/runtime/conditions" - sourcev1 "github.com/fluxcd/source-controller/api/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - apiextensionv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - "github.com/openmcp-project/controller-utils/pkg/clusters" - "github.com/openmcp-project/controller-utils/pkg/manager" - "github.com/openmcp-project/controller-utils/pkg/manager/flux" - "github.com/openmcp-project/opencontrolplane-runtime/pkg/serviceprovider/clusteraccess" - ocprtv1alpha1 "github.com/openmcp-project/opencontrolplane-runtime/testdata/api/v1alpha1" - clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" -) - -const ( - testNamespace = "test" - testChartURL = "oci://registry.example.com/charts/my-app" - testChartPullSecret = "chart-pull-secret" - testKubeconfigKey = "kubeconfig-key" -) - -// createFakeCluster sets up a ManagedCluster backed by a fake client. -// It mirrors the CreateFakeCluster helper in pkg/manager/utils_test.go but is -// inlined here because that helper is only available inside package manager. -func createFakeCluster(t *testing.T, id string) manager.ManagedCluster { - t.Helper() - scheme := runtime.NewScheme() - _ = clientgoscheme.AddToScheme(scheme) - _ = apiextensionv1.AddToScheme(scheme) - _ = ocprtv1alpha1.AddToScheme(scheme) - _ = clustersv1alpha1.AddToScheme(scheme) - _ = sourcev1.AddToScheme(scheme) - _ = helmv2.AddToScheme(scheme) - - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - c := clusters.NewTestClusterFromClient(id, fakeClient) - return manager.NewManagedCluster(c, &rest.Config{}, testNamespace, manager.PlatformCluster) -} - -// validParams returns a fully populated ManageFluxResourcesParams. Individual -// test cases mutate one field to exercise a specific validation branch. -func validParams(t *testing.T) flux.ManageFluxResourcesParams { - t.Helper() - return flux.ManageFluxResourcesParams{ - Cluster: createFakeCluster(t, "platform"), - MCPNamespace: "my-ns", - Interval: time.Hour, - ClusterContext: clusteraccess.ClusterContext{ - MCPAccessSecretKey: client.ObjectKey{ - Namespace: testNamespace, - Name: testKubeconfigKey, - }, - }, - RequestedVersion: flux.NewFluxResourceVersion("v1", "1.0", testChartURL, "", nil), - OCIRepositoryName: "my-repo", - HelmReleaseName: "my-release", - } -} - -func TestManageFluxResources(t *testing.T) { - tests := []struct { - name string - params flux.ManageFluxResourcesParams - }{ - { - name: "OCIRepository and HelmRelease are created with the correct values", - params: flux.ManageFluxResourcesParams{ - Cluster: createFakeCluster(t, "platform"), - MCPNamespace: "my-service", - Interval: time.Hour, - ClusterContext: clusteraccess.ClusterContext{ - MCPAccessSecretKey: client.ObjectKey{ - Namespace: testNamespace, - Name: testKubeconfigKey, - }, - }, - RequestedVersion: flux.NewFluxResourceVersion( - "v1.0.0", - "1.0.0", - testChartURL, - testChartPullSecret, - &apiextensionv1.JSON{Raw: []byte(`{"foo":"bar"}`)}, - ), - OCIRepositoryName: "test-repo", - HelmReleaseName: "test-release", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.NoError(t, flux.ManageFluxResources(tt.params)) - - // apply via a manager - mgr := manager.NewManager("serviceprovider-test") - mgr.AddCluster(tt.params.Cluster) - _, done, err := mgr.Apply(context.TODO()) - require.NoError(t, err) - // The fake client never sets a Flux ReadyCondition, so FluxStatus always returns - // Progressing. done must be false until Flux itself reconciles the resources. - assert.False(t, done, "expected done=false: Flux resources are not yet Ready (no ReadyCondition set by fake client)") - - // assert OCIRepository - ociRepo := &sourcev1.OCIRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-repo", - Namespace: testNamespace, - }, - } - require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(ociRepo), ociRepo)) - assert.Equal(t, tt.params.RequestedVersion.GetChartURL(), ociRepo.Spec.URL) - assert.Equal(t, tt.params.RequestedVersion.GetChartPullSecret(), ociRepo.Spec.SecretRef.Name) - assert.Equal(t, tt.params.RequestedVersion.GetChartVersion(), ociRepo.Spec.Reference.Tag) - assert.Equal(t, tt.params.Interval, ociRepo.Spec.Interval.Duration) - - // LayerSelector - require.NotNil(t, ociRepo.Spec.LayerSelector) - assert.Equal(t, "application/vnd.cncf.helm.chart.content.v1.tar+gzip", ociRepo.Spec.LayerSelector.MediaType) - assert.Equal(t, "extract", ociRepo.Spec.LayerSelector.Operation) - - // assert HelmRelease - helmRelease := &helmv2.HelmRelease{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-release", - Namespace: testNamespace, - }, - } - require.NoError(t, tt.params.Cluster.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(helmRelease), helmRelease)) - // ChartRef - assert.Equal(t, "OCIRepository", helmRelease.Spec.ChartRef.Kind) - assert.Equal(t, tt.params.OCIRepositoryName, helmRelease.Spec.ChartRef.Name) - assert.Equal(t, tt.params.Cluster.GetDefaultNamespace(), helmRelease.Spec.ChartRef.Namespace) - - // Install - assert.Equal(t, 3, helmRelease.Spec.Install.Remediation.Retries) - assert.Equal(t, true, helmRelease.Spec.Install.CreateNamespace) - - // Update - assert.Equal(t, 3, helmRelease.Spec.Upgrade.Remediation.Retries) - - // DriftDetection - assert.Equal(t, helmv2.DriftDetectionEnabled, helmRelease.Spec.DriftDetection.Mode) - - assert.Equal(t, tt.params.RequestedVersion.GetHelmValues(), helmRelease.Spec.Values) - assert.Equal(t, tt.params.Interval, helmRelease.Spec.Interval.Duration) - assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.StorageNamespace) - assert.Equal(t, tt.params.MCPNamespace, helmRelease.Spec.TargetNamespace) - assert.Equal(t, tt.params.ClusterContext.MCPAccessSecretKey.Name, helmRelease.Spec.KubeConfig.SecretRef.Name) - assert.Equal(t, "kubeconfig", helmRelease.Spec.KubeConfig.SecretRef.Key) - }) - } -} - -// TestManageFluxResources_Validation exercises every guard in validateFluxResourcesParams. -// Each case starts from a fully valid params struct and breaks exactly one field. -func TestManageFluxResources_Validation(t *testing.T) { - tests := []struct { - name string - mutate func(p *flux.ManageFluxResourcesParams) - wantErrFrag string - }{ - { - name: "nil Cluster", - mutate: func(p *flux.ManageFluxResourcesParams) { p.Cluster = nil }, - wantErrFrag: "Cluster must not be nil", - }, - { - name: "nil RequestedVersion", - mutate: func(p *flux.ManageFluxResourcesParams) { p.RequestedVersion = nil }, - wantErrFrag: "RequestedVersion must not be nil", - }, - { - name: "empty ChartURL", - mutate: func(p *flux.ManageFluxResourcesParams) { - p.RequestedVersion = flux.NewFluxResourceVersion("v1", "1.0", "", "", nil) - }, - wantErrFrag: "GetChartURL() must not be empty", - }, - { - name: "empty MCPAccessSecretKey.Name", - mutate: func(p *flux.ManageFluxResourcesParams) { - p.ClusterContext.MCPAccessSecretKey.Name = "" - }, - wantErrFrag: "MCPAccessSecretKey.Name must not be empty", - }, - { - name: "zero Interval", - mutate: func(p *flux.ManageFluxResourcesParams) { p.Interval = 0 }, - wantErrFrag: "Interval must be greater than zero", - }, - { - name: "negative Interval", - mutate: func(p *flux.ManageFluxResourcesParams) { p.Interval = -time.Second }, - wantErrFrag: "Interval must be greater than zero", - }, - { - name: "empty OCIRepositoryName", - mutate: func(p *flux.ManageFluxResourcesParams) { p.OCIRepositoryName = "" }, - wantErrFrag: "OCIRepositoryName must not be empty", - }, - { - name: "empty HelmReleaseName", - mutate: func(p *flux.ManageFluxResourcesParams) { p.HelmReleaseName = "" }, - wantErrFrag: "HelmReleaseName must not be empty", - }, - { - name: "empty MCPNamespace", - mutate: func(p *flux.ManageFluxResourcesParams) { p.MCPNamespace = "" }, - wantErrFrag: "MCPNamespace must not be empty", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := validParams(t) - tt.mutate(&p) - err := flux.ManageFluxResources(p) - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErrFrag) - }) - } -} - -// TestFluxStatus exercises all four branches of FluxStatus: -// Unknown (non-Flux object), Terminating, Ready, and Progressing (with and without a message). -func TestFluxStatus(t *testing.T) { - tests := []struct { - name string - object client.Object - wantPhase string - wantMessage string - }{ - { - name: "Unknown — object does not implement conditions.Getter", - object: &corev1.Secret{}, - wantPhase: manager.StatusPhaseUnknown, - }, - { - name: "Terminating — DeletionTimestamp is set", - object: func() client.Object { - now := metav1.Now() - o := &sourcev1.OCIRepository{} - o.DeletionTimestamp = &now - return o - }(), - wantPhase: manager.StatusPhaseTerminating, - wantMessage: "Resource is terminating.", - }, - { - name: "Ready — ReadyCondition is True", - object: func() client.Object { - o := &sourcev1.OCIRepository{} - conditions.MarkTrue(o, "Ready", "Reconciled", "chart applied successfully") - return o - }(), - wantPhase: manager.StatusPhaseReady, - }, - { - name: "Progressing — ReadyCondition is False with a message", - object: func() client.Object { - o := &sourcev1.OCIRepository{} - conditions.MarkFalse(o, "Ready", "ChartNotFound", "chart not found in registry") - return o - }(), - wantPhase: manager.StatusPhaseProgressing, - wantMessage: "chart not found in registry", - }, - { - name: "Progressing — no conditions set", - object: &sourcev1.OCIRepository{}, - wantPhase: manager.StatusPhaseProgressing, - wantMessage: "Resource is not ready", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - status := flux.FluxStatus(tt.object) - assert.Equal(t, tt.wantPhase, status.Phase) - if tt.wantMessage != "" { - assert.Equal(t, tt.wantMessage, status.Message) - } - }) - } -} From 2287a8c449748af4e259e7a8532b2a5a85a32563 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 4 Sep 2026 11:09:08 +0200 Subject: [PATCH 19/20] refactor: remove pkg/manager Signed-off-by: Jakob Bergmeier --- pkg/manager/labels.go | 25 --- pkg/manager/managedResource.go | 63 ------ pkg/manager/managedResource_test.go | 71 ------ pkg/manager/managedcluster.go | 99 -------- pkg/manager/managedobject.go | 104 --------- pkg/manager/manager.go | 298 ------------------------- pkg/manager/objectutils.go | 14 -- pkg/manager/orphancleaner.go | 128 ----------- pkg/manager/orphancleaner_test.go | 335 ---------------------------- pkg/manager/secret.go | 76 ------- pkg/manager/secret_test.go | 152 ------------- pkg/manager/status.go | 42 ---- pkg/manager/utils_test.go | 260 --------------------- 13 files changed, 1667 deletions(-) delete mode 100644 pkg/manager/labels.go delete mode 100644 pkg/manager/managedResource.go delete mode 100644 pkg/manager/managedResource_test.go delete mode 100644 pkg/manager/managedcluster.go delete mode 100644 pkg/manager/managedobject.go delete mode 100644 pkg/manager/manager.go delete mode 100644 pkg/manager/objectutils.go delete mode 100644 pkg/manager/orphancleaner.go delete mode 100644 pkg/manager/orphancleaner_test.go delete mode 100644 pkg/manager/secret.go delete mode 100644 pkg/manager/secret_test.go delete mode 100644 pkg/manager/status.go delete mode 100644 pkg/manager/utils_test.go diff --git a/pkg/manager/labels.go b/pkg/manager/labels.go deleted file mode 100644 index bca854f..0000000 --- a/pkg/manager/labels.go +++ /dev/null @@ -1,25 +0,0 @@ -package manager - -import "sigs.k8s.io/controller-runtime/pkg/client" - -const ( - // LabelManagedBy defines the managed-by label that is added to every managed object. - LabelManagedBy = "app.kubernetes.io/managed-by" -) - -// SetManagedBy sets the managed-by label of the given client.Object. -func SetManagedBy(o client.Object, managedBy string) { - labels := o.GetLabels() - if labels == nil { - labels = map[string]string{} - } - labels[LabelManagedBy] = managedBy - o.SetLabels(labels) -} - -// ManagedBy returns a list option filtering objects by managed-by label value. -func ManagedBy(managedBy string) client.ListOption { - return client.MatchingLabels{ - LabelManagedBy: managedBy, - } -} diff --git a/pkg/manager/managedResource.go b/pkg/manager/managedResource.go deleted file mode 100644 index c06bcbc..0000000 --- a/pkg/manager/managedResource.go +++ /dev/null @@ -1,63 +0,0 @@ -package manager - -// ManagedResource is the read-only view of a reconciled object returned by Apply and Delete. -// It is a plain value struct: Apply/Delete return it by value, so callers receive a copy and -// cannot affect the manager's internal state. It stays decoupled from any CRD schema; consumers -// project it into their own CRD-embedded type via ProjectResources and ResourceStatusWriter. -type ManagedResource struct { - APIGroup string - Kind string - Name string - Namespace string - Location ClusterType - Status ManagedResourceStatus -} - -// ResourceRef identifies a managed resource. It is passed to a consumer's -// ResourceStatusWriter so the consumer can decide how to store the fields -// (e.g. whether to represent an empty Namespace or APIGroup as nil). -type ResourceRef struct { - APIGroup string - Kind string - Name string - Namespace string - Location string -} - -// ResourceStatusWriter is implemented by a consumer's CRD-embedded resource type. -// ProjectResources drives it to populate a consumer-owned status entry from a -// framework ManagedResource, keeping pkg/manager decoupled from any CRD schema. -type ResourceStatusWriter interface { - // SetReference stores the identity of the managed resource. - SetReference(ResourceRef) - // SetPhase stores the lifecycle phase and human-readable message. - SetPhase(phase, message string) -} - -// ProjectResources converts framework results into a slice of a consumer-provided -// type W. The framework owns the loop and allocation; the consumer owns only the -// field mapping via the ResourceStatusWriter setters. newWriter constructs a fresh, -// writable W for each result. -// -// Example: -// -// obj.Status.Resources = manager.ProjectResources( -// resources, -// func() *apiv1alpha1.ManagedResource { return &apiv1alpha1.ManagedResource{} }, -// ) -func ProjectResources[W ResourceStatusWriter](results []ManagedResource, newWriter func() W) []W { - out := make([]W, len(results)) - for i, r := range results { - w := newWriter() - w.SetReference(ResourceRef{ - APIGroup: r.APIGroup, - Kind: r.Kind, - Name: r.Name, - Namespace: r.Namespace, - Location: string(r.Location), - }) - w.SetPhase(r.Status.Phase, r.Status.Message) - out[i] = w - } - return out -} diff --git a/pkg/manager/managedResource_test.go b/pkg/manager/managedResource_test.go deleted file mode 100644 index c2df708..0000000 --- a/pkg/manager/managedResource_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package manager - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// fakeWriter is a test implementation of ResourceStatusWriter that records -// what ProjectResources sets on it. -type fakeWriter struct { - ref ResourceRef - phase string - message string -} - -func (w *fakeWriter) SetReference(ref ResourceRef) { w.ref = ref } -func (w *fakeWriter) SetPhase(phase, message string) { - w.phase = phase - w.message = message -} - -func TestProjectResources(t *testing.T) { - t.Run("returns one writer per result with reference and phase/message applied", func(t *testing.T) { - results := []ManagedResource{ - { - APIGroup: "source.toolkit.fluxcd.io", - Kind: "OCIRepository", - Name: "eso", - Namespace: "tenant-ns", - Location: PlatformCluster, - Status: ManagedResourceStatus{Phase: StatusPhaseReady, Message: "Resource exists."}, - }, - { - Kind: "Secret", - Name: "pull-secret", - Location: ManagedControlPlane, - Status: ManagedResourceStatus{Phase: StatusPhaseProgressing, Message: "creating"}, - }, - } - - out := ProjectResources(results, func() *fakeWriter { return &fakeWriter{} }) - - require.Len(t, out, 2) - - assert.Equal(t, ResourceRef{ - APIGroup: "source.toolkit.fluxcd.io", - Kind: "OCIRepository", - Name: "eso", - Namespace: "tenant-ns", - Location: string(PlatformCluster), - }, out[0].ref) - assert.Equal(t, StatusPhaseReady, out[0].phase) - assert.Equal(t, "Resource exists.", out[0].message) - - assert.Equal(t, ResourceRef{ - Kind: "Secret", - Name: "pull-secret", - Location: string(ManagedControlPlane), - }, out[1].ref) - assert.Equal(t, StatusPhaseProgressing, out[1].phase) - assert.Equal(t, "creating", out[1].message) - }) - - t.Run("returns empty non-nil slice for no results", func(t *testing.T) { - out := ProjectResources(nil, func() *fakeWriter { return &fakeWriter{} }) - assert.NotNil(t, out) - assert.Empty(t, out) - }) -} diff --git a/pkg/manager/managedcluster.go b/pkg/manager/managedcluster.go deleted file mode 100644 index ebee63d..0000000 --- a/pkg/manager/managedcluster.go +++ /dev/null @@ -1,99 +0,0 @@ -package manager - -import ( - "strings" - - "github.com/openmcp-project/controller-utils/pkg/clusters" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - // ManagedControlPlane indicates that a cluster is a managed control plane. - ManagedControlPlane ClusterType = "ManagedControlPlane" - // PlatformCluster indicates that a cluster is a platform cluster. - PlatformCluster ClusterType = "PlatformCluster" - // WorkloadCluster indicates that a cluster is a workload cluster. - WorkloadCluster ClusterType = "WorkloadCluster" -) - -// ClusterType distinguishes between managed control plane and workload clusters. -type ClusterType string - -// NewManagedCluster creates a new ManagedCluster instance. -func NewManagedCluster(c *clusters.Cluster, cfg *rest.Config, ns string, ct ClusterType) ManagedCluster { - return &managedCluster{ - cluster: c, - cfg: cfg, - objects: []ManagedObject{}, - defaultNamespace: ns, - clusterType: ct, - } -} - -// ManagedCluster holds a set of ManagedObjects. -type ManagedCluster interface { - AddObject(o ManagedObject) - GetObjects() []ManagedObject - GetDefaultNamespace() string - GetHostAndPort() (string, string) - GetConfig() *rest.Config - GetClient() client.Client - GetCluster() *clusters.Cluster - GetClusterType() ClusterType -} - -var _ ManagedCluster = &managedCluster{} - -type managedCluster struct { - cluster *clusters.Cluster - cfg *rest.Config - objects []ManagedObject - defaultNamespace string - clusterType ClusterType -} - -// GetClient implements ManagedCluster. -func (m *managedCluster) GetClient() client.Client { - return m.cluster.Client() -} - -// GetConfig implements ManagedCluster. -func (m *managedCluster) GetConfig() *rest.Config { - return m.cfg -} - -// GetHostAndPort implements ManagedCluster. -func (m *managedCluster) GetHostAndPort() (string, string) { - input := strings.TrimPrefix(strings.TrimPrefix(m.cfg.Host, "https://"), "http://") - host, port, found := strings.Cut(input, ":") - if !found { - port = "443" - } - return host, port -} - -// GetDefaultNamespace implements ManagedCluster. -func (m *managedCluster) GetDefaultNamespace() string { - return m.defaultNamespace -} - -// AddObject implements ManagedCluster. -func (m *managedCluster) AddObject(o ManagedObject) { - m.objects = append(m.objects, o) -} - -// GetObjects implements ManagedCluster. -func (m *managedCluster) GetObjects() []ManagedObject { - return m.objects -} - -// GetCluster implements ManagedCluster. -func (m *managedCluster) GetCluster() *clusters.Cluster { - return m.cluster -} - -// GetClusterType implements ManagedCluster. -func (m *managedCluster) GetClusterType() ClusterType { - return m.clusterType -} diff --git a/pkg/manager/managedobject.go b/pkg/manager/managedobject.go deleted file mode 100644 index 5d97745..0000000 --- a/pkg/manager/managedobject.go +++ /dev/null @@ -1,104 +0,0 @@ -package manager - -import ( - "context" - - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// DeletionPolicy distinguishes between normal deletion and orphaning an object. -type DeletionPolicy string - -const ( - // Orphan indicates that an object will be orphaned when deletion is requested. - Orphan DeletionPolicy = "orphan" - // Delete indicates that an object will be deleted when deletion is requested. - Delete DeletionPolicy = "delete" -) - -// ReconcileFunc reconciles the given client.Object. -type ReconcileFunc func(ctx context.Context, o client.Object) error - -// StatusFunc provides ManagedResourceStatus information for the given client.Object. -type StatusFunc func(client.Object) ManagedResourceStatus - -// NoOp does not do anything with the provided object and returns nil. -func NoOp(context.Context, client.Object) error { - return nil -} - -// NewManagedObject creates a new ManagedObject instance to manage the given client.Object. -func NewManagedObject(o client.Object, moc ManagedObjectContext) ManagedObject { - if moc.DeletionPolicy == "" { - moc.DeletionPolicy = Delete - } - - return &managedObject{ - object: o, - reconcileFunc: moc.ReconcileFunc, - dependencies: moc.DependsOn, - deletionPolicy: moc.DeletionPolicy, - statusFunc: moc.StatusFunc, - } -} - -// ManagedObjectContext holds the data to manage a client.Object. -type ManagedObjectContext struct { - ReconcileFunc ReconcileFunc - DependsOn []ManagedObject - DeletionPolicy DeletionPolicy - StatusFunc StatusFunc -} - -// ManagedObject represents an object managed by a Manager. -type ManagedObject interface { - GetObject() client.Object - Reconcile(ctx context.Context) error - GetDependencies() []ManagedObject - GetDeletionPolicy() DeletionPolicy - GetStatus() ManagedResourceStatus -} - -var _ ManagedObject = &managedObject{} - -type managedObject struct { - object client.Object - reconcileFunc ReconcileFunc - dependencies []ManagedObject - deletionPolicy DeletionPolicy - statusFunc StatusFunc -} - -// GetDeletionPolicy implements ManagedObject. -func (m *managedObject) GetDeletionPolicy() DeletionPolicy { - return m.deletionPolicy -} - -// GetDependencies implements ManagedObject. -func (m *managedObject) GetDependencies() []ManagedObject { - return m.dependencies -} - -// Reconcile implements ManagedObject. -func (m *managedObject) Reconcile(ctx context.Context) error { - if m.reconcileFunc != nil { - return m.reconcileFunc(ctx, m.object) - } - return nil -} - -// GetObject implements ManagedObject. -func (m *managedObject) GetObject() client.Object { - return m.object -} - -// GetStatus implements ManagedObject. -func (m *managedObject) GetStatus() ManagedResourceStatus { - if m.statusFunc != nil { - return m.statusFunc(m.object) - } - return ManagedResourceStatus{ - Phase: StatusPhaseUnknown, - Message: "No status function defined.", - } -} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go deleted file mode 100644 index 505f48a..0000000 --- a/pkg/manager/manager.go +++ /dev/null @@ -1,298 +0,0 @@ -package manager - -import ( - "context" - "errors" - "fmt" - "reflect" - "slices" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -const ( - // OperationResultDeletionFailed indicates failed to be deleted - OperationResultDeletionFailed controllerutil.OperationResult = "deletionFailed" - // OperationResultDeletionRequested indicates that an object has been marked for deletion - OperationResultDeletionRequested controllerutil.OperationResult = "deletionRequested" - // OperationResultDeleted indicates that an object has been deleted - OperationResultDeleted controllerutil.OperationResult = "deleted" - // OperationResultOrphaned indicates that an object has been orphaned - OperationResultOrphaned controllerutil.OperationResult = OperationResultDeleted -) - -// ErrManagedResourcesFailed is returned by Apply/Delete when one or more -// managed resources encountered a reconcile error. -var ErrManagedResourcesFailed = errors.New("one or more managed resources failed") - -type dependents map[ManagedObject][]dependency - -// Manager manages the objects of an arbitrary number of clusters. -type Manager interface { - AddCluster(mc ManagedCluster) - AddCleaner(oc OrphanCleaner) - // Apply reconciles all managed objects in registration order. - // DependsOn is NOT used for apply ordering; it only affects deletion sequencing. - // Callers are responsible for registering objects in dependency order. - // done=true means every resource reached StatusPhaseReady. - // err wraps ErrManagedResourcesFailed when any individual reconcile failed. - Apply(ctx context.Context) (resources []ManagedResource, done bool, err error) - // Delete deletes all managed objects. - // done=true means every resource has been deleted or orphaned. - // err wraps ErrManagedResourcesFailed when any individual deletion failed. - Delete(ctx context.Context) (resources []ManagedResource, done bool, err error) -} - -// OrphanCleaner removes any previously managed objects that are no longer part of the desired state. -type OrphanCleaner interface { - // []Result contains cleanup errors that can be mapped to a managed object. - // error represents cleanup errors that cannot be mapped to a managed object. - Cleanup(ctx context.Context) ([]Result, error) -} - -// NewManager creates a new Manager instance. -func NewManager(serviceProvider string) Manager { - return &managerImpl{ - serviceProvider: serviceProvider, - clusters: []ManagedCluster{}, - cleaners: []OrphanCleaner{}, - } -} - -// managerImpl manages clusters and invokes reconciliation of ManagedObjects. -type managerImpl struct { - serviceProvider string - clusters []ManagedCluster - cleaners []OrphanCleaner -} - -// AddCluster adds a cluster to a Manager. -func (m *managerImpl) AddCluster(mc ManagedCluster) { - m.clusters = append(m.clusters, mc) -} - -// Apply implements Manager. -func (m *managerImpl) Apply(ctx context.Context) ([]ManagedResource, bool, error) { - results, err := m.reconcileObjects(ctx, false) - if err != nil { - return nil, false, err - } - resources, errs := resultsToResources(ctx, results) - if len(errs) > 0 { - return resources, false, fmt.Errorf("%w: %w", ErrManagedResourcesFailed, errors.Join(errs...)) - } - return resources, allResourcesReady(results), nil -} - -// Delete implements Manager. -func (m *managerImpl) Delete(ctx context.Context) ([]ManagedResource, bool, error) { - results, err := m.reconcileObjects(ctx, true) - if err != nil { - return nil, false, err - } - resources, errs := resultsToResources(ctx, results) - if len(errs) > 0 { - return resources, false, fmt.Errorf("%w: %w", ErrManagedResourcesFailed, errors.Join(errs...)) - } - return resources, allDeleted(results), nil -} - -// AddCleaner adds a cleaner to a Manager. -func (m *managerImpl) AddCleaner(cleaner OrphanCleaner) { - m.cleaners = append(m.cleaners, cleaner) -} - -func (m *managerImpl) reconcileObjects(ctx context.Context, isDeletion bool) ([]Result, error) { - dependents := m.getDependents() - - // Apply or delete objects from each cluster. - results := []Result{} - for _, mc := range m.clusters { - for _, mo := range mc.GetObjects() { - result := m.reconcileObject(ctx, mc, mo, dependents, isDeletion) - results = append(results, result) - } - } - - // Remove any redundant resources like secret copies that are no longer part of the desired state. - for _, c := range m.cleaners { - result, err := c.Cleanup(ctx) - if err != nil { - return results, err - } - results = slices.Concat(results, result) - } - - if len(results) == 0 { - log.FromContext(ctx).V(1).Info("manager reconciled zero objects; no clusters or managed objects have been registered") - } - - return results, nil -} - -func (m *managerImpl) reconcileObject(ctx context.Context, mc ManagedCluster, mo ManagedObject, dependents dependents, isDeletion bool) Result { - cl := mc.GetClient() - obj := mo.GetObject() - - if isDeletion { - if err := m.checkForDependents(ctx, dependents[mo]); err != nil { - return Result{ - Object: mo, - Cluster: mc, - OperationResult: controllerutil.OperationResultNone, - Error: err, - } - } - - if mo.GetDeletionPolicy() == Orphan { - return Result{ - Object: mo, - Cluster: mc, - OperationResult: OperationResultOrphaned, - } - } - - err := cl.Delete(ctx, obj) - if apierrors.IsNotFound(err) { - return Result{ - Object: mo, - Cluster: mc, - OperationResult: OperationResultDeleted, - } - } - if err != nil { - return Result{ - Object: mo, - Cluster: mc, - OperationResult: OperationResultDeletionFailed, - Error: err, - } - } - return Result{ - Object: mo, - Cluster: mc, - OperationResult: OperationResultDeletionRequested, - } - } - - opResult, err := controllerutil.CreateOrUpdate(ctx, cl, obj, func() error { - SetManagedBy(obj, m.serviceProvider) - return mo.Reconcile(ctx) - }) - return Result{ - Object: mo, - Cluster: mc, - OperationResult: opResult, - Error: err, - } -} - -func (m *managerImpl) checkForDependents(ctx context.Context, deps []dependency) error { - var errs []error - for _, dep := range deps { - obj := dep.Object.GetObject() - err := dep.Cluster.GetClient().Get(ctx, client.ObjectKeyFromObject(obj), obj) - if apierrors.IsNotFound(err) { - // "Not found" is the success case: the dependent no longer exists. - continue - } - if err != nil { - errs = append(errs, err) - continue - } - // No error: the GET succeeded, meaning the dependent still exists. - errs = append(errs, fmt.Errorf("dependent object still exists: %s", ObjectID(obj))) - } - return errors.Join(errs...) -} - -func (m *managerImpl) getDependents() dependents { - deps := dependents{} - for _, mc := range m.clusters { - for _, mo := range mc.GetObjects() { - for _, dep := range mo.GetDependencies() { - if deps[dep] == nil { - deps[dep] = []dependency{} - } - deps[dep] = append(deps[dep], dependency{ - Object: mo, - Cluster: mc, - }) - } - } - } - return deps -} - -// Result summarizes a reconciliation result. -type Result struct { - Object ManagedObject - Cluster ManagedCluster - OperationResult controllerutil.OperationResult - Error error -} - -type dependency struct { - Object ManagedObject - Cluster ManagedCluster -} - -// allDeleted returns true if every result is OperationResultDeleted or OperationResultOrphaned. -// It uses OperationResult because deletion completion is an immediate API fact, -// not a condition that needs to be polled from the object's status. -func allDeleted(results []Result) bool { - for _, r := range results { - if r.OperationResult != OperationResultDeleted && r.OperationResult != OperationResultOrphaned { - return false - } - } - return true -} - -// allResourcesReady returns true if every managed object has reached StatusPhaseReady. -// It uses the object's StatusFunc output rather than OperationResult because convergence -// (e.g. a Flux HelmRelease becoming ready) is asynchronous: OperationResult reflects -// what the API server did ("created"/"updated"), not whether the controller has reconciled. -func allResourcesReady(results []Result) bool { - for _, r := range results { - if r.Object.GetStatus().Phase != StatusPhaseReady { - return false - } - } - return true -} - -func resultsToResources(ctx context.Context, results []Result) ([]ManagedResource, []error) { - l := log.FromContext(ctx) - var errs []error - resources := make([]ManagedResource, 0, len(results)) - for _, res := range results { - obj := res.Object.GetObject() - - apiGroup := "" - kind := reflect.TypeOf(obj).Elem().Name() // fallback: Go type name == Kind by convention - if gvk, err := res.Cluster.GetClient().GroupVersionKindFor(obj); err == nil { - apiGroup = gvk.Group - kind = gvk.Kind - } else { - l.Error(err, "cannot determine GVK for managed object; apiGroup will be empty in status", "objectID", ObjectID(obj)) - } - - resources = append(resources, ManagedResource{ - APIGroup: apiGroup, - Kind: kind, - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - Status: res.Object.GetStatus(), - Location: res.Cluster.GetClusterType(), - }) - if res.Error != nil { - l.Error(res.Error, "reconcile error", "objectID", ObjectID(obj)) - errs = append(errs, res.Error) - } - } - return resources, errs -} diff --git a/pkg/manager/objectutils.go b/pkg/manager/objectutils.go deleted file mode 100644 index 34f8d89..0000000 --- a/pkg/manager/objectutils.go +++ /dev/null @@ -1,14 +0,0 @@ -package manager - -import ( - "fmt" - "reflect" - - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// ObjectID returns a human-readable identifier string for a Kubernetes object. -func ObjectID(obj client.Object) string { - typeName := reflect.TypeOf(obj).Elem().Name() - return fmt.Sprintf(`Kind=%s, Name=%s, Namespace=%s`, typeName, obj.GetName(), obj.GetNamespace()) -} diff --git a/pkg/manager/orphancleaner.go b/pkg/manager/orphancleaner.go deleted file mode 100644 index 340b1f4..0000000 --- a/pkg/manager/orphancleaner.go +++ /dev/null @@ -1,128 +0,0 @@ -package manager - -import ( - "context" - "errors" - "fmt" - "slices" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -// ErrOrphanCleanup is an user-facing error that indicates orphan cleanup failures -var ErrOrphanCleanup = errors.New("orphan cleanup failed") - -var _ OrphanCleaner = &orphanCleaner[*corev1.SecretList]{} - -type orphanCleaner[T client.ObjectList] struct { - cluster ManagedCluster - serviceProvider string - namespace string - cleanerType CleanerType[T] -} - -type CleanerType[T client.ObjectList] struct { - ObjectsToKeep []corev1.LocalObjectReference - EmptyList func() T - // PreDeletionSteps is an optional hook that is invoked before OrphanCleaner.Cleanup deletes an object. - // It returns: - // - proceedWithDeletion: true if the object is ready to be deleted. - // Returning false prevents deletion for this reconciliation cycle. - // - err: an error if the preparation step failed. - PreDeletionSteps func(context.Context, client.Object) (proceedWithDeletion bool, _ error) -} - -// NewOrphanCleaner removes redundant objects in the given target namespace. -func NewOrphanCleaner[T client.ObjectList](cluster ManagedCluster, serviceProvider string, namespace string, clType CleanerType[T]) OrphanCleaner { - return &orphanCleaner[T]{ - cluster: cluster, - serviceProvider: serviceProvider, - namespace: namespace, - cleanerType: clType, - } -} - -func (c *orphanCleaner[T]) items(list T) []client.Object { - items, _ := meta.ExtractList(list) - objList := make([]client.Object, 0, len(items)) - for _, item := range items { - if obj, ok := item.(client.Object); ok { - objList = append(objList, obj) - } - } - return objList -} - -func (c *orphanCleaner[T]) Cleanup(ctx context.Context) ([]Result, error) { - results := []Result{} - if c.cleanerType.EmptyList == nil { - return nil, fmt.Errorf("%w: orphan cleaner is missing empty list definition", ErrOrphanCleanup) - } - objList := c.cleanerType.EmptyList() - cl := c.cluster.GetClient() - if err := cl.List(ctx, objList, - client.InNamespace(c.namespace), - client.MatchingLabels{LabelManagedBy: c.serviceProvider}, - ); err != nil { - log.FromContext(ctx).Error(err, "failed to list objects for orphan cleanup") - return nil, ErrOrphanCleanup - } - for _, obj := range c.items(objList) { - if !slices.ContainsFunc(c.cleanerType.ObjectsToKeep, func(ref corev1.LocalObjectReference) bool { return obj.GetName() == ref.Name }) { - // exec delete preparation steps - if c.cleanerType.PreDeletionSteps != nil { - proceedWithDeletion, err := c.cleanerType.PreDeletionSteps(ctx, obj) - if err != nil { - results = append(results, c.deletionError(obj, err)) - continue - } - if !proceedWithDeletion { - results = append(results, c.deletionPrepared(obj)) - continue - } - } - // exec delete - if err := cl.Delete(ctx, obj); client.IgnoreNotFound(err) != nil { - results = append(results, c.deletionError(obj, err)) - } - } - } - return results, nil -} - -func (c *orphanCleaner[T]) deletionPrepared(obj client.Object) Result { - mo := &managedObject{ - object: obj, - deletionPolicy: Delete, - statusFunc: func(client.Object) ManagedResourceStatus { - return ManagedResourceStatus{Phase: StatusPhaseTerminating, Message: "Deletion prepared."} - }, - } - return Result{ - Object: mo, - Cluster: c.cluster, - OperationResult: OperationResultDeletionRequested, - } -} - -func (c *orphanCleaner[T]) deletionError(obj client.Object, err error) Result { - mo := &managedObject{ - object: obj, - deletionPolicy: Delete, - statusFunc: func(client.Object) ManagedResourceStatus { - return ManagedResourceStatus{ - Phase: StatusPhaseProgressing, - Message: fmt.Sprintf("Deletion failed, retrying: %s", err.Error()), - } - }, - } - return Result{ - Object: mo, - Cluster: c.cluster, - OperationResult: OperationResultDeletionFailed, - Error: err, - } -} diff --git a/pkg/manager/orphancleaner_test.go b/pkg/manager/orphancleaner_test.go deleted file mode 100644 index 49c2f6e..0000000 --- a/pkg/manager/orphancleaner_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package manager - -import ( - "context" - "errors" - "slices" - "testing" - - sourcev1 "github.com/fluxcd/source-controller/api/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -const serviceProvider = "serviceprovider-test" -const namespace = "test-namespace" - -func Test_orphanCleaner_Cleanup(t *testing.T) { - tests := []struct { - name string // description of this test case - targetNamespace string - cluster ManagedCluster - cleanerType CleanerType[*sourcev1.OCIRepositoryList] - want []sourcev1.OCIRepository - wantErr bool - wantResultErr bool - }{ - { - name: "only OCIRepository managed by sp-eso are deleted", - targetNamespace: namespace, - cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", namespace, true), - createOCIRepo("unmanaged", namespace, false), - })), - cleanerType: createOCIRepoCleanerType(), - want: []sourcev1.OCIRepository{ - *createOCIRepo("unmanaged", namespace, false), - }, - wantErr: false, - }, - { - name: "OCIRepository in other namespaces are not deleted", - targetNamespace: "openmcp-system", - cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", namespace, true), - createOCIRepo("unmanaged", namespace, false), - })), - cleanerType: createOCIRepoCleanerType(), - want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", namespace, true), - *createOCIRepo("unmanaged", namespace, false), - }, - wantErr: false, - }, - { - name: "objects to keep are not deleted", - targetNamespace: "openmcp-system", - cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", namespace, true), - createOCIRepo("unmanaged", namespace, false), - })), - cleanerType: createOCIRepoCleanerType(corev1.LocalObjectReference{Name: "managed"}), - want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", namespace, true), - *createOCIRepo("unmanaged", namespace, false), - }, - wantErr: false, - }, - { - name: "error is returned when list fails", - targetNamespace: namespace, - cluster: createFakeCluster(listErrorClient{}), - cleanerType: createOCIRepoCleanerType(), - want: []sourcev1.OCIRepository{}, - wantErr: true, - }, - { - name: "if a single delete fails, the single result contains an error but overall cleanup succeeds", - targetNamespace: namespace, - cluster: createFakeCluster(deleteErrorClient{ - fakeOCIRepo: *createOCIRepo("managed", namespace, true), - }), - cleanerType: createOCIRepoCleanerType(), - want: []sourcev1.OCIRepository{}, - wantErr: false, - wantResultErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := NewOrphanCleaner(tt.cluster, serviceProvider, tt.targetNamespace, tt.cleanerType) - result, gotErr := c.Cleanup(context.Background()) - if gotErr != nil { - if !tt.wantErr { - t.Errorf("Cleanup() failed: %v", gotErr) - } - return - } - if tt.wantErr { - t.Fatal("Cleanup() succeeded unexpectedly") - } - if tt.wantResultErr { - assert.True(t, slices.ContainsFunc(result, containsResultError)) - return - } - repoList := &sourcev1.OCIRepositoryList{} - require.NoError(t, tt.cluster.GetClient().List(context.Background(), repoList)) - assert.Len(t, tt.want, len(repoList.Items)) - for _, repo := range repoList.Items { - assert.True(t, slices.ContainsFunc(tt.want, func(r sourcev1.OCIRepository) bool { - return r.Name == repo.Name && r.Namespace == repo.Namespace - })) - } - assert.False(t, slices.ContainsFunc(result, containsResultError)) - }) - } -} - -func Test_orphanCleaner_Cleanup_prepareDeletion(t *testing.T) { - tests := []struct { - name string // description of this test case - targetNamespace string - cluster ManagedCluster - cleanerType CleanerType[*sourcev1.OCIRepositoryList] - option preparationOptions - want []sourcev1.OCIRepository - wantErr bool // expects orphan cleanup to return an error - wantResultErr bool // expects an error in one or more cleanup results - }{ - { - name: "preparation succeeds and proceeds with deletion", - targetNamespace: namespace, - cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", namespace, true), - createOCIRepo("unmanaged", namespace, false), - })), - cleanerType: createOCIRepoCleanerType(), - option: succeedAndDelete, - want: []sourcev1.OCIRepository{ - *createOCIRepo("unmanaged", namespace, false), - }, - wantErr: false, - wantResultErr: false, - }, - { - name: "if preparation succeeds but deletion is blocked, no repo is removed", - targetNamespace: namespace, - cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", namespace, true), - createOCIRepo("unmanaged", namespace, false), - })), - cleanerType: createOCIRepoCleanerType(), - option: succeedAndWait, - want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", namespace, true), - *createOCIRepo("unmanaged", namespace, false), - }, - wantErr: false, - wantResultErr: false, - }, - { - name: "if preparation fails, the single result contains an error but overall cleanup succeeds", - targetNamespace: namespace, - cluster: createFakeCluster(createFakeClient([]client.Object{ - createOCIRepo("managed", namespace, true), - createOCIRepo("unmanaged", namespace, false), - })), - cleanerType: createOCIRepoCleanerType(), - option: failWithError, - want: []sourcev1.OCIRepository{ - *createOCIRepo("managed", namespace, true), - *createOCIRepo("unmanaged", namespace, false), - }, - wantErr: false, - wantResultErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prepDeletionMock := prepareDeletionMock{} - var cleanerType CleanerType[*sourcev1.OCIRepositoryList] - switch tt.option { - case succeedAndDelete: - cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.successfulDelete) - case succeedAndWait: - cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.successfulAndWaitWithDelete) - case failWithError: - cleanerType = tt.cleanerType.withPreSteps(prepDeletionMock.failure) - } - c := NewOrphanCleaner(tt.cluster, serviceProvider, tt.targetNamespace, cleanerType) - result, gotErr := c.Cleanup(context.Background()) - if gotErr != nil { - if !tt.wantErr { - t.Errorf("Cleanup() failed: %v", gotErr) - } - return - } - if tt.wantErr { - t.Fatal("Cleanup() succeeded unexpectedly") - } - if prepDeletionMock.execution > 0 { - assert.Equal(t, tt.wantResultErr, prepDeletionMock.failed) - } - if tt.wantResultErr { - assert.True(t, slices.ContainsFunc(result, containsResultError)) - return - } - repoList := &sourcev1.OCIRepositoryList{} - require.NoError(t, tt.cluster.GetClient().List(context.Background(), repoList)) - assert.Len(t, tt.want, len(repoList.Items)) - for _, repo := range repoList.Items { - assert.True(t, slices.ContainsFunc(tt.want, func(r sourcev1.OCIRepository) bool { - return r.Name == repo.Name && r.Namespace == repo.Namespace - })) - } - assert.False(t, slices.ContainsFunc(result, containsResultError)) - }) - } -} - -var containsResultError = func(r Result) bool { return r.Error != nil } - -func createOCIRepoCleanerType(objectsToKeep ...corev1.LocalObjectReference) CleanerType[*sourcev1.OCIRepositoryList] { - return CleanerType[*sourcev1.OCIRepositoryList]{ - EmptyList: func() *sourcev1.OCIRepositoryList { - return &sourcev1.OCIRepositoryList{} - }, - ObjectsToKeep: objectsToKeep, - } -} - -type preparationOptions int - -const ( - succeedAndDelete preparationOptions = iota - succeedAndWait - failWithError -) - -// prepareDeletionMock is a simple mock to demonstrate how you can execute preparation steps and control when an object is ready to be deleted -type prepareDeletionMock struct { - failed bool - execution int -} - -func (p *prepareDeletionMock) successfulDelete(_ context.Context, _ client.Object) (bool, error) { - p.failed = false - p.execution++ - return true, nil -} - -func (p *prepareDeletionMock) successfulAndWaitWithDelete(_ context.Context, _ client.Object) (bool, error) { - p.failed = false - p.execution++ - return false, nil -} - -func (p *prepareDeletionMock) failure(_ context.Context, _ client.Object) (bool, error) { - p.failed = true - p.execution++ - return false, errors.New("delete prep failed") -} - -func (r CleanerType[T]) withPreSteps(preSteps func(context.Context, client.Object) (bool, error)) CleanerType[T] { - r.PreDeletionSteps = preSteps - return r -} - -func createOCIRepo(name, namespace string, managedByEso bool) *sourcev1.OCIRepository { - cm := &sourcev1.OCIRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - } - if managedByEso { - cm.Labels = map[string]string{ - LabelManagedBy: serviceProvider, - } - } - return cm -} - -var _ ManagedCluster = &fakeCluster{} - -type fakeCluster struct { - managedCluster - fakeClient client.Client -} - -// GetClient implements [ManagedCluster]. -func (f *fakeCluster) GetClient() client.Client { - return f.fakeClient -} - -func createFakeCluster(client client.Client) ManagedCluster { - return &fakeCluster{ - fakeClient: client, - } -} - -func createFakeClient(clusterObjects []client.Object) client.Client { - scheme := runtime.NewScheme() - _ = clientgoscheme.AddToScheme(scheme) - _ = sourcev1.AddToScheme(scheme) - return fake.NewClientBuilder().WithObjects(clusterObjects...).WithScheme(scheme).Build() -} - -type listErrorClient struct { - client.Client -} - -func (l listErrorClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { - return errors.New("list failed") -} - -type deleteErrorClient struct { - client.Client - fakeOCIRepo sourcev1.OCIRepository -} - -func (d deleteErrorClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { - repoList := list.(*sourcev1.OCIRepositoryList) - repoList.Items = []sourcev1.OCIRepository{d.fakeOCIRepo} - return nil -} - -func (d deleteErrorClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { - return errors.New("delete failed") -} diff --git a/pkg/manager/secret.go b/pkg/manager/secret.go deleted file mode 100644 index f71f61e..0000000 --- a/pkg/manager/secret.go +++ /dev/null @@ -1,76 +0,0 @@ -package manager - -import ( - "context" - "fmt" - - ctrlutils "github.com/openmcp-project/controller-utils/pkg/controller" - openmcpresources "github.com/openmcp-project/controller-utils/pkg/resources" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// SecretCopyConfig holds the configuration for copying a secret. -type SecretCopyConfig struct { - // SourceClient is the client to read the source secret from. - SourceClient client.Client - // SourceName is the name of the source secret. - SourceName string - // SourceNamespace is the namespace of the source secret. - SourceNamespace string - // TargetNamespace is the namespace of the target secret. - TargetNamespace string - // TargetName is the name of the target secret. - TargetName string -} - -// ManagePullSecret syncs every image pull secret the to cluster -func ManagePullSecret(targetCluster ManagedCluster, config SecretCopyConfig) { - secret := NewManagedObject(&corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: config.TargetName, - Namespace: config.TargetNamespace, - }, - }, ManagedObjectContext{ - ReconcileFunc: func(ctx context.Context, o client.Object) error { - oSecret, ok := o.(*corev1.Secret) - if !ok { - return fmt.Errorf("expected *corev1.Secret, got %T", o) - } - sourceSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: config.SourceName, - Namespace: config.SourceNamespace, - }, - } - // retrieve source secret from platform cluster - if err := config.SourceClient.Get(ctx, client.ObjectKeyFromObject(sourceSecret), sourceSecret); err != nil { - return fmt.Errorf("manage pull secret - source secret not found: %w", err) - } - mutator := openmcpresources.NewSecretMutator(config.TargetName, config.TargetNamespace, sourceSecret.Data, corev1.SecretTypeDockerConfigJson) - return mutator.Mutate(oSecret) - }, - StatusFunc: SimpleStatus, - }) - targetCluster.AddObject(secret) -} - -// PrefixSecretName adds the prefix to the given secret name -// to prevent name collisions in namespaces where multiple service providers operate. -// If the resulting name exceeds 63 characters (K8s limit), it will be truncated -// and a hash suffix appended for uniqueness via ShortenToXCharacters. -func PrefixSecretName(secretName string, prefix string) (string, error) { - return ctrlutils.ShortenToXCharacters(fmt.Sprintf("%s%s", prefix, secretName), ctrlutils.K8sMaxNameLength) -} - -// NewSecretCleaner removes redundant pull secrets in the given target namespace -// by removing any secret labeled as managed by sp-external-secrets that is not in secretsToKeep. -func NewSecretCleaner(cluster ManagedCluster, serviceProvider string, namespace string, secretsToKeep []corev1.LocalObjectReference) OrphanCleaner { - return NewOrphanCleaner(cluster, serviceProvider, namespace, CleanerType[*corev1.SecretList]{ - EmptyList: func() *corev1.SecretList { - return &corev1.SecretList{} - }, - ObjectsToKeep: secretsToKeep, - }) -} diff --git a/pkg/manager/secret_test.go b/pkg/manager/secret_test.go deleted file mode 100644 index 304c13b..0000000 --- a/pkg/manager/secret_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package manager - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - secretName = "privateregcred" - sourceNamespace = "source" - targetNamespace = "target" -) - -func TestManagePullSecrets(t *testing.T) { - fakeCluster := CreateFakeCluster(t, "platform", &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: sourceNamespace, - }, - Data: map[string][]byte{ - "test": []byte("testdata"), - }, - // Deliberately Opaque — NOT DockerConfigJson — to verify the mutator always - // sets the target type to DockerConfigJson regardless of the source type. - Type: corev1.SecretTypeOpaque, - }, &corev1.Secret{ - // existing secret with the same source secret name in the target namespace - // this secret needs to be untouched by the service provider secret copy functionality - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: targetNamespace, - }, - Data: map[string][]byte{ - "existing-secret-data": []byte("must-not-be-altered"), - }, - Type: corev1.SecretTypeDockerConfigJson, - }) - tests := []struct { - name string // description of this test case - // Named input parameters for target function. - targetCluster ManagedCluster - config SecretCopyConfig - wantErr bool - }{ - { - name: "copy secret privateregcred from source to target namespace and adjust its name", - targetCluster: NewManagedCluster(fakeCluster, &rest.Config{}, sourceNamespace, PlatformCluster), - config: SecretCopyConfig{ - SourceClient: fakeCluster.Client(), - SourceName: secretName, - SourceNamespace: sourceNamespace, - TargetNamespace: targetNamespace, - TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), - }, - wantErr: false, - }, - { - name: "source secret not found — wrong name", - targetCluster: NewManagedCluster(fakeCluster, &rest.Config{}, sourceNamespace, PlatformCluster), - config: SecretCopyConfig{ - SourceClient: fakeCluster.Client(), - SourceName: "wrongSecretName", - SourceNamespace: sourceNamespace, - TargetNamespace: targetNamespace, - TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), - }, - wantErr: true, - }, - { - name: "source secret not found — wrong namespace", - targetCluster: NewManagedCluster(fakeCluster, &rest.Config{}, sourceNamespace, PlatformCluster), - config: SecretCopyConfig{ - SourceClient: fakeCluster.Client(), - SourceName: secretName, - SourceNamespace: "wrongSourceNamespace", - TargetNamespace: targetNamespace, - TargetName: fmt.Sprintf("%s%s", "secretNamePrefix", secretName), - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ManagePullSecret(tt.targetCluster, tt.config) - - mgr := NewManager("serviceprovider-test") - mgr.AddCluster(tt.targetCluster) - _, _, err := mgr.Apply(context.TODO()) - - if tt.wantErr { - require.Error(t, err) - assert.ErrorIs(t, err, ErrManagedResourcesFailed) - return - } - - require.NoError(t, err) - - sourceSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: tt.config.SourceName, - Namespace: tt.config.SourceNamespace, - }, - } - targetSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: tt.config.TargetName, - Namespace: tt.config.TargetNamespace, - }, - } - existingSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: tt.config.TargetNamespace, - }, - } - - require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(sourceSecret), sourceSecret)) - require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(targetSecret), targetSecret)) - require.NoError(t, tt.config.SourceClient.Get(context.TODO(), client.ObjectKeyFromObject(existingSecret), existingSecret)) - assert.Equal(t, sourceSecret.Data, targetSecret.Data) - assert.Equal(t, map[string][]byte{"existing-secret-data": []byte("must-not-be-altered")}, existingSecret.Data) - assert.Equal(t, corev1.SecretTypeDockerConfigJson, targetSecret.Type, "target secret should have the correct type") - }) - } -} - -func TestPrefixSecretName(t *testing.T) { - tests := []struct { - name string - input string - }{ - {"short name", "privateregcred"}, - {"long name truncated", strings.Repeat("a", 60)}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := PrefixSecretName(tt.input, "secretNamePrefix") - require.NoError(t, err) - assert.True(t, strings.HasPrefix(got, "secretNamePrefix")) - assert.LessOrEqual(t, len(got), 63) - }) - } -} diff --git a/pkg/manager/status.go b/pkg/manager/status.go deleted file mode 100644 index 50b07a3..0000000 --- a/pkg/manager/status.go +++ /dev/null @@ -1,42 +0,0 @@ -package manager - -import ( - "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - // StatusPhaseReady indicates that the resource is ready. - StatusPhaseReady = "Ready" - // StatusPhaseProgressing indicates that the resource is not ready and being created or updated. - StatusPhaseProgressing = "Progressing" - // StatusPhaseTerminating indicates that the resource is not ready and in deletion. - StatusPhaseTerminating = "Terminating" - // StatusPhaseUnknown indicates that no StatusFunc is defined. - StatusPhaseUnknown = "Unknown" -) - -// ManagedResourceStatus defines the status attributes of a ManagedObject. -type ManagedResourceStatus struct { - Phase string - Message string -} - -// SimpleStatus indicates whether the given object is in phase terminating, progressing, or ready. -func SimpleStatus(o client.Object) ManagedResourceStatus { - if !o.GetDeletionTimestamp().IsZero() { - return ManagedResourceStatus{ - Phase: StatusPhaseTerminating, - Message: "Resource is terminating.", - } - } - if o.GetUID() == "" { - return ManagedResourceStatus{ - Phase: StatusPhaseProgressing, - Message: "Resource has not been created yet.", - } - } - return ManagedResourceStatus{ - Phase: StatusPhaseReady, - Message: "Resource exists.", - } -} diff --git a/pkg/manager/utils_test.go b/pkg/manager/utils_test.go deleted file mode 100644 index b59b646..0000000 --- a/pkg/manager/utils_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package manager - -import ( - "context" - "testing" - - helmv2 "github.com/fluxcd/helm-controller/api/v2" - sourcev1 "github.com/fluxcd/source-controller/api/v1" - "github.com/openmcp-project/controller-utils/pkg/clusters" - "github.com/openmcp-project/opencontrolplane-runtime/testdata/api/v1alpha1" - clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -// CreateFakeCluster sets up a cluster with a fake client -func CreateFakeCluster(t *testing.T, id string, clusterObjects ...client.Object) *clusters.Cluster { - t.Helper() - scheme := runtime.NewScheme() - _ = clientgoscheme.AddToScheme(scheme) - _ = apiextv1.AddToScheme(scheme) - _ = v1alpha1.AddToScheme(scheme) - _ = clustersv1alpha1.AddToScheme(scheme) - _ = sourcev1.AddToScheme(scheme) - _ = helmv2.AddToScheme(scheme) - - // init cluster with objects - fakeClient := fake.NewClientBuilder().WithObjects(clusterObjects...).WithScheme(scheme).Build() - return clusters.NewTestClusterFromClient(id, fakeClient) -} - -// ExecApply sets up a manager for the provided clusters and invokes reconciliation of all managed objects. -// wantDone asserts the expected value of the done return value from Apply. -func ExecApply(t *testing.T, clusters []ManagedCluster, expectedManagedObjects int, wantDone bool) []ManagedResource { - t.Helper() - mgr := NewManager("serviceprovider-test") - for _, cluster := range clusters { - mgr.AddCluster(cluster) - } - managedResources, done, err := mgr.Apply(context.TODO()) - require.NoError(t, err) - assert.Equal(t, wantDone, done, "unexpected done value from Apply") - return assertResult(t, managedResources, expectedManagedObjects) -} - -func assertResult(t *testing.T, managedResources []ManagedResource, expectedManagedObjects int) []ManagedResource { - t.Helper() - assert.Len(t, managedResources, expectedManagedObjects, "expected %d managed object(s), got %d managed object(s)") - return managedResources -} - -func TestApply_Done(t *testing.T) { - const ns = "default" - - t.Run("done=false when StatusFunc returns Progressing", func(t *testing.T) { - cluster := CreateFakeCluster(t, "platform") - mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) - obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns}} - mo := NewManagedObject(obj, ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, - StatusFunc: func(_ client.Object) ManagedResourceStatus { - return ManagedResourceStatus{Phase: StatusPhaseProgressing, Message: "always progressing"} - }, - }) - mc.AddObject(mo) - mgr := NewManager("test-sp") - mgr.AddCluster(mc) - - _, done, err := mgr.Apply(context.TODO()) - require.NoError(t, err) - assert.False(t, done, "expected done=false when resource reports Progressing") - }) - - t.Run("done=true when all resources report Ready via SimpleStatus", func(t *testing.T) { - // Pre-populate the fake store with the object including a UID. - // CreateOrUpdate will call Get (succeeds, UID propagated to obj), then Update. - // SimpleStatus checks GetUID() != "" → returns Ready → done=true. - preExisting := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns, UID: "test-uid-1234"}, - } - cluster := CreateFakeCluster(t, "platform", preExisting) - mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) - obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns}} - mo := NewManagedObject(obj, ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, - StatusFunc: SimpleStatus, - }) - mc.AddObject(mo) - mgr := NewManager("test-sp") - mgr.AddCluster(mc) - - _, done, err := mgr.Apply(context.TODO()) - require.NoError(t, err) - assert.True(t, done, "expected done=true when all resources report Ready") - }) - - t.Run("done=false when resource is Terminating via SimpleStatus", func(t *testing.T) { - // DeletionTimestamp must be on the server-side object (preExisting), not on the - // local obj. controllerutil.CreateOrUpdate calls cl.Get which overwrites obj with - // the server state, so any DeletionTimestamp set only on obj is lost before - // SimpleStatus is called. A finalizer is required to keep the object in the store - // while it has a DeletionTimestamp. - now := metav1.Now() - preExisting := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: ns, - UID: "test-uid-1234", - DeletionTimestamp: &now, - Finalizers: []string{"test-finalizer"}, - }, - } - cluster := CreateFakeCluster(t, "platform", preExisting) - mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) - obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: ns}} - mo := NewManagedObject(obj, ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, - StatusFunc: SimpleStatus, - }) - mc.AddObject(mo) - mgr := NewManager("test-sp") - mgr.AddCluster(mc) - - resources, done, err := mgr.Apply(context.TODO()) - require.NoError(t, err) - require.Len(t, resources, 1) - assert.False(t, done, "expected done=false when resource is Terminating") - assert.Equal(t, StatusPhaseTerminating, resources[0].Status.Phase) - }) -} - -func TestDelete_Done(t *testing.T) { - const ns = "default" - const secretName = "managed-secret" - - existingSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}, - } - cluster := CreateFakeCluster(t, "platform", existingSecret) - mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) - - obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}} - mo := NewManagedObject(obj, ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, - StatusFunc: SimpleStatus, - DeletionPolicy: Delete, - }) - mc.AddObject(mo) - mgr := NewManager("test-sp") - mgr.AddCluster(mc) - - // First Delete: fake client removes the object (returns nil, not NotFound). - // The manager receives DeletionRequested → done=false. - _, done, err := mgr.Delete(context.TODO()) - require.NoError(t, err) - assert.False(t, done, "expected done=false: deletion was requested but object not yet confirmed gone") - - // Second Delete: object is already gone, fake client returns NotFound. - // The manager receives Deleted → done=true. - _, done, err = mgr.Delete(context.TODO()) - require.NoError(t, err) - assert.True(t, done, "expected done=true: object confirmed deleted (NotFound)") -} - -func TestDelete_OrphanPolicy(t *testing.T) { - const ns = "default" - const secretName = "orphaned-secret" - - existingSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}, - } - cluster := CreateFakeCluster(t, "platform", existingSecret) - mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) - - obj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns}} - mo := NewManagedObject(obj, ManagedObjectContext{ - ReconcileFunc: func(_ context.Context, _ client.Object) error { return nil }, - StatusFunc: SimpleStatus, - DeletionPolicy: Orphan, - }) - mc.AddObject(mo) - mgr := NewManager("test-sp") - mgr.AddCluster(mc) - - stored := &corev1.Secret{} - require.NoError(t, cluster.Client().Get( - context.TODO(), - client.ObjectKey{Name: secretName, Namespace: ns}, - stored, - ), "Secret was created") - - // Orphaned objects are considered done immediately — cl.Delete is never called. - _, done, err := mgr.Delete(context.TODO()) - require.NoError(t, err) - assert.True(t, done, "expected done=true: orphaned object counts as deleted") - - // The object must still be in the store — it was left alive intentionally. - require.NoError(t, cluster.Client().Get( - context.TODO(), - client.ObjectKey{Name: secretName, Namespace: ns}, - stored, - ), "orphaned object should still exist in the store") -} - -func TestDelete_DependsOn(t *testing.T) { - const ns = "default" - // secretA is a dependency; secretB depends on A. - // A cannot be deleted while B still exists. - const nameA, nameB = "secret-a", "secret-b" - - cluster := CreateFakeCluster(t, "platform", - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameA, Namespace: ns}}, - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameB, Namespace: ns}}, - ) - mc := NewManagedCluster(cluster, &rest.Config{}, ns, PlatformCluster) - - objA := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameA, Namespace: ns}} - moA := NewManagedObject(objA, ManagedObjectContext{ - ReconcileFunc: NoOp, StatusFunc: SimpleStatus, DeletionPolicy: Delete, - }) - - objB := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: nameB, Namespace: ns}} - moB := NewManagedObject(objB, ManagedObjectContext{ - ReconcileFunc: NoOp, - StatusFunc: SimpleStatus, - DeletionPolicy: Delete, - // B depends on A: the framework blocks A's deletion until B is gone. - DependsOn: []ManagedObject{moA}, - }) - - mc.AddObject(moA) - mc.AddObject(moB) - mgr := NewManager("test-sp") - mgr.AddCluster(mc) - - // Iteration 1: A is blocked (B still exists); B deletion is requested. - _, done, err := mgr.Delete(context.TODO()) - assert.False(t, done, "iteration 1: done should be false — A is blocked") - require.Error(t, err, "iteration 1: expected error — A cannot be deleted while B exists") - assert.ErrorIs(t, err, ErrManagedResourcesFailed) - - // Iteration 2: B is confirmed gone (fake client removes immediately on Delete); - // A deletion is now requested. done is still false — A is DeletionRequested, not Deleted. - _, done, err = mgr.Delete(context.TODO()) - require.NoError(t, err) - assert.False(t, done, "iteration 2: done should be false — A deletion pending") - - // Iteration 3: both A and B return NotFound → done=true. - _, done, err = mgr.Delete(context.TODO()) - require.NoError(t, err) - assert.True(t, done, "iteration 3: expected done=true — all objects confirmed deleted") -} From ce8efb7dd5aa642da2a20a64d40fa1b63fc40750 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 4 Sep 2026 11:09:14 +0200 Subject: [PATCH 20/20] chore: remove unused dependencies Signed-off-by: Jakob Bergmeier --- go.mod | 9 --------- go.sum | 18 ------------------ 2 files changed, 27 deletions(-) diff --git a/go.mod b/go.mod index 7e701ca..b4bb034 100644 --- a/go.mod +++ b/go.mod @@ -6,18 +6,12 @@ replace github.com/openmcp-project/controller-utils/api => ./api require ( github.com/evanphx/json-patch/v5 v5.9.11 - github.com/fluxcd/helm-controller/api v1.6.3 - github.com/fluxcd/pkg/apis/meta v1.31.0 - github.com/fluxcd/pkg/runtime v0.111.0 - github.com/fluxcd/source-controller/api v1.9.3 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 github.com/openmcp-project/controller-utils/api v0.31.0 - github.com/openmcp-project/opencontrolplane-runtime v1.2.0 - github.com/openmcp-project/openmcp-operator/api v1.3.0 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 @@ -40,7 +34,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect - github.com/fluxcd/pkg/apis/acl v0.10.0 // indirect github.com/fluxcd/pkg/apis/kustomize v1.20.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect @@ -67,8 +60,6 @@ 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/openmcp-project/openmcp-operator/lib v1.3.0 // 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.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/go.sum b/go.sum index 7ce16bd..71df84c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -16,18 +14,8 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH 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.6.3 h1:8EvQASKKFDEj8Ub3xhMSjyNbGTEjNqlHZYm5WhRxJvg= -github.com/fluxcd/helm-controller/api v1.6.3/go.mod h1:CaI5bHedusLcXYj1+pkd4RkSE8TtiEHI3ReHNsUySbg= -github.com/fluxcd/pkg/apis/acl v0.10.0 h1:KPfAmELNvtvaz8wixnm/MYXqa+MJf7ntVVMUU93Aenk= -github.com/fluxcd/pkg/apis/acl v0.10.0/go.mod h1:a87i2A7AlFO5N2J8CxtzaUCCDmuLLWOHwkKu3eJF5fY= github.com/fluxcd/pkg/apis/kustomize v1.20.0 h1:Aur2337TwSYGUffDQVlawOR3SJfRvxH7ikEKD6cJSxs= github.com/fluxcd/pkg/apis/kustomize v1.20.0/go.mod h1:9FUs77fd/Rh5/mDgZbGBUCL0UqmXiGj8rYywG3T3x+s= -github.com/fluxcd/pkg/apis/meta v1.31.0 h1:5niQvTirK0wTE0TfRjnUSdmu6GTSbAFzrdnovtZ9rJ8= -github.com/fluxcd/pkg/apis/meta v1.31.0/go.mod h1:Gx+YRq26a+mTbCjotSXC7/6kSSyo0zXQ8JnsEXf2vVk= -github.com/fluxcd/pkg/runtime v0.111.0 h1:nbmFuYstyN2a1SDW0SZU5dG9+9x+yDP4A39kKQKjj2A= -github.com/fluxcd/pkg/runtime v0.111.0/go.mod h1:YUljTAXVaeWG+GLnXNZopFFMZstUoTxJs7+vl91OV58= -github.com/fluxcd/source-controller/api v1.9.3 h1:Z5Id6iPV+n1OY/14dpC18PaHRATzAyZkjAzDNYxSl4E= -github.com/fluxcd/source-controller/api v1.9.3/go.mod h1:qO98L20gw0MvLpSPapJKypyxHP7PejmJN5KxWXxDjCA= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= @@ -119,12 +107,6 @@ github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= -github.com/openmcp-project/opencontrolplane-runtime v1.2.0 h1:TYqIwEz93mZmA6mp7LSXXy0VbU/tND9vUTVo/IpzS90= -github.com/openmcp-project/opencontrolplane-runtime v1.2.0/go.mod h1:TA7vVVm8NcT6tIchu4mFeHSdCCm08lIMzFT1mF4HkBA= -github.com/openmcp-project/openmcp-operator/api v1.3.0 h1:RgetVDqslJPD7/nb65AbqWYtwM1qJf0ePgf0uJiEcQQ= -github.com/openmcp-project/openmcp-operator/api v1.3.0/go.mod h1:GMCkaGUND5Qu3zTNjpYtR44her8gcOksHkrJu5isL6I= -github.com/openmcp-project/openmcp-operator/lib v1.3.0 h1:Icx40/Qx/85+E8vWTMi1jW1WrX/MFN+FaZTDt7JWcRU= -github.com/openmcp-project/openmcp-operator/lib v1.3.0/go.mod h1:+Ptr+38neYaoGzEdY5zhR3uhfKqNZ2J9tyOcc15S7EY= 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=