From f29721f7886202b2c133dd27cf350f11c14bd3d6 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Tue, 21 Jul 2026 12:17:26 +0200 Subject: [PATCH 1/3] OTA-2084: Conditionally deploy console plugin when AgenticRun CRD is present Move the cluster-update-console-plugin manifests from install/ into embedded assets (pkg/agenticrun/bindata/). The agenticrun controller conditionally applies them at runtime when it detects the AgenticRun CRD via API discovery, replacing the TechPreviewNoUpgrade annotation gating. This allows the console plugin to deploy on any cluster where OLS agentic is installed, regardless of feature set, while avoiding unnecessary deployments on clusters without OLS. Key changes: - Embed 8 plugin manifests via //go:embed, strip gating annotations - Add crdAvailable() with 5-minute cached CRD check via apiextensions - Always start the controller goroutine (self-gating via CRD check) - Add ensureConsolePlugin() with diff-based updates and skip flag - Add cleanupConsolePlugin() when CRD disappears (OLS uninstalled) - Add HyperShift and Console capability guards - Remove shouldEnableAgenticRunController() from cvo.go - Pass plugin image from image-references via SetConsolePluginImage() Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/agenticrun/bindata/assets.go | 20 ++++ .../agenticrun/bindata/assets/configmap.yaml | 4 - .../bindata/assets/consoleplugin.yaml | 8 +- .../agenticrun/bindata/assets/deployment.yaml | 6 +- .../agenticrun/bindata/assets/namespace.yaml | 4 - .../assets/networkpolicy-allow-console.yaml | 17 +++ .../bindata/assets/networkpolicy.yaml | 4 - .../agenticrun/bindata/assets/service.yaml | 6 +- .../bindata/assets/serviceaccount.yaml | 4 - pkg/agenticrun/consoleplugin.go | 101 ++++++++++++++++++ pkg/agenticrun/controller.go | 71 ++++++++++++ pkg/agenticrun/controller_test.go | 2 + pkg/cvo/availableupdates.go | 5 +- pkg/cvo/availableupdates_test.go | 1 + pkg/cvo/cvo.go | 49 +++++---- pkg/cvo/status.go | 8 +- pkg/payload/render_test.go | 3 - 17 files changed, 251 insertions(+), 62 deletions(-) create mode 100644 pkg/agenticrun/bindata/assets.go rename install/0000_50_cluster-update-console-plugin_30_configmap.yaml => pkg/agenticrun/bindata/assets/configmap.yaml (75%) rename install/0000_50_cluster-update-console-plugin_90_consoleplugin.yaml => pkg/agenticrun/bindata/assets/consoleplugin.yaml (55%) rename install/0000_50_cluster-update-console-plugin_50_deployment.yaml => pkg/agenticrun/bindata/assets/deployment.yaml (87%) rename install/0000_50_cluster-update-console-plugin_10_namespace.yaml => pkg/agenticrun/bindata/assets/namespace.yaml (67%) create mode 100644 pkg/agenticrun/bindata/assets/networkpolicy-allow-console.yaml rename install/0000_50_cluster-update-console-plugin_20_networkpolicy.yaml => pkg/agenticrun/bindata/assets/networkpolicy.yaml (61%) rename install/0000_50_cluster-update-console-plugin_60_service.yaml => pkg/agenticrun/bindata/assets/service.yaml (62%) rename install/0000_50_cluster-update-console-plugin_15_serviceaccount.yaml => pkg/agenticrun/bindata/assets/serviceaccount.yaml (55%) create mode 100644 pkg/agenticrun/consoleplugin.go diff --git a/pkg/agenticrun/bindata/assets.go b/pkg/agenticrun/bindata/assets.go new file mode 100644 index 0000000000..03d710fab9 --- /dev/null +++ b/pkg/agenticrun/bindata/assets.go @@ -0,0 +1,20 @@ +package bindata + +import ( + "embed" +) + +//go:embed assets/* +var f embed.FS + +func Asset(name string) ([]byte, error) { + return f.ReadFile(name) +} + +func MustAsset(name string) []byte { + data, err := f.ReadFile(name) + if err != nil { + panic(err) + } + return data +} diff --git a/install/0000_50_cluster-update-console-plugin_30_configmap.yaml b/pkg/agenticrun/bindata/assets/configmap.yaml similarity index 75% rename from install/0000_50_cluster-update-console-plugin_30_configmap.yaml rename to pkg/agenticrun/bindata/assets/configmap.yaml index 89ccc8a21d..f54f33bce0 100644 --- a/install/0000_50_cluster-update-console-plugin_30_configmap.yaml +++ b/pkg/agenticrun/bindata/assets/configmap.yaml @@ -5,10 +5,6 @@ metadata: namespace: openshift-cluster-update-console-plugin annotations: kubernetes.io/description: Nginx configuration for the cluster-update console plugin. - capability.openshift.io/name: Console - release.openshift.io/feature-set: TechPreviewNoUpgrade - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" data: nginx.conf: | error_log /dev/stderr; diff --git a/install/0000_50_cluster-update-console-plugin_90_consoleplugin.yaml b/pkg/agenticrun/bindata/assets/consoleplugin.yaml similarity index 55% rename from install/0000_50_cluster-update-console-plugin_90_consoleplugin.yaml rename to pkg/agenticrun/bindata/assets/consoleplugin.yaml index 69bd0d9eee..2166f8bd7e 100644 --- a/install/0000_50_cluster-update-console-plugin_90_consoleplugin.yaml +++ b/pkg/agenticrun/bindata/assets/consoleplugin.yaml @@ -1,13 +1,9 @@ apiVersion: console.openshift.io/v1 kind: ConsolePlugin metadata: - name: openshift-cluster-update-console-plugin + name: cluster-update-console-plugin annotations: kubernetes.io/description: The OpenShift cluster-update console plugin provides a web-console interface for managing ClusterVersion updates. - capability.openshift.io/name: Console - release.openshift.io/feature-set: TechPreviewNoUpgrade - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" spec: displayName: Cluster Updates i18n: @@ -15,7 +11,7 @@ spec: backend: type: Service service: - name: openshift-cluster-update-console-plugin + name: cluster-update-console-plugin namespace: openshift-cluster-update-console-plugin port: 9001 basePath: / diff --git a/install/0000_50_cluster-update-console-plugin_50_deployment.yaml b/pkg/agenticrun/bindata/assets/deployment.yaml similarity index 87% rename from install/0000_50_cluster-update-console-plugin_50_deployment.yaml rename to pkg/agenticrun/bindata/assets/deployment.yaml index 182f1db8bc..dcbc46ae90 100644 --- a/install/0000_50_cluster-update-console-plugin_50_deployment.yaml +++ b/pkg/agenticrun/bindata/assets/deployment.yaml @@ -5,10 +5,6 @@ metadata: namespace: openshift-cluster-update-console-plugin annotations: kubernetes.io/description: The OpenShift cluster-update console plugin provides a web-console interface for managing ClusterVersion updates. - capability.openshift.io/name: Console - release.openshift.io/feature-set: TechPreviewNoUpgrade - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" spec: selector: matchLabels: @@ -30,7 +26,7 @@ spec: automountServiceAccountToken: false containers: - name: plugin - image: '{{index .Images "cluster-update-console-plugin"}}' + image: ${IMAGE} imagePullPolicy: IfNotPresent ports: - name: https diff --git a/install/0000_50_cluster-update-console-plugin_10_namespace.yaml b/pkg/agenticrun/bindata/assets/namespace.yaml similarity index 67% rename from install/0000_50_cluster-update-console-plugin_10_namespace.yaml rename to pkg/agenticrun/bindata/assets/namespace.yaml index dbfe4628be..7ef8e6c069 100644 --- a/install/0000_50_cluster-update-console-plugin_10_namespace.yaml +++ b/pkg/agenticrun/bindata/assets/namespace.yaml @@ -4,10 +4,6 @@ metadata: name: openshift-cluster-update-console-plugin annotations: kubernetes.io/description: The OpenShift cluster-update console plugin provides a web-console interface for managing ClusterVersion updates. - capability.openshift.io/name: Console - release.openshift.io/feature-set: TechPreviewNoUpgrade - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" workload.openshift.io/allowed: management labels: openshift.io/cluster-monitoring: "true" diff --git a/pkg/agenticrun/bindata/assets/networkpolicy-allow-console.yaml b/pkg/agenticrun/bindata/assets/networkpolicy-allow-console.yaml new file mode 100644 index 0000000000..f9b0e3bb5d --- /dev/null +++ b/pkg/agenticrun/bindata/assets/networkpolicy-allow-console.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-console-ingress + namespace: openshift-cluster-update-console-plugin + annotations: + kubernetes.io/description: Allow ingress from the console to the cluster-update console plugin on port 9001. +spec: + podSelector: + matchLabels: + app: cluster-update-console-plugin + ingress: + - ports: + - port: 9001 + protocol: TCP + policyTypes: + - Ingress diff --git a/install/0000_50_cluster-update-console-plugin_20_networkpolicy.yaml b/pkg/agenticrun/bindata/assets/networkpolicy.yaml similarity index 61% rename from install/0000_50_cluster-update-console-plugin_20_networkpolicy.yaml rename to pkg/agenticrun/bindata/assets/networkpolicy.yaml index 78bc822350..05daa750d1 100644 --- a/install/0000_50_cluster-update-console-plugin_20_networkpolicy.yaml +++ b/pkg/agenticrun/bindata/assets/networkpolicy.yaml @@ -5,10 +5,6 @@ metadata: namespace: openshift-cluster-update-console-plugin annotations: kubernetes.io/description: This NetworkPolicy is used to deny all ingress and egress traffic by default in this namespace, matching all Pods, and serving as a baseline. - capability.openshift.io/name: Console - release.openshift.io/feature-set: TechPreviewNoUpgrade - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" spec: podSelector: {} policyTypes: diff --git a/install/0000_50_cluster-update-console-plugin_60_service.yaml b/pkg/agenticrun/bindata/assets/service.yaml similarity index 62% rename from install/0000_50_cluster-update-console-plugin_60_service.yaml rename to pkg/agenticrun/bindata/assets/service.yaml index cd5f7b2b4c..efd58304d1 100644 --- a/install/0000_50_cluster-update-console-plugin_60_service.yaml +++ b/pkg/agenticrun/bindata/assets/service.yaml @@ -1,15 +1,11 @@ apiVersion: v1 kind: Service metadata: - name: openshift-cluster-update-console-plugin + name: cluster-update-console-plugin namespace: openshift-cluster-update-console-plugin annotations: kubernetes.io/description: The OpenShift cluster-update console plugin provides a web-console interface for managing ClusterVersion updates. service.beta.openshift.io/serving-cert-secret-name: cluster-update-console-plugin-cert - capability.openshift.io/name: Console - release.openshift.io/feature-set: TechPreviewNoUpgrade - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" spec: type: ClusterIP selector: diff --git a/install/0000_50_cluster-update-console-plugin_15_serviceaccount.yaml b/pkg/agenticrun/bindata/assets/serviceaccount.yaml similarity index 55% rename from install/0000_50_cluster-update-console-plugin_15_serviceaccount.yaml rename to pkg/agenticrun/bindata/assets/serviceaccount.yaml index e124a426b8..3ba5569e4c 100644 --- a/install/0000_50_cluster-update-console-plugin_15_serviceaccount.yaml +++ b/pkg/agenticrun/bindata/assets/serviceaccount.yaml @@ -5,7 +5,3 @@ metadata: namespace: openshift-cluster-update-console-plugin annotations: kubernetes.io/description: The OpenShift cluster-update console plugin provides a web-console interface for managing ClusterVersion updates. - capability.openshift.io/name: Console - release.openshift.io/feature-set: TechPreviewNoUpgrade - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" diff --git a/pkg/agenticrun/consoleplugin.go b/pkg/agenticrun/consoleplugin.go new file mode 100644 index 0000000000..9060f95c29 --- /dev/null +++ b/pkg/agenticrun/consoleplugin.go @@ -0,0 +1,101 @@ +package agenticrun + +import ( + "context" + "fmt" + "reflect" + "strings" + + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + + kerrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/klog/v2" + + "github.com/openshift/cluster-version-operator/pkg/agenticrun/bindata" + i "github.com/openshift/cluster-version-operator/pkg/internal" +) + +var consolePluginAssets = []string{ + "assets/namespace.yaml", + "assets/serviceaccount.yaml", + "assets/networkpolicy.yaml", + "assets/networkpolicy-allow-console.yaml", + "assets/configmap.yaml", + "assets/deployment.yaml", + "assets/service.yaml", + "assets/consoleplugin.yaml", +} + +func applyConsolePluginManifests(ctx context.Context, client ctrlruntimeclient.Client, image string) error { + for _, asset := range consolePluginAssets { + raw := bindata.MustAsset(asset) + + if asset == "assets/deployment.yaml" { + raw = []byte(strings.ReplaceAll(string(raw), "${IMAGE}", image)) + } + + obj := &unstructured.Unstructured{} + if err := yaml.NewYAMLOrJSONDecoder(strings.NewReader(string(raw)), len(raw)).Decode(obj); err != nil { + return fmt.Errorf("decoding %s: %w", asset, err) + } + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(obj.GroupVersionKind()) + err := client.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(obj), existing) + if err != nil { + if ctrlruntimeclient.IgnoreNotFound(err) != nil { + return fmt.Errorf("getting %s %s: %w", obj.GetKind(), obj.GetName(), err) + } + if err := client.Create(ctx, obj); err != nil { + return fmt.Errorf("creating %s %s: %w", obj.GetKind(), obj.GetName(), err) + } + klog.V(i.Normal).Infof("Created console plugin %s %s", obj.GetKind(), obj.GetName()) + continue + } + + if !needsUpdate(existing, obj) { + klog.V(i.Debug).Infof("Console plugin %s %s is up to date", obj.GetKind(), obj.GetName()) + continue + } + obj.SetResourceVersion(existing.GetResourceVersion()) + if err := client.Update(ctx, obj); err != nil { + return fmt.Errorf("updating %s %s: %w", obj.GetKind(), obj.GetName(), err) + } + klog.V(i.Normal).Infof("Updated console plugin %s %s", obj.GetKind(), obj.GetName()) + } + return nil +} + +func needsUpdate(existing, desired *unstructured.Unstructured) bool { + if !reflect.DeepEqual(existing.Object["spec"], desired.Object["spec"]) { + return true + } + return !reflect.DeepEqual(existing.Object["data"], desired.Object["data"]) +} + +func cleanupConsolePluginManifests(ctx context.Context, client ctrlruntimeclient.Client) error { + for idx := len(consolePluginAssets) - 1; idx >= 0; idx-- { + raw := bindata.MustAsset(consolePluginAssets[idx]) + + obj := &unstructured.Unstructured{} + if err := yaml.NewYAMLOrJSONDecoder(strings.NewReader(string(raw)), len(raw)).Decode(obj); err != nil { + return fmt.Errorf("decoding %s: %w", consolePluginAssets[idx], err) + } + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(obj.GroupVersionKind()) + existing.SetName(obj.GetName()) + existing.SetNamespace(obj.GetNamespace()) + + if err := client.Delete(ctx, existing); err != nil { + if !kerrors.IsNotFound(err) { + return fmt.Errorf("deleting %s %s: %w", obj.GetKind(), obj.GetName(), err) + } + } else { + klog.V(i.Normal).Infof("Deleted console plugin %s %s", obj.GetKind(), obj.GetName()) + } + } + return nil +} diff --git a/pkg/agenticrun/controller.go b/pkg/agenticrun/controller.go index cff2676792..1370bb657a 100644 --- a/pkg/agenticrun/controller.go +++ b/pkg/agenticrun/controller.go @@ -51,6 +51,12 @@ type Controller struct { configMapGetterFunc configMapGetterFunc getCurrentVersionFunc getCurrentVersionFunc config Config + consolePluginImage string + consolePluginEnsured bool + crdAvailableCache bool + crdLastChecked time.Time + hypershift bool + hasConsoleCapability func() bool } const controllerName = "agenticrun-lifecycle-controller" @@ -124,6 +130,55 @@ func (c *Controller) QueueKey() string { return c.queueKey } +const crdCheckInterval = 5 * time.Minute + +func (c *Controller) SetHyperShift(hypershift bool) { + c.hypershift = hypershift +} + +func (c *Controller) SetConsoleCapabilityFunc(f func() bool) { + c.hasConsoleCapability = f +} + +func (c *Controller) SetConsolePluginImage(image string) { + if c.consolePluginImage != image { + c.consolePluginImage = image + c.consolePluginEnsured = false + } +} + +func (c *Controller) crdAvailable() bool { + if time.Since(c.crdLastChecked) < crdCheckInterval { + return c.crdAvailableCache + } + c.crdLastChecked = time.Now() + if c.client == nil { + c.crdAvailableCache = false + return false + } + crd := &apiextensionsv1.CustomResourceDefinition{} + err := c.client.Get(context.Background(), ctrlruntimeclient.ObjectKey{Name: "agenticruns.agentic.openshift.io"}, crd) + c.crdAvailableCache = err == nil + return c.crdAvailableCache +} + +func (c *Controller) shouldDeployConsolePlugin() bool { + if c.hypershift { + return false + } + if c.hasConsoleCapability != nil && !c.hasConsoleCapability() { + return false + } + return true +} + +func (c *Controller) ensureConsolePlugin(ctx context.Context) error { + if c.consolePluginImage == "" { + return fmt.Errorf("console plugin image not set") + } + return applyConsolePluginManifests(ctx, c.client, c.consolePluginImage) +} + func (c *Controller) Sync(ctx context.Context, key string) error { startTime := time.Now() klog.V(i.Normal).Infof("Started syncing CVO configuration %q", key) @@ -131,6 +186,22 @@ func (c *Controller) Sync(ctx context.Context, key string) error { klog.V(i.Normal).Infof("Finished syncing CVO configuration (%v)", time.Since(startTime)) }() + if !c.crdAvailable() { + if err := cleanupConsolePluginManifests(ctx, c.client); err != nil { + klog.V(i.Normal).Infof("Failed to clean up console plugin: %v", err) + } + c.consolePluginEnsured = false + return nil + } + + if c.shouldDeployConsolePlugin() && !c.consolePluginEnsured { + if err := c.ensureConsolePlugin(ctx); err != nil { + klog.V(i.Normal).Infof("Failed to ensure console plugin: %v", err) + } else { + c.consolePluginEnsured = true + } + } + updates, conditionalUpdates, err := c.updatesGetterFunc() if err != nil { klog.Errorf("Error getting available updates: %v", err) diff --git a/pkg/agenticrun/controller_test.go b/pkg/agenticrun/controller_test.go index 49ff555e07..2569bb1172 100644 --- a/pkg/agenticrun/controller_test.go +++ b/pkg/agenticrun/controller_test.go @@ -140,6 +140,8 @@ Update path: Recommended }, func() string { return "4.22.1" }) + c.crdAvailableCache = true + c.crdLastChecked = time.Now() actual := c.Sync(context.Background(), tt.name) if diff := cmp.Diff(tt.expected, actual, cmp.Transformer("Error", func(e error) string { if e == nil { diff --git a/pkg/cvo/availableupdates.go b/pkg/cvo/availableupdates.go index bd568daef0..d05239e0d6 100644 --- a/pkg/cvo/availableupdates.go +++ b/pkg/cvo/availableupdates.go @@ -182,10 +182,7 @@ func (optr *Operator) syncAvailableUpdates(ctx context.Context, config *configv1 // queue optr.sync() to update ClusterVersion status optr.queue.Add(queueKey) - if optr.shouldEnableAgenticRunController() { - // queue optr.agenticRunController.Sync() to manage agentic runs - optr.agenticRunController.Queue().Add(optr.agenticRunController.QueueKey()) - } + optr.agenticRunController.Queue().Add(optr.agenticRunController.QueueKey()) return nil } diff --git a/pkg/cvo/availableupdates_test.go b/pkg/cvo/availableupdates_test.go index d34c9942e5..559c0b6362 100644 --- a/pkg/cvo/availableupdates_test.go +++ b/pkg/cvo/availableupdates_test.go @@ -1255,6 +1255,7 @@ func TestOperator_syncAvailableUpdates_noticeResolvedAlertsQuickly(t *testing.T) t.Fatalf("accept risk feature is not enabled") } optr.enabledCVOFeatureGates = cvgGates + optr.agenticRunController = agenticrun.NewController(nil, nil, nil, nil, nil, nil) err := optr.syncAvailableUpdates(context.Background(), &configv1.ClusterVersion{ Spec: configv1.ClusterVersionSpec{ DesiredUpdate: &configv1.Update{ diff --git a/pkg/cvo/cvo.go b/pkg/cvo/cvo.go index 5af33246b0..6cc41fdc04 100644 --- a/pkg/cvo/cvo.go +++ b/pkg/cvo/cvo.go @@ -444,6 +444,29 @@ func (optr *Operator) InitializeFromPayload(ctx context.Context, restConfig *res optr.release = update.Release optr.releaseCreated = update.ImageRef.CreationTimestamp.Time + if update.ImageRef != nil { + for _, tag := range update.ImageRef.Spec.Tags { + if tag.Name == "cluster-update-console-plugin" && tag.From != nil && tag.From.Kind == "DockerImage" { + optr.agenticRunController.SetConsolePluginImage(tag.From.Name) + break + } + } + } + + optr.agenticRunController.SetHyperShift(optr.hypershift) + optr.agenticRunController.SetConsoleCapabilityFunc(func() bool { + cv, err := optr.cvLister.Get(internal.DefaultClusterVersionName) + if err != nil { + return false + } + for _, cap := range cv.Status.Capabilities.EnabledCapabilities { + if cap == configv1.ClusterVersionCapabilityConsole { + return true + } + } + return false + }) + // after the verifier has been loaded, initialize the sync worker with a payload retriever // which will consume the verifier optr.configSync = NewSyncWorkerWithPreconditions( @@ -582,18 +605,14 @@ func (optr *Operator) Run(runContext context.Context, shutdownContext context.Co klog.Infof("The ClusterVersionOperatorConfiguration feature gate is disabled or HyperShift is detected; the configuration sync routine will not run.") } - if optr.shouldEnableAgenticRunController() { - resultChannelCount++ - go func() { - defer utilruntime.HandleCrash() - wait.UntilWithContext(runContext, func(runContext context.Context) { - optr.worker(runContext, optr.agenticRunController.Queue(), optr.agenticRunController.Sync) - }, time.Second) - resultChannel <- asyncResult{name: "agenticrun controller"} - }() - } else { - klog.Infof("The agenticrun controller is disabled.") - } + resultChannelCount++ + go func() { + defer utilruntime.HandleCrash() + wait.UntilWithContext(runContext, func(runContext context.Context) { + optr.worker(runContext, optr.agenticRunController.Queue(), optr.agenticRunController.Sync) + }, time.Second) + resultChannel <- asyncResult{name: "agenticrun controller"} + }() resultChannelCount++ go func() { @@ -1222,12 +1241,6 @@ func (optr *Operator) shouldReconcileAcceptRisks() bool { return optr.enabledCVOFeatureGates.AcceptRisks() && !optr.hypershift } -// shouldEnableAgenticRunController returns whether the CVO should enable the agentic run controller -func (optr *Operator) shouldEnableAgenticRunController() bool { - // Gated behind a feature set so featuregates.ChangeStopper restarts CVO when the return of this function flips. - return optr.requiredFeatureSet == configv1.TechPreviewNoUpgrade -} - // ApplyTLSSettings returns the function that applies TLS settings to the TLS config func (optr *Operator) ApplyTLSSettings() func(config *tls.Config) { return optr.applyTLSSettings diff --git a/pkg/cvo/status.go b/pkg/cvo/status.go index 4683760772..d81358c672 100644 --- a/pkg/cvo/status.go +++ b/pkg/cvo/status.go @@ -179,11 +179,9 @@ func (optr *Operator) syncStatus(ctx context.Context, original, config *configv1 if klog.V(6).Enabled() { klog.Infof("Apply config: %s", cmp.Diff(original, config)) } - if optr.shouldEnableAgenticRunController() { - if original != nil && len(config.Status.History) < len(original.Status.History) { - klog.V(internal.Normal).Infof("Reconciling agentic runs because ClusterVersion.status.history got pruned") - optr.agenticRunController.Queue().Add(optr.agenticRunController.QueueKey()) - } + if original != nil && len(config.Status.History) < len(original.Status.History) { + klog.V(internal.Normal).Infof("Reconciling agentic runs because ClusterVersion.status.history got pruned") + optr.agenticRunController.Queue().Add(optr.agenticRunController.QueueKey()) } updated, err := applyClusterVersionStatus(ctx, optr.client.ConfigV1(), config, original) optr.rememberLastUpdate(updated) diff --git a/pkg/payload/render_test.go b/pkg/payload/render_test.go index 64f19eb373..9bbdb2b9b2 100644 --- a/pkg/payload/render_test.go +++ b/pkg/payload/render_test.go @@ -381,9 +381,6 @@ func Test_cvoManifests(t *testing.T) { config := manifestRenderConfig{ ReleaseImage: "quay.io/cvo/release:latest", ClusterProfile: "some-profile", - Images: map[string]string{ - "cluster-update-console-plugin": "quay.io/openshift/cluster-update-console-plugin:latest", - }, } tests := []struct { From 27b4b992af08276ccef718378a0b83a6a7c834d0 Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Tue, 21 Jul 2026 17:44:59 +0200 Subject: [PATCH 2/3] OTA-2084: Auto-enable console plugin in console operator config After deploying the console plugin resources, enable it by adding cluster-update-console-plugin to spec.plugins on the console operator config. On cleanup (CRD removed), disable it by removing the entry. This makes the plugin automatically visible in the console without manual patching of the console operator config. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/agenticrun/consoleplugin.go | 67 +++++++++++++++++++++++++++++++++ pkg/agenticrun/controller.go | 5 +++ 2 files changed, 72 insertions(+) diff --git a/pkg/agenticrun/consoleplugin.go b/pkg/agenticrun/consoleplugin.go index 9060f95c29..5e1a0a48d0 100644 --- a/pkg/agenticrun/consoleplugin.go +++ b/pkg/agenticrun/consoleplugin.go @@ -2,6 +2,7 @@ package agenticrun import ( "context" + "encoding/json" "fmt" "reflect" "strings" @@ -10,9 +11,12 @@ import ( kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/klog/v2" + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/openshift/cluster-version-operator/pkg/agenticrun/bindata" i "github.com/openshift/cluster-version-operator/pkg/internal" ) @@ -99,3 +103,66 @@ func cleanupConsolePluginManifests(ctx context.Context, client ctrlruntimeclient } return nil } + +const consolePluginName = "cluster-update-console-plugin" + +func enableConsolePlugin(ctx context.Context, client ctrlruntimeclient.Client) error { + console := &operatorv1.Console{} + if err := client.Get(ctx, types.NamespacedName{Name: "cluster"}, console); err != nil { + return fmt.Errorf("getting console operator config: %w", err) + } + for _, p := range console.Spec.Plugins { + if p == consolePluginName { + return nil + } + } + plugins := append(console.Spec.Plugins, consolePluginName) + patch, err := json.Marshal(map[string]interface{}{ + "spec": map[string]interface{}{ + "plugins": plugins, + }, + }) + if err != nil { + return fmt.Errorf("marshaling patch: %w", err) + } + if err := client.Patch(ctx, console, ctrlruntimeclient.RawPatch(types.MergePatchType, patch)); err != nil { + return fmt.Errorf("enabling console plugin: %w", err) + } + klog.V(i.Normal).Infof("Enabled %s in console operator config", consolePluginName) + return nil +} + +func disableConsolePlugin(ctx context.Context, client ctrlruntimeclient.Client) error { + console := &operatorv1.Console{} + if err := client.Get(ctx, types.NamespacedName{Name: "cluster"}, console); err != nil { + if kerrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("getting console operator config: %w", err) + } + filtered := make([]string, 0, len(console.Spec.Plugins)) + found := false + for _, p := range console.Spec.Plugins { + if p == consolePluginName { + found = true + continue + } + filtered = append(filtered, p) + } + if !found { + return nil + } + patch, err := json.Marshal(map[string]interface{}{ + "spec": map[string]interface{}{ + "plugins": filtered, + }, + }) + if err != nil { + return fmt.Errorf("marshaling patch: %w", err) + } + if err := client.Patch(ctx, console, ctrlruntimeclient.RawPatch(types.MergePatchType, patch)); err != nil { + return fmt.Errorf("disabling console plugin: %w", err) + } + klog.V(i.Normal).Infof("Disabled %s in console operator config", consolePluginName) + return nil +} diff --git a/pkg/agenticrun/controller.go b/pkg/agenticrun/controller.go index 1370bb657a..50e385de1a 100644 --- a/pkg/agenticrun/controller.go +++ b/pkg/agenticrun/controller.go @@ -187,6 +187,9 @@ func (c *Controller) Sync(ctx context.Context, key string) error { }() if !c.crdAvailable() { + if err := disableConsolePlugin(ctx, c.client); err != nil { + klog.V(i.Normal).Infof("Failed to disable console plugin: %v", err) + } if err := cleanupConsolePluginManifests(ctx, c.client); err != nil { klog.V(i.Normal).Infof("Failed to clean up console plugin: %v", err) } @@ -197,6 +200,8 @@ func (c *Controller) Sync(ctx context.Context, key string) error { if c.shouldDeployConsolePlugin() && !c.consolePluginEnsured { if err := c.ensureConsolePlugin(ctx); err != nil { klog.V(i.Normal).Infof("Failed to ensure console plugin: %v", err) + } else if err := enableConsolePlugin(ctx, c.client); err != nil { + klog.V(i.Normal).Infof("Failed to enable console plugin: %v", err) } else { c.consolePluginEnsured = true } From efa5f6c640664dcb6d0675fde86761afc5d5a73b Mon Sep 17 00:00:00 2001 From: Jakub Hadvig Date: Tue, 21 Jul 2026 23:18:04 +0200 Subject: [PATCH 3/3] OTA-2084: Address review feedback on console plugin lifecycle - Skip manifest cleanup if disableConsolePlugin fails, to avoid removing backing resources while the plugin is still registered in the console config (wking review #1) - Wait for at least one available replica before enabling the plugin in the console config, so the console doesn't try to load a plugin whose backing pod isn't ready yet (wking review #2) - consolePluginEnsured remains a TP-acceptable volatile cache; active reconciliation of owned resources is deferred to pre-GA (wking #3) Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/agenticrun/consoleplugin.go | 17 ++++++++++++++++- pkg/agenticrun/controller.go | 7 ++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/pkg/agenticrun/consoleplugin.go b/pkg/agenticrun/consoleplugin.go index 5e1a0a48d0..40d8ebe7ba 100644 --- a/pkg/agenticrun/consoleplugin.go +++ b/pkg/agenticrun/consoleplugin.go @@ -9,6 +9,7 @@ import ( ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + appsv1 "k8s.io/api/apps/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" @@ -104,7 +105,21 @@ func cleanupConsolePluginManifests(ctx context.Context, client ctrlruntimeclient return nil } -const consolePluginName = "cluster-update-console-plugin" +const ( + consolePluginName = "cluster-update-console-plugin" + consolePluginNamespace = "openshift-cluster-update-console-plugin" +) + +func waitForPluginReady(ctx context.Context, client ctrlruntimeclient.Client) error { + deployment := &appsv1.Deployment{} + if err := client.Get(ctx, types.NamespacedName{Name: consolePluginName, Namespace: consolePluginNamespace}, deployment); err != nil { + return fmt.Errorf("getting deployment: %w", err) + } + if deployment.Status.AvailableReplicas < 1 { + return fmt.Errorf("deployment %s has no available replicas", consolePluginName) + } + return nil +} func enableConsolePlugin(ctx context.Context, client ctrlruntimeclient.Client) error { console := &operatorv1.Console{} diff --git a/pkg/agenticrun/controller.go b/pkg/agenticrun/controller.go index 50e385de1a..66ca7adebf 100644 --- a/pkg/agenticrun/controller.go +++ b/pkg/agenticrun/controller.go @@ -188,9 +188,8 @@ func (c *Controller) Sync(ctx context.Context, key string) error { if !c.crdAvailable() { if err := disableConsolePlugin(ctx, c.client); err != nil { - klog.V(i.Normal).Infof("Failed to disable console plugin: %v", err) - } - if err := cleanupConsolePluginManifests(ctx, c.client); err != nil { + klog.V(i.Normal).Infof("Failed to disable console plugin, skipping manifest cleanup: %v", err) + } else if err := cleanupConsolePluginManifests(ctx, c.client); err != nil { klog.V(i.Normal).Infof("Failed to clean up console plugin: %v", err) } c.consolePluginEnsured = false @@ -200,6 +199,8 @@ func (c *Controller) Sync(ctx context.Context, key string) error { if c.shouldDeployConsolePlugin() && !c.consolePluginEnsured { if err := c.ensureConsolePlugin(ctx); err != nil { klog.V(i.Normal).Infof("Failed to ensure console plugin: %v", err) + } else if err := waitForPluginReady(ctx, c.client); err != nil { + klog.V(i.Normal).Infof("Console plugin not ready yet, deferring enable: %v", err) } else if err := enableConsolePlugin(ctx, c.client); err != nil { klog.V(i.Normal).Infof("Failed to enable console plugin: %v", err) } else {