From e4d84cef21262643162408c20295d10060f08cec Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Tue, 22 Sep 2026 10:28:19 +0100 Subject: [PATCH 1/7] refactor(shared): extract object-management metadata Phase 1/7 of OPRUN-4738: remove object-controller's dependency on operator-controller's internal metadata package. Move the label constants into internal/shared/labels and update imports in both controllers and their tests. Generalize the object-controller comments to describe owners rather than ClusterExtensions. Review focus: the package move and mechanical import changes. Metadata keys and reconciliation behavior are unchanged. No generated files. Refs: OPRUN-4738 Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- .../controllers/clusterobjectset_controller.go | 9 ++++----- .../clusterobjectset_controller_internal_test.go | 2 +- .../controllers/clusterobjectset_controller_test.go | 2 +- .../object-controller/controllers/resolve_ref_test.go | 2 +- internal/operator-controller/applier/boxcutter.go | 2 +- internal/operator-controller/applier/boxcutter_test.go | 2 +- internal/operator-controller/applier/secretpacker.go | 2 +- .../operator-controller/applier/secretpacker_test.go | 2 +- .../controllers/boxcutter_reconcile_steps.go | 2 +- .../controllers/clusterextension_controller.go | 2 +- .../controllers/clusterextension_controller_test.go | 2 +- .../controllers/clusterextension_reconcile_steps.go | 2 +- .../{operator-controller => shared}/labels/labels.go | 1 + 13 files changed, 16 insertions(+), 16 deletions(-) rename internal/{operator-controller => shared}/labels/labels.go (96%) diff --git a/internal/object-controller/controllers/clusterobjectset_controller.go b/internal/object-controller/controllers/clusterobjectset_controller.go index e42e3c6144..071a48bc60 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller.go +++ b/internal/object-controller/controllers/clusterobjectset_controller.go @@ -42,15 +42,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" ) const ( clusterObjectSetTeardownFinalizer = "olm.operatorframework.io/teardown" ) -// ClusterObjectSetReconciler actions individual snapshots of ClusterExtensions, -// as part of the boxcutter integration. +// ClusterObjectSetReconciler manages the Kubernetes objects in a ClusterObjectSet. type ClusterObjectSetReconciler struct { Client client.Client RevisionEngineFactory RevisionEngineFactory @@ -417,14 +416,14 @@ func (c *ClusterObjectSetReconciler) removeFinalizer(ctx context.Context, obj cl return nil } -// listSiblingRevisions returns all active revisions belonging to the same ClusterExtension, excluding the current one. +// listSiblingRevisions returns all active revisions belonging to the same owner, excluding the current one. // This includes both lower and higher revision numbers, enabling boxcutter to properly classify // sibling owners and avoid reporting false collisions during revision handover. func (c *ClusterObjectSetReconciler) listSiblingRevisions(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]*ocv1.ClusterObjectSet, error) { return c.listOtherActiveRevisions(ctx, cos, func(*ocv1.ClusterObjectSet) bool { return true }) } -// listPreviousRevisions returns active revisions belonging to the same ClusterExtension with lower revision numbers. +// listPreviousRevisions returns active revisions belonging to the same owner with lower revision numbers. func (c *ClusterObjectSetReconciler) listPreviousRevisions(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]*ocv1.ClusterObjectSet, error) { return c.listOtherActiveRevisions(ctx, cos, func(r *ocv1.ClusterObjectSet) bool { return r.Spec.Revision < cos.Spec.Revision diff --git a/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go b/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go index 69d18ebbc7..4d67856176 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go +++ b/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go @@ -23,7 +23,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" ) func Test_ClusterObjectSetReconciler_listSiblingRevisions(t *testing.T) { diff --git a/internal/object-controller/controllers/clusterobjectset_controller_test.go b/internal/object-controller/controllers/clusterobjectset_controller_test.go index e70fbb1c88..e07124f309 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller_test.go +++ b/internal/object-controller/controllers/clusterobjectset_controller_test.go @@ -29,7 +29,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/object-controller/controllers" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" mockcontrollers "github.com/operator-framework/operator-controller/internal/testutil/mock/controllers" mockmachinery "github.com/operator-framework/operator-controller/internal/testutil/mock/machinery" ) diff --git a/internal/object-controller/controllers/resolve_ref_test.go b/internal/object-controller/controllers/resolve_ref_test.go index 56f7ba5708..ce155a66e3 100644 --- a/internal/object-controller/controllers/resolve_ref_test.go +++ b/internal/object-controller/controllers/resolve_ref_test.go @@ -23,7 +23,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/object-controller/controllers" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" ) func newSchemeWithCoreV1(t *testing.T) *apimachineryruntime.Scheme { diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index 6b2a134d92..28d33285aa 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -33,8 +33,8 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" + "github.com/operator-framework/operator-controller/internal/shared/labels" "github.com/operator-framework/operator-controller/internal/shared/util/cache" ) diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go index 4c435a19af..da9b1f863d 100644 --- a/internal/operator-controller/applier/boxcutter_test.go +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -31,7 +31,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" bundlecsv "github.com/operator-framework/operator-controller/internal/testing/bundle/csv" bundlefs "github.com/operator-framework/operator-controller/internal/testing/bundle/fs" mockapplier "github.com/operator-framework/operator-controller/internal/testutil/mock/applier" diff --git a/internal/operator-controller/applier/secretpacker.go b/internal/operator-controller/applier/secretpacker.go index 45ae9fdb04..4eaf6bc68a 100644 --- a/internal/operator-controller/applier/secretpacker.go +++ b/internal/operator-controller/applier/secretpacker.go @@ -14,7 +14,7 @@ import ( "k8s.io/utils/ptr" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" ) const ( diff --git a/internal/operator-controller/applier/secretpacker_test.go b/internal/operator-controller/applier/secretpacker_test.go index 9603cbe873..e80ea3e36a 100644 --- a/internal/operator-controller/applier/secretpacker_test.go +++ b/internal/operator-controller/applier/secretpacker_test.go @@ -14,7 +14,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" ) func TestSecretPacker_Pack(t *testing.T) { diff --git a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go index f340520fc7..4b94d9ca00 100644 --- a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go +++ b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go @@ -31,7 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" ) type BoxcutterRevisionStatesGetter struct { diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go index 0fa5fdee2c..4bbb48506c 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -50,7 +50,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" + "github.com/operator-framework/operator-controller/internal/shared/labels" errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error" k8sutil "github.com/operator-framework/operator-controller/internal/shared/util/k8s" ) diff --git a/internal/operator-controller/controllers/clusterextension_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go index 5f721adb8e..8275b4f828 100644 --- a/internal/operator-controller/controllers/clusterextension_controller_test.go +++ b/internal/operator-controller/controllers/clusterextension_controller_test.go @@ -36,8 +36,8 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/operator-controller/finalizers" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" "github.com/operator-framework/operator-controller/internal/operator-controller/resolve" + "github.com/operator-framework/operator-controller/internal/shared/labels" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" mockcontrollers "github.com/operator-framework/operator-controller/internal/testutil/mock/controllers" mockhelmclient "github.com/operator-framework/operator-controller/internal/testutil/mock/helmclient" diff --git a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go index d6ed51e994..9f6dea4ea7 100644 --- a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go +++ b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go @@ -32,8 +32,8 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil" - "github.com/operator-framework/operator-controller/internal/operator-controller/labels" "github.com/operator-framework/operator-controller/internal/operator-controller/resolve" + "github.com/operator-framework/operator-controller/internal/shared/labels" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" ) diff --git a/internal/operator-controller/labels/labels.go b/internal/shared/labels/labels.go similarity index 96% rename from internal/operator-controller/labels/labels.go rename to internal/shared/labels/labels.go index 3a0cdaf46a..d3d9a2d89d 100644 --- a/internal/operator-controller/labels/labels.go +++ b/internal/shared/labels/labels.go @@ -1,3 +1,4 @@ +// Package labels defines metadata shared by content resolution and object management. package labels import corev1 "k8s.io/api/core/v1" From b475b1d08c3f74d1c2df968598bf7dcc752a89fe Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Thu, 24 Sep 2026 17:05:05 +0100 Subject: [PATCH 2/7] feat(object-controller): add standalone ClusterObjectSet manager Add a dedicated manager with a ClusterObjectSet-only scheme and regression coverage. Reuse referenced Secret reads within each reconciliation, retry transient read failures, and show numeric revision status when no bundle version is present. Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- cmd/object-controller/main.go | 211 ++++++++++++++++++ cmd/object-controller/main_test.go | 164 ++++++++++++++ .../clusterobjectset_controller.go | 33 +-- ...usterobjectset_controller_internal_test.go | 8 +- .../controllers/referenced_secrets.go | 42 ++++ .../controllers/referenced_secrets_test.go | 144 ++++++++++++ internal/object-controller/scheme/scheme.go | 21 ++ 7 files changed, 606 insertions(+), 17 deletions(-) create mode 100644 cmd/object-controller/main.go create mode 100644 cmd/object-controller/main_test.go create mode 100644 internal/object-controller/controllers/referenced_secrets.go create mode 100644 internal/object-controller/controllers/referenced_secrets_test.go create mode 100644 internal/object-controller/scheme/scheme.go diff --git a/cmd/object-controller/main.go b/cmd/object-controller/main.go new file mode 100644 index 0000000000..a6ceec21ee --- /dev/null +++ b/cmd/object-controller/main.go @@ -0,0 +1,211 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + "k8s.io/utils/ptr" + "pkg.package-operator.run/boxcutter/managedcache" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/object-controller/controllers" + "github.com/operator-framework/operator-controller/internal/object-controller/scheme" + cacheutil "github.com/operator-framework/operator-controller/internal/shared/util/cache" + "github.com/operator-framework/operator-controller/internal/shared/util/tlsprofiles" + "github.com/operator-framework/operator-controller/internal/shared/version" +) + +type config struct { + metricsAddr string + pprofAddr string + probeAddr string + certFile string + keyFile string + enableLeaderElection bool +} + +func newCommand() *cobra.Command { + cfg := &config{} + cmd := &cobra.Command{ + Use: "object-controller", + Short: "Manage Kubernetes objects through ClusterObjectSets", + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cfg.validate(); err != nil { + return err + } + restConfig, err := ctrl.GetConfig() + if err != nil { + return err + } + mgr, err := newManager(cfg, restConfig) + if err != nil { + return err + } + ctrl.Log.WithName("setup").Info("starting object-controller", "version info", version.String()) + return mgr.Start(cmd.Context()) + }, + } + flags := cmd.Flags() + flags.StringVar(&cfg.metricsAddr, "metrics-bind-address", "", "The metrics endpoint address. Disabled without tls-cert and tls-key; defaults to ':8443' when both are supplied.") + flags.StringVar(&cfg.pprofAddr, "pprof-bind-address", "0", "The pprof endpoint address. An empty string or 0 disables pprof.") + flags.StringVar(&cfg.probeAddr, "health-probe-bind-address", ":8081", "The health probe endpoint address.") + flags.StringVar(&cfg.certFile, "tls-cert", "", "The certificate file for the metrics server. Requires tls-key.") + flags.StringVar(&cfg.keyFile, "tls-key", "", "The key file for the metrics server. Requires tls-cert.") + flags.BoolVar(&cfg.enableLeaderElection, "leader-elect", false, "Enable leader election for the controller manager.") + logFlags := flag.NewFlagSet("logging", flag.ContinueOnError) + klog.InitFlags(logFlags) + flags.AddGoFlagSet(logFlags) + tlsprofiles.AddFlags(flags) + cmd.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print object-controller version information", + Run: func(cmd *cobra.Command, _ []string) { + cmd.Println(version.String()) + }, + }) + return cmd +} + +func (c *config) validate() error { + if (c.certFile == "") != (c.keyFile == "") { + return fmt.Errorf("tls-cert and tls-key flags must be used together") + } + if c.metricsAddr != "" && c.certFile == "" { + return fmt.Errorf("metrics-bind-address requires tls-cert and tls-key flags to be set") + } + if c.certFile != "" && c.metricsAddr == "" { + c.metricsAddr = ":8443" + } + return nil +} + +func newManager(cfg *config, restConfig *rest.Config) (manager.Manager, error) { + metricsOptions := server.Options{BindAddress: "0"} + var certWatcher *certwatcher.CertWatcher + if cfg.certFile != "" { + var err error + certWatcher, err = certwatcher.New(cfg.certFile, cfg.keyFile) + if err != nil { + return nil, fmt.Errorf("initializing certificate watcher: %w", err) + } + tlsProfile, err := tlsprofiles.GetTLSConfigFunc() + if err != nil { + return nil, fmt.Errorf("getting TLS profile: %w", err) + } + metricsOptions = server.Options{ + BindAddress: cfg.metricsAddr, + SecureServing: true, + FilterProvider: filters.WithAuthenticationAndAuthorization, + TLSOpts: []func(*tls.Config){ + func(c *tls.Config) { + c.GetCertificate = certWatcher.GetCertificate + c.NextProtos = []string{"http/1.1"} + }, + tlsProfile, + }, + } + } + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: metricsOptions, + PprofBindAddress: cfg.pprofAddr, + HealthProbeBindAddress: cfg.probeAddr, + LeaderElection: cfg.enableLeaderElection, + LeaderElectionID: "object-controller-lock.olm.operatorframework.io", + LeaderElectionReleaseOnCancel: true, + LeaseDuration: ptr.To(137 * time.Second), + RenewDeadline: ptr.To(107 * time.Second), + RetryPeriod: ptr.To(26 * time.Second), + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{&ocv1.ClusterObjectSet{}: {}}, + ReaderFailOnMissingInformer: true, + DefaultTransform: cacheutil.StripAnnotations(), + }, + // References can point to immutable Secrets in any namespace. Read them + // directly, without caching unrelated cluster Secrets or requiring a system namespace. + Client: client.Options{Cache: &client.CacheOptions{DisableFor: []client.Object{&corev1.Secret{}}}}, + }) + if err != nil { + return nil, fmt.Errorf("creating manager: %w", err) + } + if certWatcher != nil { + if err := mgr.Add(certWatcher); err != nil { + return nil, fmt.Errorf("adding certificate watcher: %w", err) + } + } + trackingCache, err := managedcache.NewTrackingCache( + ctrl.Log.WithName("trackingCache"), mgr.GetConfig(), + cache.Options{Scheme: mgr.GetScheme(), Mapper: mgr.GetRESTMapper()}, + ) + if err != nil { + return nil, fmt.Errorf("creating tracking cache: %w", err) + } + if err := mgr.Add(trackingCache); err != nil { + return nil, fmt.Errorf("adding tracking cache: %w", err) + } + discoveryClient, err := discovery.NewDiscoveryClientForConfig(mgr.GetConfig()) + if err != nil { + return nil, fmt.Errorf("creating discovery client: %w", err) + } + // Keep the field owner prefix unchanged so existing objects can be reconciled after migration. + factory, err := controllers.NewDefaultRevisionEngineFactory( + mgr.GetScheme(), trackingCache, memory.NewMemCacheClient(discoveryClient), + mgr.GetRESTMapper(), "olm.operatorframework.io", mgr.GetConfig(), + ) + if err != nil { + return nil, fmt.Errorf("creating revision engine factory: %w", err) + } + if err := (&controllers.ClusterObjectSetReconciler{ + Client: mgr.GetClient(), RevisionEngineFactory: factory, TrackingCache: trackingCache, + }).SetupWithManager(mgr); err != nil { + return nil, fmt.Errorf("setting up ClusterObjectSet controller: %w", err) + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return nil, err + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return nil, err + } + return mgr, nil +} + +func main() { + ctrl.SetLogger(klog.NewKlogr()) + if err := newCommand().ExecuteContext(ctrl.SetupSignalHandler()); err != nil { + os.Exit(1) + } +} diff --git a/cmd/object-controller/main_test.go b/cmd/object-controller/main_test.go new file mode 100644 index 0000000000..cc7928cadd --- /dev/null +++ b/cmd/object-controller/main_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/object-controller/scheme" + "github.com/operator-framework/operator-controller/test" +) + +func TestValidateMetricsFlags(t *testing.T) { + for _, tc := range []struct { + name string + cfg config + wantAddr string + wantErr string + }{ + {name: "disabled"}, + {name: "certificate without key", cfg: config{certFile: "cert"}, wantErr: "must be used together"}, + {name: "key without certificate", cfg: config{keyFile: "key"}, wantErr: "must be used together"}, + {name: "insecure metrics", cfg: config{metricsAddr: ":8443"}, wantErr: "requires tls-cert and tls-key"}, + {name: "default address", cfg: config{certFile: "cert", keyFile: "key"}, wantAddr: ":8443"}, + {name: "custom address", cfg: config{certFile: "cert", keyFile: "key", metricsAddr: ":9443"}, wantAddr: ":9443"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.validate() + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantAddr, tc.cfg.metricsAddr) + }) + } +} + +func TestVersionWithoutCluster(t *testing.T) { + cmd := newCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"version"}) + require.NoError(t, cmd.Execute()) + require.NotEmpty(t, output.String()) +} + +// Exercise the actual manager and revision engine with only the ClusterObjectSet CRD installed. +func TestStandaloneController(t *testing.T) { + ctrl.SetLogger(testr.New(t)) + testEnv := test.NewEnv() + testEnv.CRDDirectoryPaths = []string{"../../helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml"} + restConfig, err := testEnv.Start() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, test.StopWithRetry(testEnv, time.Minute, time.Second)) }) + + cl, err := client.New(restConfig, client.Options{Scheme: scheme.Scheme}) + require.NoError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + require.True(t, apierrors.IsNotFound(cl.Get(ctx, client.ObjectKey{Name: "clusterextensions.olm.operatorframework.io"}, &apiextensionsv1.CustomResourceDefinition{}))) + require.True(t, apierrors.IsNotFound(cl.Get(ctx, client.ObjectKey{Name: "clustercatalogs.olm.operatorframework.io"}, &apiextensionsv1.CustomResourceDefinition{}))) + + mgr, err := newManager(&config{probeAddr: "0", pprofAddr: "0"}, restConfig) + require.NoError(t, err) + done := make(chan error, 1) + go func() { done <- mgr.Start(ctx) }() + t.Cleanup(func() { + cancel() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(30 * time.Second): + t.Error("manager did not stop") + } + }) + syncCtx, syncCancel := context.WithTimeout(ctx, 30*time.Second) + defer syncCancel() + require.True(t, mgr.GetCache().WaitForCacheSync(syncCtx), "manager cache did not synchronize") + + for _, name := range []string{"inline", "secret-ref"} { + t.Run(name, func(t *testing.T) { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "standalone-"}} + require.NoError(t, cl.Create(ctx, ns)) + manifest := unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", "kind": "ConfigMap", + "metadata": map[string]any{"name": name, "namespace": ns.Name}, + "data": map[string]any{"hello": "world"}, + }} + obj := ocv1.ClusterObjectSetObject{Object: manifest} + if name == "secret-ref" { + data, err := json.Marshal(manifest.Object) + require.NoError(t, err) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "content", Namespace: ns.Name}, + Immutable: ptr.To(true), Data: map[string][]byte{"object": data}, + } + require.NoError(t, cl.Create(ctx, secret)) + obj = ocv1.ClusterObjectSetObject{Ref: ocv1.ObjectSourceRef{Name: secret.Name, Namespace: secret.Namespace, Key: "object"}} + } + cos := &ocv1.ClusterObjectSet{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: ocv1.ClusterObjectSetSpec{ + LifecycleState: ocv1.ClusterObjectSetLifecycleStateActive, Revision: 1, + CollisionProtection: ocv1.CollisionProtectionPrevent, + Phases: []ocv1.ClusterObjectSetPhase{{Name: "deploy", Objects: []ocv1.ClusterObjectSetObject{obj}}}, + }, + } + require.NoError(t, cl.Create(ctx, cos)) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + if !assert.NoError(collect, cl.Get(ctx, client.ObjectKeyFromObject(cos), cos)) { + return + } + assert.True(collect, meta.IsStatusConditionTrue(cos.Status.Conditions, ocv1.ClusterObjectSetTypeSucceeded), "%v", cos.Status.Conditions) + progressing := meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing) + if assert.NotNil(collect, progressing) { + assert.Equal(collect, "Revision 1 has rolled out.", progressing.Message) + } + }, time.Minute, 100*time.Millisecond) + cm := &corev1.ConfigMap{} + require.NoError(t, cl.Get(ctx, client.ObjectKey{Name: name, Namespace: ns.Name}, cm)) + require.Equal(t, "world", cm.Data["hello"]) + require.NotNil(t, metav1.GetControllerOf(cm)) + require.Equal(t, cos.UID, metav1.GetControllerOf(cm).UID) + + // Observe managed-object changes without updating the ClusterObjectSet. + originalUID := cm.UID + require.NoError(t, cl.Delete(ctx, cm)) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + if !assert.NoError(collect, cl.Get(ctx, client.ObjectKeyFromObject(cm), cm)) { + return + } + assert.NotEqual(collect, originalUID, cm.UID) + assert.Equal(collect, "world", cm.Data["hello"]) + if assert.NotNil(collect, metav1.GetControllerOf(cm)) { + assert.Equal(collect, cos.UID, metav1.GetControllerOf(cm).UID) + } + }, time.Minute, 100*time.Millisecond) + + // The controller releases its finalizer independently of ClusterExtension. + // The owner reference above lets Kubernetes garbage-collect the ConfigMap; + // envtest does not run that garbage collector. + require.NoError(t, cl.Delete(ctx, cos)) + require.Eventually(t, func() bool { + return apierrors.IsNotFound(cl.Get(ctx, client.ObjectKeyFromObject(cos), cos)) + }, time.Minute, 100*time.Millisecond) + }) + } +} diff --git a/internal/object-controller/controllers/clusterobjectset_controller.go b/internal/object-controller/controllers/clusterobjectset_controller.go index 071a48bc60..ae1526a0fb 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller.go +++ b/internal/object-controller/controllers/clusterobjectset_controller.go @@ -15,7 +15,6 @@ import ( "time" "github.com/go-logr/logr" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" @@ -128,14 +127,20 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl remaining, hasDeadline := durationUntilDeadline(c.Clock, cos) isDeadlineExceeded := hasDeadline && remaining <= 0 + secretReader := newReferencedSecretReader(c.Client) // Blocked takes precedence over ProgressDeadlineExceeded: it is more actionable for the user. - if err := c.verifyReferencedSecretsImmutable(ctx, cos); err != nil { + if err := c.verifyReferencedSecretsImmutable(ctx, cos, secretReader); err != nil { + var mutableSecrets *mutableSecretsError + if !errors.As(err, &mutableSecrets) { + setRetryingConditions(l, cos, err.Error(), isDeadlineExceeded) + return ctrl.Result{}, err + } l.Error(err, "referenced Secret verification failed, blocking reconciliation") markAsNotProgressing(cos, ocv1.ClusterObjectSetReasonBlocked, err.Error()) return ctrl.Result{}, nil } - phases, currentPhases, opts, err := c.buildBoxcutterPhases(ctx, cos) + phases, currentPhases, opts, err := c.buildBoxcutterPhases(ctx, cos, secretReader) if err != nil { setRetryingConditions(l, cos, err.Error(), isDeadlineExceeded) return ctrl.Result{}, fmt.Errorf("converting to boxcutter revision: %v", err) @@ -221,6 +226,9 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl } revVersion := cos.GetAnnotations()[labels.BundleVersionKey] + if revVersion == "" { + revVersion = fmt.Sprint(cos.Spec.Revision) + } if rres.InTransition() { markAsProgressing(l, cos, ocv1.ReasonRollingOut, fmt.Sprintf("Revision %s is rolling out.", revVersion), isDeadlineExceeded) } @@ -466,7 +474,7 @@ func (c *ClusterObjectSetReconciler) listOtherActiveRevisions( return result, nil } -func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, cos *ocv1.ClusterObjectSet) ([]boxcutter.Phase, []ocv1.ObservedPhase, []boxcutter.RevisionReconcileOption, error) { +func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, cos *ocv1.ClusterObjectSet, secretReader *referencedSecretReader) ([]boxcutter.Phase, []ocv1.ObservedPhase, []boxcutter.RevisionReconcileOption, error) { siblings, err := c.listSiblingRevisions(ctx, cos) if err != nil { return nil, nil, nil, fmt.Errorf("listing sibling revisions: %w", err) @@ -498,7 +506,7 @@ func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, c case specObj.Object.Object != nil: obj = specObj.Object.DeepCopy() case specObj.Ref.Name != "": - resolved, err := c.resolveObjectRef(ctx, specObj.Ref) + resolved, err := secretReader.resolveObjectRef(ctx, specObj.Ref) if err != nil { return nil, nil, nil, fmt.Errorf("resolving ref in phase %q: %w", specPhase.Name, err) } @@ -540,10 +548,10 @@ func (c *ClusterObjectSetReconciler) buildBoxcutterPhases(ctx context.Context, c // resolveObjectRef fetches the referenced Secret, reads the value at the specified key, // auto-detects gzip compression, and deserializes into an unstructured.Unstructured. -func (c *ClusterObjectSetReconciler) resolveObjectRef(ctx context.Context, ref ocv1.ObjectSourceRef) (*unstructured.Unstructured, error) { - secret := &corev1.Secret{} +func (r *referencedSecretReader) resolveObjectRef(ctx context.Context, ref ocv1.ObjectSourceRef) (*unstructured.Unstructured, error) { key := client.ObjectKey{Name: ref.Name, Namespace: ref.Namespace} - if err := c.Client.Get(ctx, key, secret); err != nil { + secret, err := r.get(ctx, key) + if err != nil { return nil, fmt.Errorf("getting Secret %s/%s: %w", ref.Namespace, ref.Name, err) } @@ -776,7 +784,7 @@ func verifyObservedPhases(stored, current []ocv1.ObservedPhase) error { // verifyReferencedSecretsImmutable checks that all referenced Secrets // have Immutable set to true. It collects all violations and returns // a single error listing every misconfigured Secret. -func (c *ClusterObjectSetReconciler) verifyReferencedSecretsImmutable(ctx context.Context, cos *ocv1.ClusterObjectSet) error { +func (c *ClusterObjectSetReconciler) verifyReferencedSecretsImmutable(ctx context.Context, cos *ocv1.ClusterObjectSet, secretReader *referencedSecretReader) error { type secretRef struct { name string namespace string @@ -799,9 +807,9 @@ func (c *ClusterObjectSetReconciler) verifyReferencedSecretsImmutable(ctx contex var mutableSecrets []string for _, ref := range refs { - secret := &corev1.Secret{} key := client.ObjectKey{Name: ref.name, Namespace: ref.namespace} - if err := c.Client.Get(ctx, key, secret); err != nil { + secret, err := secretReader.get(ctx, key) + if err != nil { if apierrors.IsNotFound(err) { // Secret not yet available — skip verification. // resolveObjectRef will handle the not-found with a retryable error. @@ -816,8 +824,7 @@ func (c *ClusterObjectSetReconciler) verifyReferencedSecretsImmutable(ctx contex } if len(mutableSecrets) > 0 { - return fmt.Errorf("the following secrets are not immutable (referenced secrets must have immutable set to true): %s", - strings.Join(mutableSecrets, ", ")) + return &mutableSecretsError{names: mutableSecrets} } return nil diff --git a/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go b/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go index 4d67856176..cc0581dbad 100644 --- a/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go +++ b/internal/object-controller/controllers/clusterobjectset_controller_internal_test.go @@ -436,7 +436,7 @@ func TestVerifyReferencedSecretsImmutable(t *testing.T) { }, } - err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos) + err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos, newReferencedSecretReader(testClient)) require.NoError(t, err) }) @@ -466,7 +466,7 @@ func TestVerifyReferencedSecretsImmutable(t *testing.T) { }, } - err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos) + err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos, newReferencedSecretReader(testClient)) require.Error(t, err) assert.Contains(t, err.Error(), "not immutable") }) @@ -489,7 +489,7 @@ func TestVerifyReferencedSecretsImmutable(t *testing.T) { }, } - err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos) + err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos, newReferencedSecretReader(testClient)) require.NoError(t, err) }) @@ -523,7 +523,7 @@ func TestVerifyReferencedSecretsImmutable(t *testing.T) { }, } - err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos) + err := reconciler.verifyReferencedSecretsImmutable(t.Context(), cos, newReferencedSecretReader(testClient)) require.NoError(t, err) assert.Equal(t, int32(1), secretGetCount.Load(), "secret should be fetched only once despite multiple references") }) diff --git a/internal/object-controller/controllers/referenced_secrets.go b/internal/object-controller/controllers/referenced_secrets.go new file mode 100644 index 0000000000..e4cf1b080d --- /dev/null +++ b/internal/object-controller/controllers/referenced_secrets.go @@ -0,0 +1,42 @@ +package controllers + +import ( + "context" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// referencedSecretReader shares a Secret snapshot between verification and object +// decoding within one reconciliation. A new reader must be used on the next +// reconciliation so that deleted and recreated Secrets are read again. +type referencedSecretReader struct { + reader client.Reader + secrets map[client.ObjectKey]*corev1.Secret +} + +func newReferencedSecretReader(reader client.Reader) *referencedSecretReader { + return &referencedSecretReader{reader: reader, secrets: make(map[client.ObjectKey]*corev1.Secret)} +} + +func (r *referencedSecretReader) get(ctx context.Context, key client.ObjectKey) (*corev1.Secret, error) { + if secret, ok := r.secrets[key]; ok { + return secret, nil + } + secret := &corev1.Secret{} + if err := r.reader.Get(ctx, key, secret); err != nil { + return nil, err + } + r.secrets[key] = secret + return secret, nil +} + +type mutableSecretsError struct { + names []string +} + +func (e *mutableSecretsError) Error() string { + return fmt.Sprintf("the following secrets are not immutable (referenced secrets must have immutable set to true): %s", strings.Join(e.names, ", ")) +} diff --git a/internal/object-controller/controllers/referenced_secrets_test.go b/internal/object-controller/controllers/referenced_secrets_test.go new file mode 100644 index 0000000000..ac0924edce --- /dev/null +++ b/internal/object-controller/controllers/referenced_secrets_test.go @@ -0,0 +1,144 @@ +package controllers_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clocktesting "k8s.io/utils/clock/testing" + "k8s.io/utils/ptr" + "pkg.package-operator.run/boxcutter/machinery" + machinerytypes "pkg.package-operator.run/boxcutter/machinery/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/object-controller/controllers" +) + +func TestReferencedSecretsReadOncePerReconcile(t *testing.T) { + ctx := t.Context() + mockCtrl := gomock.NewController(t) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "content", Namespace: "source-a"}, + Immutable: ptr.To(true), Data: map[string][]byte{}, + } + cos := newRefTestCOS("packed", ocv1.ObjectSourceRef{}) + cos.Spec.Phases = nil + for phaseIndex := range 2 { + phase := ocv1.ClusterObjectSetPhase{Name: fmt.Sprintf("phase-%d", phaseIndex)} + for objectIndex := range 25 { + name := fmt.Sprintf("cm-%d-%d", phaseIndex, objectIndex) + secret.Data[name] = []byte(fmt.Sprintf(`{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":%q,"namespace":"target"}}`, name)) + phase.Objects = append(phase.Objects, ocv1.ClusterObjectSetObject{Ref: ocv1.ObjectSourceRef{Name: secret.Name, Namespace: secret.Namespace, Key: name}}) + } + cos.Spec.Phases = append(cos.Spec.Phases, phase) + } + otherSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secret.Name, Namespace: "source-b"}, + Immutable: ptr.To(true), + Data: map[string][]byte{"other": []byte(`{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"other","namespace":"target"}}`)}, + } + cos.Spec.Phases[1].Objects = append(cos.Spec.Phases[1].Objects, ocv1.ClusterObjectSetObject{Ref: ocv1.ObjectSourceRef{Name: otherSecret.Name, Namespace: otherSecret.Namespace, Key: "other"}}) + reads := map[client.ObjectKey]int{} + cl := fake.NewClientBuilder().WithScheme(newSchemeWithCoreV1(t)).WithObjects(secret, otherSecret, cos). + WithStatusSubresource(&ocv1.ClusterObjectSet{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Secret); ok { + reads[key]++ + } + return cl.Get(ctx, key, obj, opts...) + }, + }).Build() + engine := newMockRevisionEngineWithReconcile(mockCtrl, + func(context.Context, machinerytypes.Revision, ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) { + return newMockRevisionResult(mockCtrl, revisionResultConfig{inTransition: true}), nil + }, nil) + reconciler := &controllers.ClusterObjectSetReconciler{ + Client: cl, TrackingCache: newMockTrackingCache(mockCtrl, cl, nil), + RevisionEngineFactory: newMockRevisionEngineFactoryWithEngine(mockCtrl, engine, nil), + Clock: clocktesting.NewFakeClock(metav1.Now().Time), + } + reconcile := func(wantReads int, wantReason string) { + t.Helper() + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cos)}) + require.NoError(t, err) + require.Equal(t, wantReads, reads[client.ObjectKeyFromObject(secret)]) + require.Equal(t, wantReads, reads[client.ObjectKeyFromObject(otherSecret)]) + require.NoError(t, cl.Get(ctx, client.ObjectKeyFromObject(cos), cos)) + condition := meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing) + require.NotNil(t, condition) + require.Equal(t, wantReason, condition.Reason) + } + reconcile(1, ocv1.ReasonRollingOut) + reconcile(2, ocv1.ReasonRollingOut) + + // Reusing a fetched Secret must not hide a replacement on the next reconcile. + require.NoError(t, cl.Delete(ctx, secret)) + changed := secret.DeepCopy() + changed.ResourceVersion = "" + changed.Data["cm-0-0"] = []byte(`{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm-0-0","namespace":"target"},"data":{"value":"changed"}}`) + require.NoError(t, cl.Create(ctx, changed)) + reconcile(3, ocv1.ClusterObjectSetReasonBlocked) + require.Contains(t, meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing).Message, "resolved content of 1 phase(s) has changed") + + require.NoError(t, cl.Delete(ctx, changed)) + secret.ResourceVersion = "" + require.NoError(t, cl.Create(ctx, secret)) + reconcile(4, ocv1.ReasonRollingOut) +} + +func TestReferencedSecretReadFailureRetries(t *testing.T) { + ctx := t.Context() + mockCtrl := gomock.NewController(t) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "content", Namespace: "source"}, + Immutable: ptr.To(true), + Data: map[string][]byte{"object": []byte(`{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"cm","namespace":"target"}}`)}, + } + cos := newRefTestCOS("retry", ocv1.ObjectSourceRef{Name: secret.Name, Namespace: secret.Namespace, Key: "object"}) + failReads := true + cl := fake.NewClientBuilder().WithScheme(newSchemeWithCoreV1(t)).WithObjects(secret, cos). + WithStatusSubresource(&ocv1.ClusterObjectSet{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Secret); ok && failReads { + return apierrors.NewServiceUnavailable("temporary API outage") + } + return cl.Get(ctx, key, obj, opts...) + }, + }).Build() + engine := newMockRevisionEngineWithReconcile(mockCtrl, + func(context.Context, machinerytypes.Revision, ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) { + return newMockRevisionResult(mockCtrl, revisionResultConfig{inTransition: true}), nil + }, nil) + reconciler := &controllers.ClusterObjectSetReconciler{ + Client: cl, TrackingCache: newMockTrackingCache(mockCtrl, cl, nil), + RevisionEngineFactory: newMockRevisionEngineFactoryWithEngine(mockCtrl, engine, nil), + Clock: clocktesting.NewFakeClock(metav1.Now().Time), + } + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(cos)} + for range 2 { + _, err := reconciler.Reconcile(ctx, req) + require.ErrorContains(t, err, "temporary API outage") + require.NoError(t, cl.Get(ctx, req.NamespacedName, cos)) + condition := meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing) + require.NotNil(t, condition) + require.Equal(t, metav1.ConditionTrue, condition.Status) + require.Equal(t, ocv1.ReasonRetrying, condition.Reason) + } + failReads = false + _, err := reconciler.Reconcile(ctx, req) + require.NoError(t, err) + require.NoError(t, cl.Get(ctx, req.NamespacedName, cos)) + require.Equal(t, ocv1.ReasonRollingOut, meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing).Reason) +} diff --git a/internal/object-controller/scheme/scheme.go b/internal/object-controller/scheme/scheme.go new file mode 100644 index 0000000000..33a4a965d2 --- /dev/null +++ b/internal/object-controller/scheme/scheme.go @@ -0,0 +1,21 @@ +package scheme + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +var Scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(Scheme)) + utilruntime.Must(apiextensionsv1.AddToScheme(Scheme)) + // Object management does not require the ClusterExtension or ClusterCatalog APIs. + Scheme.AddKnownTypes(ocv1.GroupVersion, &ocv1.ClusterObjectSet{}, &ocv1.ClusterObjectSetList{}) + metav1.AddToGroupVersion(Scheme, ocv1.GroupVersion) +} From af4aee3cbd3cda719c780858676f1ea3b0683f65 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Thu, 24 Sep 2026 17:07:10 +0100 Subject: [PATCH 3/7] build(object-controller): package the standalone controller Add object-controller to local and Linux builds, Docker image creation, KIND loading, and multi-architecture GoReleaser output. Refs: OPRUN-4774 Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- .goreleaser.yml | 58 ++++++++++++++++++++++++++++++++++++ Dockerfile.object-controller | 7 +++++ Makefile | 14 +++++++-- 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 Dockerfile.object-controller diff --git a/.goreleaser.yml b/.goreleaser.yml index fc7a51c7da..42affcb9ff 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,6 +4,22 @@ before: - go mod tidy - go mod download builds: + - id: object-controller + main: ./cmd/object-controller/ + binary: object-controller + asmflags: "{{ .Env.GO_BUILD_ASMFLAGS }}" + gcflags: "{{ .Env.GO_BUILD_GCFLAGS }}" + ldflags: "{{ .Env.GO_BUILD_LDFLAGS }}" + tags: + - "{{ .Env.GO_BUILD_TAGS }}" + mod_timestamp: "{{ .CommitTimestamp }}" + goos: + - linux + goarch: + - amd64 + - arm64 + - ppc64le + - s390x - id: operator-controller main: ./cmd/operator-controller/ binary: operator-controller @@ -37,6 +53,42 @@ builds: - ppc64le - s390x dockers: + - image_templates: + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-amd64" + dockerfile: Dockerfile.object-controller + goos: linux + goarch: amd64 + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + - "--provenance=false" + - image_templates: + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-arm64" + dockerfile: Dockerfile.object-controller + goos: linux + goarch: arm64 + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + - "--provenance=false" + - image_templates: + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-ppc64le" + dockerfile: Dockerfile.object-controller + goos: linux + goarch: ppc64le + use: buildx + build_flag_templates: + - "--platform=linux/ppc64le" + - "--provenance=false" + - image_templates: + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-s390x" + dockerfile: Dockerfile.object-controller + goos: linux + goarch: s390x + use: buildx + build_flag_templates: + - "--platform=linux/s390x" + - "--provenance=false" - image_templates: - "{{ .Env.OPCON_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-amd64" dockerfile: Dockerfile.operator-controller @@ -110,6 +162,12 @@ dockers: - "--platform=linux/s390x" - "--provenance=false" docker_manifests: + - name_template: "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}" + image_templates: + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-amd64" + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-arm64" + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-ppc64le" + - "{{ .Env.OBJECT_CONTROLLER_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-s390x" - name_template: "{{ .Env.OPCON_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}" image_templates: - "{{ .Env.OPCON_IMAGE_REPO }}:{{ .Env.IMAGE_TAG }}-amd64" diff --git a/Dockerfile.object-controller b/Dockerfile.object-controller new file mode 100644 index 0000000000..42f320bdc5 --- /dev/null +++ b/Dockerfile.object-controller @@ -0,0 +1,7 @@ +# Build the binary with make build-linux before building this image. +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY object-controller object-controller +USER 65532:65532 + +ENTRYPOINT ["/object-controller"] diff --git a/Makefile b/Makefile index 770cf11b29..26e3c7d56f 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,11 @@ CATD_IMAGE_REPO := $(IMAGE_REGISTRY)/catalogd endif export CATD_IMAGE_REPO +ifeq ($(origin OBJECT_CONTROLLER_IMAGE_REPO), undefined) +OBJECT_CONTROLLER_IMAGE_REPO := $(IMAGE_REGISTRY)/object-controller +endif +export OBJECT_CONTROLLER_IMAGE_REPO + ifeq ($(origin IMAGE_TAG), undefined) IMAGE_TAG := devel endif @@ -31,6 +36,7 @@ export IMAGE_TAG OPCON_IMG := $(OPCON_IMAGE_REPO):$(IMAGE_TAG) CATD_IMG := $(CATD_IMAGE_REPO):$(IMAGE_TAG) +OBJECT_CONTROLLER_IMG := $(OBJECT_CONTROLLER_IMAGE_REPO):$(IMAGE_TAG) # Extract Kubernetes client-go version used to set the version to the PSA labels, for ENVTEST and KIND ifeq ($(origin K8S_VERSION), undefined) @@ -351,6 +357,7 @@ kind-cluster-%: $(KIND) #EXHELP Create a kind cluster named after the stem (%). kind-load-%: kind-cluster-% docker-build $(KIND) load docker-image $(OPCON_IMG) --name $* $(KIND) load docker-image $(CATD_IMG) --name $* + $(KIND) load docker-image $(OBJECT_CONTROLLER_IMG) --name $* .PHONY: kind-deploy-% kind-deploy-%: kind-load-% manifests @@ -605,7 +612,7 @@ export GO_BUILD_LDFLAGS := -s -w \ -X '$(VERSION_PATH).version=$(VERSION)' \ -X '$(VERSION_PATH).gitCommit=$(GIT_COMMIT)' \ -BINARIES=operator-controller catalogd +BINARIES=operator-controller catalogd object-controller .PHONY: $(BINARIES) $(BINARIES): @@ -644,9 +651,10 @@ FLUENTBIT_NAMESPACE := fluent-bit FLUENTBIT_CHART_VERSION := 0.57.9 .PHONY: docker-build -docker-build: build-linux #EXHELP Build docker image for operator-controller and catalog with GOOS=linux and local GOARCH. +docker-build: build-linux #EXHELP Build controller images with GOOS=linux and local GOARCH. $(CONTAINER_RUNTIME) build -t $(OPCON_IMG) -f Dockerfile.operator-controller ./bin/linux $(CONTAINER_RUNTIME) build -t $(CATD_IMG) -f Dockerfile.catalogd ./bin/linux + $(CONTAINER_RUNTIME) build -t $(OBJECT_CONTROLLER_IMG) -f Dockerfile.object-controller ./bin/linux #SECTION Release ifeq ($(origin ENABLE_RELEASE_PIPELINE), undefined) @@ -661,7 +669,7 @@ export GORELEASER_ARGS .PHONY: release release: $(GORELEASER) #EXHELP Runs goreleaser for the operator-controller. By default, this will run only as a snapshot and will not publish any artifacts unless it is run with different arguments. To override the arguments, run with "GORELEASER_ARGS=...". When run as a github action from a tag, this target will publish a full release. - OPCON_IMAGE_REPO=$(OPCON_IMAGE_REPO) CATD_IMAGE_REPO=$(CATD_IMAGE_REPO) $(GORELEASER) $(GORELEASER_ARGS) + OPCON_IMAGE_REPO=$(OPCON_IMAGE_REPO) CATD_IMAGE_REPO=$(CATD_IMAGE_REPO) OBJECT_CONTROLLER_IMAGE_REPO=$(OBJECT_CONTROLLER_IMAGE_REPO) $(GORELEASER) $(GORELEASER_ARGS) .PHONY: quickstart quickstart: export STANDARD_MANIFEST_URL := "https://github.com/operator-framework/operator-controller/releases/download/$(VERSION)/$(notdir $(STANDARD_RELEASE_MANIFEST))" From 0d8ba9a628be9a3f5da7476b8028a86c5b757db3 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Thu, 24 Sep 2026 17:07:51 +0100 Subject: [PATCH 4/7] feat(object-controller): deploy the standalone controller with Helm Move ClusterObjectSet CRD and deployment ownership to object-controller. Remove the embedded ClusterObjectSet manager from operator-controller, and update local development, installation, and documentation paths. Add chart rendering coverage for standalone, Boxcutter, downstream, and PodDisruptionBudget configurations. Refs: OPRUN-4775 Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- README.md | 3 +- Tiltfile | 15 ++ cmd/object-controller/main_test.go | 2 +- cmd/operator-controller/main.go | 81 ++-------- docs/draft/concepts/clusterobjectsets.md | 46 ++++++ hack/test/e2e-coverage.sh | 7 + hack/tools/update-crds.sh | 2 +- helm/experimental.yaml | 12 +- ...peratorframework.io_clusterobjectsets.yaml | 2 +- helm/olmv1/templates/_helpers.tpl | 26 +++- ...te-olmv1-system-object-controller-cert.yml | 25 ++++ ...terobjectsets.olm.operatorframework.io.yml | 12 +- ...m-object-controller-controller-manager.yml | 120 +++++++++++++++ ...m-object-controller-controller-manager.yml | 25 ++++ ...nbudget-olmv1-system-object-controller.yml | 22 +++ .../clusterrole-common-metrics-reader.yml | 3 + .../rbac/clusterrole-common-proxy-role.yml | 3 + ...errolebinding-common-proxy-rolebinding.yml | 3 + ...-object-controller-manager-rolebinding.yml | 20 +++ ...mv1-system-common-leader-election-role.yml | 3 + ...role-olmv1-system-metrics-monitor-role.yml | 2 +- ...tem-common-leader-election-rolebinding.yml | 3 + ...mv1-system-metrics-monitor-rolebinding.yml | 2 +- ...olmv1-system-object-controller-service.yml | 23 +++ ...olmv1-system-common-controller-manager.yml | 3 + ...stem-object-controller-metrics-monitor.yml | 34 +++++ helm/olmv1/values.yaml | 15 +- .../controllers/suite_test.go | 2 + .../manifests/manifests_test.go | 141 ++++++++++++++++++ scripts/install.tpl.sh | 4 + test/utils.go | 1 + 31 files changed, 566 insertions(+), 96 deletions(-) rename helm/olmv1/base/{operator-controller => object-controller}/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml (99%) create mode 100644 helm/olmv1/templates/cert-manager/certificate-olmv1-system-object-controller-cert.yml create mode 100644 helm/olmv1/templates/deployment-olmv1-system-object-controller-controller-manager.yml create mode 100644 helm/olmv1/templates/networkpolicy/networkpolicy-olmv1-system-object-controller-controller-manager.yml create mode 100644 helm/olmv1/templates/poddisruptionbudget-olmv1-system-object-controller.yml create mode 100644 helm/olmv1/templates/rbac/clusterrolebinding-object-controller-manager-rolebinding.yml create mode 100644 helm/olmv1/templates/service-olmv1-system-object-controller-service.yml create mode 100644 helm/olmv1/templates/servicemonitor-olmv1-system-object-controller-metrics-monitor.yml create mode 100644 internal/object-controller/manifests/manifests_test.go diff --git a/README.md b/README.md index 98597901a2..9fb5b6f3c6 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ controllers, and tooling that support the packaging, distribution, and lifecycli - provide secure, high-quality, and predictable user experiences centered around declarative GitOps concepts - give cluster admins the minimal necessary controls to build their desired cluster architectures and to have ultimate control -OLM v1 consists of two different components: +OLM v1 consists of the following components: * operator-controller * catalogd +* object-controller (experimental): manages `ClusterObjectSet` resources independently of `ClusterExtension` For a more complete overview of OLM v1 and how it differs from OLM v0, see our [overview](docs/project/olmv1_design_decisions.md). diff --git a/Tiltfile b/Tiltfile index d736b8f94d..6a2154cff7 100644 --- a/Tiltfile +++ b/Tiltfile @@ -20,4 +20,19 @@ olmv1 = { 'yaml': 'helm/tilt.yaml', } +values = read_yaml(olmv1['yaml']) +options = values.get('options', {}) +features = options.get('operatorController', {}).get('features', {}) +object_controller_enabled = options.get('objectController', {}).get('enabled') +if object_controller_enabled == None: + object_controller_enabled = options.get('operatorController', {}).get('enabled', True) and 'BoxcutterRuntime' in features.get('enabled', []) and 'BoxcutterRuntime' not in features.get('disabled', ['BoxcutterRuntime']) +if object_controller_enabled: + olmv1['repos']['object-controller'] = { + 'image': 'quay.io/operator-framework/object-controller', + 'binary': './cmd/object-controller', + 'deployment': 'object-controller-controller-manager', + 'deps': ['api', 'cmd/object-controller', 'internal/object-controller', 'internal/shared', 'go.mod', 'go.sum'], + 'starting_debug_port': 40000, + } + deploy_repo(olmv1, '-tags containers_image_openpgp') diff --git a/cmd/object-controller/main_test.go b/cmd/object-controller/main_test.go index cc7928cadd..a4cdb32bad 100644 --- a/cmd/object-controller/main_test.go +++ b/cmd/object-controller/main_test.go @@ -64,7 +64,7 @@ func TestVersionWithoutCluster(t *testing.T) { func TestStandaloneController(t *testing.T) { ctrl.SetLogger(testr.New(t)) testEnv := test.NewEnv() - testEnv.CRDDirectoryPaths = []string{"../../helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml"} + testEnv.CRDDirectoryPaths = []string{"../../helm/olmv1/base/object-controller/crd/experimental"} restConfig, err := testEnv.Start() require.NoError(t, err) t.Cleanup(func() { require.NoError(t, test.StopWithRetry(testEnv, time.Minute, time.Second)) }) diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 5203db1ac4..dfae2b6a97 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -30,15 +30,12 @@ import ( "github.com/spf13/cobra" "go.podman.io/image/v5/types" - corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextensionsv1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" k8slabels "k8s.io/apimachinery/pkg/labels" k8stypes "k8s.io/apimachinery/pkg/types" apimachineryrand "k8s.io/apimachinery/pkg/util/rand" - "k8s.io/client-go/discovery" - "k8s.io/client-go/discovery/cached/memory" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/klog/v2" @@ -61,7 +58,6 @@ import ( helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" ocv1 "github.com/operator-framework/operator-controller/api/v1" - clusterobjctrl "github.com/operator-framework/operator-controller/internal/object-controller/controllers" "github.com/operator-framework/operator-controller/internal/operator-controller/action" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/cache" @@ -119,7 +115,6 @@ type boxcutterReconcilerConfigurator struct { imageCache imageutil.Cache imagePuller imageutil.Puller finalizers crfinalizer.Finalizers - trackingCache managedcache.TrackingCache } type helmReconcilerConfigurator struct { @@ -461,26 +456,22 @@ func run() error { crdupgradesafety.NewPreflight(aeClient.CustomResourceDefinitions()), } - trackingCache, err := managedcache.NewTrackingCache( - ctrl.Log.WithName("trackingCache"), - mgr.GetConfig(), - crcache.Options{ - Scheme: mgr.GetScheme(), Mapper: mgr.GetRESTMapper(), - }, - ) - if err != nil { - setupLog.Error(err, "unable to create tracking cache") - return err - } - if err := mgr.Add(trackingCache); err != nil { - setupLog.Error(err, "unable to add tracking cache to manager") - return err - } - + var trackingCache managedcache.TrackingCache var ctrlBuilderOpts []controllers.ControllerBuilderOption if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { ctrlBuilderOpts = append(ctrlBuilderOpts, controllers.WithOwns(&ocv1.ClusterObjectSet{})) } else { + trackingCache, err = managedcache.NewTrackingCache( + ctrl.Log.WithName("trackingCache"), + mgr.GetConfig(), + crcache.Options{Scheme: mgr.GetScheme(), Mapper: mgr.GetRESTMapper()}, + ) + if err != nil { + return fmt.Errorf("unable to create tracking cache: %w", err) + } + if err := mgr.Add(trackingCache); err != nil { + return fmt.Errorf("unable to add tracking cache to manager: %w", err) + } ctrlBuilderOpts = append(ctrlBuilderOpts, controllers.WithWatchesRawSource( trackingCache.Source( crhandler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &ocv1.ClusterExtension{}), @@ -520,7 +511,6 @@ func run() error { imageCache: imageCache, imagePuller: imagePuller, finalizers: clusterExtensionFinalizers, - trackingCache: trackingCache, } } else { cerCfg = &helmReconcilerConfigurator{ @@ -665,38 +655,6 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl controllers.ApplyBundleWithBoxcutter(appl.Apply), } - baseDiscoveryClient, err := discovery.NewDiscoveryClientForConfig(c.mgr.GetConfig()) - if err != nil { - return fmt.Errorf("unable to create discovery client: %w", err) - } - - // Wrap the discovery client with caching to reduce memory usage from repeated OpenAPI schema fetches - discoveryClient := memory.NewMemCacheClient(baseDiscoveryClient) - - revisionEngineFactory, err := clusterobjctrl.NewDefaultRevisionEngineFactory( - c.mgr.GetScheme(), - c.trackingCache, - discoveryClient, - c.mgr.GetRESTMapper(), - fieldOwnerPrefix, - c.mgr.GetConfig(), - ) - if err != nil { - return fmt.Errorf("unable to create revision engine factory: %w", err) - } - - cosClient := &secretFallbackClient{ - Client: c.mgr.GetClient(), - apiReader: c.mgr.GetAPIReader(), - systemNamespace: cfg.systemNamespace, - } - if err = (&clusterobjctrl.ClusterObjectSetReconciler{ - Client: cosClient, - RevisionEngineFactory: revisionEngineFactory, - TrackingCache: c.trackingCache, - }).SetupWithManager(c.mgr); err != nil { - return fmt.Errorf("unable to setup ClusterObjectSet controller: %w", err) - } return nil } @@ -761,18 +719,3 @@ func main() { os.Exit(1) } } - -// secretFallbackClient wraps a cached client.Client and falls back to direct -// API reads for Secrets outside the system namespace, where the cache does not watch. -type secretFallbackClient struct { - client.Client - apiReader client.Reader - systemNamespace string -} - -func (c *secretFallbackClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - if _, isSecret := obj.(*corev1.Secret); isSecret && key.Namespace != c.systemNamespace { - return c.apiReader.Get(ctx, key, obj, opts...) - } - return c.Client.Get(ctx, key, obj, opts...) -} diff --git a/docs/draft/concepts/clusterobjectsets.md b/docs/draft/concepts/clusterobjectsets.md index 46a3684ef7..d0b3bb7e8b 100644 --- a/docs/draft/concepts/clusterobjectsets.md +++ b/docs/draft/concepts/clusterobjectsets.md @@ -18,6 +18,49 @@ Each ClusterObjectSet has: ClusterObjectSets can be used by any controller or system that needs to manage the rollout of a set of Kubernetes resources in a controlled, phased manner. Within OLM, the operator-controller uses ClusterObjectSets as the mechanism to deploy and upgrade ClusterExtensions. +### Controller deployment + +The experimental `object-controller` runs in its own Deployment with its own service +account and leader-election lease. It watches ClusterObjectSets and the objects they +manage; it does not require ClusterExtension or ClusterCatalog resources or controllers. +The operator-controller continues to resolve and unpack bundles, create ClusterObjectSets, +and observe their status when `BoxcutterRuntime` is enabled. + +The Helm chart automatically enables object-controller alongside operator-controller +when `BoxcutterRuntime` is enabled. To deploy it independently, first build an image +and make it available to your cluster (push it to a registry, or load it into your +local cluster). Set the image reference below, then render and apply the manifests: + +```sh +helm template object-controller helm/olmv1 \ + --set options.featureSet=experimental \ + --set options.objectController.enabled=true \ + --set options.operatorController.enabled=false \ + --set options.catalogd.enabled=false \ + --set-string options.objectController.deployment.image='' \ + > object-controller.yaml +kubectl apply -f object-controller.yaml +kubectl -n olmv1-system rollout status deployment/object-controller-controller-manager --timeout=180s +``` + +Set `options.objectController.deployment.image` to the built image when using a local +or downstream image. `make go-build-local` builds `bin/object-controller`; +`make docker-build` builds its image alongside the existing controller images. +`OBJECT_CONTROLLER_IMAGE_REPO` controls its build and release repository. + +Object-controller reads immutable referenced Secrets directly from the API server in +the namespace specified by each reference. Metrics require a TLS certificate and key; +the chart configures these through cert-manager or the OpenShift service CA when enabled. +The minimal standalone example above enables neither certificate provider, so metrics +are disabled. Health and readiness probes remain enabled. +The default service account has cluster-admin privileges because ClusterObjectSets can +manage arbitrary Kubernetes resources, matching the existing experimental runtime. + +For downstream builds enabling `BoxcutterRuntime`, package the new binary and configure +its image in addition to the operator-controller image. It is possible to package both +binaries in one image while running them in separate Deployments. Standard installations +and default Helm installations without `BoxcutterRuntime` do not enable object-controller. + ## Why ClusterObjectSets? ClusterObjectSets solve several problems that arise when managing sets of related Kubernetes resources: @@ -382,6 +425,9 @@ kubectl get clusterobjectsets -l olm.operatorframework.io/owner-name=my-extensio # View full details for a specific revision kubectl get clusterobjectset -o yaml + +# Inspect reconciliation in the independent controller +kubectl -n olmv1-system logs deployment/object-controller-controller-manager ``` Example output: diff --git a/hack/test/e2e-coverage.sh b/hack/test/e2e-coverage.sh index e4b4610d45..e75a359810 100755 --- a/hack/test/e2e-coverage.sh +++ b/hack/test/e2e-coverage.sh @@ -10,6 +10,8 @@ OPERATOR_CONTROLLER_MANAGER_DEPLOYMENT_NAME="operator-controller-controller-mana CATALOGD_NAMESPACE="olmv1-system" CATALOGD_MANAGER_DEPLOYMENT_NAME="catalogd-controller-manager" +OBJECT_CONTROLLER_MANAGER_DEPLOYMENT_NAME="object-controller-controller-manager" + COPY_POD_NAME="e2e-coverage-copy-pod" # Create a temporary directory for coverage @@ -26,6 +28,11 @@ kubectl -n "$CATALOGD_NAMESPACE" scale deployment/"$CATALOGD_MANAGER_DEPLOYMENT_ kubectl -n "$OPERATOR_CONTROLLER_NAMESPACE" wait --for=delete pods -l control-plane="$OPERATOR_CONTROLLER_MANAGER_DEPLOYMENT_NAME" --timeout=60s kubectl -n "$CATALOGD_NAMESPACE" wait --for=delete pods -l control-plane="$CATALOGD_MANAGER_DEPLOYMENT_NAME" --timeout=60s +if kubectl -n "$OPERATOR_CONTROLLER_NAMESPACE" get deployment/"$OBJECT_CONTROLLER_MANAGER_DEPLOYMENT_NAME" >/dev/null 2>&1; then + kubectl -n "$OPERATOR_CONTROLLER_NAMESPACE" scale deployment/"$OBJECT_CONTROLLER_MANAGER_DEPLOYMENT_NAME" --replicas=0 + kubectl -n "$OPERATOR_CONTROLLER_NAMESPACE" wait --for=delete pods -l control-plane="$OBJECT_CONTROLLER_MANAGER_DEPLOYMENT_NAME" --timeout=60s +fi + # Copy the coverage data from the temporary pod kubectl -n "$OPERATOR_CONTROLLER_NAMESPACE" cp "$COPY_POD_NAME":/e2e-coverage/ "$COVERAGE_DIR" diff --git a/hack/tools/update-crds.sh b/hack/tools/update-crds.sh index e7f3ce1392..c68dba4c15 100755 --- a/hack/tools/update-crds.sh +++ b/hack/tools/update-crds.sh @@ -11,7 +11,7 @@ CR="olm.operatorframework.io_clusterobjectsets.yaml" # order for modules and crds must match # each item in crds must be unique, and should be associated with a module -modules=("operator-controller" "catalogd" "operator-controller") +modules=("operator-controller" "catalogd" "object-controller") crds=("${CE}" "${CC}" "${CR}") # Channels must much those in the generator diff --git a/helm/experimental.yaml b/helm/experimental.yaml index cfbfa16afb..4eaa30eacf 100644 --- a/helm/experimental.yaml +++ b/helm/experimental.yaml @@ -1,11 +1,13 @@ # experimental values for OLMv1. # This is a YAML-formatted file. # Declare variables to be passed into your templates. - # List of enabled experimental features for operator-controller # Use with {{- if has "FeatureGate" .Values.options.operatorController.features.enabled }} # to pull in resources or additions options: + objectController: + deployment: + replicas: 2 operatorController: deployment: replicas: 2 @@ -18,9 +20,9 @@ options: - WebhookProviderCertManager disabled: - WebhookProviderOpenshiftServiceCA -# List of enabled experimental features for catalogd -# Use with {{- if has "FeatureGate" .Values.options.catalogd.features.enabled }} -# to pull in resources or additions + # List of enabled experimental features for catalogd + # Use with {{- if has "FeatureGate" .Values.options.catalogd.features.enabled }} + # to pull in resources or additions catalogd: deployment: replicas: 2 @@ -29,5 +31,5 @@ options: - APIV1MetasHandler - GraphQLCatalogQueries disabled: [] -# This can be one of: standard or experimental + # This can be one of: standard or experimental featureSet: experimental diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml similarity index 99% rename from helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml rename to helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml index 0c94049181..97c4db9a90 100644 --- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml +++ b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.21.0 olm.operatorframework.io/generator: experimental name: clusterobjectsets.olm.operatorframework.io spec: diff --git a/helm/olmv1/templates/_helpers.tpl b/helm/olmv1/templates/_helpers.tpl index 89cb398934..f3e2a3c859 100644 --- a/helm/olmv1/templates/_helpers.tpl +++ b/helm/olmv1/templates/_helpers.tpl @@ -16,7 +16,9 @@ Create chart name and version as used by the chart label. Return the name of the active component for a prefix, but _only_ if one is enabled */}} {{- define "component.name.prefix" -}} -{{- if and (.Values.options.operatorController.enabled) (not .Values.options.catalogd.enabled) -}} +{{- if and (not .Values.options.operatorController.enabled) (not .Values.options.catalogd.enabled) (include "objectController.enabled" .) -}} +object-controller- +{{- else if and (.Values.options.operatorController.enabled) (not .Values.options.catalogd.enabled) -}} operator-controller- {{- else if and (not .Values.options.operatorController.enabled) (.Values.options.catalogd.enabled) -}} catalogd- @@ -45,7 +47,9 @@ Insertion of additional rules for RBAC Returns "operator-controller", "catalogd" or "olmv1" depending on enabled components */}} {{- define "olmv1.label.name" -}} -{{- if (and .Values.options.operatorController.enabled (not .Values.options.catalogd.enabled)) -}} +{{- if and (not .Values.options.operatorController.enabled) (not .Values.options.catalogd.enabled) (include "objectController.enabled" .) -}} +object-controller +{{- else if (and .Values.options.operatorController.enabled (not .Values.options.catalogd.enabled)) -}} operator-controller {{- else if (and (not .Values.options.operatorController.enabled) .Values.options.catalogd.enabled) -}} catalogd @@ -54,6 +58,24 @@ olmv1 {{- end -}} {{- end -}} +{{/* +Default to a separate object-controller whenever operator-controller uses Boxcutter. +An explicit enabled value also permits installing object-controller on its own. +Return an empty string when disabled so the helper can be used in conditionals. +*/}} +{{- define "objectController.enabled" -}} +{{- $enabled := .Values.options.objectController.enabled -}} +{{- if eq (toJson $enabled) "null" -}} +{{- $enabled = and .Values.options.operatorController.enabled (has "BoxcutterRuntime" .Values.options.operatorController.features.enabled) (not (has "BoxcutterRuntime" .Values.options.operatorController.features.disabled)) -}} +{{- end -}} +{{- if $enabled -}} +{{- if ne .Values.options.featureSet "experimental" -}} +{{- fail "objectController requires options.featureSet=experimental" -}} +{{- end -}} +true +{{- end -}} +{{- end -}} + {{/* When rendering with OpenShift, only one of the main components (catalogd, operatorController) should be enabled diff --git a/helm/olmv1/templates/cert-manager/certificate-olmv1-system-object-controller-cert.yml b/helm/olmv1/templates/cert-manager/certificate-olmv1-system-object-controller-cert.yml new file mode 100644 index 0000000000..576004cf35 --- /dev/null +++ b/helm/olmv1/templates/cert-manager/certificate-olmv1-system-object-controller-cert.yml @@ -0,0 +1,25 @@ +{{- if and (include "objectController.enabled" .) .Values.options.certManager.enabled }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + annotations: + {{- include "olmv1.annotations" . | nindent 4 }} + labels: + app.kubernetes.io/name: {{ include "olmv1.label.name" . }} + {{- include "olmv1.labels" . | nindent 4 }} + name: object-controller-cert + namespace: {{ .Values.namespaces.olmv1.name }} +spec: + dnsNames: + - object-controller-service.{{ .Values.namespaces.olmv1.name }}.svc + - object-controller-service.{{ .Values.namespaces.olmv1.name }}.svc.cluster.local + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: olmv1-ca + privateKey: + algorithm: ECDSA + rotationPolicy: Always + size: 256 + secretName: object-controller-cert +{{- end }} diff --git a/helm/olmv1/templates/crds/customresourcedefinition-clusterobjectsets.olm.operatorframework.io.yml b/helm/olmv1/templates/crds/customresourcedefinition-clusterobjectsets.olm.operatorframework.io.yml index 11abb0d2ce..e9a88d0c4d 100644 --- a/helm/olmv1/templates/crds/customresourcedefinition-clusterobjectsets.olm.operatorframework.io.yml +++ b/helm/olmv1/templates/crds/customresourcedefinition-clusterobjectsets.olm.operatorframework.io.yml @@ -1,11 +1,3 @@ -{{- if .Values.options.operatorController.enabled }} -{{- if (eq .Values.options.featureSet "standard") }} -{{- /* Add when GA: tpl (.Files.Get "base/operator-controller/crd/standard/olm.operatorframework.io_clusterobjectsetss.yaml") . */}} -{{- else if (eq .Values.options.featureSet "experimental") }} -{{- if has "BoxcutterRuntime" .Values.options.operatorController.features.enabled }} -{{ tpl (.Files.Get "base/operator-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml") . }} -{{- end }} -{{- else }} -{{- fail "options.featureSet must be set to one of: {standard,experimental}" }} -{{- end }} +{{- if include "objectController.enabled" . }} +{{ tpl (.Files.Get "base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectsets.yaml") . }} {{- end }} diff --git a/helm/olmv1/templates/deployment-olmv1-system-object-controller-controller-manager.yml b/helm/olmv1/templates/deployment-olmv1-system-object-controller-controller-manager.yml new file mode 100644 index 0000000000..0df55a6750 --- /dev/null +++ b/helm/olmv1/templates/deployment-olmv1-system-object-controller-controller-manager.yml @@ -0,0 +1,120 @@ +{{- if include "objectController.enabled" . }} +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + kubectl.kubernetes.io/default-logs-container: manager + {{- include "olmv1.annotations" . | nindent 4 }} + labels: + app.kubernetes.io/name: object-controller + {{- include "olmv1.labels" . | nindent 4 }} + name: object-controller-controller-manager + namespace: {{ .Values.namespaces.olmv1.name }} +spec: + replicas: {{ .Values.options.objectController.deployment.replicas }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + control-plane: object-controller-controller-manager + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + {{- include "olmv1.annotations" . | nindent 8 }} + {{- if .Values.options.openshift.enabled }} + target.workload.openshift.io/management: '{"effect": "PreferredDuringScheduling"}' + openshift.io/required-scc: privileged + {{- end }} + labels: + app.kubernetes.io/name: object-controller + control-plane: object-controller-controller-manager + {{- include "olmv1.labels" . | nindent 8 }} + {{- with .Values.options.objectController.deployment.podLabels }} + {{- toYamlPretty . | nindent 8 }} + {{- end }} + spec: + containers: + - name: manager + command: + - /object-controller + args: + - --health-probe-bind-address=:8081 + {{- if not .Values.options.tilt.enabled }} + - --leader-elect + {{- end }} + {{- if .Values.options.profiling.enabled }} + - --pprof-bind-address=:6060 + {{- end }} + {{- if or .Values.options.certManager.enabled .Values.options.openshift.enabled }} + - --metrics-bind-address=:8443 + - --tls-cert=/var/certs/tls.crt + - --tls-key=/var/certs/tls.key + {{- end }} + {{- if .Values.options.openshift.enabled }} + - --v=${LOG_VERBOSITY} + {{- end }} + {{- if .Values.options.e2e.enabled }} + - --tls-profile=modern + {{- end }} + {{- range .Values.options.objectController.deployment.extraArguments }} + - {{ . }} + {{- end }} + image: "{{ .Values.options.objectController.deployment.image }}" + {{- if .Values.options.e2e.enabled }} + env: + - name: GOCOVERDIR + value: /e2e-coverage + {{- end }} + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 64Mi + {{- if or .Values.options.certManager.enabled .Values.options.openshift.enabled .Values.options.e2e.enabled }} + volumeMounts: + {{- if or .Values.options.certManager.enabled .Values.options.openshift.enabled }} + - name: object-controller-certs + mountPath: /var/certs + readOnly: true + {{- end }} + {{- if .Values.options.e2e.enabled }} + - name: e2e-coverage-volume + mountPath: /e2e-coverage + {{- end }} + {{- end }} + {{- with .Values.deployments.containerSpec }} + {{- toYaml . | nindent 10 }} + {{- end }} + serviceAccountName: object-controller-controller-manager + {{- if or .Values.options.certManager.enabled .Values.options.openshift.enabled .Values.options.e2e.enabled }} + volumes: + {{- if or .Values.options.certManager.enabled .Values.options.openshift.enabled }} + - name: object-controller-certs + secret: + secretName: object-controller-cert + {{- end }} + {{- if .Values.options.e2e.enabled }} + - name: e2e-coverage-volume + persistentVolumeClaim: + claimName: e2e-coverage + {{- end }} + {{- end }} + {{- with .Values.deployments.templateSpec }} + {{- toYamlPretty . | nindent 6 }} + {{- end }} +{{- end }} diff --git a/helm/olmv1/templates/networkpolicy/networkpolicy-olmv1-system-object-controller-controller-manager.yml b/helm/olmv1/templates/networkpolicy/networkpolicy-olmv1-system-object-controller-controller-manager.yml new file mode 100644 index 0000000000..e1c1dd3c08 --- /dev/null +++ b/helm/olmv1/templates/networkpolicy/networkpolicy-olmv1-system-object-controller-controller-manager.yml @@ -0,0 +1,25 @@ +{{- if include "objectController.enabled" . }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + annotations: + {{- include "olmv1.annotations" . | nindent 4 }} + labels: + app.kubernetes.io/name: object-controller + {{- include "olmv1.labels" . | nindent 4 }} + name: object-controller-controller-manager + namespace: {{ .Values.namespaces.olmv1.name }} +spec: + egress: + - {} + ingress: + - ports: + - port: 8443 + protocol: TCP + podSelector: + matchLabels: + control-plane: object-controller-controller-manager + policyTypes: + - Ingress + - Egress +{{- end }} diff --git a/helm/olmv1/templates/poddisruptionbudget-olmv1-system-object-controller.yml b/helm/olmv1/templates/poddisruptionbudget-olmv1-system-object-controller.yml new file mode 100644 index 0000000000..ee9a2d0702 --- /dev/null +++ b/helm/olmv1/templates/poddisruptionbudget-olmv1-system-object-controller.yml @@ -0,0 +1,22 @@ +{{- if and (include "objectController.enabled" .) .Values.options.objectController.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: object-controller-controller-manager + namespace: {{ .Values.namespaces.olmv1.name }} + labels: + app.kubernetes.io/name: object-controller + {{- include "olmv1.labels" . | nindent 4 }} + annotations: + {{- include "olmv1.annotations" . | nindent 4 }} +spec: + {{- if ne (toJson .Values.options.objectController.podDisruptionBudget.maxUnavailable) "null" }} + maxUnavailable: {{ .Values.options.objectController.podDisruptionBudget.maxUnavailable }} + {{- else if ne (toJson .Values.options.objectController.podDisruptionBudget.minAvailable) "null" }} + minAvailable: {{ .Values.options.objectController.podDisruptionBudget.minAvailable }} + {{- end }} + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + control-plane: object-controller-controller-manager +{{- end }} diff --git a/helm/olmv1/templates/rbac/clusterrole-common-metrics-reader.yml b/helm/olmv1/templates/rbac/clusterrole-common-metrics-reader.yml index 069041955d..0de749cd78 100644 --- a/helm/olmv1/templates/rbac/clusterrole-common-metrics-reader.yml +++ b/helm/olmv1/templates/rbac/clusterrole-common-metrics-reader.yml @@ -1,4 +1,7 @@ {{- $options := list }} +{{- if include "objectController.enabled" . }} +{{- $options = append $options "object-controller" }} +{{- end }} {{- if .Values.options.catalogd.enabled }} {{- $options = append $options "catalogd" }} {{- end }} diff --git a/helm/olmv1/templates/rbac/clusterrole-common-proxy-role.yml b/helm/olmv1/templates/rbac/clusterrole-common-proxy-role.yml index 266348e2f3..504ee7a0e7 100644 --- a/helm/olmv1/templates/rbac/clusterrole-common-proxy-role.yml +++ b/helm/olmv1/templates/rbac/clusterrole-common-proxy-role.yml @@ -1,4 +1,7 @@ {{- $options := list }} +{{- if include "objectController.enabled" . }} +{{- $options = append $options "object-controller" }} +{{- end }} {{- if .Values.options.catalogd.enabled }} {{- $options = append $options "catalogd" }} {{- end }} diff --git a/helm/olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml b/helm/olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml index b53096f139..6627d61edd 100644 --- a/helm/olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml +++ b/helm/olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml @@ -1,4 +1,7 @@ {{- $options := list }} +{{- if include "objectController.enabled" . }} +{{- $options = append $options "object-controller" }} +{{- end }} {{- if .Values.options.catalogd.enabled }} {{- $options = append $options "catalogd" }} {{- end }} diff --git a/helm/olmv1/templates/rbac/clusterrolebinding-object-controller-manager-rolebinding.yml b/helm/olmv1/templates/rbac/clusterrolebinding-object-controller-manager-rolebinding.yml new file mode 100644 index 0000000000..4ccb1c9e6e --- /dev/null +++ b/helm/olmv1/templates/rbac/clusterrolebinding-object-controller-manager-rolebinding.yml @@ -0,0 +1,20 @@ +{{- if include "objectController.enabled" . }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: + {{- include "olmv1.annotations" $ | nindent 4 }} + labels: + app.kubernetes.io/name: object-controller + {{- include "olmv1.labels" $ | nindent 4 }} + name: object-controller-cluster-admin-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: object-controller-controller-manager + namespace: {{ $.Values.namespaces.olmv1.name }} +{{- end }} diff --git a/helm/olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml b/helm/olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml index 41c1c7bb73..32bc20b9cf 100644 --- a/helm/olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml +++ b/helm/olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml @@ -1,4 +1,7 @@ {{- $options := list }} +{{- if include "objectController.enabled" . }} +{{- $options = append $options "object-controller" }} +{{- end }} {{- if .Values.options.catalogd.enabled }} {{- $options = append $options "catalogd" }} {{- end }} diff --git a/helm/olmv1/templates/rbac/role-olmv1-system-metrics-monitor-role.yml b/helm/olmv1/templates/rbac/role-olmv1-system-metrics-monitor-role.yml index 0cf8ee17ab..292cde03b6 100644 --- a/helm/olmv1/templates/rbac/role-olmv1-system-metrics-monitor-role.yml +++ b/helm/olmv1/templates/rbac/role-olmv1-system-metrics-monitor-role.yml @@ -1,5 +1,5 @@ {{- if .Values.options.openshift.enabled -}} -{{- if or .Values.options.catalogd.enabled .Values.options.operatorController.enabled -}} +{{- if or .Values.options.catalogd.enabled .Values.options.operatorController.enabled (include "objectController.enabled" .) -}} apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: diff --git a/helm/olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml b/helm/olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml index d8ab8f1178..3280c4d9ee 100644 --- a/helm/olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml +++ b/helm/olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml @@ -1,4 +1,7 @@ {{- $options := list }} +{{- if include "objectController.enabled" . }} +{{- $options = append $options "object-controller" }} +{{- end }} {{- if .Values.options.catalogd.enabled }} {{- $options = append $options "catalogd" }} {{- end }} diff --git a/helm/olmv1/templates/rbac/rolebinding-olmv1-system-metrics-monitor-rolebinding.yml b/helm/olmv1/templates/rbac/rolebinding-olmv1-system-metrics-monitor-rolebinding.yml index 18ec318a2a..1da0db2c01 100644 --- a/helm/olmv1/templates/rbac/rolebinding-olmv1-system-metrics-monitor-rolebinding.yml +++ b/helm/olmv1/templates/rbac/rolebinding-olmv1-system-metrics-monitor-rolebinding.yml @@ -1,5 +1,5 @@ {{- if .Values.options.openshift.enabled -}} -{{- if or .Values.options.catalogd.enabled .Values.options.operatorController.enabled -}} +{{- if or .Values.options.catalogd.enabled .Values.options.operatorController.enabled (include "objectController.enabled" .) -}} apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: diff --git a/helm/olmv1/templates/service-olmv1-system-object-controller-service.yml b/helm/olmv1/templates/service-olmv1-system-object-controller-service.yml new file mode 100644 index 0000000000..6c3b56ec2f --- /dev/null +++ b/helm/olmv1/templates/service-olmv1-system-object-controller-service.yml @@ -0,0 +1,23 @@ +{{- if include "objectController.enabled" . }} +apiVersion: v1 +kind: Service +metadata: + annotations: + {{- include "olmv1.annotations" . | nindent 4 }} + {{- if .Values.options.openshift.enabled }} + service.beta.openshift.io/serving-cert-secret-name: object-controller-cert + {{- end }} + labels: + app.kubernetes.io/name: object-controller + {{- include "olmv1.labels" . | nindent 4 }} + name: object-controller-service + namespace: {{ .Values.namespaces.olmv1.name }} +spec: + ports: + - name: metrics + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + app.kubernetes.io/name: object-controller +{{- end }} diff --git a/helm/olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml b/helm/olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml index f29464ede3..0936414151 100644 --- a/helm/olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml +++ b/helm/olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml @@ -1,4 +1,7 @@ {{- $options := list }} +{{- if include "objectController.enabled" . }} +{{- $options = append $options "object-controller" }} +{{- end }} {{- if .Values.options.catalogd.enabled }} {{- $options = append $options "catalogd" }} {{- end }} diff --git a/helm/olmv1/templates/servicemonitor-olmv1-system-object-controller-metrics-monitor.yml b/helm/olmv1/templates/servicemonitor-olmv1-system-object-controller-metrics-monitor.yml new file mode 100644 index 0000000000..ddaf02bb2b --- /dev/null +++ b/helm/olmv1/templates/servicemonitor-olmv1-system-object-controller-metrics-monitor.yml @@ -0,0 +1,34 @@ +{{- if .Values.options.openshift.enabled -}} +{{- if include "objectController.enabled" . -}} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + annotations: + {{- include "olmv1.annotations" . | nindent 4 }} + labels: + openshift.io/cluster-monitoring: 'true' + app.kubernetes.io/name: object-controller + {{- include "olmv1.labels" . | nindent 4 }} + name: object-controller-metrics-monitor + namespace: {{ .Values.namespaces.olmv1.name }} +spec: + endpoints: + - bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + interval: 30s + path: /metrics + port: metrics + scheme: https + tlsConfig: + caFile: /etc/prometheus/configmaps/serving-certs-ca-bundle/service-ca.crt + certFile: /etc/prometheus/secrets/metrics-client-certs/tls.crt + keyFile: /etc/prometheus/secrets/metrics-client-certs/tls.key + serverName: object-controller-service.{{ .Values.namespaces.olmv1.name }}.svc + namespaceSelector: + matchNames: + - {{ .Values.namespaces.olmv1.name }} + selector: + matchLabels: + app.kubernetes.io/name: object-controller + serviceDiscoveryRole: EndpointSlice +{{- end -}} +{{- end -}} diff --git a/helm/olmv1/values.yaml b/helm/olmv1/values.yaml index fda0bdc900..bc3e709403 100644 --- a/helm/olmv1/values.yaml +++ b/helm/olmv1/values.yaml @@ -1,9 +1,18 @@ # Default values for OLMv1. # This is a YAML-formatted file. # Declare variables to be passed into your templates. - # List of components to include options: + objectController: + # null follows operatorController's BoxcutterRuntime gate. Set true to deploy independently. + enabled: null + deployment: + image: quay.io/operator-framework/object-controller:devel + replicas: 1 + extraArguments: [] + podDisruptionBudget: + enabled: true + minAvailable: 1 operatorController: enabled: true deployment: @@ -49,15 +58,13 @@ options: version: v4.20 # This can be one of: standard or experimental featureSet: standard - # The set of namespaces namespaces: olmv1: name: olmv1-system certManager: name: cert-manager - -# Common deployment values for operator-controller and catalogd +# Common deployment values for all controllers deployments: templateSpec: affinity: diff --git a/internal/object-controller/controllers/suite_test.go b/internal/object-controller/controllers/suite_test.go index 4074fbaca2..e1c6636e14 100644 --- a/internal/object-controller/controllers/suite_test.go +++ b/internal/object-controller/controllers/suite_test.go @@ -32,6 +32,7 @@ import ( ) func newScheme(t *testing.T) *apimachineryruntime.Scheme { + t.Helper() sch := apimachineryruntime.NewScheme() require.NoError(t, ocv1.AddToScheme(sch)) return sch @@ -41,6 +42,7 @@ var config *rest.Config func TestMain(m *testing.M) { testEnv := test.NewEnv() + testEnv.CRDDirectoryPaths = []string{"../../../helm/olmv1/base/object-controller/crd/experimental"} var err error config, err = testEnv.Start() diff --git a/internal/object-controller/manifests/manifests_test.go b/internal/object-controller/manifests/manifests_test.go new file mode 100644 index 0000000000..b1d4b81e6a --- /dev/null +++ b/internal/object-controller/manifests/manifests_test.go @@ -0,0 +1,141 @@ +package manifests_test + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" + policyv1 "k8s.io/api/policy/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/utils/ptr" +) + +func TestObjectControllerManifests(t *testing.T) { + for _, tc := range []struct { + name string + values string + wantObjectController bool + wantOperatorController bool + wantCatalogd bool + wantOpenShift bool + wantErr string + }{ + {name: "standard", values: `{}`, wantOperatorController: true, wantCatalogd: true}, + {name: "disabled gate takes precedence", values: `options: {featureSet: experimental, operatorController: {features: {enabled: [BoxcutterRuntime], disabled: [BoxcutterRuntime]}}}`, wantOperatorController: true, wantCatalogd: true}, + {name: "experimental with Boxcutter", values: `options: {featureSet: experimental, operatorController: {features: {enabled: [BoxcutterRuntime], disabled: []}}}`, wantObjectController: true, wantOperatorController: true, wantCatalogd: true}, + {name: "standalone", values: `options: {featureSet: experimental, objectController: {enabled: true}, operatorController: {enabled: false}, catalogd: {enabled: false}}`, wantObjectController: true}, + {name: "standalone with cert-manager", values: `options: {featureSet: experimental, objectController: {enabled: true}, operatorController: {enabled: false}, catalogd: {enabled: false}, certManager: {enabled: true}}`, wantObjectController: true}, + {name: "explicitly disabled", values: `options: {featureSet: experimental, objectController: {enabled: false}}`, wantOperatorController: true, wantCatalogd: true}, + {name: "downstream default", values: `options: {openshift: {enabled: true}, catalogd: {enabled: false}}`, wantOperatorController: true, wantOpenShift: true}, + {name: "downstream experimental with Helm", values: `options: {featureSet: experimental, openshift: {enabled: true}, catalogd: {enabled: false}, operatorController: {features: {enabled: [WebhookProviderOpenshiftServiceCA], disabled: [BoxcutterRuntime]}}}`, wantOperatorController: true, wantOpenShift: true}, + {name: "downstream experimental with Boxcutter", values: `options: {featureSet: experimental, openshift: {enabled: true}, catalogd: {enabled: false}, operatorController: {features: {enabled: [BoxcutterRuntime], disabled: []}}}`, wantObjectController: true, wantOperatorController: true, wantOpenShift: true}, + {name: "downstream standalone", values: `options: {featureSet: experimental, openshift: {enabled: true}, objectController: {enabled: true}, operatorController: {enabled: false}, catalogd: {enabled: false}}`, wantObjectController: true, wantOpenShift: true}, + {name: "catalogd only", values: `options: {featureSet: experimental, operatorController: {enabled: false, features: {enabled: [BoxcutterRuntime]}}}`, wantCatalogd: true}, + {name: "standard cannot enable experimental API", values: `options: {objectController: {enabled: true}}`, wantErr: "objectController requires options.featureSet=experimental"}, + } { + t.Run(tc.name, func(t *testing.T) { + rendered, err := renderChart(t, tc.values) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + objects := map[string]*unstructured.Unstructured{} + seen := map[string]struct{}{} + for _, content := range rendered { + decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(content), 4096) + for { + obj := &unstructured.Unstructured{} + err := decoder.Decode(obj) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if obj.GetKind() != "" { + key := obj.GetKind() + "/" + obj.GetName() + resourceKey := obj.GetKind() + "/" + obj.GetNamespace() + "/" + obj.GetName() + require.NotContains(t, seen, resourceKey, "duplicate resource") + seen[resourceKey] = struct{}{} + objects[key] = obj + } + } + } + for name, enabled := range map[string]bool{"object-controller": tc.wantObjectController, "operator-controller": tc.wantOperatorController, "catalogd": tc.wantCatalogd} { + require.Equal(t, enabled, objects["Deployment/"+name+"-controller-manager"] != nil, name) + } + require.Equal(t, tc.wantOperatorController, objects["CustomResourceDefinition/clusterextensions.olm.operatorframework.io"] != nil) + require.Equal(t, tc.wantCatalogd, objects["CustomResourceDefinition/clustercatalogs.olm.operatorframework.io"] != nil) + require.Equal(t, tc.wantObjectController, objects["CustomResourceDefinition/clusterobjectsets.olm.operatorframework.io"] != nil) + for _, resource := range []string{ + "ServiceAccount/object-controller-controller-manager", "ClusterRoleBinding/object-controller-cluster-admin-rolebinding", + "Role/object-controller-leader-election-role", "RoleBinding/object-controller-leader-election-rolebinding", + "Service/object-controller-service", "NetworkPolicy/object-controller-controller-manager", + } { + require.Equal(t, tc.wantObjectController, objects[resource] != nil, resource) + } + if tc.wantObjectController { + deployment := objects["Deployment/object-controller-controller-manager"] + sa, _, err := unstructured.NestedString(deployment.Object, "spec", "template", "spec", "serviceAccountName") + require.NoError(t, err) + require.Equal(t, "object-controller-controller-manager", sa) + if tc.wantOpenShift { + require.Contains(t, objects, "ServiceMonitor/object-controller-metrics-monitor") + require.Equal(t, "object-controller-cert", objects["Service/object-controller-service"].GetAnnotations()["service.beta.openshift.io/serving-cert-secret-name"]) + } + } + }) + } +} + +func TestObjectControllerPodDisruptionBudget(t *testing.T) { + for _, tc := range []struct { + name string + values string + wantMinAvailable *intstr.IntOrString + wantMaxUnavailable *intstr.IntOrString + disabled bool + }{ + {name: "default minimum", values: `{}`, wantMinAvailable: ptr.To(intstr.FromInt32(1))}, + {name: "maximum overrides default minimum", values: `{maxUnavailable: 1}`, wantMaxUnavailable: ptr.To(intstr.FromInt32(1))}, + {name: "zero maximum", values: `{maxUnavailable: 0}`, wantMaxUnavailable: ptr.To(intstr.FromInt32(0))}, + {name: "percentage maximum", values: `{maxUnavailable: "50%"}`, wantMaxUnavailable: ptr.To(intstr.FromString("50%"))}, + {name: "zero minimum", values: `{minAvailable: 0}`, wantMinAvailable: ptr.To(intstr.FromInt32(0))}, + {name: "percentage minimum", values: `{minAvailable: "50%"}`, wantMinAvailable: ptr.To(intstr.FromString("50%"))}, + {name: "null maximum preserves minimum", values: `{maxUnavailable: null}`, wantMinAvailable: ptr.To(intstr.FromInt32(1))}, + {name: "null minimum with maximum", values: `{minAvailable: null, maxUnavailable: 1}`, wantMaxUnavailable: ptr.To(intstr.FromInt32(1))}, + {name: "disabled", values: `{enabled: false}`, disabled: true}, + } { + t.Run(tc.name, func(t *testing.T) { + rendered, err := renderChart(t, `options: {featureSet: experimental, objectController: {enabled: true, podDisruptionBudget: `+tc.values+`}}`) + require.NoError(t, err) + content := rendered["olmv1/templates/poddisruptionbudget-olmv1-system-object-controller.yml"] + if tc.disabled { + require.Empty(t, strings.TrimSpace(content)) + return + } + var budget policyv1.PodDisruptionBudget + require.NoError(t, yaml.NewYAMLOrJSONDecoder(strings.NewReader(content), 4096).Decode(&budget)) + require.Equal(t, "object-controller-controller-manager", budget.Name) + require.Equal(t, tc.wantMinAvailable, budget.Spec.MinAvailable) + require.Equal(t, tc.wantMaxUnavailable, budget.Spec.MaxUnavailable) + }) + } +} + +func renderChart(t *testing.T, valuesYAML string) (map[string]string, error) { + t.Helper() + chart, err := loader.Load("../../../helm/olmv1") + require.NoError(t, err) + values, err := chartutil.ReadValues([]byte(valuesYAML)) + require.NoError(t, err) + renderValues, err := chartutil.ToRenderValues(chart, values, chartutil.ReleaseOptions{Name: "olmv1"}, nil) + require.NoError(t, err) + return engine.Render(chart, renderValues) +} diff --git a/scripts/install.tpl.sh b/scripts/install.tpl.sh index 4be62168bf..f2ec280705 100644 --- a/scripts/install.tpl.sh +++ b/scripts/install.tpl.sh @@ -127,6 +127,10 @@ curl -L -s "${olmv1_manifest}" | sed "s/olmv1-system/${olmv1_namespace}/g" | kub kubectl_wait_rollout "${olmv1_namespace}" "deployment/catalogd-controller-manager" "60s" kubectl_wait "${olmv1_namespace}" "deployment/catalogd-controller-manager" "60s" kubectl_wait "${olmv1_namespace}" "deployment/operator-controller-controller-manager" "60s" +if kubectl get deployment/object-controller-controller-manager --namespace="${olmv1_namespace}" >/dev/null 2>&1; then + kubectl_wait_rollout "${olmv1_namespace}" "deployment/object-controller-controller-manager" "60s" + kubectl_wait "${olmv1_namespace}" "deployment/object-controller-controller-manager" "60s" +fi if [[ "${install_default_catalogs}" != "false" ]]; then kubectl apply -f "${default_catalogs_manifest}" diff --git a/test/utils.go b/test/utils.go index 5aa9c41260..441aa7d689 100644 --- a/test/utils.go +++ b/test/utils.go @@ -22,6 +22,7 @@ func NewEnv() *envtest.Environment { testEnv := &envtest.Environment{ CRDDirectoryPaths: []string{ pathFromProjectRoot("helm/olmv1/base/operator-controller/crd/experimental"), + pathFromProjectRoot("helm/olmv1/base/object-controller/crd/experimental"), pathFromProjectRoot("helm/olmv1/base/catalogd/crd/experimental"), }, ErrorIfCRDPathMissing: true, From 76f25b0f69dfc203b7323547b092e9bbef69aa06 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Thu, 24 Sep 2026 17:07:59 +0100 Subject: [PATCH 5/7] build(object-controller): refresh experimental manifests Include the standalone object-controller deployment, RBAC, networking, metrics, and disruption budget in the generated experimental installation manifests. Refs: OPRUN-4775 Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- manifests/experimental-e2e.yaml | 355 +++++++++++++++++++++++++++++++- manifests/experimental.yaml | 345 ++++++++++++++++++++++++++++++- 2 files changed, 698 insertions(+), 2 deletions(-) diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 885d648b4a..a65ee62dbc 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -62,6 +62,31 @@ spec: - Ingress - Egress --- +# Source: olmv1/templates/networkpolicy/networkpolicy-olmv1-system-object-controller-controller-manager.yml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-controller-manager + namespace: olmv1-system +spec: + egress: + - {} + ingress: + - ports: + - port: 8443 + protocol: TCP + podSelector: + matchLabels: + control-plane: object-controller-controller-manager + policyTypes: + - Ingress + - Egress +--- # Source: olmv1/templates/networkpolicy/networkpolicy-olmv1-system-operator-controller-controller-manager.yml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy @@ -105,6 +130,24 @@ spec: matchLabels: control-plane: catalogd-controller-manager --- +# Source: olmv1/templates/poddisruptionbudget-olmv1-system-object-controller.yml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: object-controller-controller-manager + namespace: olmv1-system + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + annotations: + olm.operatorframework.io/feature-set: experimental-e2e +spec: + minAvailable: 1 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + control-plane: object-controller-controller-manager +--- # Source: olmv1/templates/poddisruptionbudget-olmv1-system-operator-controller.yml apiVersion: policy/v1 kind: PodDisruptionBudget @@ -126,6 +169,18 @@ spec: # Source: olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml apiVersion: v1 kind: ServiceAccount +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-controller-manager + namespace: olmv1-system +--- +# Source: olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml +apiVersion: v1 +kind: ServiceAccount metadata: annotations: olm.operatorframework.io/feature-set: experimental-e2e @@ -1359,7 +1414,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.21.0 olm.operatorframework.io/generator: experimental name: clusterobjectsets.olm.operatorframework.io spec: @@ -2068,6 +2123,22 @@ rules: # Source: olmv1/templates/rbac/clusterrole-common-metrics-reader.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-metrics-reader +rules: + - nonResourceURLs: + - /metrics + verbs: + - get +--- +# Source: olmv1/templates/rbac/clusterrole-common-metrics-reader.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: annotations: olm.operatorframework.io/feature-set: experimental-e2e @@ -2100,6 +2171,30 @@ rules: # Source: olmv1/templates/rbac/clusterrole-common-proxy-role.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-proxy-role +rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create + - apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +# Source: olmv1/templates/rbac/clusterrole-common-proxy-role.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: annotations: olm.operatorframework.io/feature-set: experimental-e2e @@ -2187,6 +2282,25 @@ subjects: # Source: olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: object-controller-proxy-role +subjects: + - kind: ServiceAccount + name: object-controller-controller-manager + namespace: olmv1-system +--- +# Source: olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding metadata: annotations: olm.operatorframework.io/feature-set: experimental-e2e @@ -2222,6 +2336,25 @@ subjects: name: operator-controller-controller-manager namespace: olmv1-system --- +# Source: olmv1/templates/rbac/clusterrolebinding-object-controller-manager-rolebinding.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-cluster-admin-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: object-controller-controller-manager + namespace: olmv1-system +--- # Source: olmv1/templates/rbac/clusterrolebinding-operator-controller-manager-rolebinding.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -2266,6 +2399,38 @@ rules: # Source: olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml apiVersion: rbac.authorization.k8s.io/v1 kind: Role +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-leader-election-role + namespace: olmv1-system +rules: + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +# Source: olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role metadata: annotations: olm.operatorframework.io/feature-set: experimental-e2e @@ -2364,6 +2529,26 @@ rules: # Source: olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-leader-election-rolebinding + namespace: olmv1-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: object-controller-leader-election-role +subjects: + - kind: ServiceAccount + name: object-controller-controller-manager + namespace: olmv1-system +--- +# Source: olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding metadata: annotations: olm.operatorframework.io/feature-set: experimental-e2e @@ -2469,6 +2654,26 @@ spec: selector: app.kubernetes.io/name: catalogd --- +# Source: olmv1/templates/service-olmv1-system-object-controller-service.yml +apiVersion: v1 +kind: Service +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-service + namespace: olmv1-system +spec: + ports: + - name: metrics + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + app.kubernetes.io/name: object-controller +--- # Source: olmv1/templates/service-olmv1-system-operator-controller-service.yml apiVersion: v1 kind: Service @@ -2683,6 +2888,129 @@ spec: operator: Exists tolerationSeconds: 120 --- +# Source: olmv1/templates/deployment-olmv1-system-object-controller-controller-manager.yml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + kubectl.kubernetes.io/default-logs-container: manager + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-controller-manager + namespace: olmv1-system +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + control-plane: object-controller-controller-manager + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: object-controller + control-plane: object-controller-controller-manager + app.kubernetes.io/part-of: olm + spec: + containers: + - name: manager + command: + - /object-controller + args: + - --health-probe-bind-address=:8081 + - --leader-elect + - --pprof-bind-address=:6060 + - --metrics-bind-address=:8443 + - --tls-cert=/var/certs/tls.crt + - --tls-key=/var/certs/tls.key + - --tls-profile=modern + image: "quay.io/operator-framework/object-controller:devel" + env: + - name: GOCOVERDIR + value: /e2e-coverage + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 64Mi + volumeMounts: + - name: object-controller-certs + mountPath: /var/certs + readOnly: true + - name: e2e-coverage-volume + mountPath: /e2e-coverage + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + terminationMessagePolicy: FallbackToLogsOnError + serviceAccountName: object-controller-controller-manager + volumes: + - name: object-controller-certs + secret: + secretName: object-controller-cert + - name: e2e-coverage-volume + persistentVolumeClaim: + claimName: e2e-coverage + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + - arm64 + - ppc64le + - s390x + - key: kubernetes.io/os + operator: In + values: + - linux + nodeSelector: + kubernetes.io/os: linux + node-role.kubernetes.io/control-plane: "" + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + terminationGracePeriodSeconds: 10 + tolerations: + - effect: NoSchedule + key: node-role.kubernetes.io/control-plane + operator: Exists + - effect: NoExecute + key: node.kubernetes.io/unreachable + operator: Exists + tolerationSeconds: 120 + - effect: NoExecute + key: node.kubernetes.io/not-ready + operator: Exists + tolerationSeconds: 120 +--- # Source: olmv1/templates/deployment-olmv1-system-operator-controller-controller-manager.yml apiVersion: apps/v1 kind: Deployment @@ -2896,6 +3224,31 @@ spec: size: 256 secretName: catalogd-service-cert-git-version --- +# Source: olmv1/templates/cert-manager/certificate-olmv1-system-object-controller-cert.yml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental-e2e + labels: + app.kubernetes.io/name: olmv1 + app.kubernetes.io/part-of: olm + name: object-controller-cert + namespace: olmv1-system +spec: + dnsNames: + - object-controller-service.olmv1-system.svc + - object-controller-service.olmv1-system.svc.cluster.local + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: olmv1-ca + privateKey: + algorithm: ECDSA + rotationPolicy: Always + size: 256 + secretName: object-controller-cert +--- # Source: olmv1/templates/cert-manager/certificate-olmv1-system-operator-controller-cert.yml apiVersion: cert-manager.io/v1 kind: Certificate diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 2ceb70b4b5..d14d11e11d 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -62,6 +62,31 @@ spec: - Ingress - Egress --- +# Source: olmv1/templates/networkpolicy/networkpolicy-olmv1-system-object-controller-controller-manager.yml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-controller-manager + namespace: olmv1-system +spec: + egress: + - {} + ingress: + - ports: + - port: 8443 + protocol: TCP + podSelector: + matchLabels: + control-plane: object-controller-controller-manager + policyTypes: + - Ingress + - Egress +--- # Source: olmv1/templates/networkpolicy/networkpolicy-olmv1-system-operator-controller-controller-manager.yml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy @@ -105,6 +130,24 @@ spec: matchLabels: control-plane: catalogd-controller-manager --- +# Source: olmv1/templates/poddisruptionbudget-olmv1-system-object-controller.yml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: object-controller-controller-manager + namespace: olmv1-system + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + annotations: + olm.operatorframework.io/feature-set: experimental +spec: + minAvailable: 1 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + control-plane: object-controller-controller-manager +--- # Source: olmv1/templates/poddisruptionbudget-olmv1-system-operator-controller.yml apiVersion: policy/v1 kind: PodDisruptionBudget @@ -126,6 +169,18 @@ spec: # Source: olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml apiVersion: v1 kind: ServiceAccount +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-controller-manager + namespace: olmv1-system +--- +# Source: olmv1/templates/serviceaccount-olmv1-system-common-controller-manager.yml +apiVersion: v1 +kind: ServiceAccount metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -1320,7 +1375,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.21.0 olm.operatorframework.io/generator: experimental name: clusterobjectsets.olm.operatorframework.io spec: @@ -2029,6 +2084,22 @@ rules: # Source: olmv1/templates/rbac/clusterrole-common-metrics-reader.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-metrics-reader +rules: + - nonResourceURLs: + - /metrics + verbs: + - get +--- +# Source: olmv1/templates/rbac/clusterrole-common-metrics-reader.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -2061,6 +2132,30 @@ rules: # Source: olmv1/templates/rbac/clusterrole-common-proxy-role.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-proxy-role +rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create + - apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +# Source: olmv1/templates/rbac/clusterrole-common-proxy-role.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -2148,6 +2243,25 @@ subjects: # Source: olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: object-controller-proxy-role +subjects: + - kind: ServiceAccount + name: object-controller-controller-manager + namespace: olmv1-system +--- +# Source: olmv1/templates/rbac/clusterrolebinding-common-proxy-rolebinding.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -2183,6 +2297,25 @@ subjects: name: operator-controller-controller-manager namespace: olmv1-system --- +# Source: olmv1/templates/rbac/clusterrolebinding-object-controller-manager-rolebinding.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-cluster-admin-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: object-controller-controller-manager + namespace: olmv1-system +--- # Source: olmv1/templates/rbac/clusterrolebinding-operator-controller-manager-rolebinding.yml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -2227,6 +2360,38 @@ rules: # Source: olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml apiVersion: rbac.authorization.k8s.io/v1 kind: Role +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-leader-election-role + namespace: olmv1-system +rules: + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +# Source: olmv1/templates/rbac/role-olmv1-system-common-leader-election-role.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -2325,6 +2490,26 @@ rules: # Source: olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-leader-election-rolebinding + namespace: olmv1-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: object-controller-leader-election-role +subjects: + - kind: ServiceAccount + name: object-controller-controller-manager + namespace: olmv1-system +--- +# Source: olmv1/templates/rbac/rolebinding-olmv1-system-common-leader-election-rolebinding.yml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding metadata: annotations: olm.operatorframework.io/feature-set: experimental @@ -2430,6 +2615,26 @@ spec: selector: app.kubernetes.io/name: catalogd --- +# Source: olmv1/templates/service-olmv1-system-object-controller-service.yml +apiVersion: v1 +kind: Service +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-service + namespace: olmv1-system +spec: + ports: + - name: metrics + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + app.kubernetes.io/name: object-controller +--- # Source: olmv1/templates/service-olmv1-system-operator-controller-service.yml apiVersion: v1 kind: Service @@ -2590,6 +2795,119 @@ spec: operator: Exists tolerationSeconds: 120 --- +# Source: olmv1/templates/deployment-olmv1-system-object-controller-controller-manager.yml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + kubectl.kubernetes.io/default-logs-container: manager + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + app.kubernetes.io/part-of: olm + name: object-controller-controller-manager + namespace: olmv1-system +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + control-plane: object-controller-controller-manager + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: object-controller + control-plane: object-controller-controller-manager + app.kubernetes.io/part-of: olm + spec: + containers: + - name: manager + command: + - /object-controller + args: + - --health-probe-bind-address=:8081 + - --leader-elect + - --metrics-bind-address=:8443 + - --tls-cert=/var/certs/tls.crt + - --tls-key=/var/certs/tls.key + image: "quay.io/operator-framework/object-controller:devel" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 64Mi + volumeMounts: + - name: object-controller-certs + mountPath: /var/certs + readOnly: true + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + terminationMessagePolicy: FallbackToLogsOnError + serviceAccountName: object-controller-controller-manager + volumes: + - name: object-controller-certs + secret: + secretName: object-controller-cert + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + - arm64 + - ppc64le + - s390x + - key: kubernetes.io/os + operator: In + values: + - linux + nodeSelector: + kubernetes.io/os: linux + node-role.kubernetes.io/control-plane: "" + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + terminationGracePeriodSeconds: 10 + tolerations: + - effect: NoSchedule + key: node-role.kubernetes.io/control-plane + operator: Exists + - effect: NoExecute + key: node.kubernetes.io/unreachable + operator: Exists + tolerationSeconds: 120 + - effect: NoExecute + key: node.kubernetes.io/not-ready + operator: Exists + tolerationSeconds: 120 +--- # Source: olmv1/templates/deployment-olmv1-system-operator-controller-controller-manager.yml apiVersion: apps/v1 kind: Deployment @@ -2788,6 +3106,31 @@ spec: size: 256 secretName: catalogd-service-cert-git-version --- +# Source: olmv1/templates/cert-manager/certificate-olmv1-system-object-controller-cert.yml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + annotations: + olm.operatorframework.io/feature-set: experimental + labels: + app.kubernetes.io/name: olmv1 + app.kubernetes.io/part-of: olm + name: object-controller-cert + namespace: olmv1-system +spec: + dnsNames: + - object-controller-service.olmv1-system.svc + - object-controller-service.olmv1-system.svc.cluster.local + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: olmv1-ca + privateKey: + algorithm: ECDSA + rotationPolicy: Always + size: 256 + secretName: object-controller-cert +--- # Source: olmv1/templates/cert-manager/certificate-olmv1-system-operator-controller-cert.yml apiVersion: cert-manager.io/v1 kind: Certificate From 6ff7daab5f53b58350363d693faacb76db03b2d9 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Thu, 24 Sep 2026 17:17:36 +0100 Subject: [PATCH 6/7] fix(helm): reject disabling object-controller with BoxcutterRuntime The standalone controller is the only ClusterObjectSet reconciler after the cutover. Reject an explicit objectController.enabled=false when operator-controller has an effective BoxcutterRuntime gate so Helm cannot render an installation without a reconciler or CRD. Cover the invalid combination and the inactive-gate boundary in chart tests. Refs: OPRUN-4775 Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- helm/olmv1/templates/_helpers.tpl | 7 ++++++- internal/object-controller/manifests/manifests_test.go | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/helm/olmv1/templates/_helpers.tpl b/helm/olmv1/templates/_helpers.tpl index f3e2a3c859..6b343e9e60 100644 --- a/helm/olmv1/templates/_helpers.tpl +++ b/helm/olmv1/templates/_helpers.tpl @@ -61,12 +61,17 @@ olmv1 {{/* Default to a separate object-controller whenever operator-controller uses Boxcutter. An explicit enabled value also permits installing object-controller on its own. +Disabling it while Boxcutter is active would leave ClusterObjectSets without a controller. Return an empty string when disabled so the helper can be used in conditionals. */}} {{- define "objectController.enabled" -}} +{{- $boxcutterEnabled := and .Values.options.operatorController.enabled (has "BoxcutterRuntime" .Values.options.operatorController.features.enabled) (not (has "BoxcutterRuntime" .Values.options.operatorController.features.disabled)) -}} +{{- if and $boxcutterEnabled (eq (toJson .Values.options.objectController.enabled) "false") -}} +{{- fail "options.objectController.enabled=false is incompatible with enabled BoxcutterRuntime" -}} +{{- end -}} {{- $enabled := .Values.options.objectController.enabled -}} {{- if eq (toJson $enabled) "null" -}} -{{- $enabled = and .Values.options.operatorController.enabled (has "BoxcutterRuntime" .Values.options.operatorController.features.enabled) (not (has "BoxcutterRuntime" .Values.options.operatorController.features.disabled)) -}} +{{- $enabled = $boxcutterEnabled -}} {{- end -}} {{- if $enabled -}} {{- if ne .Values.options.featureSet "experimental" -}} diff --git a/internal/object-controller/manifests/manifests_test.go b/internal/object-controller/manifests/manifests_test.go index b1d4b81e6a..35f4fc628e 100644 --- a/internal/object-controller/manifests/manifests_test.go +++ b/internal/object-controller/manifests/manifests_test.go @@ -30,6 +30,8 @@ func TestObjectControllerManifests(t *testing.T) { {name: "standard", values: `{}`, wantOperatorController: true, wantCatalogd: true}, {name: "disabled gate takes precedence", values: `options: {featureSet: experimental, operatorController: {features: {enabled: [BoxcutterRuntime], disabled: [BoxcutterRuntime]}}}`, wantOperatorController: true, wantCatalogd: true}, {name: "experimental with Boxcutter", values: `options: {featureSet: experimental, operatorController: {features: {enabled: [BoxcutterRuntime], disabled: []}}}`, wantObjectController: true, wantOperatorController: true, wantCatalogd: true}, + {name: "cannot disable object-controller with Boxcutter", values: `options: {featureSet: experimental, objectController: {enabled: false}, operatorController: {features: {enabled: [BoxcutterRuntime], disabled: []}}}`, wantErr: "options.objectController.enabled=false is incompatible with enabled BoxcutterRuntime"}, + {name: "explicitly disabled with inactive Boxcutter", values: `options: {featureSet: experimental, objectController: {enabled: false}, operatorController: {features: {enabled: [BoxcutterRuntime], disabled: [BoxcutterRuntime]}}}`, wantOperatorController: true, wantCatalogd: true}, {name: "standalone", values: `options: {featureSet: experimental, objectController: {enabled: true}, operatorController: {enabled: false}, catalogd: {enabled: false}}`, wantObjectController: true}, {name: "standalone with cert-manager", values: `options: {featureSet: experimental, objectController: {enabled: true}, operatorController: {enabled: false}, catalogd: {enabled: false}, certManager: {enabled: true}}`, wantObjectController: true}, {name: "explicitly disabled", values: `options: {featureSet: experimental, objectController: {enabled: false}}`, wantOperatorController: true, wantCatalogd: true}, From 2e4d2331c1659f7f948db0b9431e8768f7229d67 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Thu, 24 Sep 2026 17:12:58 +0100 Subject: [PATCH 7/7] test(object-controller): isolate direct ClusterObjectSet E2E scenarios Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- test/e2e/README.md | 30 +++++ test/e2e/features/revision.feature | 4 +- test/e2e/features_test.go | 1 + test/e2e/steps/hooks.go | 53 +++++--- test/e2e/steps/object_controller_steps.go | 125 ++++++++++++++++++ .../e2e/steps/object_controller_steps_test.go | 73 ++++++++++ test/e2e/steps/steps.go | 83 +----------- test/e2e/steps/upgrade_steps.go | 6 +- 8 files changed, 275 insertions(+), 100 deletions(-) create mode 100644 test/e2e/steps/object_controller_steps.go create mode 100644 test/e2e/steps/object_controller_steps_test.go diff --git a/test/e2e/README.md b/test/e2e/README.md index 60a7b79d71..ea41259232 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -2,6 +2,36 @@ This directory contains end-to-end (e2e) tests, written using the [Godog](https://github.com/cucumber/godog) framework. +### Independent object-controller suite + +`features/revision.feature` is the direct ClusterObjectSet suite, selected by +`@ObjectController`. Its API steps live in `steps/object_controller_steps.go` and +use shared resource helpers. ClusterExtension integration scenarios remain in +the other feature files. The existing experimental test run includes both groups; +`make e2e/revision` still selects the direct ClusterObjectSet scenarios. + +On a disposable cluster with only object-controller and the ClusterObjectSet CRD +installed, run the existing nine scenarios independently: + +```sh +cd test/e2e +KUBECONFIG=/path/to/standalone.kubeconfig \ + go test -tags containers_image_openpgp -count=1 -v . -timeout=20m \ + -args --e2e.object-controller-only --godog.tags=@ObjectController \ + --godog.concurrency=1 features/revision.feature +``` + +The standalone flag requires ClusterExtension and ClusterCatalog APIs to be +absent. Missing or unavailable object-controller fails readiness rather than +skipping the explicitly requested suite. A successful run executes all nine +scenarios with zero skipped or undefined scenarios. Run serially because some +scenarios change the progress-deadline CRD validation. + +In the general suite, standard installations skip this group. Experimental +Boxcutter installations run it, and fail if their required object-controller is +missing. Discovery uses the object-controller Deployment and its namespace; +standalone execution does not depend on an operator-controller feature gate. + ## Overview ### What is Godog/BDD/Cucumber? diff --git a/test/e2e/features/revision.feature b/test/e2e/features/revision.feature index 0a4ab87627..25939548ae 100644 --- a/test/e2e/features/revision.feature +++ b/test/e2e/features/revision.feature @@ -1,10 +1,10 @@ -@BoxcutterRuntime +@ObjectController Feature: Install ClusterObjectSet As an OLM user I would like to install a ClusterObjectSet directly, without using the cluster extension API. Background: - Given OLM is available + Given object-controller is available Scenario: Probe failure for PersistentVolumeClaim halts phase progression Given ServiceAccount "pvc-probe-sa" with needed permissions is available in test namespace diff --git a/test/e2e/features_test.go b/test/e2e/features_test.go index 65eaebe255..1712d0bba0 100644 --- a/test/e2e/features_test.go +++ b/test/e2e/features_test.go @@ -139,6 +139,7 @@ func InitializeSuite(tc *godog.TestSuiteContext) { func InitializeScenario(sc *godog.ScenarioContext) { steps.RegisterSteps(sc) + steps.RegisterObjectControllerSteps(sc) steps.RegisterDemoSteps(sc) steps.RegisterHooks(sc) } diff --git a/test/e2e/steps/hooks.go b/test/e2e/steps/hooks.go index ee93303408..c559b0cbf9 100644 --- a/test/e2e/steps/hooks.go +++ b/test/e2e/steps/hooks.go @@ -87,14 +87,16 @@ const ( ) var ( - devMode = false - featureGates = map[featuregate.Feature]bool{ + devMode = false + objectControllerOnly bool + featureGates = map[featuregate.Feature]bool{ features.WebhookProviderCertManager: true, features.SingleOwnNamespaceInstallSupport: false, features.WebhookProviderOpenshiftServiceCA: false, features.BoxcutterRuntime: false, features.DeploymentConfig: false, catalogdHAFeature: false, + objectControllerFeature: false, } logger logr.Logger ) @@ -102,6 +104,7 @@ var ( func init() { flagSet := pflag.CommandLine flagSet.BoolVar(&devMode, "log.debug", false, "print debug log level") + flagSet.BoolVar(&objectControllerOnly, "e2e.object-controller-only", false, "require object-controller with no ClusterExtension or ClusterCatalog APIs for direct ClusterObjectSet scenarios") } func RegisterHooks(sc *godog.ScenarioContext) { @@ -112,33 +115,30 @@ func RegisterHooks(sc *godog.ScenarioContext) { sc.After(ScenarioCleanup) } -// detectOLMDeployments returns the operator-controller deployment (first) and the catalogd -// deployment (second) found via the app.kubernetes.io/part-of=olm label across all namespaces. -// The catalogd return value may be nil when OLM is not yet installed (upgrade scenarios -// install it in a Background step). -func detectOLMDeployments() (*appsv1.Deployment, *appsv1.Deployment, error) { +// detectOLMDeployments finds installed components across namespaces. Missing +// components are valid for standalone installations and upgrade scenarios. +func detectOLMDeployments() (map[string]*appsv1.Deployment, error) { raw, err := k8sClient(context.Background(), "get", "deployments", "-A", "-l", "app.kubernetes.io/part-of=olm", "-o", "jsonpath={.items}") if err != nil { - return nil, nil, err + return nil, err } dl := []appsv1.Deployment{} if err := json.Unmarshal([]byte(raw), &dl); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal OLM deployments: %v", err) + return nil, fmt.Errorf("failed to unmarshal OLM deployments: %v", err) } - var operatorController, catalogd *appsv1.Deployment + deployments := map[string]*appsv1.Deployment{} for i := range dl { switch dl[i].Name { case olmDeploymentName: - operatorController = &dl[i] + deployments["operator-controller"] = &dl[i] case catalogdDeploymentName: - catalogd = &dl[i] + deployments["catalogd"] = &dl[i] + case objectControllerDeploymentName: + deployments["object-controller"] = &dl[i] } } - if operatorController == nil { - return nil, nil, fmt.Errorf("failed to detect OLM Deployment") - } - return operatorController, catalogd, nil + return deployments, nil } func BeforeSuite() { @@ -156,11 +156,25 @@ func BeforeSuite() { featureGates[catalogdHAFeature] = true } - olm, catalogdDep, err := detectOLMDeployments() + deployments, err := detectOLMDeployments() if err != nil { logger.Info("OLM deployments not found; skipping feature gate detection (upgrade scenarios will install OLM in Background)") return } + configureControllerFeatures(deployments) +} + +func configureControllerFeatures(deployments map[string]*appsv1.Deployment) { + objectController := deployments["object-controller"] + featureGates[objectControllerFeature] = objectController != nil + if objectController != nil { + componentNamespaces["object-controller"] = objectController.Namespace + } + olm, catalogdDep := deployments["operator-controller"], deployments["catalogd"] + if olm == nil { + logger.Info("operator-controller is absent; using independently detected components") + return + } olmNamespace = olm.Namespace componentNamespaces["operator-controller"] = olmNamespace @@ -203,6 +217,11 @@ func BeforeSuite() { func CheckFeatureTags(ctx context.Context, sc *godog.Scenario) (context.Context, error) { for _, tag := range sc.Tags { if enabled, found := featureGates[featuregate.Feature(tag.Name[1:])]; found && !enabled { + // An explicitly requested standalone suite, or an integrated Boxcutter + // installation, must fail readiness if its controller is missing. + if tag.Name == "@"+string(objectControllerFeature) && (objectControllerOnly || featureGates[features.BoxcutterRuntime]) { + continue + } logger.Info(fmt.Sprintf("Skipping scenario %q because feature gate %q is disabled", sc.Name, tag.Name[1:])) return ctx, godog.ErrSkip } diff --git a/test/e2e/steps/object_controller_steps.go b/test/e2e/steps/object_controller_steps.go new file mode 100644 index 0000000000..7f74a8b8c5 --- /dev/null +++ b/test/e2e/steps/object_controller_steps.go @@ -0,0 +1,125 @@ +package steps + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/cucumber/godog" + "k8s.io/component-base/featuregate" + "k8s.io/utils/ptr" +) + +const ( + objectControllerFeature featuregate.Feature = "ObjectController" + objectControllerDeploymentName string = "object-controller-controller-manager" +) + +// ObjectControllerIsAvailable verifies the independently deployed controller. +// Explicit standalone runs also reject ClusterExtension and ClusterCatalog APIs. +func ObjectControllerIsAvailable(ctx context.Context) error { + if objectControllerOnly { + out, err := k8sClient(ctx, "get", "crd", "clusterextensions.olm.operatorframework.io", "clustercatalogs.olm.operatorframework.io", "--ignore-not-found", "-o", "name") + if err != nil { + return fmt.Errorf("checking standalone API isolation: %w", err) + } + if strings.TrimSpace(out) != "" { + return fmt.Errorf("standalone object-controller tests require ClusterExtension and ClusterCatalog APIs to be absent; found: %s", strings.TrimSpace(out)) + } + } + if _, err := k8sClient(ctx, "get", "crd", "clusterobjectsets.olm.operatorframework.io"); err != nil { + return fmt.Errorf("ClusterObjectSet API is unavailable: %w", err) + } + _, err := k8sClient(ctx, "rollout", "status", "deployment/"+objectControllerDeploymentName, + "-n", namespaceForComponent("object-controller"), "--timeout="+timeout.String()) + if err != nil { + return fmt.Errorf("object-controller is unavailable: %w", err) + } + return nil +} + +// RegisterObjectControllerSteps registers direct ClusterObjectSet API operations. +// ClusterExtension scenarios can reuse these assertions without defining the +// standalone suite's availability or feature-gate requirements. +func RegisterObjectControllerSteps(sc *godog.ScenarioContext) { + sc.Step(`^object-controller is available$`, ObjectControllerIsAvailable) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" lifecycle is set to "([^"]+)"$`, ClusterObjectSetLifecycleUpdate) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterObjectSetReportsConditionWithoutMsg) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message:$`, ClusterObjectSetReportsConditionWithMsg) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterObjectSetReportsConditionWithMessageFragment) + sc.Step(`^(?i)ClusterObjectSet is applied(?:\s+.*)?$`, ResourceIsApplied) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reconciliation is triggered$`, TriggerClusterObjectSetReconciliation) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has observed phase "([^"]+)" with a non-empty digest$`, ClusterObjectSetHasObservedPhase) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" is archived$`, ClusterObjectSetIsArchived) +} + +// ClusterObjectSetLifecycleUpdate patches the ClusterObjectSet's lifecycleState to the specified value. +func ClusterObjectSetLifecycleUpdate(ctx context.Context, cosName, lifecycle string) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + patch := map[string]any{ + "spec": map[string]any{ + "lifecycleState": lifecycle, + }, + } + pb, err := json.Marshal(patch) + if err != nil { + return err + } + _, err = k8sClient(ctx, "patch", "clusterobjectset", cosName, "--type", "merge", "-p", string(pb)) + return err +} + +// ClusterObjectSetReportsConditionWithoutMsg waits for the named ClusterObjectSet to have a condition +// matching type, status, and reason. Polls with timeout. +func ClusterObjectSetReportsConditionWithoutMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, nil) +} + +// ClusterObjectSetReportsConditionWithMsg waits for the named ClusterObjectSet to have a condition +// matching type, status, reason, and message. Polls with timeout. +func ClusterObjectSetReportsConditionWithMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msg *godog.DocString) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageComparison(ctx, msg)) +} + +// ClusterObjectSetReportsConditionWithMessageFragment waits for the named ClusterObjectSet to have a condition +// matching type, status, reason, with a message containing the specified fragment. Polls with timeout. +func ClusterObjectSetReportsConditionWithMessageFragment(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msgFragment *godog.DocString) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageFragmentComparison(ctx, msgFragment)) +} + +// TriggerClusterObjectSetReconciliation annotates the named ClusterObjectSet +// to trigger a new reconciliation cycle. +func TriggerClusterObjectSetReconciliation(ctx context.Context, cosName string) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + _, err := k8sClient(ctx, "annotate", "clusterobjectset", cosName, "--overwrite", + fmt.Sprintf("e2e-trigger=%d", time.Now().UnixNano())) + return err +} + +// ClusterObjectSetHasObservedPhase waits for the named ClusterObjectSet to have +// an observedPhases entry matching the given phase name with a non-empty digest. Polls with timeout. +func ClusterObjectSetHasObservedPhase(ctx context.Context, cosName, phaseName string) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + phaseName = substituteScenarioVars(phaseName, sc) + + waitFor(ctx, func() bool { + out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", + fmt.Sprintf(`jsonpath={.status.observedPhases[?(@.name=="%s")].digest}`, phaseName)) + if err != nil { + return false + } + return strings.TrimSpace(out) != "" + }) + return nil +} + +// ClusterObjectSetIsArchived waits for the named ClusterObjectSet to have Progressing=False +// with reason Archived. Polls with timeout. +func ClusterObjectSetIsArchived(ctx context.Context, revisionName string) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), "Progressing", "False", ptr.To("Archived"), nil) +} diff --git a/test/e2e/steps/object_controller_steps_test.go b/test/e2e/steps/object_controller_steps_test.go new file mode 100644 index 0000000000..dbe03528ce --- /dev/null +++ b/test/e2e/steps/object_controller_steps_test.go @@ -0,0 +1,73 @@ +package steps + +import ( + "context" + "encoding/json" + "maps" + "testing" + + "github.com/cucumber/godog" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/operator-framework/operator-controller/internal/operator-controller/features" +) + +func TestObjectControllerScenarioSelection(t *testing.T) { + originalGates, originalNamespaces := featureGates, componentNamespaces + originalNamespace, originalOnly := olmNamespace, objectControllerOnly + t.Cleanup(func() { + featureGates, componentNamespaces = originalGates, originalNamespaces + olmNamespace, objectControllerOnly = originalNamespace, originalOnly + }) + for _, tc := range []struct { + name string + objectPresent bool + operator bool + boxcutter bool + standalone bool + wantSkip bool + }{ + {name: "standalone", objectPresent: true, standalone: true}, + {name: "standard", operator: true, wantSkip: true}, + {name: "integrated", objectPresent: true, operator: true, boxcutter: true}, + {name: "unready deployment must run readiness check", objectPresent: true}, + {name: "missing integrated controller must fail readiness", operator: true, boxcutter: true}, + {name: "missing standalone controller must fail readiness", standalone: true}, + } { + t.Run(tc.name, func(t *testing.T) { + featureGates = maps.Clone(originalGates) + componentNamespaces = map[string]string{} + objectControllerOnly = tc.standalone + deployments := map[string]*appsv1.Deployment{} + if tc.objectPresent { + // Discovery must enable scenarios even before the Deployment is ready. + deployments["object-controller"] = &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: "objects"}} + } + if tc.operator { + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: "extensions"}} + deployment.Spec.Template.Spec.Containers = []corev1.Container{{Name: "manager", Args: []string{"--feature-gates=BoxcutterRuntime=false"}}} + if tc.boxcutter { + deployment.Spec.Template.Spec.Containers[0].Args = []string{"--feature-gates=BoxcutterRuntime=true"} + } + deployments["operator-controller"] = deployment + } + configureControllerFeatures(deployments) + require.Equal(t, tc.objectPresent, featureGates[objectControllerFeature]) + require.Equal(t, tc.boxcutter, featureGates[features.BoxcutterRuntime]) + if tc.objectPresent { + require.Equal(t, "objects", namespaceForComponent("object-controller")) + } + scenario := &godog.Scenario{} + require.NoError(t, json.Unmarshal([]byte(`{"tags":[{"name":"@ObjectController"}]}`), scenario)) + _, err := CheckFeatureTags(context.Background(), scenario) + if tc.wantSkip { + require.ErrorIs(t, err, godog.ErrSkip) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index d3edd5169c..ce864f64ab 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -35,7 +35,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/sets" k8sresource "k8s.io/cli-runtime/pkg/resource" - "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" @@ -112,7 +111,6 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterExtension is applied(?:\s+.*)?$`, ResourceIsApplied) sc.Step(`^(?i)ClusterExtension version is updated to "([^"]+)"$`, ClusterExtensionVersionUpdate) sc.Step(`^(?i)ClusterExtension is updated(?:\s+.*)?$`, ResourceIsApplied) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" lifecycle is set to "([^"]+)"$`, ClusterObjectSetLifecycleUpdate) sc.Step(`^(?i)ClusterExtension is available$`, ClusterExtensionIsAvailable) sc.Step(`^(?i)ClusterExtension is rolled out$`, ClusterExtensionIsRolledOut) sc.Step(`^(?i)ClusterExtension resources are created and labeled$`, ClusterExtensionResourcesCreatedAndAreLabeled) @@ -124,14 +122,7 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterExtensionReportsConditionWithMessageFragment) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutMsg) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutReason) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterObjectSetReportsConditionWithoutMsg) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message:$`, ClusterObjectSetReportsConditionWithMsg) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterObjectSetReportsConditionWithMessageFragment) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) transition between (\d+) and (\d+) minutes since its creation$`, ClusterExtensionReportsConditionTransitionTime) - sc.Step(`^(?i)ClusterObjectSet is applied(?:\s+.*)?$`, ResourceIsApplied) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reconciliation is triggered$`, TriggerClusterObjectSetReconciliation) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has observed phase "([^"]+)" with a non-empty digest$`, ClusterObjectSetHasObservedPhase) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" is archived$`, ClusterObjectSetIsArchived) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" contains annotation "([^"]+)" with value$`, ClusterObjectSetHasAnnotationWithValue) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has label "([^"]+)" with value "([^"]+)"$`, ClusterObjectSetHasLabelWithValue) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" phase objects are not found or not owned by the revision$`, ClusterObjectSetObjectsNotFoundOrNotOwned) @@ -595,23 +586,6 @@ func ClusterExtensionVersionUpdate(ctx context.Context, version string) error { return err } -// ClusterObjectSetLifecycleUpdate patches the ClusterObjectSet's lifecycleState to the specified value. -func ClusterObjectSetLifecycleUpdate(ctx context.Context, cosName, lifecycle string) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - patch := map[string]any{ - "spec": map[string]any{ - "lifecycleState": lifecycle, - }, - } - pb, err := json.Marshal(patch) - if err != nil { - return err - } - _, err = k8sClient(ctx, "patch", "clusterobjectset", cosName, "--type", "merge", "-p", string(pb)) - return err -} - // ResourceIsApplied applies the provided YAML resource to the cluster and in case of ClusterExtension or ClusterObjectSet it captures // its name in the test context so that it can be referred to in later steps with ${NAME} or ${COS_NAME}, respectively func ResourceIsApplied(ctx context.Context, yamlTemplate *godog.DocString) error { @@ -926,58 +900,6 @@ func ClusterExtensionReportsActiveRevisions(ctx context.Context, rawRevisionName return nil } -// ClusterObjectSetReportsConditionWithoutMsg waits for the named ClusterObjectSet to have a condition -// matching type, status, and reason. Polls with timeout. -func ClusterObjectSetReportsConditionWithoutMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, nil) -} - -// ClusterObjectSetReportsConditionWithMsg waits for the named ClusterObjectSet to have a condition -// matching type, status, reason, and message. Polls with timeout. -func ClusterObjectSetReportsConditionWithMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msg *godog.DocString) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageComparison(ctx, msg)) -} - -// ClusterObjectSetReportsConditionWithMessageFragment waits for the named ClusterObjectSet to have a condition -// matching type, status, reason, with a message containing the specified fragment. Polls with timeout. -func ClusterObjectSetReportsConditionWithMessageFragment(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msgFragment *godog.DocString) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageFragmentComparison(ctx, msgFragment)) -} - -// TriggerClusterObjectSetReconciliation annotates the named ClusterObjectSet -// to trigger a new reconciliation cycle. -func TriggerClusterObjectSetReconciliation(ctx context.Context, cosName string) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - _, err := k8sClient(ctx, "annotate", "clusterobjectset", cosName, "--overwrite", - fmt.Sprintf("e2e-trigger=%d", time.Now().UnixNano())) - return err -} - -// ClusterObjectSetHasObservedPhase waits for the named ClusterObjectSet to have -// an observedPhases entry matching the given phase name with a non-empty digest. Polls with timeout. -func ClusterObjectSetHasObservedPhase(ctx context.Context, cosName, phaseName string) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - phaseName = substituteScenarioVars(phaseName, sc) - - waitFor(ctx, func() bool { - out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", - fmt.Sprintf(`jsonpath={.status.observedPhases[?(@.name=="%s")].digest}`, phaseName)) - if err != nil { - return false - } - return strings.TrimSpace(out) != "" - }) - return nil -} - -// ClusterObjectSetIsArchived waits for the named ClusterObjectSet to have Progressing=False -// with reason Archived. Polls with timeout. -func ClusterObjectSetIsArchived(ctx context.Context, revisionName string) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), "Progressing", "False", ptr.To("Archived"), nil) -} - // ClusterObjectSetHasAnnotationWithValue waits for the named ClusterObjectSet to have the specified // annotation with the expected value. Polls with timeout. func ClusterObjectSetHasAnnotationWithValue(ctx context.Context, revisionName, annotationKey string, annotationValue *godog.DocString) error { @@ -1548,10 +1470,11 @@ func ServiceAccountIsAvailableInNamespace(ctx context.Context, serviceAccount st } // ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace creates a ServiceAccount and applies standard RBAC permissions. -// The RBAC template is selected based on the service account and BoxcutterRuntime feature gate: --rbac-template.yaml +// Direct ClusterObjectSet scenarios use boxcutter RBAC independently of the +// operator-controller feature gate. Extension scenarios select their enabled runtime. func ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace(ctx context.Context, serviceAccount string) error { kernel := "helm" - if enabled, found := featureGates[features.BoxcutterRuntime]; found && enabled { + if featureGates[features.BoxcutterRuntime] || scenarioCtx(ctx).featureName == "revision" { kernel = "boxcutter" } rbacTemplate := fmt.Sprintf("%s-%s-rbac-template.yaml", serviceAccount, kernel) diff --git a/test/e2e/steps/upgrade_steps.go b/test/e2e/steps/upgrade_steps.go index 4267dccd69..215de3324d 100644 --- a/test/e2e/steps/upgrade_steps.go +++ b/test/e2e/steps/upgrade_steps.go @@ -29,10 +29,14 @@ var ( if err != nil { return err } - olm, _, err := detectOLMDeployments() + deployments, err := detectOLMDeployments() if err != nil { return err } + olm := deployments["operator-controller"] + if olm == nil { + return fmt.Errorf("operator-controller deployment not found after installation") + } olmNamespace = olm.Namespace return nil })