diff --git a/cmd/deploy.go b/cmd/deploy.go index 40325e1..3805b14 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.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.Operator.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) @@ -241,25 +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. - 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) - } - 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) @@ -390,10 +385,6 @@ func configureConfig(log *logger.Logger, components component.Component, deployS return fmt.Errorf("configuring operator configuration: %w", err) } - if deploySettings.Roxie.KonfluxImagesEnabled() { - deployer.PopulateKonfluxEnvVars(deploySettings) - } - if components.IncludesCentral() { if err := deploySettings.Central.ConfigureSpec(&deploySettings.Roxie); err != nil { return fmt.Errorf("configuring Central spec: %w", err) @@ -458,5 +449,38 @@ 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 are not supported with operator-only deploy") + } + if deploySettings.Operator.DeployViaOlmEnabled() { + return errors.New("mixed versions are not supported with OLM deployment mode") + } + } + + 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 (operator version %s in %s does not)", + stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String(), instance.Version, instance.Namespace) + } + } + } + return nil } diff --git a/internal/deployer/acs_images.go b/internal/deployer/acs_images.go index 1d2a340..34cc90b 100644 --- a/internal/deployer/acs_images.go +++ b/internal/deployer/acs_images.go @@ -7,30 +7,51 @@ import ( ) func imagesForConfig(config Config) []string { - images := make([]string, 0) + var images []string prefix := "" if config.Roxie.KonfluxImagesEnabled() { prefix = "release-" } 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)) + + for _, mainTag := range uniqueMainVersions(config) { + 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 if !config.Roxie.KonfluxImagesEnabled() { - prefix = "stackrox-" + operatorPrefix = "stackrox-" + } + for _, instance := range config.OperatorInstances() { + images = append(images, + fmt.Sprintf("%s/%s%s:%s", imageRegistry, operatorPrefix, "operator", instance.Version), + OperatorBundleImage(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 OperatorBundleImage(config Config) string { +func uniqueMainVersions(config Config) []string { + central := config.CentralVersion() + sc := config.SecuredClusterVersion() + if central == sc { + return []string{central} + } + return []string{central, sc} +} + +// OperatorBundleImage returns the operator bundle image for a specific operator version. +func OperatorBundleImage(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..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 } @@ -115,6 +117,8 @@ type WaitConfig struct { // CentralConfig holds deployment settings for the Central component. type CentralConfig struct { + // 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"` @@ -262,6 +266,8 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { // SecuredClusterConfig holds deployment settings for the SecuredCluster component. type SecuredClusterConfig struct { + // 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/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 8358fb7..8c59358 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.teardownStaleOperatorNamespaces(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 { + PopulateKonfluxEnvVars(instances[i].EnvVars, instances[i].Version) + } + return instances +} + +// 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) teardownStaleOperatorNamespaces(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 previous operator from %s (no longer needed)...", 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 (mixed versions are rejected in validation). +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.teardownStaleOperatorNamespaces(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..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.Roxie.Version + 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.Roxie.Version + 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/konflux.go b/internal/deployer/konflux.go index 5c5eff2..855f2d4 100644 --- a/internal/deployer/konflux.go +++ b/internal/deployer/konflux.go @@ -19,27 +19,24 @@ 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 an operator 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) - } +// 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 := config.Operator.EnvVars[envName]; exists { + if _, exists := envVars[envName]; exists { continue } - config.Operator.EnvVars[envName] = fmt.Sprintf( + envVars[envName] = fmt.Sprintf( "%s/release-%s:%s", constants.DefaultRegistry, imageSuffix, - config.Operator.Version, // Konflux built images use the "operator tag". + operatorVersion, ) } } diff --git a/internal/deployer/konflux_test.go b/internal/deployer/konflux_test.go index 7da65f4..b44b356 100644 --- a/internal/deployer/konflux_test.go +++ b/internal/deployer/konflux_test.go @@ -10,45 +10,34 @@ 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) { - config := &Config{ - Operator: OperatorConfig{Version: "4.9.2"}, - } + envVars := make(map[string]string) + PopulateKonfluxEnvVars(envVars, "4.9.2") - PopulateKonfluxEnvVars(config) - - 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 { @@ -56,18 +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)) } diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index 094fc5c..8027c9c 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 := OperatorBundleImage(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 := OperatorBundleImage(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..bf849c6 --- /dev/null +++ b/internal/deployer/operator_instance.go @@ -0,0 +1,138 @@ +package deployer + +import ( + "cmp" + "maps" + "slices" + "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 a single operator instance (single- or mixed-version mode). +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 +} + +// 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 != "" { + return c.Central.Operator.Version + } + return c.Roxie.Version +} + +// 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 != "" { + return c.SecuredCluster.Operator.Version + } + return c.Roxie.Version +} + +// 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.CentralVersion() != c.SecuredClusterVersion() +} + +// 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.HasMixedVersions() { + return []OperatorInstance{{ + Version: helpers.ConvertToOperatorTag(c.CentralVersion()), + Namespace: operatorNamespaceSystem, + EnvVars: maps.Clone(c.Operator.EnvVars), + }} + } + + centralEnvVars := make(map[string]string, len(c.Central.Operator.EnvVars)+1) + maps.Copy(centralEnvVars, c.Central.Operator.EnvVars) + centralEnvVars[envSecuredClusterReconcilerEnabled] = "false" + + sensorEnvVars := make(map[string]string, len(c.SecuredCluster.Operator.EnvVars)+1) + maps.Copy(sensorEnvVars, c.SecuredCluster.Operator.EnvVars) + sensorEnvVars[envCentralReconcilerEnabled] = "false" + + return []OperatorInstance{ + { + Version: helpers.ConvertToOperatorTag(c.CentralVersion()), + Namespace: operatorNamespaceCentral, + EnvVars: centralEnvVars, + RoleNameSuffix: "central", + }, + { + Version: helpers.ConvertToOperatorTag(c.SecuredClusterVersion()), + 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 { + 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 cmp.Compare(a.Version, b.Version) + }) + return newest.Version +} + +func parseOperatorSemver(version string) (*semver.Version, error) { + // Leading semver only; see NewestOperatorVersion. + version, _, _ = strings.Cut(version, "-") + return semver.NewVersion(version) +} diff --git a/internal/deployer/operator_instance_test.go b/internal/deployer/operator_instance_test.go new file mode 100644 index 0000000..113e4a3 --- /dev/null +++ b/internal/deployer/operator_instance_test.go @@ -0,0 +1,220 @@ +package deployer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersions_DefaultToRoxieVersion(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + } + assert.Equal(t, "4.9.0", cfg.CentralVersion()) + assert.Equal(t, "4.9.0", cfg.SecuredClusterVersion()) + assert.False(t, cfg.HasMixedVersions()) +} + +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.CentralVersion()) + assert.Equal(t, "4.9.0", cfg.SecuredClusterVersion()) + assert.True(t, cfg.HasMixedVersions()) +} + +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.CentralVersion()) + assert.Equal(t, "4.7.0", cfg.SecuredClusterVersion()) + assert.True(t, cfg.HasMixedVersions()) +} + +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.CentralVersion()) + assert.Equal(t, "4.8.0", cfg.SecuredClusterVersion()) + assert.False(t, cfg.HasMixedVersions()) + + 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) { + 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_MixedVersions(t *testing.T) { + cfg := Config{ + Roxie: RoxieConfig{Version: "4.9.0"}, + Central: CentralConfig{ + Operator: OperatorConfig{ + Version: "4.8.0", + EnvVars: map[string]string{"CUSTOM": "1"}, + }, + }, + SecuredCluster: SecuredClusterConfig{ + Operator: OperatorConfig{ + Version: "4.9.0", + 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"]) +} + +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) + + // 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.NotContains(t, 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"}} + 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{Operator: OperatorConfig{Version: "4.10.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{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{Operator: OperatorConfig{Version: "4.11.1"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorConfig{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{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()) + }) +} + +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"}}, + } + + 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") +} 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