diff --git a/.github/workflows/ci-e2e-test-nightly.yaml b/.github/workflows/ci-e2e-test-nightly.yaml index 043718a5d..4cbaaa73e 100644 --- a/.github/workflows/ci-e2e-test-nightly.yaml +++ b/.github/workflows/ci-e2e-test-nightly.yaml @@ -2,7 +2,7 @@ name: Nightly E2E Workflow on: workflow_dispatch: schedule: - - cron: "0 1-6 * * *" # run hourly from 1am to 6am + - cron: "0 1-5/2 * * *" # run every 2 hours from 1am to 5am jobs: init: diff --git a/cmd/greenhouse/controllers.go b/cmd/greenhouse/controllers.go index ba068bed9..341ede37b 100644 --- a/cmd/greenhouse/controllers.go +++ b/cmd/greenhouse/controllers.go @@ -39,7 +39,7 @@ var knownControllers = map[string]func(controllerName string, mgr ctrl.Manager) "catalog": startCatalogReconciler, "pluginDefinition": startPluginDefinitionReconciler, - "clusterPluginDefinition": (&plugindefinitioncontroller.ClusterPluginDefinitionReconciler{}).SetupWithManager, + "clusterPluginDefinition": startClusterPluginDefinitionReconciler, // Cluster controllers "bootstrap": (&clustercontrollers.BootstrapReconciler{}).SetupWithManager, @@ -97,6 +97,12 @@ func startPluginDefinitionReconciler(name string, mgr ctrl.Manager) error { }).SetupWithManager(name, mgr) } +func startClusterPluginDefinitionReconciler(name string, mgr ctrl.Manager) error { + return (&plugindefinitioncontroller.ClusterPluginDefinitionReconciler{ + OCIMirroringEnabled: featureFlags.IsOCIMirroringEnabled(), + }).SetupWithManager(name, mgr) +} + func startClusterReconciler(name string, mgr ctrl.Manager) error { if renewRemoteClusterBearerTokenAfter > remoteClusterBearerTokenValidity { setupLog.Info("WARN: remoteClusterBearerTokenValidity is less than renewRemoteClusterBearerTokenAfter") diff --git a/e2e/clusterplugindefinition/e2e_test.go b/e2e/clusterplugindefinition/e2e_test.go new file mode 100644 index 000000000..736cc90c5 --- /dev/null +++ b/e2e/clusterplugindefinition/e2e_test.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build clusterplugindefinitionE2E + +package clusterplugindefinition + +import ( + "context" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/cloudoperators/greenhouse/e2e/clusterplugindefinition/scenarios" + "github.com/cloudoperators/greenhouse/e2e/shared" + "github.com/cloudoperators/greenhouse/internal/clientutil" +) + +var ( + env *shared.TestEnv + ctx context.Context + adminClient client.Client + testStartTime time.Time +) + +func TestE2e(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ClusterPluginDefinition E2E Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx = context.Background() + env = shared.NewExecutionEnv() + + var err error + adminClient, err = clientutil.NewK8sClientFromRestClientGetter(env.AdminRestClientGetter) + Expect(err).ToNot(HaveOccurred(), "there should be no error creating the admin client") + + env = env.WithOrganization(ctx, adminClient, "./testdata/organization.yaml") + shared.SetupOCIMirroringForOrg(ctx, adminClient, env.TestNamespace) + + testStartTime = time.Now().UTC() +}) + +var _ = AfterSuite(func() { + env.GenerateGreenhouseControllerLogs(ctx, testStartTime) + env.GenerateFluxControllerLogs(ctx, "helm-controller", testStartTime) + shared.TeardownOCIMirroringForOrg(ctx, adminClient, env.TestNamespace) +}) + +var _ = Describe("ClusterPluginDefinition E2E", Ordered, func() { + It("should replicate helm chart to registry mirror", func() { + scenarios.ClusterPDChartReplication(ctx, adminClient) + }) + + It("should fail chart replication for non-existent chart version", func() { + scenarios.ClusterPDChartReplicationFailure(ctx, adminClient) + }) +}) diff --git a/e2e/clusterplugindefinition/scenarios/cluster_pd_chart_replication.go b/e2e/clusterplugindefinition/scenarios/cluster_pd_chart_replication.go new file mode 100644 index 000000000..b73bdc4c4 --- /dev/null +++ b/e2e/clusterplugindefinition/scenarios/cluster_pd_chart_replication.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" + "github.com/cloudoperators/greenhouse/e2e/shared" + "github.com/cloudoperators/greenhouse/internal/test" + "github.com/cloudoperators/greenhouse/pkg/lifecycle" +) + +// ClusterPDChartReplication verifies that the ClusterPluginDefinition controller replicates a Helm chart OCI artifact via the registry mirror configured on the greenhouse Organization. +func ClusterPDChartReplication(ctx context.Context, adminClient client.Client) { + cpd := test.NewClusterPluginDefinition(ctx, "podinfo-cluster-chart-replication", + test.WithVersion("6.7.1"), + test.WithHelmChart(&greenhousev1alpha1.HelmChartReference{ + Name: "podinfo", + Repository: "oci://registry:5000/greenhouse-ghcr-io-mirror/stefanprodan/charts", + Version: "6.7.1", + }), + ) + + By("creating ClusterPluginDefinition with chart URL at registry mirror") + err := adminClient.Create(ctx, cpd) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred()) + DeferCleanup(func() { test.EventuallyDeleted(ctx, adminClient, cpd) }) + + By("verifying OCIReplicationReady condition becomes True with OCIReplicationSucceeded") + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + cond := cpd.Status.StatusConditions.GetConditionByType(greenhousev1alpha1.OCIReplicationReadyCondition) + g.Expect(cond).NotTo(BeNil(), "OCIReplicationReady condition should be set") + g.Expect(cond.IsTrue()).To(BeTrue(), "chart replication should succeed") + g.Expect(cond.Reason).To(Equal(greenhousev1alpha1.OCIReplicationSucceededReason)) + }).WithTimeout(shared.OCIReplicationTimeout).Should(Succeed(), "registry mirror should pull-through the podinfo chart from ghcr.io") + + By("verifying LastSyncedArtifact is populated") + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + artifact := cpd.GetLastSyncedArtifact() + g.Expect(artifact).NotTo(BeNil(), "LastSyncedArtifact should be set after replication") + g.Expect(artifact.Registry).To(Equal("registry:5000")) + g.Expect(artifact.ChartName).To(Equal("greenhouse-ghcr-io-mirror/stefanprodan/charts/podinfo")) + g.Expect(artifact.Version).To(Equal("6.7.1")) + g.Expect(artifact.Digest).NotTo(BeEmpty(), "digest should be populated after successful replication") + g.Expect(artifact.ReplicationStatus).To(Equal(greenhousev1alpha1.ReplicationStatusReplicated)) + }).WithTimeout(shared.OCIReplicationTimeout).Should(Succeed()) + + By("verifying HelmChartReady condition becomes True") + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + cond := cpd.Status.StatusConditions.GetConditionByType(greenhousev1alpha1.HelmChartReadyCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.IsTrue()).To(BeTrue(), "Flux should fetch the chart from the registry mirror over HTTP") + }).WithTimeout(shared.OCIReplicationTimeout).Should(Succeed()) + + By("verifying ClusterPluginDefinition is Ready") + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + g.Expect(cpd.Status.IsReadyTrue()).To(BeTrue()) + }).WithTimeout(shared.OCIReplicationTimeout).Should(Succeed()) + + By("verifying chart replication is idempotent on re-reconciliation") + Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + originalDigest := cpd.GetLastSyncedArtifact().Digest + + ann := cpd.GetAnnotations() + if ann == nil { + ann = make(map[string]string) + } + ann[lifecycle.ReconcileAnnotation] = time.Now().UTC().Format(time.RFC3339Nano) + cpd.SetAnnotations(ann) + Expect(adminClient.Update(ctx, cpd)).To(Succeed()) + + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + cond := cpd.Status.StatusConditions.GetConditionByType(greenhousev1alpha1.OCIReplicationReadyCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.IsTrue()).To(BeTrue()) + artifact := cpd.GetLastSyncedArtifact() + g.Expect(artifact).NotTo(BeNil()) + g.Expect(artifact.Digest).To(Equal(originalDigest), "re-reconciliation must not change the replicated artifact digest") + }).WithTimeout(shared.OCIReplicationTimeout).Should(Succeed()) +} + +// ClusterPDChartReplicationFailure verifies that the ClusterPluginDefinition controller sets OCIReplicationFailed for a non-existent chart version. +func ClusterPDChartReplicationFailure(ctx context.Context, adminClient client.Client) { + cpd := test.NewClusterPluginDefinition(ctx, "podinfo-cluster-chart-failure", + test.WithVersion("99.99.99"), + test.WithHelmChart(&greenhousev1alpha1.HelmChartReference{ + Name: "podinfo", + Repository: "oci://registry:5000/greenhouse-ghcr-io-mirror/stefanprodan/charts", + Version: "99.99.99", + }), + ) + + By("creating ClusterPluginDefinition with non-existent chart version") + err := adminClient.Create(ctx, cpd) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred()) + DeferCleanup(func() { test.EventuallyDeleted(ctx, adminClient, cpd) }) + + By("verifying OCIReplicationReady condition becomes False with OCIReplicationFailed reason") + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + cond := cpd.Status.StatusConditions.GetConditionByType(greenhousev1alpha1.OCIReplicationReadyCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.IsTrue()).To(BeFalse()) + g.Expect(cond.Reason).To(Equal(greenhousev1alpha1.OCIReplicationFailedReason)) + }).WithTimeout(shared.OCIReplicationTimeout).Should(Succeed(), "registry mirror pull-through should fail for a non-existent chart version") + + By("verifying ClusterPluginDefinition is not Ready") + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cpd), cpd)).To(Succeed()) + g.Expect(cpd.Status.IsReadyTrue()).To(BeFalse()) + }).WithTimeout(shared.OCIReplicationTimeout).Should(Succeed()) +} diff --git a/e2e/clusterplugindefinition/testdata/organization.yaml b/e2e/clusterplugindefinition/testdata/organization.yaml new file mode 100644 index 000000000..97444072b --- /dev/null +++ b/e2e/clusterplugindefinition/testdata/organization.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: greenhouse.sap/v1alpha1 +kind: Organization +metadata: + name: greenhouse +spec: + description: greenhouse organization + displayName: Greenhouse + mappedOrgAdminIdPGroup: GREENHOUSE_ORG_ADMIN diff --git a/e2e/plugin/e2e_test.go b/e2e/plugin/e2e_test.go index e06f501de..923a846c8 100644 --- a/e2e/plugin/e2e_test.go +++ b/e2e/plugin/e2e_test.go @@ -56,6 +56,7 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred(), "there should be no error creating the admin client") remoteClient, err = clientutil.NewK8sClientFromRestClientGetter(env.RemoteRestClientGetter) Expect(err).ToNot(HaveOccurred(), "there should be no error creating the remote client") + env = env.WithOrganization(ctx, adminClient, "./testdata/greenhouse_organization.yaml") env = env.WithOrganization(ctx, adminClient, "./testdata/organization.yaml") team = test.NewTeam(ctx, "test-plugin-e2e-team", env.TestNamespace, test.WithTeamLabel(greenhouseapis.LabelKeySupportGroup, "true")) err = adminClient.Create(ctx, team) diff --git a/e2e/plugin/testdata/greenhouse_organization.yaml b/e2e/plugin/testdata/greenhouse_organization.yaml new file mode 100644 index 000000000..108954d4e --- /dev/null +++ b/e2e/plugin/testdata/greenhouse_organization.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +# Cluster Scoped Resource +apiVersion: greenhouse.sap/v1alpha1 +kind: Organization +metadata: + name: greenhouse +spec: + description: greenhouse organization for cluster-scoped plugin definitions + displayName: Greenhouse + mappedOrgAdminIdPGroup: GREENHOUSE_ORG_ADMIN diff --git a/internal/controller/plugindefinition/cluster_plugindefinition_controller.go b/internal/controller/plugindefinition/cluster_plugindefinition_controller.go index c9b1c8788..853a4b463 100644 --- a/internal/controller/plugindefinition/cluster_plugindefinition_controller.go +++ b/internal/controller/plugindefinition/cluster_plugindefinition_controller.go @@ -24,8 +24,9 @@ import ( type ClusterPluginDefinitionReconciler struct { client.Client - Scheme *runtime.Scheme - recorder events.EventRecorder + Scheme *runtime.Scheme + recorder events.EventRecorder + OCIMirroringEnabled bool } func (r *ClusterPluginDefinitionReconciler) SetupWithManager(name string, mgr ctrl.Manager) error { @@ -46,6 +47,9 @@ func (r *ClusterPluginDefinitionReconciler) SetupWithManager(name string, mgr ct // +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=helmcharts/status,verbs=get;update;patch // +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=helmcharts/finalizers,verbs=get;create;update;patch;delete // +kubebuilder:rbac:groups="events.k8s.io",resources=events,verbs=create;patch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch +// +kubebuilder:rbac:groups=greenhouse.sap,resources=organizations,verbs=get // +kubebuilder:rbac:groups=greenhouse.sap,resources=clusterplugindefinitions,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=greenhouse.sap,resources=clusterplugindefinitions/status,verbs=get;update;patch // +kubebuilder:rbac:groups=greenhouse.sap,resources=clusterplugindefinitions/finalizers,verbs=get;create;update;patch;delete @@ -81,7 +85,7 @@ func (r *ClusterPluginDefinitionReconciler) EnsureCreated(ctx context.Context, o recorder: r.recorder, pluginDef: clusterDef, namespaceName: flux.HelmRepositoryDefaultNamespace, - ociMirroringEnabled: false, + ociMirroringEnabled: r.OCIMirroringEnabled, } helmRepo, err := h.createUpdateHelmRepository(ctx) @@ -89,18 +93,16 @@ func (r *ClusterPluginDefinitionReconciler) EnsureCreated(ctx context.Context, o return ctrl.Result{}, lifecycle.Failed, err } + if replicationErr := h.ensureChartReplication(ctx); replicationErr != nil { + return ctrl.Result{}, lifecycle.Failed, replicationErr + } + helmChart, err := h.createUpdateHelmChart(ctx, helmRepo) if err != nil { return ctrl.Result{}, lifecycle.Failed, err } h.setHelmChartReadyCondition(ctx, helmChart) - // OCI replication for ClusterPluginDefinitions is not yet supported. - clusterDef.SetCondition(greenhousemetav1alpha1.TrueCondition( - greenhousev1alpha1.OCIReplicationReadyCondition, - greenhousev1alpha1.OCIReplicationNotConfiguredReason, - "OCI replication for ClusterPluginDefinitions is not yet supported")) - return ctrl.Result{}, lifecycle.Success, nil }