From 85ebd0d8e54795221410972bcd5ba556d2fe2c1f Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Fri, 17 Jul 2026 18:11:57 +0200 Subject: [PATCH 01/17] ROX-35435: Support differing versions of Central and SecuredCluster --- cmd/deploy.go | 48 ++++- internal/deployer/acs_images.go | 63 +++++- internal/deployer/config.go | 4 + internal/deployer/deploy_via_operator.go | 158 +++++++++++--- internal/deployer/deployer.go | 4 +- internal/deployer/konflux.go | 31 +-- internal/deployer/konflux_test.go | 16 +- internal/deployer/operator.go | 217 +++++++++++++------- internal/deployer/operator_instance.go | 154 ++++++++++++++ internal/deployer/operator_instance_test.go | 181 ++++++++++++++++ 10 files changed, 741 insertions(+), 135 deletions(-) create mode 100644 internal/deployer/operator_instance.go create mode 100644 internal/deployer/operator_instance_test.go diff --git a/cmd/deploy.go b/cmd/deploy.go index 40325e1..3eacffb 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -135,6 +135,20 @@ this flag can be used to tell roxie how to pre-load images for the current clust }), ) + registerFlag(cmd, settings, "central-tag", "Image tag for Central (overrides --tag for Central)", + withApplyFn("version", func(config *deployer.Config, tag string) error { + config.Central.Version = tag + return nil + }), + ) + + registerFlag(cmd, settings, "secured-cluster-tag", "Image tag for SecuredCluster (overrides --tag for SecuredCluster)", + withApplyFn("version", func(config *deployer.Config, tag string) error { + config.SecuredCluster.Version = tag + return nil + }), + ) + registerFlag(cmd, settings, "operator-env", "Operator environment variables (e.g., RELATED_IMAGE_MAIN=quay.io/...)", withApplyFn("env-var", func(config *deployer.Config, envExpr string) error { key, value, err := deployer.ParseOperatorEnvVar(envExpr) @@ -251,12 +265,22 @@ func runDeploy(cmd *cobra.Command, args []string) error { // storing of the derived operator version within the operator configuration. // // This is why we use the operator version here when checking version constraints. - hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(deploySettings.Operator.Version) - if err != nil { - return fmt.Errorf("checking version constraint on main image tag %s: %w", deploySettings.Roxie.Version, err) + // With split versions, check every operator instance that will be deployed. + versionsToCheck := []string{deploySettings.Operator.Version} + if deploySettings.NeedsSplitOperators() { + versionsToCheck = nil + for _, instance := range deploySettings.OperatorInstances() { + versionsToCheck = append(versionsToCheck, instance.Version) + } } - if !hasSupport { - return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s", stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String()) + for _, opVersion := range versionsToCheck { + hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(opVersion) + if err != nil { + return fmt.Errorf("checking version constraint on operator version %s: %w", opVersion, err) + } + if !hasSupport { + return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s", stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String()) + } } } @@ -390,7 +414,10 @@ func configureConfig(log *logger.Logger, components component.Component, deployS return fmt.Errorf("configuring operator configuration: %w", err) } - if deploySettings.Roxie.KonfluxImagesEnabled() { + // For the single-operator path (including OLM), populate RELATED_IMAGE_* on the + // top-level OperatorConfig. Split-version deployments apply Konflux env vars + // per OperatorInstance during deployment instead. + if deploySettings.Roxie.KonfluxImagesEnabled() && !deploySettings.NeedsSplitOperators() { deployer.PopulateKonfluxEnvVars(deploySettings) } @@ -458,5 +485,14 @@ func deployValidate(components component.Component, deploySettings *deployer.Con } } + if deploySettings.NeedsSplitOperators() { + if components.IncludesOperatorExplicitly() { + return errors.New("split versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with operator-only deploy") + } + if deploySettings.Operator.DeployViaOlmEnabled() { + return errors.New("split versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with OLM deployment mode") + } + } + return nil } diff --git a/internal/deployer/acs_images.go b/internal/deployer/acs_images.go index 1d2a340..2d90d13 100644 --- a/internal/deployer/acs_images.go +++ b/internal/deployer/acs_images.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackrox/roxie/internal/constants" + "github.com/stackrox/roxie/internal/helpers" ) func imagesForConfig(config Config) []string { @@ -14,23 +15,65 @@ func imagesForConfig(config Config) []string { } imageRegistry := constants.DefaultRegistry - images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "main", config.Roxie.Version)) - images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "central-db", config.Roxie.Version)) - images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4-db", config.Roxie.Version)) - images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4", config.Roxie.Version)) + seen := make(map[string]bool) + add := func(image string) { + if seen[image] { + return + } + seen[image] = true + images = append(images, image) + } + + for _, mainTag := range uniqueMainVersions(config) { + add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "main", mainTag)) + add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "central-db", mainTag)) + add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4-db", mainTag)) + add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4", mainTag)) + } + + operatorPrefix := prefix if !config.Roxie.KonfluxImagesEnabled() { - prefix = "stackrox-" + operatorPrefix = "stackrox-" + } + for _, instance := range config.OperatorInstances() { + add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, operatorPrefix, "operator", instance.Version)) + add(OperatorBundleImageForVersion(instance.Version, config.Roxie.KonfluxImagesEnabled())) } - images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "operator", config.Operator.Version)) - images = append(images, OperatorBundleImage(config)) return images } +func uniqueMainVersions(config Config) []string { + versions := []string{config.EffectiveCentralVersion(), config.EffectiveSecuredClusterVersion()} + seen := make(map[string]bool) + var unique []string + for _, v := range versions { + if v == "" || seen[v] { + continue + } + seen[v] = true + unique = append(unique, v) + } + if len(unique) == 0 && config.Roxie.Version != "" { + unique = append(unique, config.Roxie.Version) + } + return unique +} + +// OperatorBundleImage returns the operator bundle image for the top-level operator version. func OperatorBundleImage(config Config) string { + version := config.Operator.Version + if version == "" { + version = helpers.ConvertMainTagToOperatorTag(config.Roxie.Version) + } + return OperatorBundleImageForVersion(version, config.Roxie.KonfluxImagesEnabled()) +} + +// OperatorBundleImageForVersion returns the operator bundle image for a specific operator version. +func OperatorBundleImageForVersion(operatorVersion string, konflux bool) string { imageRegistry := constants.DefaultRegistry - if config.Roxie.KonfluxImagesEnabled() { - return fmt.Sprintf("%s/release-operator-bundle:v%s", imageRegistry, config.Operator.Version) + if konflux { + return fmt.Sprintf("%s/release-operator-bundle:v%s", imageRegistry, operatorVersion) } - return fmt.Sprintf("%s/stackrox-operator-bundle:v%s", imageRegistry, config.Operator.Version) + return fmt.Sprintf("%s/stackrox-operator-bundle:v%s", imageRegistry, operatorVersion) } diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 0b37ebe..ce90d2c 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -115,6 +115,8 @@ type WaitConfig struct { // CentralConfig holds deployment settings for the Central component. type CentralConfig struct { + // Version overrides Roxie.Version for Central (and its operator when split). + Version string `yaml:"version,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` PauseReconciliation *bool `yaml:"pauseReconciliation,omitempty"` @@ -262,6 +264,8 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { // SecuredClusterConfig holds deployment settings for the SecuredCluster component. type SecuredClusterConfig struct { + // Version overrides Roxie.Version for SecuredCluster (and its operator when split). + Version string `yaml:"version,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` PauseReconciliation *bool `yaml:"pauseReconciliation,omitempty"` diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 8358fb7..4a4abf5 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -45,7 +45,7 @@ func (d *Deployer) deployOperatorOnly(ctx context.Context) error { return nil } -// ensureOperatorDeployed ensures the operator is deployed with the correct version and mode +// ensureOperatorDeployed ensures the required operator instance(s) are deployed. func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { // Skip operator deployment/checks if flag is set to false if d.config.Operator.SkipDeploymentEnabled() { @@ -58,7 +58,118 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { return fmt.Errorf("failed to ensure CRDs installed: %w", err) } - // Detect current operator deployment mode + instances := d.preparedOperatorInstances() + + if d.config.Operator.DeployViaOlmEnabled() { + return d.ensureOperatorDeployedOLM(ctx) + } + + // Switching from OLM to non-OLM in the system namespace. + operatorExists, currentMode, err := d.detectOperatorDeploymentMode(ctx) + if err != nil { + return fmt.Errorf("detecting operator deployment mode: %w", err) + } + if operatorExists && currentMode == OperatorModeOLM { + d.logger.Info("๐Ÿ”„ Switching operator from OLM to non-OLM mode...") + if err := d.teardownOperatorOLM(ctx); err != nil { + return fmt.Errorf("failed to teardown OLM operator: %w", err) + } + } + + if err := d.teardownUnwantedOperatorNamespaces(ctx, instances); err != nil { + return err + } + + for _, instance := range instances { + if err := d.ensureOperatorInstanceNonOLM(ctx, instance); err != nil { + return fmt.Errorf("failed to deploy operator in %s: %w", instance.Namespace, err) + } + } + + return nil +} + +// preparedOperatorInstances returns OperatorInstances with Konflux env vars applied when enabled. +func (d *Deployer) preparedOperatorInstances() []OperatorInstance { + instances := d.config.OperatorInstances() + if !d.config.Roxie.KonfluxImagesEnabled() { + return instances + } + for i := range instances { + instances[i].EnvVars = MergeKonfluxEnvVars(instances[i].EnvVars, instances[i].Version) + } + return instances +} + +// teardownUnwantedOperatorNamespaces removes operators from namespaces that are not +// part of the desired deployment plan (e.g. switching between single and split). +func (d *Deployer) teardownUnwantedOperatorNamespaces(ctx context.Context, desired []OperatorInstance) error { + desiredNS := make(map[string]bool, len(desired)) + for _, inst := range desired { + desiredNS[inst.Namespace] = true + } + + for _, ns := range AllOperatorNamespaces { + if desiredNS[ns] { + continue + } + if !d.operatorDeploymentExists(ctx, ns) && !d.namespaceExists(ns) { + continue + } + d.logger.Infof("๐Ÿ”„ Removing operator from unwanted namespace %s...", ns) + instance := OperatorInstance{Namespace: ns} + switch ns { + case operatorNamespaceCentral: + instance.RoleNameSuffix = "central" + case operatorNamespaceSensor: + instance.RoleNameSuffix = "sensor" + } + if err := d.teardownOperatorNonOLMInNamespace(ctx, instance); err != nil { + return fmt.Errorf("tearing down unwanted operator in %s: %w", ns, err) + } + } + return nil +} + +// ensureOperatorInstanceNonOLM ensures one non-OLM operator instance matches the desired version. +func (d *Deployer) ensureOperatorInstanceNonOLM(ctx context.Context, instance OperatorInstance) error { + exists := d.operatorDeploymentExists(ctx, instance.Namespace) + needsDeployment := !exists + needsTeardown := false + + if exists { + if d.isOperatorVersionCorrect(ctx, instance) { + d.logger.Infof("โœ“ Operator already deployed with correct version in %s", instance.Namespace) + return nil + } + d.logger.Infof("๐Ÿ”„ Operator version mismatch in %s, redeploying...", instance.Namespace) + needsTeardown = true + needsDeployment = true + } + + if needsTeardown { + if err := d.teardownOperatorNonOLMInNamespace(ctx, instance); err != nil { + return fmt.Errorf("failed to teardown non-OLM operator: %w", err) + } + } + + if needsDeployment { + if err := d.deployOperatorNonOLM(ctx, instance); err != nil { + return fmt.Errorf("failed to deploy operator: %w", err) + } + } + + return nil +} + +// ensureOperatorDeployedOLM is the single-operator OLM path (split versions are rejected in validation). +func (d *Deployer) ensureOperatorDeployedOLM(ctx context.Context) error { + // Remove any leftover split-operator namespaces before installing via OLM. + desired := []OperatorInstance{{Namespace: operatorNamespaceSystem}} + if err := d.teardownUnwantedOperatorNamespaces(ctx, desired); err != nil { + return err + } + operatorExists, currentMode, err := d.detectOperatorDeploymentMode(ctx) if err != nil { return fmt.Errorf("detecting operator deployment mode: %w", err) @@ -68,19 +179,17 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { if !operatorExists { needsDeployment = true - } else if d.config.Operator.DeployViaOlmEnabled() && currentMode == OperatorModeNonOLM { - // Switching from non-OLM to OLM + } else if currentMode == OperatorModeNonOLM { d.logger.Info("๐Ÿ”„ Switching operator from non-OLM to OLM mode...") needsTeardown = true needsDeployment = true - } else if !d.config.Operator.DeployViaOlmEnabled() && currentMode == OperatorModeOLM { - // Switching from OLM to non-OLM - d.logger.Info("๐Ÿ”„ Switching operator from OLM to non-OLM mode...") - needsTeardown = true - needsDeployment = true } else { - // Same mode, check version - if d.isOperatorVersionCorrect(ctx) { + instance := OperatorInstance{ + Version: d.config.Operator.Version, + Namespace: operatorNamespaceSystem, + EnvVars: d.config.Operator.EnvVars, + } + if d.isOperatorVersionCorrect(ctx, instance) { d.logger.Info("โœ“ Operator already deployed with correct version") } else { d.logger.Info("๐Ÿ”„ Operator version mismatch, redeploying...") @@ -90,7 +199,6 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { } if needsTeardown { - // Perform teardown for the current mode if currentMode == OperatorModeOLM { if err := d.teardownOperatorOLM(ctx); err != nil { return fmt.Errorf("failed to teardown OLM operator: %w", err) @@ -103,14 +211,8 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { } if needsDeployment { - if d.config.Operator.DeployViaOlmEnabled() { - if err := d.deployOperatorViaOLM(ctx); err != nil { - return fmt.Errorf("failed to deploy operator via OLM: %w", err) - } - } else { - if err := d.deployOperatorNonOLM(ctx); err != nil { - return fmt.Errorf("failed to deploy operator: %w", err) - } + if err := d.deployOperatorViaOLM(ctx); err != nil { + return fmt.Errorf("failed to deploy operator via OLM: %w", err) } } @@ -154,9 +256,9 @@ func (d *Deployer) deployCentralOperator(ctx context.Context) error { return d.configureCentralEndpoint(ctx) } -// isOperatorVersionCorrect checks if the deployed operator matches the desired version -func (d *Deployer) isOperatorVersionCorrect(ctx context.Context) bool { - currentImage, err := d.getDeployedOperatorImage(ctx) +// isOperatorVersionCorrect checks if the deployed operator matches the desired version. +func (d *Deployer) isOperatorVersionCorrect(ctx context.Context, instance OperatorInstance) bool { + currentImage, err := d.getDeployedOperatorImage(ctx, instance.Namespace) if err != nil { d.logger.Warningf("Could not retrieve operator image: %v", err) return false @@ -170,19 +272,19 @@ func (d *Deployer) isOperatorVersionCorrect(ctx context.Context) bool { } currentTag := parts[1] - if currentTag != d.config.Operator.Version { + if currentTag != instance.Version { d.logger.Info("Operator version mismatch detected:") d.logger.Infof(" Current: %s", currentTag) - d.logger.Infof(" Desired: %s", d.config.Operator.Version) + d.logger.Infof(" Desired: %s", instance.Version) return false } return true } -// getDeployedOperatorImage gets the image of the currently deployed operator -func (d *Deployer) getDeployedOperatorImage(ctx context.Context) (string, error) { +// getDeployedOperatorImage gets the image of the currently deployed operator in a namespace. +func (d *Deployer) getDeployedOperatorImage(ctx context.Context, namespace string) (string, error) { result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "deployment", operatorDeploymentName, "-n", operatorNamespace, + Args: []string{"get", "deployment", operatorDeploymentName, "-n", namespace, "-o", "jsonpath={.spec.template.spec.containers[0].image}"}, }) if err != nil { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 77a1b93..e81941e 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -729,7 +729,7 @@ func (d *Deployer) writeEnvrcFile(ctx context.Context) error { func (d *Deployer) PrintCentralDeploymentSummary() { component := "Central" - mainImageTag := d.config.Roxie.Version + mainImageTag := d.config.EffectiveCentralVersion() olm := d.config.Operator.DeployViaOlmEnabled() exposure := d.config.Central.GetExposure() portForwarding := d.config.Central.PortForwardingEnabled() @@ -910,7 +910,7 @@ func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace st // extracted func (d *Deployer) PrintSecuredClusterDeploymentSummary() { component := "Secured Cluster" - mainImageTag := d.config.Roxie.Version + mainImageTag := d.config.EffectiveSecuredClusterVersion() olm := d.config.Operator.DeployViaOlmEnabled() log := d.logger kubeContext := d.kubeContext diff --git a/internal/deployer/konflux.go b/internal/deployer/konflux.go index 5c5eff2..1e5301e 100644 --- a/internal/deployer/konflux.go +++ b/internal/deployer/konflux.go @@ -19,27 +19,32 @@ var konfluxRelatedImages = map[string]string{ "RELATED_IMAGE_FACT": "fact", } -// KonfluxOperatorImage returns the Konflux-built operator image reference. -func KonfluxOperatorImage(config *Config) string { - return fmt.Sprintf("%s/release-operator:%s", constants.DefaultRegistry, config.Operator.Version) +// KonfluxOperatorImage returns the Konflux-built operator image reference for a version. +func KonfluxOperatorImage(operatorVersion string) string { + return fmt.Sprintf("%s/release-operator:%s", constants.DefaultRegistry, operatorVersion) } -// PopulateKonfluxEnvVars populates config.Operator.EnvVars with RELATED_IMAGE_* -// entries for Konflux image rewriting. Explicitly-provided env vars (e.g. from -// --operator-env) take precedence and are not overwritten. -func PopulateKonfluxEnvVars(config *Config) { - if config.Operator.EnvVars == nil { - config.Operator.EnvVars = make(map[string]string) - } +// MergeKonfluxEnvVars returns a copy of envVars with RELATED_IMAGE_* entries filled +// for the given operator version. Explicitly-provided env vars take precedence. +func MergeKonfluxEnvVars(envVars map[string]string, operatorVersion string) map[string]string { + result := copyStringMap(envVars) for envName, imageSuffix := range konfluxRelatedImages { - if _, exists := config.Operator.EnvVars[envName]; exists { + if _, exists := result[envName]; exists { continue } - config.Operator.EnvVars[envName] = fmt.Sprintf( + result[envName] = fmt.Sprintf( "%s/release-%s:%s", constants.DefaultRegistry, imageSuffix, - config.Operator.Version, // Konflux built images use the "operator tag". + operatorVersion, // Konflux built images use the "operator tag". ) } + return result +} + +// PopulateKonfluxEnvVars populates config.Operator.EnvVars with RELATED_IMAGE_* +// entries for Konflux image rewriting. Used for the single-operator / OLM path +// where env vars live on the top-level OperatorConfig. +func PopulateKonfluxEnvVars(config *Config) { + config.Operator.EnvVars = MergeKonfluxEnvVars(config.Operator.EnvVars, config.Operator.Version) } diff --git a/internal/deployer/konflux_test.go b/internal/deployer/konflux_test.go index 7da65f4..4172dbb 100644 --- a/internal/deployer/konflux_test.go +++ b/internal/deployer/konflux_test.go @@ -10,11 +10,8 @@ import ( ) func TestKonfluxOperatorImage(t *testing.T) { - config := &Config{ - Operator: OperatorConfig{Version: "4.9.2"}, - } expected := fmt.Sprintf("%s/release-operator:4.9.2", constants.DefaultRegistry) - assert.Equal(t, expected, KonfluxOperatorImage(config)) + assert.Equal(t, expected, KonfluxOperatorImage("4.9.2")) } func TestPopulateKonfluxEnvVars_AllEntries(t *testing.T) { @@ -71,3 +68,14 @@ func TestPopulateKonfluxEnvVars_NilEnvVarsMap(t *testing.T) { require.NotNil(t, config.Operator.EnvVars) assert.Len(t, config.Operator.EnvVars, len(konfluxRelatedImages)) } + +func TestMergeKonfluxEnvVars_PerInstanceVersion(t *testing.T) { + base := map[string]string{"CUSTOM": "1"} + merged := MergeKonfluxEnvVars(base, "4.8.0") + + assert.Equal(t, "1", merged["CUSTOM"]) + assert.Equal(t, fmt.Sprintf("%s/release-main:4.8.0", constants.DefaultRegistry), merged["RELATED_IMAGE_MAIN"]) + assert.Equal(t, "1", base["CUSTOM"], "base map should not be mutated for existing keys") + _, ok := base["RELATED_IMAGE_MAIN"] + assert.False(t, ok, "base map should not receive new keys") +} diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index 094fc5c..1c7d8e5 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -21,9 +21,11 @@ import ( const ( adminPasswordSecretName = "admin-password" - operatorNamespace = "rhacs-operator-system" - operatorDeploymentName = "rhacs-operator-controller-manager" - managerContainerName = "manager" + // operatorNamespace is the single-operator namespace used by the OLM path + // and other code that still references the package-level constant. + operatorNamespace = operatorNamespaceSystem + operatorDeploymentName = "rhacs-operator-controller-manager" + managerContainerName = "manager" ) var requiredCRDs = []string{ @@ -32,10 +34,10 @@ var requiredCRDs = []string{ "securitypolicies.config.stackrox.io", } -// deployOperatorNonOLM deploys the RHACS operator without OLM -func (d *Deployer) deployOperatorNonOLM(ctx context.Context) error { - d.logger.Infof("Operator tag: %s", d.config.Operator.Version) - bundleImage := OperatorBundleImage(d.config) +// deployOperatorNonOLM deploys one RHACS operator instance without OLM. +func (d *Deployer) deployOperatorNonOLM(ctx context.Context, instance OperatorInstance) error { + d.logger.Infof("Operator tag: %s (namespace %s)", instance.Version, instance.Namespace) + bundleImage := OperatorBundleImageForVersion(instance.Version, d.config.Roxie.KonfluxImagesEnabled()) bundleDir, err := d.downloadAndExtractOperatorBundle(ctx, bundleImage) if err != nil { @@ -45,16 +47,22 @@ func (d *Deployer) deployOperatorNonOLM(ctx context.Context) error { d.logger.Infof("Bundle image: %s", bundleImage) - crdFiles, err := d.identifyCRDFileNames(bundleDir) - if err != nil { - return err - } - - if err := d.applyCRDsToCluster(ctx, crdFiles); err != nil { - return err + // Only the newest planned operator version may apply CRDs, so an older + // companion operator cannot downgrade cluster CRD schemas. + if instance.Version == d.config.NewestOperatorVersion() { + crdFiles, err := d.identifyCRDFileNames(bundleDir) + if err != nil { + return err + } + if err := d.applyCRDsToCluster(ctx, crdFiles); err != nil { + return err + } + } else { + d.logger.Dimf("Skipping CRD apply for older operator version %s (newest is %s)", + instance.Version, d.config.NewestOperatorVersion()) } - if err := d.deployOperatorFromCSV(ctx, bundleDir); err != nil { + if err := d.deployOperatorFromCSV(ctx, bundleDir, instance); err != nil { return err } @@ -160,7 +168,11 @@ func (d *Deployer) ensureCRDsInstalled(ctx context.Context) error { } if len(missing) > 0 { - bundleImage := OperatorBundleImage(d.config) + version := d.config.NewestOperatorVersion() + if version == "" { + version = d.config.Operator.Version + } + bundleImage := OperatorBundleImageForVersion(version, d.config.Roxie.KonfluxImagesEnabled()) d.logger.Warningf("Missing CRDs detected (%s)", strings.Join(missing, ", ")) d.logger.Warningf("Fetching bundle %s", bundleImage) @@ -181,8 +193,8 @@ func (d *Deployer) ensureCRDsInstalled(ctx context.Context) error { return nil } -// deployOperatorFromCSV deploys the operator from CSV -func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string) error { +// deployOperatorFromCSV deploys the operator from CSV into the given instance namespace. +func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string, instance OperatorInstance) error { csvFile := filepath.Join(bundleDir, "rhacs-operator.clusterserviceversion.yaml") if _, err := os.Stat(csvFile); os.IsNotExist(err) { return errors.New("ClusterServiceVersion file not found in bundle") @@ -199,45 +211,45 @@ func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string) d.useOperatorPullSecrets = d.config.Roxie.KonfluxImagesEnabled() && d.config.Roxie.ClusterType.NeedsPullSecrets() d.logger.Info("๐Ÿ“‹ Operator deployment plan:") - d.logger.Dimf(" โ€ข Namespace: %s", operatorNamespace) + d.logger.Dimf(" โ€ข Namespace: %s", instance.Namespace) d.logger.Dimf(" โ€ข ServiceAccount: %s", serviceAccountName) d.logger.Dimf(" โ€ข Setting up pull secrets: %v", d.useOperatorPullSecrets) - if len(d.config.Operator.EnvVars) > 0 { - d.logger.Dimf(" โ€ข Custom operator env vars: %d", len(d.config.Operator.EnvVars)) - for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) { + if len(instance.EnvVars) > 0 { + d.logger.Dimf(" โ€ข Custom operator env vars: %d", len(instance.EnvVars)) + for _, envVar := range envVarsToSortedList(instance.EnvVars) { ev := envVar.(map[string]any) d.logger.Dimf(" %s=%s", ev["name"], ev["value"]) } } - if err := d.prepareNamespace(ctx, operatorNamespace, d.useOperatorPullSecrets); err != nil { + if err := d.prepareNamespace(ctx, instance.Namespace, d.useOperatorPullSecrets); err != nil { return err } - if err := d.createServiceAccount(ctx, operatorNamespace, serviceAccountName); err != nil { + if err := d.createServiceAccount(ctx, instance.Namespace, serviceAccountName); err != nil { return err } - if err := d.createClusterRoleFromCSV(ctx, deploymentSpec); err != nil { + if err := d.createClusterRoleFromCSV(ctx, deploymentSpec, instance); err != nil { return err } - if err := d.createClusterRoleBinding(ctx, operatorNamespace, serviceAccountName); err != nil { + if err := d.createClusterRoleBinding(ctx, instance, serviceAccountName); err != nil { return err } - if err := d.createDeploymentFromCSV(ctx, operatorNamespace, deploymentSpec); err != nil { + if err := d.createDeploymentFromCSV(ctx, instance, deploymentSpec); err != nil { return err } // Apply bundle service resources (if they exist) - _ = d.applyBundleServiceResources(ctx, bundleDir, operatorNamespace) + _ = d.applyBundleServiceResources(ctx, bundleDir, instance.Namespace) - if err := d.waitForOperatorReady(ctx, operatorNamespace, operatorDeploymentName, 300); err != nil { + if err := d.waitForOperatorReady(ctx, instance.Namespace, operatorDeploymentName, 300); err != nil { return err } - d.logger.Success("๐ŸŽ‰ Operator deployment completed successfully!") + d.logger.Successf("๐ŸŽ‰ Operator deployment completed successfully in %s!", instance.Namespace) return nil } @@ -311,8 +323,8 @@ func (d *Deployer) createServiceAccount(ctx context.Context, namespace, name str return nil } -// createClusterRoleFromCSV creates ClusterRole from CSV -func (d *Deployer) createClusterRoleFromCSV(ctx context.Context, deploymentSpec map[string]any) error { +// createClusterRoleFromCSV creates ClusterRole from CSV for the given operator instance. +func (d *Deployer) createClusterRoleFromCSV(ctx context.Context, deploymentSpec map[string]any, instance OperatorInstance) error { clusterPermissions := deploymentSpec["cluster_permissions"].([]any) if len(clusterPermissions) == 0 { d.logger.Warning("No cluster permissions found in CSV") @@ -321,12 +333,13 @@ func (d *Deployer) createClusterRoleFromCSV(ctx context.Context, deploymentSpec firstPerm := clusterPermissions[0].(map[string]any) rules := firstPerm["rules"] + roleName := instance.ClusterRoleName() clusterRole := map[string]any{ "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "ClusterRole", "metadata": map[string]any{ - "name": "rhacs-operator-manager-role", + "name": roleName, "labels": map[string]string{"app": "rhacs-operator"}, }, "rules": rules, @@ -334,7 +347,7 @@ func (d *Deployer) createClusterRoleFromCSV(ctx context.Context, deploymentSpec yamlData, err := yaml.Marshal(clusterRole) if err != nil { - return fmt.Errorf("failed to marshal ClusterRole 'rhacs-operator-manager-role': %w", err) + return fmt.Errorf("failed to marshal ClusterRole '%s': %w", roleName, err) } _, err = d.runKubectl(ctx, k8s.KubectlOptions{ @@ -342,37 +355,40 @@ func (d *Deployer) createClusterRoleFromCSV(ctx context.Context, deploymentSpec Stdin: bytes.NewReader(yamlData), }) if err != nil { - return fmt.Errorf("failed to create ClusterRole 'rhacs-operator-manager-role': %w", err) + return fmt.Errorf("failed to create ClusterRole '%s': %w", roleName, err) } return nil } -// createClusterRoleBinding creates ClusterRoleBinding -func (d *Deployer) createClusterRoleBinding(ctx context.Context, namespace, serviceAccountName string) error { +// createClusterRoleBinding creates ClusterRoleBinding for the given operator instance. +func (d *Deployer) createClusterRoleBinding(ctx context.Context, instance OperatorInstance, serviceAccountName string) error { + roleName := instance.ClusterRoleName() + bindingName := instance.ClusterRoleBindingName() + crb := map[string]any{ "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "ClusterRoleBinding", "metadata": map[string]any{ - "name": "rhacs-operator-manager-rolebinding", + "name": bindingName, "labels": map[string]string{"app": "rhacs-operator"}, }, "roleRef": map[string]any{ "apiGroup": "rbac.authorization.k8s.io", "kind": "ClusterRole", - "name": "rhacs-operator-manager-role", + "name": roleName, }, "subjects": []any{ map[string]any{ "kind": "ServiceAccount", "name": serviceAccountName, - "namespace": namespace, + "namespace": instance.Namespace, }, }, } yamlData, err := yaml.Marshal(crb) if err != nil { - return fmt.Errorf("failed to marshal ClusterRoleBinding 'rhacs-operator-manager-rolebinding' for ServiceAccount '%s/%s': %w", namespace, serviceAccountName, err) + return fmt.Errorf("failed to marshal ClusterRoleBinding '%s' for ServiceAccount '%s/%s': %w", bindingName, instance.Namespace, serviceAccountName, err) } _, err = d.runKubectl(ctx, k8s.KubectlOptions{ @@ -380,13 +396,13 @@ func (d *Deployer) createClusterRoleBinding(ctx context.Context, namespace, serv Stdin: bytes.NewReader(yamlData), }) if err != nil { - return fmt.Errorf("failed to create ClusterRoleBinding 'rhacs-operator-manager-rolebinding': %w", err) + return fmt.Errorf("failed to create ClusterRoleBinding '%s': %w", bindingName, err) } return nil } -// createDeploymentFromCSV creates Deployment from CSV -func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string, deploymentSpec map[string]any) error { +// createDeploymentFromCSV creates Deployment from CSV for the given operator instance. +func (d *Deployer) createDeploymentFromCSV(ctx context.Context, instance OperatorInstance, deploymentSpec map[string]any) error { deployments := deploymentSpec["deployments"].([]any) if len(deployments) == 0 { return errors.New("no deployments found in CSV") @@ -405,7 +421,7 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string "kind": "Deployment", "metadata": map[string]any{ "name": deploymentName, - "namespace": namespace, + "namespace": instance.Namespace, "labels": csvDeployment["label"], }, "spec": deploymentTemplate, @@ -430,16 +446,16 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string podSpec["serviceAccountName"] = deploymentSpec["service_account"] if d.config.Roxie.KonfluxImagesEnabled() { - d.rewriteKonfluxOperatorImage(managerContainer) + d.rewriteKonfluxOperatorImage(managerContainer, instance.Version) } - if len(d.config.Operator.EnvVars) > 0 { - d.injectEnvVarsIntoManagerContainer(managerContainer) + if len(instance.EnvVars) > 0 { + injectEnvVarsIntoManagerContainer(managerContainer, instance.EnvVars) } yamlData, err := yaml.Marshal(deployment) if err != nil { - return fmt.Errorf("failed to marshal Deployment '%s/%s': %w", namespace, deploymentName, err) + return fmt.Errorf("failed to marshal Deployment '%s/%s': %w", instance.Namespace, deploymentName, err) } _, err = d.runKubectl(ctx, k8s.KubectlOptions{ @@ -447,7 +463,7 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string Stdin: bytes.NewReader(yamlData), }) if err != nil { - return fmt.Errorf("failed to create Deployment '%s/%s': %w", namespace, deploymentName, err) + return fmt.Errorf("failed to create Deployment '%s/%s': %w", instance.Namespace, deploymentName, err) } return nil } @@ -472,7 +488,7 @@ func managerContainerFromPodSpec(podSpec map[string]any) (map[string]any, error) // injectEnvVarsIntoManagerContainer merges configured operator env vars into // the manager container, overriding any existing env vars with the same name. -func (d *Deployer) injectEnvVarsIntoManagerContainer(container map[string]any) { +func injectEnvVarsIntoManagerContainer(container map[string]any, envVars map[string]string) { existing := make(map[string]int) envList, _ := container["env"].([]any) for i, item := range envList { @@ -483,7 +499,7 @@ func (d *Deployer) injectEnvVarsIntoManagerContainer(container map[string]any) { } } - for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) { + for _, envVar := range envVarsToSortedList(envVars) { name := envVar.(map[string]any)["name"].(string) if idx, found := existing[name]; found { envList[idx] = envVar @@ -496,9 +512,9 @@ func (d *Deployer) injectEnvVarsIntoManagerContainer(container map[string]any) { } // rewriteKonfluxOperatorImage replaces the manager container's image with the -// Konflux-built operator image. -func (d *Deployer) rewriteKonfluxOperatorImage(container map[string]any) { - newImage := KonfluxOperatorImage(&d.config) +// Konflux-built operator image for the given operator version. +func (d *Deployer) rewriteKonfluxOperatorImage(container map[string]any, operatorVersion string) { + newImage := KonfluxOperatorImage(operatorVersion) d.logger.Infof("Rewriting operator image to %s", newImage) container["image"] = newImage } @@ -551,35 +567,74 @@ func generateClusterName() string { return fmt.Sprintf("sensor-%d", n.Int64()+1000) } -// teardownOperatorNonOLM removes the operator when installed without OLM. -func (d *Deployer) teardownOperatorNonOLM(ctx context.Context) error { - d.logger.Info("๐Ÿงน Tearing down operator deployed without OLM...") +// teardownOperatorNonOLMInNamespace removes a non-OLM operator from the given namespace +// and deletes its cluster-scoped RBAC resources for that instance. +func (d *Deployer) teardownOperatorNonOLMInNamespace(ctx context.Context, instance OperatorInstance) error { + d.logger.Infof("๐Ÿงน Tearing down non-OLM operator in namespace %s...", instance.Namespace) - // Delete operator namespace. d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"delete", "namespace", operatorNamespace, "--wait=false"}, + Args: []string{"delete", "namespace", instance.Namespace, "--wait=false"}, IgnoreErrors: true, }) - // Delete cluster-scoped resources created by non-OLM flow. - clusterResources := []struct { + for _, resource := range []struct { name string kind string }{ - {name: "rhacs-operator-manager-rolebinding", kind: "clusterrolebinding"}, - {name: "rhacs-operator-manager-role", kind: "clusterrole"}, - } - for _, resource := range clusterResources { + {name: instance.ClusterRoleBindingName(), kind: "clusterrolebinding"}, + {name: instance.ClusterRoleName(), kind: "clusterrole"}, + } { d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", resource.kind, resource.name, "--ignore-not-found=true"}, IgnoreErrors: true, }) } - if err := d.waitForNamespaceDeletion(operatorNamespace); err != nil { - d.logger.Warningf("Namespace %s deletion incomplete: %v", operatorNamespace, err) + if err := d.waitForNamespaceDeletion(instance.Namespace); err != nil { + d.logger.Warningf("Namespace %s deletion incomplete: %v", instance.Namespace, err) + } + + d.logger.Successf("โœ“ Non-OLM operator resources removed from %s", instance.Namespace) + return nil +} + +// teardownAllOperatorClusterRBAC deletes all known operator ClusterRole/Binding names. +func (d *Deployer) teardownAllOperatorClusterRBAC(ctx context.Context) { + for _, instance := range []OperatorInstance{ + {Namespace: operatorNamespaceSystem}, + {Namespace: operatorNamespaceCentral, RoleNameSuffix: "central"}, + {Namespace: operatorNamespaceSensor, RoleNameSuffix: "sensor"}, + } { + d.runKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"delete", "clusterrolebinding", instance.ClusterRoleBindingName(), "--ignore-not-found=true"}, + IgnoreErrors: true, + }) + d.runKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"delete", "clusterrole", instance.ClusterRoleName(), "--ignore-not-found=true"}, + IgnoreErrors: true, + }) + } +} + +// teardownOperatorNonOLM removes non-OLM operators from all known namespaces. +func (d *Deployer) teardownOperatorNonOLM(ctx context.Context) error { + d.logger.Info("๐Ÿงน Tearing down operator deployed without OLM...") + + for _, ns := range AllOperatorNamespaces { + if !d.namespaceExists(ns) { + continue + } + instance := OperatorInstance{Namespace: ns} + switch ns { + case operatorNamespaceCentral: + instance.RoleNameSuffix = "central" + case operatorNamespaceSensor: + instance.RoleNameSuffix = "sensor" + } + _ = d.teardownOperatorNonOLMInNamespace(ctx, instance) } + d.teardownAllOperatorClusterRBAC(ctx) d.logger.Success("โœ“ Non-OLM operator resources removed") return nil } @@ -590,13 +645,31 @@ func (d *Deployer) teardownOperator(ctx context.Context) error { if err != nil { return fmt.Errorf("detecting operator deployment mode: %w", err) } - if !operatorExists { + if operatorExists && operatorMode == OperatorModeOLM { + return d.teardownOperatorOLM(ctx) + } + + foundAny := operatorExists + for _, ns := range AllOperatorNamespaces { + if ns == operatorNamespaceSystem { + continue // already covered by detectOperatorDeploymentMode for non-OLM + } + if d.operatorDeploymentExists(ctx, ns) { + foundAny = true + break + } + } + if !foundAny { d.logger.Dim("No operator deployment found, skipping operator teardown") return nil } - if operatorMode == OperatorModeOLM { - return d.teardownOperatorOLM(ctx) - } return d.teardownOperatorNonOLM(ctx) } + +func (d *Deployer) operatorDeploymentExists(ctx context.Context, namespace string) bool { + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"get", "deployment", operatorDeploymentName, "-n", namespace}, + }) + return err == nil +} diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go new file mode 100644 index 0000000..154a5b9 --- /dev/null +++ b/internal/deployer/operator_instance.go @@ -0,0 +1,154 @@ +package deployer + +import ( + "maps" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/stackrox/roxie/internal/helpers" +) + +const ( + operatorNamespaceSystem = "rhacs-operator-system" + operatorNamespaceCentral = "rhacs-operator-central" + operatorNamespaceSensor = "rhacs-operator-sensor" + + envCentralReconcilerEnabled = "CENTRAL_RECONCILER_ENABLED" + envSecuredClusterReconcilerEnabled = "SECURED_CLUSTER_RECONCILER_ENABLED" +) + +// AllOperatorNamespaces lists every namespace where roxie may deploy an operator. +var AllOperatorNamespaces = []string{ + operatorNamespaceSystem, + operatorNamespaceCentral, + operatorNamespaceSensor, +} + +// OperatorInstance describes one operator deployment (single- or split-version). +type OperatorInstance struct { + Version string + Namespace string + EnvVars map[string]string + // RoleNameSuffix is appended to cluster-scoped RBAC resource names. + // Empty for the single-operator (rhacs-operator-system) case. + RoleNameSuffix string +} + +// ClusterRoleName returns the ClusterRole name for this operator instance. +func (o OperatorInstance) ClusterRoleName() string { + if o.RoleNameSuffix == "" { + return "rhacs-operator-manager-role" + } + return "rhacs-operator-manager-role-" + o.RoleNameSuffix +} + +// ClusterRoleBindingName returns the ClusterRoleBinding name for this operator instance. +func (o OperatorInstance) ClusterRoleBindingName() string { + if o.RoleNameSuffix == "" { + return "rhacs-operator-manager-rolebinding" + } + return "rhacs-operator-manager-rolebinding-" + o.RoleNameSuffix +} + +// EffectiveCentralVersion returns the main image tag used for Central. +func (c *Config) EffectiveCentralVersion() string { + if c.Central.Version != "" { + return c.Central.Version + } + return c.Roxie.Version +} + +// EffectiveSecuredClusterVersion returns the main image tag used for SecuredCluster. +func (c *Config) EffectiveSecuredClusterVersion() string { + if c.SecuredCluster.Version != "" { + return c.SecuredCluster.Version + } + return c.Roxie.Version +} + +// NeedsSplitOperators reports whether Central and SecuredCluster require different operators. +func (c *Config) NeedsSplitOperators() bool { + return c.EffectiveCentralVersion() != c.EffectiveSecuredClusterVersion() +} + +// OperatorInstances builds the operator deployment plan for this config. +// When versions match, a single operator is deployed to rhacs-operator-system. +// When they differ, two operators are deployed with reconciler toggles. +func (c *Config) OperatorInstances() []OperatorInstance { + if !c.NeedsSplitOperators() { + version := helpers.ConvertMainTagToOperatorTag(c.Roxie.Version) + if version == "" { + version = c.Operator.Version + } + return []OperatorInstance{{ + Version: version, + Namespace: operatorNamespaceSystem, + EnvVars: copyStringMap(c.Operator.EnvVars), + }} + } + + centralEnvVars := copyStringMap(c.Operator.EnvVars) + centralEnvVars[envSecuredClusterReconcilerEnabled] = "false" + + sensorEnvVars := copyStringMap(c.Operator.EnvVars) + sensorEnvVars[envCentralReconcilerEnabled] = "false" + + return []OperatorInstance{ + { + Version: helpers.ConvertMainTagToOperatorTag(c.EffectiveCentralVersion()), + Namespace: operatorNamespaceCentral, + EnvVars: centralEnvVars, + RoleNameSuffix: "central", + }, + { + Version: helpers.ConvertMainTagToOperatorTag(c.EffectiveSecuredClusterVersion()), + Namespace: operatorNamespaceSensor, + EnvVars: sensorEnvVars, + RoleNameSuffix: "sensor", + }, + } +} + +// NewestOperatorVersion returns the highest operator version among planned instances. +// CRDs should always be installed from this version so an older companion operator +// cannot leave the cluster on a stale (or downgraded) CRD schema. +// +// Ordering uses the leading semver of each tag (suffix after "-" is ignored), which +// is sufficient for release-vs-release compat testing (e.g. 4.8.x vs 4.9.x). +func (c *Config) NewestOperatorVersion() string { + instances := c.OperatorInstances() + if len(instances) == 0 { + return c.Operator.Version + } + newest := instances[0].Version + for _, inst := range instances[1:] { + if operatorVersionGreater(inst.Version, newest) { + newest = inst.Version + } + } + return newest +} + +// operatorVersionGreater reports whether a is a newer operator tag than b. +func operatorVersionGreater(a, b string) bool { + av, aerr := parseOperatorSemver(a) + bv, berr := parseOperatorSemver(b) + if aerr == nil && berr == nil { + return av.GreaterThan(bv) + } + // Fall back to lexicographic compare when tags are not parseable as semver. + return a > b +} + +func parseOperatorSemver(version string) (*semver.Version, error) { + // Leading semver only; see NewestOperatorVersion. + version, _, _ = strings.Cut(version, "-") + return semver.NewVersion(version) +} + +func copyStringMap(m map[string]string) map[string]string { + if m == nil { + return make(map[string]string) + } + return maps.Clone(m) +} diff --git a/internal/deployer/operator_instance_test.go b/internal/deployer/operator_instance_test.go new file mode 100644 index 0000000..c272996 --- /dev/null +++ b/internal/deployer/operator_instance_test.go @@ -0,0 +1,181 @@ +package deployer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEffectiveVersions_DefaultToRoxieVersion(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + } + assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) + assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) + assert.False(t, cfg.NeedsSplitOperators()) +} + +func TestEffectiveVersions_CentralOverride(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + Central: CentralConfig{Version: "4.8.0"}, + } + assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) + assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) + assert.True(t, cfg.NeedsSplitOperators()) +} + +func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + SecuredCluster: SecuredClusterConfig{Version: "4.7.0"}, + } + assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) + assert.Equal(t, "4.7.0", cfg.EffectiveSecuredClusterVersion()) + assert.True(t, cfg.NeedsSplitOperators()) +} + +func TestEffectiveVersions_BothOverridesSame_NoSplit(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + Central: CentralConfig{Version: "4.8.0"}, + SecuredCluster: SecuredClusterConfig{Version: "4.8.0"}, + } + assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) + assert.Equal(t, "4.8.0", cfg.EffectiveSecuredClusterVersion()) + assert.False(t, cfg.NeedsSplitOperators()) +} + +func TestOperatorInstances_SingleVersion(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0-dirty"}, + Operator: OperatorConfig{ + EnvVars: map[string]string{"FOO": "bar"}, + }, + } + + instances := cfg.OperatorInstances() + require.Len(t, instances, 1) + assert.Equal(t, "4.9.0", instances[0].Version) + assert.Equal(t, operatorNamespaceSystem, instances[0].Namespace) + assert.Equal(t, "", instances[0].RoleNameSuffix) + assert.Equal(t, "rhacs-operator-manager-role", instances[0].ClusterRoleName()) + assert.Equal(t, "rhacs-operator-manager-rolebinding", instances[0].ClusterRoleBindingName()) + assert.Equal(t, "bar", instances[0].EnvVars["FOO"]) + assert.NotContains(t, instances[0].EnvVars, envCentralReconcilerEnabled) + assert.NotContains(t, instances[0].EnvVars, envSecuredClusterReconcilerEnabled) +} + +func TestOperatorInstances_SplitVersions(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + Central: CentralConfig{ + Version: "4.8.0", + }, + SecuredCluster: SecuredClusterConfig{ + Version: "4.9.0", + }, + Operator: OperatorConfig{ + EnvVars: map[string]string{"CUSTOM": "1"}, + }, + } + + instances := cfg.OperatorInstances() + require.Len(t, instances, 2) + + central := instances[0] + assert.Equal(t, "4.8.0", central.Version) + assert.Equal(t, operatorNamespaceCentral, central.Namespace) + assert.Equal(t, "central", central.RoleNameSuffix) + assert.Equal(t, "rhacs-operator-manager-role-central", central.ClusterRoleName()) + assert.Equal(t, "rhacs-operator-manager-rolebinding-central", central.ClusterRoleBindingName()) + assert.Equal(t, "1", central.EnvVars["CUSTOM"]) + assert.Equal(t, "false", central.EnvVars[envSecuredClusterReconcilerEnabled]) + assert.NotContains(t, central.EnvVars, envCentralReconcilerEnabled) + + sensor := instances[1] + assert.Equal(t, "4.9.0", sensor.Version) + assert.Equal(t, operatorNamespaceSensor, sensor.Namespace) + assert.Equal(t, "sensor", sensor.RoleNameSuffix) + assert.Equal(t, "rhacs-operator-manager-role-sensor", sensor.ClusterRoleName()) + assert.Equal(t, "rhacs-operator-manager-rolebinding-sensor", sensor.ClusterRoleBindingName()) + assert.Equal(t, "1", sensor.EnvVars["CUSTOM"]) + assert.Equal(t, "false", sensor.EnvVars[envCentralReconcilerEnabled]) + assert.NotContains(t, sensor.EnvVars, envSecuredClusterReconcilerEnabled) + + // Env var maps must be independent copies. + central.EnvVars["CUSTOM"] = "changed" + assert.Equal(t, "1", sensor.EnvVars["CUSTOM"]) + assert.Equal(t, "1", cfg.Operator.EnvVars["CUSTOM"]) +} + +func TestNewestOperatorVersion(t *testing.T) { + t.Run("single version", func(t *testing.T) { + cfg := Config{Roxie: RoxieConfig{Version: "4.9.0"}} + assert.Equal(t, "4.9.0", cfg.NewestOperatorVersion()) + }) + + t.Run("secured cluster newer", func(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.11.1"}, + Central: CentralConfig{Version: "4.10.0"}, + SecuredCluster: SecuredClusterConfig{Version: "4.11.1"}, + } + assert.Equal(t, "4.11.1", cfg.NewestOperatorVersion()) + }) + + t.Run("central newer", func(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.11.1"}, + Central: CentralConfig{Version: "4.11.1"}, + SecuredCluster: SecuredClusterConfig{Version: "4.10.0"}, + } + assert.Equal(t, "4.11.1", cfg.NewestOperatorVersion()) + }) + + t.Run("build suffix uses leading semver", func(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.10.0"}, + Central: CentralConfig{Version: "4.10.0"}, + SecuredCluster: SecuredClusterConfig{Version: "4.11.0-937-gf0da38f1a"}, + } + assert.Equal(t, "4.11.0-937-gf0da38f1a", cfg.NewestOperatorVersion()) + }) +} + +func TestImagesForConfig_SplitVersions(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + Central: CentralConfig{Version: "4.8.0"}, + SecuredCluster: SecuredClusterConfig{Version: "4.9.0"}, + } + + images := imagesForConfig(cfg) + assert.Contains(t, images, "quay.io/rhacs-eng/main:4.8.0") + assert.Contains(t, images, "quay.io/rhacs-eng/main:4.9.0") + assert.Contains(t, images, "quay.io/rhacs-eng/stackrox-operator:4.8.0") + assert.Contains(t, images, "quay.io/rhacs-eng/stackrox-operator:4.9.0") + assert.Contains(t, images, "quay.io/rhacs-eng/stackrox-operator-bundle:v4.8.0") + assert.Contains(t, images, "quay.io/rhacs-eng/stackrox-operator-bundle:v4.9.0") +} + +func TestImagesForConfig_SingleVersionUnchanged(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + Operator: OperatorConfig{Version: "4.9.0"}, + } + + images := imagesForConfig(cfg) + assert.Contains(t, images, "quay.io/rhacs-eng/main:4.9.0") + assert.Contains(t, images, "quay.io/rhacs-eng/stackrox-operator:4.9.0") + assert.Contains(t, images, "quay.io/rhacs-eng/stackrox-operator-bundle:v4.9.0") + + mainCount := 0 + for _, img := range images { + if img == "quay.io/rhacs-eng/main:4.9.0" { + mainCount++ + } + } + assert.Equal(t, 1, mainCount, "main image should appear once") +} From 05bd6998d5c52f47466c1dd0c777cdc063f2989c Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Sat, 18 Jul 2026 13:33:05 +0200 Subject: [PATCH 02/17] Derive the single operator from the effective component version --- internal/deployer/operator_instance.go | 2 +- internal/deployer/operator_instance_test.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go index 154a5b9..0855956 100644 --- a/internal/deployer/operator_instance.go +++ b/internal/deployer/operator_instance.go @@ -76,7 +76,7 @@ func (c *Config) NeedsSplitOperators() bool { // When they differ, two operators are deployed with reconciler toggles. func (c *Config) OperatorInstances() []OperatorInstance { if !c.NeedsSplitOperators() { - version := helpers.ConvertMainTagToOperatorTag(c.Roxie.Version) + version := helpers.ConvertMainTagToOperatorTag(c.EffectiveCentralVersion()) if version == "" { version = c.Operator.Version } diff --git a/internal/deployer/operator_instance_test.go b/internal/deployer/operator_instance_test.go index c272996..aa5929c 100644 --- a/internal/deployer/operator_instance_test.go +++ b/internal/deployer/operator_instance_test.go @@ -45,6 +45,10 @@ func TestEffectiveVersions_BothOverridesSame_NoSplit(t *testing.T) { assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.8.0", cfg.EffectiveSecuredClusterVersion()) assert.False(t, cfg.NeedsSplitOperators()) + + instances := cfg.OperatorInstances() + require.Len(t, instances, 1) + assert.Equal(t, "4.8.0", instances[0].Version, "operator should use the override version, not Roxie.Version") } func TestOperatorInstances_SingleVersion(t *testing.T) { From c89ec2280b5fd9cf241654ef08adcdfbfc8050b8 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 21 Jul 2026 10:56:40 +0200 Subject: [PATCH 03/17] Use 'mixed' instead of 'split' versions --- cmd/deploy.go | 14 +++++++------- internal/deployer/config.go | 4 ++-- internal/deployer/deploy_via_operator.go | 6 +++--- internal/deployer/operator_instance.go | 8 ++++---- internal/deployer/operator_instance_test.go | 14 +++++++------- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 3eacffb..107dc12 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -265,9 +265,9 @@ func runDeploy(cmd *cobra.Command, args []string) error { // storing of the derived operator version within the operator configuration. // // This is why we use the operator version here when checking version constraints. - // With split versions, check every operator instance that will be deployed. + // With mixed versions, check every operator instance that will be deployed. versionsToCheck := []string{deploySettings.Operator.Version} - if deploySettings.NeedsSplitOperators() { + if deploySettings.HasMixedVersions() { versionsToCheck = nil for _, instance := range deploySettings.OperatorInstances() { versionsToCheck = append(versionsToCheck, instance.Version) @@ -415,9 +415,9 @@ func configureConfig(log *logger.Logger, components component.Component, deployS } // For the single-operator path (including OLM), populate RELATED_IMAGE_* on the - // top-level OperatorConfig. Split-version deployments apply Konflux env vars + // top-level OperatorConfig. Mixed-version deployments apply Konflux env vars // per OperatorInstance during deployment instead. - if deploySettings.Roxie.KonfluxImagesEnabled() && !deploySettings.NeedsSplitOperators() { + if deploySettings.Roxie.KonfluxImagesEnabled() && !deploySettings.HasMixedVersions() { deployer.PopulateKonfluxEnvVars(deploySettings) } @@ -485,12 +485,12 @@ func deployValidate(components component.Component, deploySettings *deployer.Con } } - if deploySettings.NeedsSplitOperators() { + if deploySettings.HasMixedVersions() { if components.IncludesOperatorExplicitly() { - return errors.New("split versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with operator-only deploy") + return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with operator-only deploy") } if deploySettings.Operator.DeployViaOlmEnabled() { - return errors.New("split versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with OLM deployment mode") + return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with OLM deployment mode") } } diff --git a/internal/deployer/config.go b/internal/deployer/config.go index ce90d2c..d8998b5 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -115,7 +115,7 @@ type WaitConfig struct { // CentralConfig holds deployment settings for the Central component. type CentralConfig struct { - // Version overrides Roxie.Version for Central (and its operator when split). + // Version overrides Roxie.Version for Central (and its operator when versions differ). Version string `yaml:"version,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` @@ -264,7 +264,7 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { // SecuredClusterConfig holds deployment settings for the SecuredCluster component. type SecuredClusterConfig struct { - // Version overrides Roxie.Version for SecuredCluster (and its operator when split). + // Version overrides Roxie.Version for SecuredCluster (and its operator when versions differ). Version string `yaml:"version,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 4a4abf5..a85f002 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -102,7 +102,7 @@ func (d *Deployer) preparedOperatorInstances() []OperatorInstance { } // teardownUnwantedOperatorNamespaces removes operators from namespaces that are not -// part of the desired deployment plan (e.g. switching between single and split). +// part of the desired deployment plan (e.g. switching between single and mixed versions). func (d *Deployer) teardownUnwantedOperatorNamespaces(ctx context.Context, desired []OperatorInstance) error { desiredNS := make(map[string]bool, len(desired)) for _, inst := range desired { @@ -162,9 +162,9 @@ func (d *Deployer) ensureOperatorInstanceNonOLM(ctx context.Context, instance Op return nil } -// ensureOperatorDeployedOLM is the single-operator OLM path (split versions are rejected in validation). +// ensureOperatorDeployedOLM is the single-operator OLM path (mixed versions are rejected in validation). func (d *Deployer) ensureOperatorDeployedOLM(ctx context.Context) error { - // Remove any leftover split-operator namespaces before installing via OLM. + // Remove any leftover dual-operator namespaces before installing via OLM. desired := []OperatorInstance{{Namespace: operatorNamespaceSystem}} if err := d.teardownUnwantedOperatorNamespaces(ctx, desired); err != nil { return err diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go index 0855956..72caa09 100644 --- a/internal/deployer/operator_instance.go +++ b/internal/deployer/operator_instance.go @@ -24,7 +24,7 @@ var AllOperatorNamespaces = []string{ operatorNamespaceSensor, } -// OperatorInstance describes one operator deployment (single- or split-version). +// OperatorInstance describes one operator deployment (single- or mixed-version). type OperatorInstance struct { Version string Namespace string @@ -66,8 +66,8 @@ func (c *Config) EffectiveSecuredClusterVersion() string { return c.Roxie.Version } -// NeedsSplitOperators reports whether Central and SecuredCluster require different operators. -func (c *Config) NeedsSplitOperators() bool { +// HasMixedVersions reports whether Central and SecuredCluster use different versions. +func (c *Config) HasMixedVersions() bool { return c.EffectiveCentralVersion() != c.EffectiveSecuredClusterVersion() } @@ -75,7 +75,7 @@ func (c *Config) NeedsSplitOperators() bool { // When versions match, a single operator is deployed to rhacs-operator-system. // When they differ, two operators are deployed with reconciler toggles. func (c *Config) OperatorInstances() []OperatorInstance { - if !c.NeedsSplitOperators() { + if !c.HasMixedVersions() { version := helpers.ConvertMainTagToOperatorTag(c.EffectiveCentralVersion()) if version == "" { version = c.Operator.Version diff --git a/internal/deployer/operator_instance_test.go b/internal/deployer/operator_instance_test.go index aa5929c..bc72aea 100644 --- a/internal/deployer/operator_instance_test.go +++ b/internal/deployer/operator_instance_test.go @@ -13,7 +13,7 @@ func TestEffectiveVersions_DefaultToRoxieVersion(t *testing.T) { } assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) - assert.False(t, cfg.NeedsSplitOperators()) + assert.False(t, cfg.HasMixedVersions()) } func TestEffectiveVersions_CentralOverride(t *testing.T) { @@ -23,7 +23,7 @@ func TestEffectiveVersions_CentralOverride(t *testing.T) { } assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) - assert.True(t, cfg.NeedsSplitOperators()) + assert.True(t, cfg.HasMixedVersions()) } func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { @@ -33,10 +33,10 @@ func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { } assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.7.0", cfg.EffectiveSecuredClusterVersion()) - assert.True(t, cfg.NeedsSplitOperators()) + assert.True(t, cfg.HasMixedVersions()) } -func TestEffectiveVersions_BothOverridesSame_NoSplit(t *testing.T) { +func TestEffectiveVersions_BothOverridesSame_NoMixed(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{Version: "4.8.0"}, @@ -44,7 +44,7 @@ func TestEffectiveVersions_BothOverridesSame_NoSplit(t *testing.T) { } assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.8.0", cfg.EffectiveSecuredClusterVersion()) - assert.False(t, cfg.NeedsSplitOperators()) + assert.False(t, cfg.HasMixedVersions()) instances := cfg.OperatorInstances() require.Len(t, instances, 1) @@ -71,7 +71,7 @@ func TestOperatorInstances_SingleVersion(t *testing.T) { assert.NotContains(t, instances[0].EnvVars, envSecuredClusterReconcilerEnabled) } -func TestOperatorInstances_SplitVersions(t *testing.T) { +func TestOperatorInstances_MixedVersions(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{ @@ -148,7 +148,7 @@ func TestNewestOperatorVersion(t *testing.T) { }) } -func TestImagesForConfig_SplitVersions(t *testing.T) { +func TestImagesForConfig_MixedVersions(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{Version: "4.8.0"}, From ca14c02cbbea22376f8cedb02b535c6b522ff77f Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 21 Jul 2026 14:01:14 +0200 Subject: [PATCH 04/17] Replace 'unwanted' with 'stale' --- internal/deployer/deploy_via_operator.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index a85f002..36e3930 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -76,7 +76,7 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { } } - if err := d.teardownUnwantedOperatorNamespaces(ctx, instances); err != nil { + if err := d.teardownStaleOperatorNamespaces(ctx, instances); err != nil { return err } @@ -101,9 +101,9 @@ func (d *Deployer) preparedOperatorInstances() []OperatorInstance { return instances } -// teardownUnwantedOperatorNamespaces removes operators from namespaces that are not +// teardownStaleOperatorNamespaces removes operators from namespaces that are no longer // part of the desired deployment plan (e.g. switching between single and mixed versions). -func (d *Deployer) teardownUnwantedOperatorNamespaces(ctx context.Context, desired []OperatorInstance) error { +func (d *Deployer) teardownStaleOperatorNamespaces(ctx context.Context, desired []OperatorInstance) error { desiredNS := make(map[string]bool, len(desired)) for _, inst := range desired { desiredNS[inst.Namespace] = true @@ -116,7 +116,7 @@ func (d *Deployer) teardownUnwantedOperatorNamespaces(ctx context.Context, desir if !d.operatorDeploymentExists(ctx, ns) && !d.namespaceExists(ns) { continue } - d.logger.Infof("๐Ÿ”„ Removing operator from unwanted namespace %s...", ns) + d.logger.Infof("๐Ÿ”„ Removing previous operator from %s (no longer needed)...", ns) instance := OperatorInstance{Namespace: ns} switch ns { case operatorNamespaceCentral: @@ -166,7 +166,7 @@ func (d *Deployer) ensureOperatorInstanceNonOLM(ctx context.Context, instance Op func (d *Deployer) ensureOperatorDeployedOLM(ctx context.Context) error { // Remove any leftover dual-operator namespaces before installing via OLM. desired := []OperatorInstance{{Namespace: operatorNamespaceSystem}} - if err := d.teardownUnwantedOperatorNamespaces(ctx, desired); err != nil { + if err := d.teardownStaleOperatorNamespaces(ctx, desired); err != nil { return err } From d63b2af250f0dc26ba34900ff105cbae168e8b9c Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 21 Jul 2026 16:06:49 +0200 Subject: [PATCH 05/17] reject invalid configuration --- cmd/deploy.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/deploy.go b/cmd/deploy.go index 107dc12..34566a3 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -494,5 +494,12 @@ func deployValidate(components component.Component, deploySettings *deployer.Con } } + if deploySettings.Central.Version != "" && deploySettings.SecuredCluster.Version != "" && + deploySettings.Central.Version == deploySettings.SecuredCluster.Version && + deploySettings.Central.Version != deploySettings.Roxie.Version { + return fmt.Errorf("both --central-tag and --secured-cluster-tag are set to %s which differs from --tag %s; use --tag %s instead", + deploySettings.Central.Version, deploySettings.Roxie.Version, deploySettings.Central.Version) + } + return nil } From e96547a343480a2c19bff52ad5ace6320f40576c Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Wed, 22 Jul 2026 15:07:54 +0200 Subject: [PATCH 06/17] Restructure version overrides to use central.operator/securedCluster.operator config blocks --- cmd/deploy.go | 34 ++++++------ internal/deployer/config.go | 10 ++-- internal/deployer/operator_instance.go | 34 +++++++++--- internal/deployer/operator_instance_test.go | 60 ++++++++++++++++----- 4 files changed, 99 insertions(+), 39 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 34566a3..40c23b5 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -137,14 +137,20 @@ this flag can be used to tell roxie how to pre-load images for the current clust registerFlag(cmd, settings, "central-tag", "Image tag for Central (overrides --tag for Central)", withApplyFn("version", func(config *deployer.Config, tag string) error { - config.Central.Version = tag + if config.Central.Operator == nil { + config.Central.Operator = &deployer.OperatorConfig{} + } + config.Central.Operator.Version = tag return nil }), ) registerFlag(cmd, settings, "secured-cluster-tag", "Image tag for SecuredCluster (overrides --tag for SecuredCluster)", withApplyFn("version", func(config *deployer.Config, tag string) error { - config.SecuredCluster.Version = tag + if config.SecuredCluster.Operator == nil { + config.SecuredCluster.Operator = &deployer.OperatorConfig{} + } + config.SecuredCluster.Operator.Version = tag return nil }), ) @@ -265,13 +271,10 @@ func runDeploy(cmd *cobra.Command, args []string) error { // storing of the derived operator version within the operator configuration. // // This is why we use the operator version here when checking version constraints. - // With mixed versions, check every operator instance that will be deployed. - versionsToCheck := []string{deploySettings.Operator.Version} - if deploySettings.HasMixedVersions() { - versionsToCheck = nil - for _, instance := range deploySettings.OperatorInstances() { - versionsToCheck = append(versionsToCheck, instance.Version) - } + // Check every operator instance that will be deployed. + var versionsToCheck []string + for _, instance := range deploySettings.OperatorInstances() { + versionsToCheck = append(versionsToCheck, instance.Version) } for _, opVersion := range versionsToCheck { hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(opVersion) @@ -487,18 +490,19 @@ func deployValidate(components component.Component, deploySettings *deployer.Con if deploySettings.HasMixedVersions() { if components.IncludesOperatorExplicitly() { - return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with operator-only deploy") + return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.operator / securedCluster.operator) are not supported with operator-only deploy") } if deploySettings.Operator.DeployViaOlmEnabled() { - return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.version / securedCluster.version) are not supported with OLM deployment mode") + return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.operator / securedCluster.operator) are not supported with OLM deployment mode") } } - if deploySettings.Central.Version != "" && deploySettings.SecuredCluster.Version != "" && - deploySettings.Central.Version == deploySettings.SecuredCluster.Version && - deploySettings.Central.Version != deploySettings.Roxie.Version { + centralVer := deploySettings.EffectiveCentralVersion() + scVer := deploySettings.EffectiveSecuredClusterVersion() + if centralVer == scVer && centralVer != deploySettings.Roxie.Version && + deploySettings.Central.Operator != nil && deploySettings.SecuredCluster.Operator != nil { return fmt.Errorf("both --central-tag and --secured-cluster-tag are set to %s which differs from --tag %s; use --tag %s instead", - deploySettings.Central.Version, deploySettings.Roxie.Version, deploySettings.Central.Version) + centralVer, deploySettings.Roxie.Version, centralVer) } return nil diff --git a/internal/deployer/config.go b/internal/deployer/config.go index d8998b5..054fb7b 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -115,8 +115,9 @@ type WaitConfig struct { // CentralConfig holds deployment settings for the Central component. type CentralConfig struct { - // Version overrides Roxie.Version for Central (and its operator when versions differ). - Version string `yaml:"version,omitempty"` + // Operator, when set, provides a per-component operator config for Central. + // Its presence (with a version) triggers dual-operator mode. + Operator *OperatorConfig `yaml:"operator,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` PauseReconciliation *bool `yaml:"pauseReconciliation,omitempty"` @@ -264,8 +265,9 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { // SecuredClusterConfig holds deployment settings for the SecuredCluster component. type SecuredClusterConfig struct { - // Version overrides Roxie.Version for SecuredCluster (and its operator when versions differ). - Version string `yaml:"version,omitempty"` + // Operator, when set, provides a per-component operator config for SecuredCluster. + // Its presence (with a version) triggers dual-operator mode. + Operator *OperatorConfig `yaml:"operator,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` PauseReconciliation *bool `yaml:"pauseReconciliation,omitempty"` diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go index 72caa09..3710c48 100644 --- a/internal/deployer/operator_instance.go +++ b/internal/deployer/operator_instance.go @@ -51,22 +51,28 @@ func (o OperatorInstance) ClusterRoleBindingName() string { } // EffectiveCentralVersion returns the main image tag used for Central. +// If central.operator.version is set, it is converted back to a main tag; +// otherwise falls back to roxie.version. func (c *Config) EffectiveCentralVersion() string { - if c.Central.Version != "" { - return c.Central.Version + if c.Central.Operator != nil && c.Central.Operator.Version != "" { + return c.Central.Operator.Version } return c.Roxie.Version } // EffectiveSecuredClusterVersion returns the main image tag used for SecuredCluster. +// If securedCluster.operator.version is set, it is converted back to a main tag; +// otherwise falls back to roxie.version. func (c *Config) EffectiveSecuredClusterVersion() string { - if c.SecuredCluster.Version != "" { - return c.SecuredCluster.Version + if c.SecuredCluster.Operator != nil && c.SecuredCluster.Operator.Version != "" { + return c.SecuredCluster.Operator.Version } return c.Roxie.Version } -// HasMixedVersions reports whether Central and SecuredCluster use different versions. +// HasMixedVersions reports whether Central and SecuredCluster use different operator versions. +// This is true when at least one component has a per-component operator config with a version +// that differs from the other component's effective version. func (c *Config) HasMixedVersions() bool { return c.EffectiveCentralVersion() != c.EffectiveSecuredClusterVersion() } @@ -80,17 +86,33 @@ func (c *Config) OperatorInstances() []OperatorInstance { if version == "" { version = c.Operator.Version } + envVars := copyStringMap(c.Operator.EnvVars) + if c.Central.Operator != nil { + for k, v := range c.Central.Operator.EnvVars { + envVars[k] = v + } + } return []OperatorInstance{{ Version: version, Namespace: operatorNamespaceSystem, - EnvVars: copyStringMap(c.Operator.EnvVars), + EnvVars: envVars, }} } centralEnvVars := copyStringMap(c.Operator.EnvVars) + if c.Central.Operator != nil { + for k, v := range c.Central.Operator.EnvVars { + centralEnvVars[k] = v + } + } centralEnvVars[envSecuredClusterReconcilerEnabled] = "false" sensorEnvVars := copyStringMap(c.Operator.EnvVars) + if c.SecuredCluster.Operator != nil { + for k, v := range c.SecuredCluster.Operator.EnvVars { + sensorEnvVars[k] = v + } + } sensorEnvVars[envCentralReconcilerEnabled] = "false" return []OperatorInstance{ diff --git a/internal/deployer/operator_instance_test.go b/internal/deployer/operator_instance_test.go index bc72aea..944310a 100644 --- a/internal/deployer/operator_instance_test.go +++ b/internal/deployer/operator_instance_test.go @@ -19,7 +19,7 @@ func TestEffectiveVersions_DefaultToRoxieVersion(t *testing.T) { func TestEffectiveVersions_CentralOverride(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - Central: CentralConfig{Version: "4.8.0"}, + Central: CentralConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, } assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) @@ -29,7 +29,7 @@ func TestEffectiveVersions_CentralOverride(t *testing.T) { func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - SecuredCluster: SecuredClusterConfig{Version: "4.7.0"}, + SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.7.0"}}, } assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.7.0", cfg.EffectiveSecuredClusterVersion()) @@ -39,8 +39,8 @@ func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { func TestEffectiveVersions_BothOverridesSame_NoMixed(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - Central: CentralConfig{Version: "4.8.0"}, - SecuredCluster: SecuredClusterConfig{Version: "4.8.0"}, + Central: CentralConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, } assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.8.0", cfg.EffectiveSecuredClusterVersion()) @@ -75,10 +75,10 @@ func TestOperatorInstances_MixedVersions(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{ - Version: "4.8.0", + Operator: &OperatorConfig{Version: "4.8.0"}, }, SecuredCluster: SecuredClusterConfig{ - Version: "4.9.0", + Operator: &OperatorConfig{Version: "4.9.0"}, }, Operator: OperatorConfig{ EnvVars: map[string]string{"CUSTOM": "1"}, @@ -114,6 +114,38 @@ func TestOperatorInstances_MixedVersions(t *testing.T) { assert.Equal(t, "1", cfg.Operator.EnvVars["CUSTOM"]) } +func TestOperatorInstances_PerComponentEnvVars(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + Central: CentralConfig{ + Operator: &OperatorConfig{ + Version: "4.8.0", + EnvVars: map[string]string{"CENTRAL_ONLY": "yes"}, + }, + }, + SecuredCluster: SecuredClusterConfig{ + Operator: &OperatorConfig{ + Version: "4.9.0", + EnvVars: map[string]string{"SC_ONLY": "yes"}, + }, + }, + Operator: OperatorConfig{ + EnvVars: map[string]string{"SHARED": "1"}, + }, + } + + instances := cfg.OperatorInstances() + require.Len(t, instances, 2) + + assert.Equal(t, "1", instances[0].EnvVars["SHARED"]) + assert.Equal(t, "yes", instances[0].EnvVars["CENTRAL_ONLY"]) + assert.NotContains(t, instances[0].EnvVars, "SC_ONLY") + + assert.Equal(t, "1", instances[1].EnvVars["SHARED"]) + assert.Equal(t, "yes", instances[1].EnvVars["SC_ONLY"]) + assert.NotContains(t, instances[1].EnvVars, "CENTRAL_ONLY") +} + func TestNewestOperatorVersion(t *testing.T) { t.Run("single version", func(t *testing.T) { cfg := Config{Roxie: RoxieConfig{Version: "4.9.0"}} @@ -123,8 +155,8 @@ func TestNewestOperatorVersion(t *testing.T) { t.Run("secured cluster newer", func(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.11.1"}, - Central: CentralConfig{Version: "4.10.0"}, - SecuredCluster: SecuredClusterConfig{Version: "4.11.1"}, + Central: CentralConfig{Operator: &OperatorConfig{Version: "4.10.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.11.1"}}, } assert.Equal(t, "4.11.1", cfg.NewestOperatorVersion()) }) @@ -132,8 +164,8 @@ func TestNewestOperatorVersion(t *testing.T) { t.Run("central newer", func(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.11.1"}, - Central: CentralConfig{Version: "4.11.1"}, - SecuredCluster: SecuredClusterConfig{Version: "4.10.0"}, + Central: CentralConfig{Operator: &OperatorConfig{Version: "4.11.1"}}, + SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.10.0"}}, } assert.Equal(t, "4.11.1", cfg.NewestOperatorVersion()) }) @@ -141,8 +173,8 @@ func TestNewestOperatorVersion(t *testing.T) { t.Run("build suffix uses leading semver", func(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.10.0"}, - Central: CentralConfig{Version: "4.10.0"}, - SecuredCluster: SecuredClusterConfig{Version: "4.11.0-937-gf0da38f1a"}, + Central: CentralConfig{Operator: &OperatorConfig{Version: "4.10.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.11.0-937-gf0da38f1a"}}, } assert.Equal(t, "4.11.0-937-gf0da38f1a", cfg.NewestOperatorVersion()) }) @@ -151,8 +183,8 @@ func TestNewestOperatorVersion(t *testing.T) { func TestImagesForConfig_MixedVersions(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - Central: CentralConfig{Version: "4.8.0"}, - SecuredCluster: SecuredClusterConfig{Version: "4.9.0"}, + Central: CentralConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.9.0"}}, } images := imagesForConfig(cfg) From 1e430496e27634fec4798bb6cf44f060ed25b1b6 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 14:44:21 +0200 Subject: [PATCH 07/17] Simplify Konflux handling --- cmd/deploy.go | 15 ------- internal/deployer/deploy_via_operator.go | 2 +- internal/deployer/konflux.go | 22 ++++------ internal/deployer/konflux_test.go | 51 +++++------------------- 4 files changed, 18 insertions(+), 72 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 40c23b5..fa73c4d 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -417,13 +417,6 @@ func configureConfig(log *logger.Logger, components component.Component, deployS return fmt.Errorf("configuring operator configuration: %w", err) } - // For the single-operator path (including OLM), populate RELATED_IMAGE_* on the - // top-level OperatorConfig. Mixed-version deployments apply Konflux env vars - // per OperatorInstance during deployment instead. - if deploySettings.Roxie.KonfluxImagesEnabled() && !deploySettings.HasMixedVersions() { - deployer.PopulateKonfluxEnvVars(deploySettings) - } - if components.IncludesCentral() { if err := deploySettings.Central.ConfigureSpec(&deploySettings.Roxie); err != nil { return fmt.Errorf("configuring Central spec: %w", err) @@ -497,13 +490,5 @@ func deployValidate(components component.Component, deploySettings *deployer.Con } } - centralVer := deploySettings.EffectiveCentralVersion() - scVer := deploySettings.EffectiveSecuredClusterVersion() - if centralVer == scVer && centralVer != deploySettings.Roxie.Version && - deploySettings.Central.Operator != nil && deploySettings.SecuredCluster.Operator != nil { - return fmt.Errorf("both --central-tag and --secured-cluster-tag are set to %s which differs from --tag %s; use --tag %s instead", - centralVer, deploySettings.Roxie.Version, centralVer) - } - return nil } diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 36e3930..8c59358 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -96,7 +96,7 @@ func (d *Deployer) preparedOperatorInstances() []OperatorInstance { return instances } for i := range instances { - instances[i].EnvVars = MergeKonfluxEnvVars(instances[i].EnvVars, instances[i].Version) + PopulateKonfluxEnvVars(instances[i].EnvVars, instances[i].Version) } return instances } diff --git a/internal/deployer/konflux.go b/internal/deployer/konflux.go index 1e5301e..f11dd44 100644 --- a/internal/deployer/konflux.go +++ b/internal/deployer/konflux.go @@ -24,27 +24,19 @@ func KonfluxOperatorImage(operatorVersion string) string { return fmt.Sprintf("%s/release-operator:%s", constants.DefaultRegistry, operatorVersion) } -// MergeKonfluxEnvVars returns a copy of envVars with RELATED_IMAGE_* entries filled -// for the given operator version. Explicitly-provided env vars take precedence. -func MergeKonfluxEnvVars(envVars map[string]string, operatorVersion string) map[string]string { - result := copyStringMap(envVars) +// PopulateKonfluxEnvVars adds RELATED_IMAGE_* entries to envVars for the given +// operator version. Explicitly-provided env vars (e.g. from --operator-env) +// take precedence and are not overwritten. +func PopulateKonfluxEnvVars(envVars map[string]string, operatorVersion string) { for envName, imageSuffix := range konfluxRelatedImages { - if _, exists := result[envName]; exists { + if _, exists := envVars[envName]; exists { continue } - result[envName] = fmt.Sprintf( + envVars[envName] = fmt.Sprintf( "%s/release-%s:%s", constants.DefaultRegistry, imageSuffix, - operatorVersion, // Konflux built images use the "operator tag". + operatorVersion, ) } - return result -} - -// PopulateKonfluxEnvVars populates config.Operator.EnvVars with RELATED_IMAGE_* -// entries for Konflux image rewriting. Used for the single-operator / OLM path -// where env vars live on the top-level OperatorConfig. -func PopulateKonfluxEnvVars(config *Config) { - config.Operator.EnvVars = MergeKonfluxEnvVars(config.Operator.EnvVars, config.Operator.Version) } diff --git a/internal/deployer/konflux_test.go b/internal/deployer/konflux_test.go index 4172dbb..b44b356 100644 --- a/internal/deployer/konflux_test.go +++ b/internal/deployer/konflux_test.go @@ -15,37 +15,29 @@ func TestKonfluxOperatorImage(t *testing.T) { } func TestPopulateKonfluxEnvVars_AllEntries(t *testing.T) { - config := &Config{ - Operator: OperatorConfig{Version: "4.9.2"}, - } - - PopulateKonfluxEnvVars(config) + envVars := make(map[string]string) + PopulateKonfluxEnvVars(envVars, "4.9.2") - require.Len(t, config.Operator.EnvVars, len(konfluxRelatedImages)) + require.Len(t, envVars, len(konfluxRelatedImages)) for envName, imageSuffix := range konfluxRelatedImages { expected := fmt.Sprintf("%s/release-%s:%s", constants.DefaultRegistry, imageSuffix, "4.9.2") - assert.Equal(t, expected, config.Operator.EnvVars[envName], "mismatch for %s", envName) + assert.Equal(t, expected, envVars[envName], "mismatch for %s", envName) } } func TestPopulateKonfluxEnvVars_UserOverridePreserved(t *testing.T) { userValue := "quay.io/custom/my-main:latest" - config := &Config{ - Operator: OperatorConfig{ - Version: "4.9.2", - EnvVars: map[string]string{ - "RELATED_IMAGE_MAIN": userValue, - }, - }, + envVars := map[string]string{ + "RELATED_IMAGE_MAIN": userValue, } - PopulateKonfluxEnvVars(config) + PopulateKonfluxEnvVars(envVars, "4.9.2") - assert.Equal(t, userValue, config.Operator.EnvVars["RELATED_IMAGE_MAIN"], + assert.Equal(t, userValue, envVars["RELATED_IMAGE_MAIN"], "user override should be preserved") - assert.Len(t, config.Operator.EnvVars, len(konfluxRelatedImages), + assert.Len(t, envVars, len(konfluxRelatedImages), "all other entries should be populated") for envName, imageSuffix := range konfluxRelatedImages { @@ -53,29 +45,6 @@ func TestPopulateKonfluxEnvVars_UserOverridePreserved(t *testing.T) { continue } expected := fmt.Sprintf("%s/release-%s:%s", constants.DefaultRegistry, imageSuffix, "4.9.2") - assert.Equal(t, expected, config.Operator.EnvVars[envName], "mismatch for %s", envName) - } -} - -func TestPopulateKonfluxEnvVars_NilEnvVarsMap(t *testing.T) { - config := &Config{ - Operator: OperatorConfig{Version: "4.9.2"}, + assert.Equal(t, expected, envVars[envName], "mismatch for %s", envName) } - assert.Nil(t, config.Operator.EnvVars) - - PopulateKonfluxEnvVars(config) - - require.NotNil(t, config.Operator.EnvVars) - assert.Len(t, config.Operator.EnvVars, len(konfluxRelatedImages)) -} - -func TestMergeKonfluxEnvVars_PerInstanceVersion(t *testing.T) { - base := map[string]string{"CUSTOM": "1"} - merged := MergeKonfluxEnvVars(base, "4.8.0") - - assert.Equal(t, "1", merged["CUSTOM"]) - assert.Equal(t, fmt.Sprintf("%s/release-main:4.8.0", constants.DefaultRegistry), merged["RELATED_IMAGE_MAIN"]) - assert.Equal(t, "1", base["CUSTOM"], "base map should not be mutated for existing keys") - _, ok := base["RELATED_IMAGE_MAIN"] - assert.False(t, ok, "base map should not receive new keys") } From 3fd9d1e2749629db15426c6e222b228668d646b2 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 15:13:30 +0200 Subject: [PATCH 08/17] Make OperatorConfig non-pointer + remove merging --- cmd/deploy.go | 6 --- internal/deployer/config.go | 10 ++--- internal/deployer/operator_instance.go | 35 +++------------ internal/deployer/operator_instance_test.go | 47 +++++++++++---------- 4 files changed, 36 insertions(+), 62 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index fa73c4d..55c9c8d 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -137,9 +137,6 @@ this flag can be used to tell roxie how to pre-load images for the current clust registerFlag(cmd, settings, "central-tag", "Image tag for Central (overrides --tag for Central)", withApplyFn("version", func(config *deployer.Config, tag string) error { - if config.Central.Operator == nil { - config.Central.Operator = &deployer.OperatorConfig{} - } config.Central.Operator.Version = tag return nil }), @@ -147,9 +144,6 @@ this flag can be used to tell roxie how to pre-load images for the current clust registerFlag(cmd, settings, "secured-cluster-tag", "Image tag for SecuredCluster (overrides --tag for SecuredCluster)", withApplyFn("version", func(config *deployer.Config, tag string) error { - if config.SecuredCluster.Operator == nil { - config.SecuredCluster.Operator = &deployer.OperatorConfig{} - } config.SecuredCluster.Operator.Version = tag return nil }), diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 054fb7b..a785f6c 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -115,9 +115,8 @@ type WaitConfig struct { // CentralConfig holds deployment settings for the Central component. type CentralConfig struct { - // Operator, when set, provides a per-component operator config for Central. - // Its presence (with a version) triggers dual-operator mode. - Operator *OperatorConfig `yaml:"operator,omitempty"` + // Operator allows per-component operator overrides; only used in dual-operator mode. + Operator OperatorConfig `yaml:"operator,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` PauseReconciliation *bool `yaml:"pauseReconciliation,omitempty"` @@ -265,9 +264,8 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { // SecuredClusterConfig holds deployment settings for the SecuredCluster component. type SecuredClusterConfig struct { - // Operator, when set, provides a per-component operator config for SecuredCluster. - // Its presence (with a version) triggers dual-operator mode. - Operator *OperatorConfig `yaml:"operator,omitempty"` + // Operator allows per-component operator overrides; only used in dual-operator mode. + Operator OperatorConfig `yaml:"operator,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` PauseReconciliation *bool `yaml:"pauseReconciliation,omitempty"` diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go index 3710c48..f0d1975 100644 --- a/internal/deployer/operator_instance.go +++ b/internal/deployer/operator_instance.go @@ -54,7 +54,7 @@ func (o OperatorInstance) ClusterRoleBindingName() string { // If central.operator.version is set, it is converted back to a main tag; // otherwise falls back to roxie.version. func (c *Config) EffectiveCentralVersion() string { - if c.Central.Operator != nil && c.Central.Operator.Version != "" { + if c.Central.Operator.Version != "" { return c.Central.Operator.Version } return c.Roxie.Version @@ -64,7 +64,7 @@ func (c *Config) EffectiveCentralVersion() string { // If securedCluster.operator.version is set, it is converted back to a main tag; // otherwise falls back to roxie.version. func (c *Config) EffectiveSecuredClusterVersion() string { - if c.SecuredCluster.Operator != nil && c.SecuredCluster.Operator.Version != "" { + if c.SecuredCluster.Operator.Version != "" { return c.SecuredCluster.Operator.Version } return c.Roxie.Version @@ -86,33 +86,19 @@ func (c *Config) OperatorInstances() []OperatorInstance { if version == "" { version = c.Operator.Version } - envVars := copyStringMap(c.Operator.EnvVars) - if c.Central.Operator != nil { - for k, v := range c.Central.Operator.EnvVars { - envVars[k] = v - } - } return []OperatorInstance{{ Version: version, Namespace: operatorNamespaceSystem, - EnvVars: envVars, + EnvVars: maps.Clone(c.Operator.EnvVars), }} } - centralEnvVars := copyStringMap(c.Operator.EnvVars) - if c.Central.Operator != nil { - for k, v := range c.Central.Operator.EnvVars { - centralEnvVars[k] = v - } - } + centralEnvVars := make(map[string]string, len(c.Central.Operator.EnvVars)+1) + maps.Copy(centralEnvVars, c.Central.Operator.EnvVars) centralEnvVars[envSecuredClusterReconcilerEnabled] = "false" - sensorEnvVars := copyStringMap(c.Operator.EnvVars) - if c.SecuredCluster.Operator != nil { - for k, v := range c.SecuredCluster.Operator.EnvVars { - sensorEnvVars[k] = v - } - } + sensorEnvVars := make(map[string]string, len(c.SecuredCluster.Operator.EnvVars)+1) + maps.Copy(sensorEnvVars, c.SecuredCluster.Operator.EnvVars) sensorEnvVars[envCentralReconcilerEnabled] = "false" return []OperatorInstance{ @@ -167,10 +153,3 @@ func parseOperatorSemver(version string) (*semver.Version, error) { version, _, _ = strings.Cut(version, "-") return semver.NewVersion(version) } - -func copyStringMap(m map[string]string) map[string]string { - if m == nil { - return make(map[string]string) - } - return maps.Clone(m) -} diff --git a/internal/deployer/operator_instance_test.go b/internal/deployer/operator_instance_test.go index 944310a..bfb874e 100644 --- a/internal/deployer/operator_instance_test.go +++ b/internal/deployer/operator_instance_test.go @@ -19,7 +19,7 @@ func TestEffectiveVersions_DefaultToRoxieVersion(t *testing.T) { func TestEffectiveVersions_CentralOverride(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - Central: CentralConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, + Central: CentralConfig{Operator: OperatorConfig{Version: "4.8.0"}}, } assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) @@ -29,7 +29,7 @@ func TestEffectiveVersions_CentralOverride(t *testing.T) { func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.7.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.7.0"}}, } assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.7.0", cfg.EffectiveSecuredClusterVersion()) @@ -39,8 +39,8 @@ func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { func TestEffectiveVersions_BothOverridesSame_NoMixed(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - Central: CentralConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, - SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, + Central: CentralConfig{Operator: OperatorConfig{Version: "4.8.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.8.0"}}, } assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) assert.Equal(t, "4.8.0", cfg.EffectiveSecuredClusterVersion()) @@ -75,13 +75,16 @@ func TestOperatorInstances_MixedVersions(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{ - Operator: &OperatorConfig{Version: "4.8.0"}, + Operator: OperatorConfig{ + Version: "4.8.0", + EnvVars: map[string]string{"CUSTOM": "1"}, + }, }, SecuredCluster: SecuredClusterConfig{ - Operator: &OperatorConfig{Version: "4.9.0"}, - }, - Operator: OperatorConfig{ - EnvVars: map[string]string{"CUSTOM": "1"}, + Operator: OperatorConfig{ + Version: "4.9.0", + EnvVars: map[string]string{"CUSTOM": "1"}, + }, }, } @@ -111,20 +114,19 @@ func TestOperatorInstances_MixedVersions(t *testing.T) { // Env var maps must be independent copies. central.EnvVars["CUSTOM"] = "changed" assert.Equal(t, "1", sensor.EnvVars["CUSTOM"]) - assert.Equal(t, "1", cfg.Operator.EnvVars["CUSTOM"]) } func TestOperatorInstances_PerComponentEnvVars(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{ - Operator: &OperatorConfig{ + Operator: OperatorConfig{ Version: "4.8.0", EnvVars: map[string]string{"CENTRAL_ONLY": "yes"}, }, }, SecuredCluster: SecuredClusterConfig{ - Operator: &OperatorConfig{ + Operator: OperatorConfig{ Version: "4.9.0", EnvVars: map[string]string{"SC_ONLY": "yes"}, }, @@ -137,11 +139,12 @@ func TestOperatorInstances_PerComponentEnvVars(t *testing.T) { instances := cfg.OperatorInstances() require.Len(t, instances, 2) - assert.Equal(t, "1", instances[0].EnvVars["SHARED"]) + // Top-level Operator.EnvVars are NOT inherited in dual-operator mode. + assert.NotContains(t, instances[0].EnvVars, "SHARED") assert.Equal(t, "yes", instances[0].EnvVars["CENTRAL_ONLY"]) assert.NotContains(t, instances[0].EnvVars, "SC_ONLY") - assert.Equal(t, "1", instances[1].EnvVars["SHARED"]) + assert.NotContains(t, instances[1].EnvVars, "SHARED") assert.Equal(t, "yes", instances[1].EnvVars["SC_ONLY"]) assert.NotContains(t, instances[1].EnvVars, "CENTRAL_ONLY") } @@ -155,8 +158,8 @@ func TestNewestOperatorVersion(t *testing.T) { t.Run("secured cluster newer", func(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.11.1"}, - Central: CentralConfig{Operator: &OperatorConfig{Version: "4.10.0"}}, - SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.11.1"}}, + Central: CentralConfig{Operator: OperatorConfig{Version: "4.10.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.11.1"}}, } assert.Equal(t, "4.11.1", cfg.NewestOperatorVersion()) }) @@ -164,8 +167,8 @@ func TestNewestOperatorVersion(t *testing.T) { t.Run("central newer", func(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.11.1"}, - Central: CentralConfig{Operator: &OperatorConfig{Version: "4.11.1"}}, - SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.10.0"}}, + Central: CentralConfig{Operator: OperatorConfig{Version: "4.11.1"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.10.0"}}, } assert.Equal(t, "4.11.1", cfg.NewestOperatorVersion()) }) @@ -173,8 +176,8 @@ func TestNewestOperatorVersion(t *testing.T) { t.Run("build suffix uses leading semver", func(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.10.0"}, - Central: CentralConfig{Operator: &OperatorConfig{Version: "4.10.0"}}, - SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.11.0-937-gf0da38f1a"}}, + Central: CentralConfig{Operator: OperatorConfig{Version: "4.10.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.11.0-937-gf0da38f1a"}}, } assert.Equal(t, "4.11.0-937-gf0da38f1a", cfg.NewestOperatorVersion()) }) @@ -183,8 +186,8 @@ func TestNewestOperatorVersion(t *testing.T) { func TestImagesForConfig_MixedVersions(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, - Central: CentralConfig{Operator: &OperatorConfig{Version: "4.8.0"}}, - SecuredCluster: SecuredClusterConfig{Operator: &OperatorConfig{Version: "4.9.0"}}, + Central: CentralConfig{Operator: OperatorConfig{Version: "4.8.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.9.0"}}, } images := imagesForConfig(cfg) From 8b86e834b4e80de672f420bfe0c51bd1546ddfd6 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 15:43:56 +0200 Subject: [PATCH 09/17] Drop Effective version naming --- internal/deployer/acs_images.go | 2 +- internal/deployer/deployer.go | 4 ++-- internal/deployer/operator_instance.go | 22 +++++++++---------- internal/deployer/operator_instance_test.go | 24 ++++++++++----------- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/internal/deployer/acs_images.go b/internal/deployer/acs_images.go index 2d90d13..7449552 100644 --- a/internal/deployer/acs_images.go +++ b/internal/deployer/acs_images.go @@ -44,7 +44,7 @@ func imagesForConfig(config Config) []string { } func uniqueMainVersions(config Config) []string { - versions := []string{config.EffectiveCentralVersion(), config.EffectiveSecuredClusterVersion()} + versions := []string{config.CentralVersion(), config.SecuredClusterVersion()} seen := make(map[string]bool) var unique []string for _, v := range versions { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index e81941e..a90aa12 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -729,7 +729,7 @@ func (d *Deployer) writeEnvrcFile(ctx context.Context) error { func (d *Deployer) PrintCentralDeploymentSummary() { component := "Central" - mainImageTag := d.config.EffectiveCentralVersion() + mainImageTag := d.config.CentralVersion() olm := d.config.Operator.DeployViaOlmEnabled() exposure := d.config.Central.GetExposure() portForwarding := d.config.Central.PortForwardingEnabled() @@ -910,7 +910,7 @@ func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace st // extracted func (d *Deployer) PrintSecuredClusterDeploymentSummary() { component := "Secured Cluster" - mainImageTag := d.config.EffectiveSecuredClusterVersion() + mainImageTag := d.config.SecuredClusterVersion() olm := d.config.Operator.DeployViaOlmEnabled() log := d.logger kubeContext := d.kubeContext diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go index f0d1975..fefb4ff 100644 --- a/internal/deployer/operator_instance.go +++ b/internal/deployer/operator_instance.go @@ -50,20 +50,18 @@ func (o OperatorInstance) ClusterRoleBindingName() string { return "rhacs-operator-manager-rolebinding-" + o.RoleNameSuffix } -// EffectiveCentralVersion returns the main image tag used for Central. -// If central.operator.version is set, it is converted back to a main tag; -// otherwise falls back to roxie.version. -func (c *Config) EffectiveCentralVersion() string { +// CentralVersion returns the main image tag used for Central. +// Uses central.operator.version if set, otherwise falls back to roxie.version. +func (c *Config) CentralVersion() string { if c.Central.Operator.Version != "" { return c.Central.Operator.Version } return c.Roxie.Version } -// EffectiveSecuredClusterVersion returns the main image tag used for SecuredCluster. -// If securedCluster.operator.version is set, it is converted back to a main tag; -// otherwise falls back to roxie.version. -func (c *Config) EffectiveSecuredClusterVersion() string { +// SecuredClusterVersion returns the main image tag used for SecuredCluster. +// Uses securedCluster.operator.version if set, otherwise falls back to roxie.version. +func (c *Config) SecuredClusterVersion() string { if c.SecuredCluster.Operator.Version != "" { return c.SecuredCluster.Operator.Version } @@ -74,7 +72,7 @@ func (c *Config) EffectiveSecuredClusterVersion() string { // This is true when at least one component has a per-component operator config with a version // that differs from the other component's effective version. func (c *Config) HasMixedVersions() bool { - return c.EffectiveCentralVersion() != c.EffectiveSecuredClusterVersion() + return c.CentralVersion() != c.SecuredClusterVersion() } // OperatorInstances builds the operator deployment plan for this config. @@ -82,7 +80,7 @@ func (c *Config) HasMixedVersions() bool { // When they differ, two operators are deployed with reconciler toggles. func (c *Config) OperatorInstances() []OperatorInstance { if !c.HasMixedVersions() { - version := helpers.ConvertMainTagToOperatorTag(c.EffectiveCentralVersion()) + version := helpers.ConvertMainTagToOperatorTag(c.CentralVersion()) if version == "" { version = c.Operator.Version } @@ -103,13 +101,13 @@ func (c *Config) OperatorInstances() []OperatorInstance { return []OperatorInstance{ { - Version: helpers.ConvertMainTagToOperatorTag(c.EffectiveCentralVersion()), + Version: helpers.ConvertMainTagToOperatorTag(c.CentralVersion()), Namespace: operatorNamespaceCentral, EnvVars: centralEnvVars, RoleNameSuffix: "central", }, { - Version: helpers.ConvertMainTagToOperatorTag(c.EffectiveSecuredClusterVersion()), + Version: helpers.ConvertMainTagToOperatorTag(c.SecuredClusterVersion()), Namespace: operatorNamespaceSensor, EnvVars: sensorEnvVars, RoleNameSuffix: "sensor", diff --git a/internal/deployer/operator_instance_test.go b/internal/deployer/operator_instance_test.go index bfb874e..113e4a3 100644 --- a/internal/deployer/operator_instance_test.go +++ b/internal/deployer/operator_instance_test.go @@ -7,43 +7,43 @@ import ( "github.com/stretchr/testify/require" ) -func TestEffectiveVersions_DefaultToRoxieVersion(t *testing.T) { +func TestVersions_DefaultToRoxieVersion(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, } - assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) - assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) + assert.Equal(t, "4.9.0", cfg.CentralVersion()) + assert.Equal(t, "4.9.0", cfg.SecuredClusterVersion()) assert.False(t, cfg.HasMixedVersions()) } -func TestEffectiveVersions_CentralOverride(t *testing.T) { +func TestVersions_CentralOverride(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{Operator: OperatorConfig{Version: "4.8.0"}}, } - assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) - assert.Equal(t, "4.9.0", cfg.EffectiveSecuredClusterVersion()) + assert.Equal(t, "4.8.0", cfg.CentralVersion()) + assert.Equal(t, "4.9.0", cfg.SecuredClusterVersion()) assert.True(t, cfg.HasMixedVersions()) } -func TestEffectiveVersions_SecuredClusterOverride(t *testing.T) { +func TestVersions_SecuredClusterOverride(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.7.0"}}, } - assert.Equal(t, "4.9.0", cfg.EffectiveCentralVersion()) - assert.Equal(t, "4.7.0", cfg.EffectiveSecuredClusterVersion()) + assert.Equal(t, "4.9.0", cfg.CentralVersion()) + assert.Equal(t, "4.7.0", cfg.SecuredClusterVersion()) assert.True(t, cfg.HasMixedVersions()) } -func TestEffectiveVersions_BothOverridesSame_NoMixed(t *testing.T) { +func TestVersions_BothOverridesSame_NoMixed(t *testing.T) { cfg := Config{ Roxie: RoxieConfig{Version: "4.9.0"}, Central: CentralConfig{Operator: OperatorConfig{Version: "4.8.0"}}, SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{Version: "4.8.0"}}, } - assert.Equal(t, "4.8.0", cfg.EffectiveCentralVersion()) - assert.Equal(t, "4.8.0", cfg.EffectiveSecuredClusterVersion()) + assert.Equal(t, "4.8.0", cfg.CentralVersion()) + assert.Equal(t, "4.8.0", cfg.SecuredClusterVersion()) assert.False(t, cfg.HasMixedVersions()) instances := cfg.OperatorInstances() From 23f70af6e36db1b567779f48448c60ccaae29846 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 16:12:40 +0200 Subject: [PATCH 10/17] Remove dead code --- internal/deployer/acs_images.go | 16 +++------------- internal/deployer/operator.go | 4 ++-- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/internal/deployer/acs_images.go b/internal/deployer/acs_images.go index 7449552..c5f5af2 100644 --- a/internal/deployer/acs_images.go +++ b/internal/deployer/acs_images.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/stackrox/roxie/internal/constants" - "github.com/stackrox/roxie/internal/helpers" ) func imagesForConfig(config Config) []string { @@ -37,7 +36,7 @@ func imagesForConfig(config Config) []string { } for _, instance := range config.OperatorInstances() { add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, operatorPrefix, "operator", instance.Version)) - add(OperatorBundleImageForVersion(instance.Version, config.Roxie.KonfluxImagesEnabled())) + add(OperatorBundleImage(instance.Version, config.Roxie.KonfluxImagesEnabled())) } return images @@ -60,17 +59,8 @@ func uniqueMainVersions(config Config) []string { return unique } -// OperatorBundleImage returns the operator bundle image for the top-level operator version. -func OperatorBundleImage(config Config) string { - version := config.Operator.Version - if version == "" { - version = helpers.ConvertMainTagToOperatorTag(config.Roxie.Version) - } - return OperatorBundleImageForVersion(version, config.Roxie.KonfluxImagesEnabled()) -} - -// OperatorBundleImageForVersion returns the operator bundle image for a specific operator version. -func OperatorBundleImageForVersion(operatorVersion string, konflux bool) string { +// OperatorBundleImage returns the operator bundle image for a specific operator version. +func OperatorBundleImage(operatorVersion string, konflux bool) string { imageRegistry := constants.DefaultRegistry if konflux { return fmt.Sprintf("%s/release-operator-bundle:v%s", imageRegistry, operatorVersion) diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index 1c7d8e5..8027c9c 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -37,7 +37,7 @@ var requiredCRDs = []string{ // deployOperatorNonOLM deploys one RHACS operator instance without OLM. func (d *Deployer) deployOperatorNonOLM(ctx context.Context, instance OperatorInstance) error { d.logger.Infof("Operator tag: %s (namespace %s)", instance.Version, instance.Namespace) - bundleImage := OperatorBundleImageForVersion(instance.Version, d.config.Roxie.KonfluxImagesEnabled()) + bundleImage := OperatorBundleImage(instance.Version, d.config.Roxie.KonfluxImagesEnabled()) bundleDir, err := d.downloadAndExtractOperatorBundle(ctx, bundleImage) if err != nil { @@ -172,7 +172,7 @@ func (d *Deployer) ensureCRDsInstalled(ctx context.Context) error { if version == "" { version = d.config.Operator.Version } - bundleImage := OperatorBundleImageForVersion(version, d.config.Roxie.KonfluxImagesEnabled()) + bundleImage := OperatorBundleImage(version, d.config.Roxie.KonfluxImagesEnabled()) d.logger.Warningf("Missing CRDs detected (%s)", strings.Join(missing, ", ")) d.logger.Warningf("Fetching bundle %s", bundleImage) From 9169418eb55a125324ca2b3ee05396e6fa916618 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 16:19:36 +0200 Subject: [PATCH 11/17] Simplify imagesForConfig & uniqueMainVersions --- internal/deployer/acs_images.go | 44 ++++++++++++--------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/internal/deployer/acs_images.go b/internal/deployer/acs_images.go index c5f5af2..34cc90b 100644 --- a/internal/deployer/acs_images.go +++ b/internal/deployer/acs_images.go @@ -7,27 +7,21 @@ import ( ) func imagesForConfig(config Config) []string { - images := make([]string, 0) + var images []string prefix := "" if config.Roxie.KonfluxImagesEnabled() { prefix = "release-" } imageRegistry := constants.DefaultRegistry - seen := make(map[string]bool) - add := func(image string) { - if seen[image] { - return - } - seen[image] = true - images = append(images, image) - } for _, mainTag := range uniqueMainVersions(config) { - add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "main", mainTag)) - add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "central-db", mainTag)) - add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4-db", mainTag)) - add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4", mainTag)) + images = append(images, + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "main", mainTag), + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "central-db", mainTag), + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4-db", mainTag), + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4", mainTag), + ) } operatorPrefix := prefix @@ -35,28 +29,22 @@ func imagesForConfig(config Config) []string { operatorPrefix = "stackrox-" } for _, instance := range config.OperatorInstances() { - add(fmt.Sprintf("%s/%s%s:%s", imageRegistry, operatorPrefix, "operator", instance.Version)) - add(OperatorBundleImage(instance.Version, config.Roxie.KonfluxImagesEnabled())) + images = append(images, + fmt.Sprintf("%s/%s%s:%s", imageRegistry, operatorPrefix, "operator", instance.Version), + OperatorBundleImage(instance.Version, config.Roxie.KonfluxImagesEnabled()), + ) } return images } func uniqueMainVersions(config Config) []string { - versions := []string{config.CentralVersion(), config.SecuredClusterVersion()} - seen := make(map[string]bool) - var unique []string - for _, v := range versions { - if v == "" || seen[v] { - continue - } - seen[v] = true - unique = append(unique, v) - } - if len(unique) == 0 && config.Roxie.Version != "" { - unique = append(unique, config.Roxie.Version) + central := config.CentralVersion() + sc := config.SecuredClusterVersion() + if central == sc { + return []string{central} } - return unique + return []string{central, sc} } // OperatorBundleImage returns the operator bundle image for a specific operator version. From 80adfab6be78314149104a458d654eb183a603b8 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 16:22:15 +0200 Subject: [PATCH 12/17] Apply suggestions from code review Co-authored-by: Moritz Clasmeier <111092021+mclasmeier@users.noreply.github.com> --- internal/deployer/konflux.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/deployer/konflux.go b/internal/deployer/konflux.go index f11dd44..855f2d4 100644 --- a/internal/deployer/konflux.go +++ b/internal/deployer/konflux.go @@ -19,7 +19,7 @@ var konfluxRelatedImages = map[string]string{ "RELATED_IMAGE_FACT": "fact", } -// KonfluxOperatorImage returns the Konflux-built operator image reference for a version. +// KonfluxOperatorImage returns the Konflux-built operator image reference for an operator version. func KonfluxOperatorImage(operatorVersion string) string { return fmt.Sprintf("%s/release-operator:%s", constants.DefaultRegistry, operatorVersion) } From c765e2ab265e1d2f221d8f1a1f73aa3c506de4fb Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 16:31:08 +0200 Subject: [PATCH 13/17] Apply suggestions from code review --- internal/deployer/operator_instance.go | 39 ++++++++------------------ 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go index fefb4ff..1470f74 100644 --- a/internal/deployer/operator_instance.go +++ b/internal/deployer/operator_instance.go @@ -1,7 +1,9 @@ package deployer import ( + "cmp" "maps" + "slices" "strings" "github.com/Masterminds/semver/v3" @@ -24,7 +26,7 @@ var AllOperatorNamespaces = []string{ operatorNamespaceSensor, } -// OperatorInstance describes one operator deployment (single- or mixed-version). +// OperatorInstance describes a single operator instance (single- or mixed-version mode). type OperatorInstance struct { Version string Namespace string @@ -80,12 +82,8 @@ func (c *Config) HasMixedVersions() bool { // When they differ, two operators are deployed with reconciler toggles. func (c *Config) OperatorInstances() []OperatorInstance { if !c.HasMixedVersions() { - version := helpers.ConvertMainTagToOperatorTag(c.CentralVersion()) - if version == "" { - version = c.Operator.Version - } return []OperatorInstance{{ - Version: version, + Version: helpers.ConvertMainTagToOperatorTag(c.CentralVersion()), Namespace: operatorNamespaceSystem, EnvVars: maps.Clone(c.Operator.EnvVars), }} @@ -122,28 +120,15 @@ func (c *Config) OperatorInstances() []OperatorInstance { // Ordering uses the leading semver of each tag (suffix after "-" is ignored), which // is sufficient for release-vs-release compat testing (e.g. 4.8.x vs 4.9.x). func (c *Config) NewestOperatorVersion() string { - instances := c.OperatorInstances() - if len(instances) == 0 { - return c.Operator.Version - } - newest := instances[0].Version - for _, inst := range instances[1:] { - if operatorVersionGreater(inst.Version, newest) { - newest = inst.Version + newest := slices.MaxFunc(c.OperatorInstances(), func(a, b OperatorInstance) int { + av, aerr := parseOperatorSemver(a.Version) + bv, berr := parseOperatorSemver(b.Version) + if aerr == nil && berr == nil { + return av.Compare(bv) } - } - return newest -} - -// operatorVersionGreater reports whether a is a newer operator tag than b. -func operatorVersionGreater(a, b string) bool { - av, aerr := parseOperatorSemver(a) - bv, berr := parseOperatorSemver(b) - if aerr == nil && berr == nil { - return av.GreaterThan(bv) - } - // Fall back to lexicographic compare when tags are not parseable as semver. - return a > b + return cmp.Compare(a.Version, b.Version) + }) + return newest.Version } func parseOperatorSemver(version string) (*semver.Version, error) { From a7a6b9e1051dfaa853f741b9b131a834e42c2ba1 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 16:49:21 +0200 Subject: [PATCH 14/17] Move code from runDeploy to deployValidate --- cmd/deploy.go | 48 ++++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 55c9c8d..144e75c 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -255,32 +255,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { return err } - if !deploySettings.Central.EarlyReadinessEnabled() || !deploySettings.SecuredCluster.EarlyReadinessEnabled() { - // Explanation on the versions involved here: - // Deploying StackRox begins with picking a "main image tag" -- this is a version identifier, which cannot be reliably parsed as a semver. - // But there is a derived version from that -- the operator version -- which can be parsed as a semver. - // - // The invocation of deploySettings.Operator.Configure() above in this function prepares the operator deployment config in the sense - // that top-level roxie configuration options are propagated to the concrete operator deployment configuration. This includes also - // storing of the derived operator version within the operator configuration. - // - // This is why we use the operator version here when checking version constraints. - // Check every operator instance that will be deployed. - var versionsToCheck []string - for _, instance := range deploySettings.OperatorInstances() { - versionsToCheck = append(versionsToCheck, instance.Version) - } - for _, opVersion := range versionsToCheck { - hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(opVersion) - if err != nil { - return fmt.Errorf("checking version constraint on operator version %s: %w", opVersion, err) - } - if !hasSupport { - return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s", stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String()) - } - } - } - d, err := deployer.New(log) if err != nil { return fmt.Errorf("failed to create deployer: %w", err) @@ -484,5 +458,27 @@ func deployValidate(components component.Component, deploySettings *deployer.Con } } + if !deploySettings.Central.EarlyReadinessEnabled() || !deploySettings.SecuredCluster.EarlyReadinessEnabled() { + // Explanation on the versions involved here: + // Deploying StackRox begins with picking a "main image tag" -- this is a version identifier, which cannot be reliably parsed as a semver. + // But there is a derived version from that -- the operator version -- which can be parsed as a semver. + // + // The invocation of deploySettings.Operator.Configure() above in this function prepares the operator deployment config in the sense + // that top-level roxie configuration options are propagated to the concrete operator deployment configuration. This includes also + // storing of the derived operator version within the operator configuration. + // + // This is why we use the operator version here when checking version constraints. + // Check every operator instance that will be deployed. + for _, instance := range deploySettings.OperatorInstances() { + hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(instance.Version) + if err != nil { + return fmt.Errorf("checking version constraint on operator version %s: %w", instance.Version, err) + } + if !hasSupport { + return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s", stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String()) + } + } + } + return nil } From 570f0e205719047e99414774155062253c90abe0 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Tue, 28 Jul 2026 16:52:52 +0200 Subject: [PATCH 15/17] Simplify error messages --- cmd/deploy.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 144e75c..4774c83 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -450,11 +450,12 @@ func deployValidate(components component.Component, deploySettings *deployer.Con } if deploySettings.HasMixedVersions() { + globalLogger.Dimf("Mixed versions detected (configured via --central-tag / --secured-cluster-tag or central.operator / securedCluster.operator)") if components.IncludesOperatorExplicitly() { - return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.operator / securedCluster.operator) are not supported with operator-only deploy") + return errors.New("mixed versions are not supported with operator-only deploy") } if deploySettings.Operator.DeployViaOlmEnabled() { - return errors.New("mixed versions (--central-tag / --secured-cluster-tag / central.operator / securedCluster.operator) are not supported with OLM deployment mode") + return errors.New("mixed versions are not supported with OLM deployment mode") } } From 114c96f1f57157c37800050f08995b20b8606e21 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Wed, 29 Jul 2026 15:08:27 +0200 Subject: [PATCH 16/17] Clarify operator tags / main tags mixup --- internal/deployer/config.go | 12 +++++++----- internal/deployer/deployer.go | 8 ++++---- internal/deployer/operator_instance.go | 10 +++++----- internal/helpers/tag.go | 9 ++++++--- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index a785f6c..6bb0024 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -77,10 +77,12 @@ func NewRoxieConfig() RoxieConfig { // OperatorConfig controls how the ACS operator is deployed. type OperatorConfig struct { - SkipDeployment *bool `yaml:"skipDeployment,omitempty"` - DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"` - Version string `yaml:"version,omitempty"` - EnvVars map[string]string `yaml:"envVars,omitempty"` + SkipDeployment *bool `yaml:"skipDeployment,omitempty"` + DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"` + // Version can hold either a main image tag or an operator tag. It should be normalized to an + // operator tag via ConvertToOperatorTag before use (the function is idempotent, so either form is accepted). + Version string `yaml:"version,omitempty"` + EnvVars map[string]string `yaml:"envVars,omitempty"` } func (c *OperatorConfig) SkipDeploymentSet() bool { @@ -101,7 +103,7 @@ func (c *OperatorConfig) DeployViaOlmEnabled() bool { // Configure derives the operator version from the roxie configuration. func (c *OperatorConfig) Configure(roxieConfig *RoxieConfig) error { - c.Version = helpers.ConvertMainTagToOperatorTag(roxieConfig.Version) + c.Version = helpers.ConvertToOperatorTag(roxieConfig.Version) return nil } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index a90aa12..70abc1c 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -729,7 +729,7 @@ func (d *Deployer) writeEnvrcFile(ctx context.Context) error { func (d *Deployer) PrintCentralDeploymentSummary() { component := "Central" - mainImageTag := d.config.CentralVersion() + imageTag := d.config.CentralVersion() olm := d.config.Operator.DeployViaOlmEnabled() exposure := d.config.Central.GetExposure() portForwarding := d.config.Central.PortForwardingEnabled() @@ -784,7 +784,7 @@ func (d *Deployer) PrintCentralDeploymentSummary() { // Deployment details log.Info(cyan.Sprint("โ”‚") + createRow("Component", component)) log.Info(cyan.Sprint("โ”‚") + createRow("Cluster Type", d.config.Roxie.ClusterType.String())) - log.Info(cyan.Sprint("โ”‚") + createRow("Main Tag", mainImageTag)) + log.Info(cyan.Sprint("โ”‚") + createRow("Image Tag", imageTag)) log.Info(cyan.Sprint("โ”‚") + createRow("Kubernetes Context", kubeContext)) if olm { @@ -910,7 +910,7 @@ func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace st // extracted func (d *Deployer) PrintSecuredClusterDeploymentSummary() { component := "Secured Cluster" - mainImageTag := d.config.SecuredClusterVersion() + imageTag := d.config.SecuredClusterVersion() olm := d.config.Operator.DeployViaOlmEnabled() log := d.logger kubeContext := d.kubeContext @@ -963,7 +963,7 @@ func (d *Deployer) PrintSecuredClusterDeploymentSummary() { // Deployment details log.Info(cyan.Sprint("โ”‚") + createRow("Component", component)) log.Info(cyan.Sprint("โ”‚") + createRow("Cluster Type", d.config.Roxie.ClusterType.String())) - log.Info(cyan.Sprint("โ”‚") + createRow("Main Tag", mainImageTag)) + log.Info(cyan.Sprint("โ”‚") + createRow("Image Tag", imageTag)) log.Info(cyan.Sprint("โ”‚") + createRow("Kubernetes Context", kubeContext)) if olm { diff --git a/internal/deployer/operator_instance.go b/internal/deployer/operator_instance.go index 1470f74..bf849c6 100644 --- a/internal/deployer/operator_instance.go +++ b/internal/deployer/operator_instance.go @@ -52,7 +52,7 @@ func (o OperatorInstance) ClusterRoleBindingName() string { return "rhacs-operator-manager-rolebinding-" + o.RoleNameSuffix } -// CentralVersion returns the main image tag used for Central. +// CentralVersion returns the version tag for Central (may be a main tag or operator tag). // Uses central.operator.version if set, otherwise falls back to roxie.version. func (c *Config) CentralVersion() string { if c.Central.Operator.Version != "" { @@ -61,7 +61,7 @@ func (c *Config) CentralVersion() string { return c.Roxie.Version } -// SecuredClusterVersion returns the main image tag used for SecuredCluster. +// SecuredClusterVersion returns the version tag for SecuredCluster (may be a main tag or operator tag). // Uses securedCluster.operator.version if set, otherwise falls back to roxie.version. func (c *Config) SecuredClusterVersion() string { if c.SecuredCluster.Operator.Version != "" { @@ -83,7 +83,7 @@ func (c *Config) HasMixedVersions() bool { func (c *Config) OperatorInstances() []OperatorInstance { if !c.HasMixedVersions() { return []OperatorInstance{{ - Version: helpers.ConvertMainTagToOperatorTag(c.CentralVersion()), + Version: helpers.ConvertToOperatorTag(c.CentralVersion()), Namespace: operatorNamespaceSystem, EnvVars: maps.Clone(c.Operator.EnvVars), }} @@ -99,13 +99,13 @@ func (c *Config) OperatorInstances() []OperatorInstance { return []OperatorInstance{ { - Version: helpers.ConvertMainTagToOperatorTag(c.CentralVersion()), + Version: helpers.ConvertToOperatorTag(c.CentralVersion()), Namespace: operatorNamespaceCentral, EnvVars: centralEnvVars, RoleNameSuffix: "central", }, { - Version: helpers.ConvertMainTagToOperatorTag(c.SecuredClusterVersion()), + Version: helpers.ConvertToOperatorTag(c.SecuredClusterVersion()), Namespace: operatorNamespaceSensor, EnvVars: sensorEnvVars, RoleNameSuffix: "sensor", diff --git a/internal/helpers/tag.go b/internal/helpers/tag.go index b079ac8..6f77a80 100644 --- a/internal/helpers/tag.go +++ b/internal/helpers/tag.go @@ -72,12 +72,15 @@ func LookupLatestTag(ctx context.Context, log *logger.Logger) (string, error) { return "", fmt.Errorf("failed to verify main image existence for tags %s", strings.Join(tags, ", ")) } -func ConvertMainTagToOperatorTag(mainTag string) string { - if mainTag == "" { +// ConvertToOperatorTag normalizes a tag to an operator-compatible form by +// removing "-dirty" and replacing ".x" with ".0". The function is idempotent: +// calling it on a tag that is already an operator tag returns it unchanged. +func ConvertToOperatorTag(tag string) string { + if tag == "" { return "" } - operatorTag := strings.ReplaceAll(mainTag, "-dirty", "") + operatorTag := strings.ReplaceAll(tag, "-dirty", "") operatorTag = strings.ReplaceAll(operatorTag, ".x", ".0") return operatorTag From d1c85cef511a31813aa0f5f22d2a00b4462c76e6 Mon Sep 17 00:00:00 2001 From: Vlad Bologa Date: Wed, 29 Jul 2026 17:13:53 +0200 Subject: [PATCH 17/17] Address CodeRabbit comment --- cmd/deploy.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 4774c83..3805b14 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -476,7 +476,8 @@ func deployValidate(components component.Component, deploySettings *deployer.Con return fmt.Errorf("checking version constraint on operator version %s: %w", instance.Version, err) } if !hasSupport { - return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s", stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String()) + return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s (operator version %s in %s does not)", + stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String(), instance.Version, instance.Namespace) } } }