diff --git a/cmd/deploy.go b/cmd/deploy.go index cc104a7..85098a1 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -139,6 +139,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) @@ -253,25 +267,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) @@ -427,10 +422,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) @@ -495,6 +486,42 @@ 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() { + if err := checkEarlyReadinessSupport("Central", deploySettings.CentralVersion()); err != nil { + return err + } + } + if !deploySettings.SecuredCluster.EarlyReadinessEnabled() { + if err := checkEarlyReadinessSupport("SecuredCluster", deploySettings.SecuredClusterVersion()); err != nil { + return err + } + } + + return nil +} + +func checkEarlyReadinessSupport(componentName, mainTag string) error { + // The main image tag is not reliably parsible as semver, so we derive the operator + // tag (via ConvertToOperatorTag) for the constraint check. + version := helpers.ConvertToOperatorTag(mainTag) + hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(version) + if err != nil { + return fmt.Errorf("checking version constraint on %s operator version %s: %w", componentName, version, err) + } + if !hasSupport { + return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s (%s version %s does not)", + stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String(), componentName, version) + } return nil } diff --git a/internal/deployer/acs_images.go b/internal/deployer/acs_images.go index 1d2a340..579abbd 100644 --- a/internal/deployer/acs_images.go +++ b/internal/deployer/acs_images.go @@ -4,33 +4,30 @@ import ( "fmt" "github.com/stackrox/roxie/internal/constants" + "github.com/stackrox/roxie/internal/helpers" ) func imagesForConfig(config Config) []string { - images := make([]string, 0) - prefix := "" - if config.Roxie.KonfluxImagesEnabled() { - prefix = "release-" - } - + var images []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)) - if !config.Roxie.KonfluxImagesEnabled() { - prefix = "stackrox-" + + for _, instance := range config.OperatorInstances() { + prefix := "" + operatorPrefix := "stackrox-" + if instance.KonfluxImagesEnabled() { + prefix = "release-" + operatorPrefix = "release-" + } + operatorTag := helpers.ConvertToOperatorTag(instance.Version) + images = append(images, + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "main", instance.Version), + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "central-db", instance.Version), + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4-db", instance.Version), + fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4", instance.Version), + fmt.Sprintf("%s/%s%s:%s", imageRegistry, operatorPrefix, "operator", operatorTag), + instance.BundleImage(), + ) } - 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 { - imageRegistry := constants.DefaultRegistry - if config.Roxie.KonfluxImagesEnabled() { - return fmt.Sprintf("%s/release-operator-bundle:v%s", imageRegistry, config.Operator.Version) - } - return fmt.Sprintf("%s/stackrox-operator-bundle:v%s", imageRegistry, config.Operator.Version) -} diff --git a/internal/deployer/config.go b/internal/deployer/config.go index a66c29d..bec6b8b 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/stackrox/roxie/internal/constants" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/types" "gopkg.in/yaml.v3" @@ -75,12 +76,59 @@ func NewRoxieConfig() RoxieConfig { } } -// OperatorConfig controls how the ACS operator is deployed. -type OperatorConfig struct { - SkipDeployment *bool `yaml:"skipDeployment,omitempty"` - DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"` +// OperatorInstanceConfig describes how to deploy a single operator instance. +// In the common single-operator mode, the top-level OperatorConfig (which embeds this) +// is used directly. In mixed-operator mode, CentralConfig.Operator and +// SecuredClusterConfig.Operator override the top-level defaults. +type OperatorInstanceConfig struct { + // Version holds a main image tag (e.g. "4.8.0", "4.8.0-dirty"). + // Methods that need the operator tag (semver-only) convert via helpers.ConvertToOperatorTag. Version string `yaml:"version,omitempty"` EnvVars map[string]string `yaml:"envVars,omitempty"` + Namespace string `yaml:"namespace,omitempty"` + RoleNameSuffix string `yaml:"roleNameSuffix,omitempty"` + KonfluxImages *bool `yaml:"konfluxImages,omitempty"` +} + +func (c *OperatorInstanceConfig) KonfluxImagesSet() bool { + return c.KonfluxImages != nil +} + +func (c *OperatorInstanceConfig) KonfluxImagesEnabled() bool { + return c.KonfluxImages != nil && *c.KonfluxImages +} + +// ClusterRoleName returns the ClusterRole name for this operator instance. +func (c *OperatorInstanceConfig) ClusterRoleName() string { + if c.RoleNameSuffix == "" { + return "rhacs-operator-manager-role" + } + return "rhacs-operator-manager-role-" + c.RoleNameSuffix +} + +// ClusterRoleBindingName returns the ClusterRoleBinding name for this operator instance. +func (c *OperatorInstanceConfig) ClusterRoleBindingName() string { + if c.RoleNameSuffix == "" { + return "rhacs-operator-manager-rolebinding" + } + return "rhacs-operator-manager-rolebinding-" + c.RoleNameSuffix +} + +// BundleImage returns the operator bundle image for this operator instance. +func (c *OperatorInstanceConfig) BundleImage() string { + imageRegistry := constants.DefaultRegistry + operatorTag := helpers.ConvertToOperatorTag(c.Version) + if c.KonfluxImagesEnabled() { + return fmt.Sprintf("%s/release-operator-bundle:v%s", imageRegistry, operatorTag) + } + return fmt.Sprintf("%s/stackrox-operator-bundle:v%s", imageRegistry, operatorTag) +} + +// OperatorConfig is the top-level operator configuration used in single-operator mode. +type OperatorConfig struct { + SkipDeployment *bool `yaml:"skipDeployment,omitempty"` + DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"` + OperatorInstanceConfig `yaml:",inline"` } func (c *OperatorConfig) SkipDeploymentSet() bool { @@ -101,7 +149,10 @@ 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) + if c.KonfluxImages == nil { + c.KonfluxImages = roxieConfig.KonfluxImages + } return nil } @@ -115,6 +166,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 OperatorInstanceConfig `yaml:"operator,omitempty"` Namespace string `yaml:"namespace,omitempty"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` PauseReconciliation *bool `yaml:"pauseReconciliation,omitempty"` @@ -265,6 +318,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 OperatorInstanceConfig `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 e7d8cfc..6e0fc11 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,105 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { return fmt.Errorf("failed to ensure CRDs installed: %w", err) } - // Detect current operator deployment mode + if d.config.Operator.DeployViaOlmEnabled() { + return d.ensureOperatorDeployedOLM(ctx) + } + + 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) + } + } + + instances := d.config.OperatorInstances() + if err := d.teardownStaleOperatorNamespaces(ctx, instances); err != nil { + return err + } + + for _, instance := range instances { + PopulateKonfluxEnvVars(&instance) + if err := d.ensureOperatorInstanceNonOLM(ctx, instance); err != nil { + return fmt.Errorf("failed to deploy operator in %s: %w", instance.Namespace, err) + } + } + + return nil +} + +// 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 []OperatorInstanceConfig) 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 := OperatorInstanceConfig{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 OperatorInstanceConfig) 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 := []OperatorInstanceConfig{{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 +166,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 := OperatorInstanceConfig{ + 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 +186,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 +198,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 +243,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 OperatorInstanceConfig) 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 +259,20 @@ func (d *Deployer) isOperatorVersionCorrect(ctx context.Context) bool { } currentTag := parts[1] - if currentTag != d.config.Operator.Version { + desiredTag := helpers.ConvertToOperatorTag(instance.Version) + if currentTag != desiredTag { 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", desiredTag) 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 d5c42b9..d530ef8 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -754,7 +754,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() @@ -809,7 +809,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 { @@ -935,7 +935,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 @@ -988,7 +988,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..d3b2d55 100644 --- a/internal/deployer/konflux.go +++ b/internal/deployer/konflux.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackrox/roxie/internal/constants" + "github.com/stackrox/roxie/internal/helpers" ) var konfluxRelatedImages = map[string]string{ @@ -19,27 +20,31 @@ 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 the instance's EnvVars +// for its version. It is a no-op when Konflux images are not enabled for the instance. +// Explicitly-provided env vars (e.g. from --operator-env) take precedence and are not overwritten. +func PopulateKonfluxEnvVars(instance *OperatorInstanceConfig) { + if !instance.KonfluxImagesEnabled() { + return } + if instance.EnvVars == nil { + instance.EnvVars = make(map[string]string, len(konfluxRelatedImages)) + } + operatorTag := helpers.ConvertToOperatorTag(instance.Version) for envName, imageSuffix := range konfluxRelatedImages { - if _, exists := config.Operator.EnvVars[envName]; exists { + if _, exists := instance.EnvVars[envName]; exists { continue } - config.Operator.EnvVars[envName] = fmt.Sprintf( + instance.EnvVars[envName] = fmt.Sprintf( "%s/release-%s:%s", constants.DefaultRegistry, imageSuffix, - config.Operator.Version, // Konflux built images use the "operator tag". + operatorTag, ) } } diff --git a/internal/deployer/konflux_test.go b/internal/deployer/konflux_test.go index 7da65f4..be854e8 100644 --- a/internal/deployer/konflux_test.go +++ b/internal/deployer/konflux_test.go @@ -7,48 +7,42 @@ import ( "github.com/stackrox/roxie/internal/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" ) 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"}, - } - - PopulateKonfluxEnvVars(config) + instance := &OperatorInstanceConfig{Version: "4.9.2", KonfluxImages: ptr.To(true)} + PopulateKonfluxEnvVars(instance) - require.Len(t, config.Operator.EnvVars, len(konfluxRelatedImages)) + require.Len(t, instance.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, instance.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, - }, + instance := &OperatorInstanceConfig{ + Version: "4.9.2", + KonfluxImages: ptr.To(true), + EnvVars: map[string]string{ + "RELATED_IMAGE_MAIN": userValue, }, } - PopulateKonfluxEnvVars(config) + PopulateKonfluxEnvVars(instance) - assert.Equal(t, userValue, config.Operator.EnvVars["RELATED_IMAGE_MAIN"], + assert.Equal(t, userValue, instance.EnvVars["RELATED_IMAGE_MAIN"], "user override should be preserved") - assert.Len(t, config.Operator.EnvVars, len(konfluxRelatedImages), + assert.Len(t, instance.EnvVars, len(konfluxRelatedImages), "all other entries should be populated") for envName, imageSuffix := range konfluxRelatedImages { @@ -56,18 +50,12 @@ 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) + assert.Equal(t, expected, instance.EnvVars[envName], "mismatch for %s", envName) } } -func TestPopulateKonfluxEnvVars_NilEnvVarsMap(t *testing.T) { - config := &Config{ - Operator: OperatorConfig{Version: "4.9.2"}, - } - assert.Nil(t, config.Operator.EnvVars) - - PopulateKonfluxEnvVars(config) - - require.NotNil(t, config.Operator.EnvVars) - assert.Len(t, config.Operator.EnvVars, len(konfluxRelatedImages)) +func TestPopulateKonfluxEnvVars_NoOpWhenDisabled(t *testing.T) { + instance := &OperatorInstanceConfig{Version: "4.9.2", KonfluxImages: ptr.To(false)} + PopulateKonfluxEnvVars(instance) + assert.Nil(t, instance.EnvVars) } diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index adf7eec..a5c464a 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -13,15 +13,18 @@ import ( "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/ocihelper" ) 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{ @@ -30,10 +33,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 OperatorInstanceConfig) error { + d.logger.Infof("Operator tag: %s (namespace %s)", instance.Version, instance.Namespace) + bundleImage := instance.BundleImage() bundleDir, err := d.downloadAndExtractOperatorBundle(ctx, bundleImage) if err != nil { @@ -43,16 +46,23 @@ 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. + operatorTag := helpers.ConvertToOperatorTag(instance.Version) + if operatorTag == 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)", + operatorTag, d.config.NewestOperatorVersion()) } - if err := d.deployOperatorFromCSV(ctx, bundleDir); err != nil { + if err := d.deployOperatorFromCSV(ctx, bundleDir, instance); err != nil { return err } @@ -158,7 +168,15 @@ 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 + } + crdInstance := OperatorInstanceConfig{ + Version: version, + KonfluxImages: d.config.Roxie.KonfluxImages, + } + bundleImage := crdInstance.BundleImage() d.logger.Warningf("Missing CRDs detected (%s)", strings.Join(missing, ", ")) d.logger.Warningf("Fetching bundle %s", bundleImage) @@ -179,8 +197,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 OperatorInstanceConfig) 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") @@ -194,48 +212,48 @@ func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string) } serviceAccountName := deploymentSpec["service_account"].(string) - d.useOperatorPullSecrets = d.config.Roxie.KonfluxImagesEnabled() && d.config.Roxie.ClusterType.NeedsPullSecrets() + d.useOperatorPullSecrets = instance.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 } @@ -309,8 +327,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 OperatorInstanceConfig) error { clusterPermissions := deploymentSpec["cluster_permissions"].([]any) if len(clusterPermissions) == 0 { d.logger.Warning("No cluster permissions found in CSV") @@ -319,12 +337,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, @@ -332,7 +351,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{ @@ -340,37 +359,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 OperatorInstanceConfig, 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{ @@ -378,13 +400,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 OperatorInstanceConfig, deploymentSpec map[string]any) error { deployments := deploymentSpec["deployments"].([]any) if len(deployments) == 0 { return errors.New("no deployments found in CSV") @@ -403,7 +425,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, @@ -427,17 +449,17 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string } podSpec["serviceAccountName"] = deploymentSpec["service_account"] - if d.config.Roxie.KonfluxImagesEnabled() { - d.rewriteKonfluxOperatorImage(managerContainer) + if instance.KonfluxImagesEnabled() { + d.rewriteKonfluxOperatorImage(managerContainer, helpers.ConvertToOperatorTag(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{ @@ -445,7 +467,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 } @@ -470,7 +492,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 { @@ -481,7 +503,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 @@ -494,9 +516,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 } @@ -544,35 +566,74 @@ func (d *Deployer) waitForOperatorReady(ctx context.Context, namespace, deployme return errors.New("timeout waiting for operator deployment to become ready") } -// 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 OperatorInstanceConfig) 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 []OperatorInstanceConfig{ + {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 := OperatorInstanceConfig{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 } @@ -583,13 +644,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_instances.go b/internal/deployer/operator_instances.go new file mode 100644 index 0000000..1bdfdfb --- /dev/null +++ b/internal/deployer/operator_instances.go @@ -0,0 +1,125 @@ +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, +} + +// CentralVersion returns the main image tag 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 +} + +// SecuredClusterVersion returns the main image tag 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 + } + 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() []OperatorInstanceConfig { + if !c.HasMixedVersions() { + return []OperatorInstanceConfig{{ + Version: c.CentralVersion(), + Namespace: operatorNamespaceSystem, + EnvVars: maps.Clone(c.Operator.EnvVars), + KonfluxImages: c.resolveKonfluxImages(&c.Operator.OperatorInstanceConfig), + }} + } + + 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 []OperatorInstanceConfig{ + { + Version: c.CentralVersion(), + Namespace: operatorNamespaceCentral, + EnvVars: centralEnvVars, + RoleNameSuffix: "central", + KonfluxImages: c.resolveKonfluxImages(&c.Central.Operator), + }, + { + Version: c.SecuredClusterVersion(), + Namespace: operatorNamespaceSensor, + EnvVars: sensorEnvVars, + RoleNameSuffix: "sensor", + KonfluxImages: c.resolveKonfluxImages(&c.SecuredCluster.Operator), + }, + } +} + +// resolveKonfluxImages returns the effective KonfluxImages setting for a per-component +// operator config, falling back to Roxie.KonfluxImages if not set at the component level. +func (c *Config) resolveKonfluxImages(instanceCfg *OperatorInstanceConfig) *bool { + if instanceCfg.KonfluxImagesSet() { + return instanceCfg.KonfluxImages + } + return c.Roxie.KonfluxImages +} + +// 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. +// +// Comparison uses the leading semver (everything before the first "-" in the operator +// tag), 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() + newest := slices.MaxFunc(instances, func(a, b OperatorInstanceConfig) int { + av, aerr := parseLeadingSemver(a.Version) + bv, berr := parseLeadingSemver(b.Version) + if aerr == nil && berr == nil { + return av.Compare(bv) + } + return cmp.Compare(a.Version, b.Version) + }) + return helpers.ConvertToOperatorTag(newest.Version) +} + +func parseLeadingSemver(version string) (*semver.Version, error) { + tag := helpers.ConvertToOperatorTag(version) + base, _, _ := strings.Cut(tag, "-") + return semver.NewVersion(base) +} diff --git a/internal/deployer/operator_instances_test.go b/internal/deployer/operator_instances_test.go new file mode 100644 index 0000000..79da8d0 --- /dev/null +++ b/internal/deployer/operator_instances_test.go @@ -0,0 +1,226 @@ +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: OperatorInstanceConfig{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: OperatorInstanceConfig{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: OperatorInstanceConfig{Version: "4.8.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorInstanceConfig{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{ + OperatorInstanceConfig: OperatorInstanceConfig{ + EnvVars: map[string]string{"FOO": "bar"}, + }, + }, + } + + instances := cfg.OperatorInstances() + require.Len(t, instances, 1) + assert.Equal(t, "4.9.0-dirty", 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: OperatorInstanceConfig{ + Version: "4.8.0", + EnvVars: map[string]string{"CUSTOM": "1"}, + }, + }, + SecuredCluster: SecuredClusterConfig{ + Operator: OperatorInstanceConfig{ + 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: OperatorInstanceConfig{ + Version: "4.8.0", + EnvVars: map[string]string{"CENTRAL_ONLY": "yes"}, + }, + }, + SecuredCluster: SecuredClusterConfig{ + Operator: OperatorInstanceConfig{ + Version: "4.9.0", + EnvVars: map[string]string{"SC_ONLY": "yes"}, + }, + }, + Operator: OperatorConfig{ + OperatorInstanceConfig: OperatorInstanceConfig{ + 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: OperatorInstanceConfig{Version: "4.10.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorInstanceConfig{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: OperatorInstanceConfig{Version: "4.11.1"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorInstanceConfig{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: OperatorInstanceConfig{Version: "4.10.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorInstanceConfig{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: OperatorInstanceConfig{Version: "4.8.0"}}, + SecuredCluster: SecuredClusterConfig{Operator: OperatorInstanceConfig{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{ + OperatorInstanceConfig: OperatorInstanceConfig{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