From c9eb6ac104ce0c5e8298ae07c50a51edef985450 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 27 Apr 2026 16:40:49 +0200 Subject: [PATCH 01/30] Implement new config-file first configuration approach. --- cmd/deploy.go | 545 +++++++++++++----- cmd/main.go | 32 +- cmd/subshell.go | 38 +- cmd/teardown.go | 28 +- go.mod | 4 +- internal/clusterdefaults/clusterdefaults.go | 273 +++------ .../clusterdefaults/clusterdefaults_test.go | 284 ++------- internal/deployer/config.go | 220 +++++++ internal/deployer/crs.go | 2 +- internal/deployer/deploy_via_operator.go | 245 +++----- internal/deployer/deployer.go | 474 ++++----------- internal/deployer/feature_flags.go | 19 +- internal/deployer/feature_flags_test.go | 14 +- internal/deployer/operator.go | 15 +- internal/deployer/operator_olm.go | 10 +- internal/deployer/override.go | 48 -- internal/env/env.go | 77 +-- internal/env/env_integration_test.go | 4 +- internal/env/env_test.go | 144 +++-- internal/helpers/helpers.go | 65 ++- internal/helpers/helpers_test.go | 8 +- internal/types/cluster_type.go | 52 ++ internal/types/exposure.go | 81 +++ internal/types/resources.go | 76 +++ 24 files changed, 1424 insertions(+), 1334 deletions(-) create mode 100644 internal/deployer/config.go delete mode 100644 internal/deployer/override.go create mode 100644 internal/types/cluster_type.go create mode 100644 internal/types/exposure.go create mode 100644 internal/types/resources.go diff --git a/cmd/deploy.go b/cmd/deploy.go index f3ea869..acbc085 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -5,17 +5,82 @@ import ( "errors" "fmt" "os" + "reflect" + "strings" "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stackrox/roxie/internal/clusterdefaults" "github.com/stackrox/roxie/internal/component" "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/types" + + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" +) + +var ( + sharedNamespace = "stackrox" ) -func newDeployCmd() *cobra.Command { +// For extended short-cut parameters. +type configShortCut struct { + settings *deployer.Config + flagType string + applyFn func(val string, settings *deployer.Config) error +} + +func newConfigShortCut( + settings *deployer.Config, + flagType string, + applyFn func(val string, settings *deployer.Config) error, +) *configShortCut { + return &configShortCut{ + flagType: flagType, + settings: settings, + applyFn: applyFn, + } +} + +func (y *configShortCut) Set(val string) error { + return y.applyFn(val, y.settings) +} + +func (y *configShortCut) String() string { + return "" // Not sure what to return here. +} + +func (y *configShortCut) Type() string { + return y.flagType +} + +func newConfigShortCutBool(settings *deployer.Config, path string) *configShortCut { + pathElements := strings.Split(path, ".") + applyFn := func(val string, settings *deployer.Config) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + u, err := helpers.StructToMap(settings) + if err != nil { + return err + } + if err := unstructured.SetNestedField(u, valParsed, pathElements...); err != nil { + return err + } + return helpers.MapToStruct(u, settings) + } + return newConfigShortCut(settings, "bool", applyFn) +} + +func newDeployCmd(settings *deployer.Config) *cobra.Command { + var flag *pflag.Flag + cmd := &cobra.Command{ Use: "deploy [component]", Short: "Deploy ACS components", @@ -31,30 +96,267 @@ Examples: RunE: runDeploy, } - cmd.Flags().BoolVar(&olm, "olm", false, "Deploy operator via OLM (requires OLM installed)") - cmd.Flags().BoolVar(&konflux, "konflux", false, "Use Konflux images") - cmd.Flags().BoolVar(&deployOperator, "deploy-operator", true, "Deploy and check operator (set to false to skip operator deployment/checks)") - cmd.Flags().BoolVar(&portForwarding, "port-forwarding", false, "Enable localhost port-forward for Central") - cmd.Flags().BoolVar(&pauseReconciliation, "pause-reconciliation", false, "Pause reconciliation after deployment") - cmd.Flags().StringVar(&overrideFile, "override", "", "Path to YAML file with overrides") - cmd.Flags().StringArrayVar(&overrideSetExpressions, "set", []string{}, "Set override values (can specify multiple times, e.g., --set foo.bar=val)") - cmd.Flags().StringVar(&exposure, "exposure", "loadbalancer", "Central exposure backend (loadbalancer, none)") - cmd.Flags().StringVar(&resources, "resources", "acs-defaults", "Resource sizing preset (acs-defaults, auto, medium, small, ci)") + // --shell . cmd.Flags().StringVar(&shell, "shell", "", "Shell to spawn after Central deployment") + + // --envrc . cmd.Flags().StringVar(&envrc, "envrc", "", "Write environment to file instead of spawning sub-shell") - cmd.Flags().BoolVar(&singleNamespace, "single-namespace", false, "Deploy all components in a single namespace ('stackrox' by default)") - cmd.Flags().StringVarP(&tag, "tag", "t", "", "Main image tag to use for deployment (takes precedence over MAIN_IMAGE_TAG environment variable)") - cmd.Flags().StringSliceVar(&featureFlags, "features", []string{}, "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)") - cmd.Flags().StringVar(¢ralWait, "central-wait", deployer.DefaultCentralWaitTimeout.String(), "Maximum wait time for Central to become ready (e.g., 5m, 10m)") - cmd.Flags().StringVar(&securedClusterWait, "secured-cluster-wait", deployer.DefaultSecuredClusterWaitTimeout.String(), "Maximum wait time for SecuredCluster to become ready (e.g., 5m, 10m)") + + // --olm[=true/false]. + flag = cmd.Flags().VarPF(newConfigShortCutBool(settings, "operator.deployViaOlm"), "olm", "", "Deploy operator via OLM (requires OLM installed)") + flag.NoOptDefVal = "true" + + // --konflux[=true/false] + flag = cmd.Flags().VarPF(newConfigShortCutBool(settings, "roxie.konfluxImages"), "konflux", "", "Use Konflux images") + flag.NoOptDefVal = "true" + + // --deploy-operator[=true/false]. + flag = cmd.Flags().VarPF( + newConfigShortCut( + settings, + "bool", + func(val string, settings *deployer.Config) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + settings.Operator.SkipDeployment = !valParsed + return nil + }, + ), "deploy-operator", "", "Whether to deploy and manage the operator") + flag.NoOptDefVal = "true" + + // --port-forward[=true/false]. + flag = cmd.Flags().VarPF( + newConfigShortCut( + settings, "bool", + func(val string, settings *deployer.Config) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + settings.Central.PortForwarding = ptr.To(valParsed) + return nil + }, + ), "port-forwarding", "", "Enable localhost port-forward for Central") + flag.NoOptDefVal = "true" + + // --pause-reconciliation[=true/false]. + flag = cmd.Flags().VarPF( + newConfigShortCut( + settings, "bool", + func(val string, settings *deployer.Config) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + settings.Central.PauseReconciliation = valParsed + settings.SecuredCluster.PauseReconciliation = valParsed + return nil + }, + ), "pause-reconciliation", "", "Pause reconciliation after deployment") + flag.NoOptDefVal = "true" + + // --config/-c . + cmd.Flags().VarP( + newConfigShortCut( + settings, "file", + func(filename string, settings *deployer.Config) error { + if filename == "-" { + filename = "/dev/stdin" + } + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read config file %q: %w", filename, err) + } + var obj map[string]interface{} + if err := yaml.Unmarshal(data, &obj); err != nil { + return fmt.Errorf("failed to decode config file %q: %w", filename, err) + } + return settings.MergeInUnstructured(obj) + }, + ), "config", "c", "Path to YAML config file") + + // --exposure loadbalancer/none. + cmd.Flags().Var( + newConfigShortCut( + settings, "exposure", + func(val string, settings *deployer.Config) error { + var exposure types.Exposure + if err := yaml.Unmarshal([]byte(val), &exposure); err != nil { + return err + } + settings.Central.Exposure = exposure + return nil + }, + ), "exposure", "Central exposure backend (loadbalancer, none)") + + // --resources . + cmd.Flags().Var( + newConfigShortCut( + settings, "resource-profile", + func(val string, settings *deployer.Config) error { + var valParsed types.ResourceProfile + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + settings.Central.ResourceProfile = valParsed + settings.SecuredCluster.ResourceProfile = valParsed + return nil + }, + ), "resources", fmt.Sprintf("Resource sizing preset (%s)", types.ResourceProfilesJoined())) + + // --set . + cmd.Flags().Var(newConfigShortCut(settings, "set-expression", + func(expr string, settings *deployer.Config) error { + key, yamlValue, found := strings.Cut(expr, "=") + if !found { + return fmt.Errorf("invalid set expression '%s': expected format 'key.path=value'", expr) + } + var val interface{} + if err := yaml.Unmarshal([]byte(yamlValue), &val); err != nil { + return fmt.Errorf("failed to unmarshal value '%s' for key '%s': %w", yamlValue, key, err) + } + // SetNestedField requires JSON-compatible types: float64 for numbers, not int. + switch v := val.(type) { + case int: + val = float64(v) + case int64: + val = float64(v) + } + pathElements := strings.Split(key, ".") + if len(pathElements) > 0 && pathElements[0] == "spec" { + // Special error reporting for this case, because it was supported previously. + return errors.New("set expression begin with 'spec.' -- it must be prefixed with 'central.' or 'securedCluster.'") + } + u, err := helpers.StructToMap(settings) + if err != nil { + return err + } + if err := unstructured.SetNestedField(u, val, pathElements...); err != nil { + return err + } + var updatedSettings deployer.Config + if err := helpers.MapToStruct(u, &updatedSettings); err != nil { + return err + } + if reflect.DeepEqual(settings, &updatedSettings) { + return fmt.Errorf("Set expression %q had no effect -- typo?", expr) + } + *settings = updatedSettings + + return nil + + }, + ), "set", "Set expressions, e.g. securedCluster.spec.clusterName=sensor") + + // --single-namespace[=true/false]. + flag = cmd.Flags().VarPF( + newConfigShortCut( + settings, "bool", + func(val string, settings *deployer.Config) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + if valParsed { + settings.Central.Namespace = sharedNamespace + settings.SecuredCluster.Namespace = sharedNamespace + } + return nil + }, + ), "single-namespace", "", "Deploy all components in a single namespace ('stackrox')") + flag.NoOptDefVal = "true" + + // --tag/-t
. + cmd.Flags().VarP( + newConfigShortCut( + settings, "version", + func(mainImageTag string, settings *deployer.Config) error { + settings.Roxie.Version = mainImageTag + return nil + }, + ), "tag", "t", "Main image tag to use for deployment (takes precedence over MAIN_IMAGE_TAG environment variable)") + + // --features + cmd.Flags().Var( + newConfigShortCut( + settings, "feature-flags", + func(featureFlagExpr string, settings *deployer.Config) error { + featureFlags, err := deployer.ParseFeatureFlags([]string{featureFlagExpr}) + if err != nil { + return fmt.Errorf("parsing feature flags: %w", err) + } + for k, v := range featureFlags { + settings.Roxie.FeatureFlags[k] = v + } + return nil + }, + ), "features", "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)") + + // --central-wait . + cmd.Flags().Var( + newConfigShortCut( + settings, "duration", + func(val string, settings *deployer.Config) error { + duration, err := time.ParseDuration(val) + if err != nil { + return err + } + settings.Central.DeployTimeout = duration + return nil + }, + ), "central-wait", "maximum wait time for central to become ready (e.g., 5m, 10m)") + + // --secured-cluster-wait . + cmd.Flags().Var( + newConfigShortCut( + settings, "duration", + func(val string, settings *deployer.Config) error { + duration, err := time.ParseDuration(val) + if err != nil { + return err + } + settings.SecuredCluster.DeployTimeout = duration + return nil + }, + ), "secured-cluster-wait", "maximum wait time for secured cluster to become ready (e.g., 5m, 10m)") + + // --early-readiness[=true/false]. + flag = cmd.Flags().VarPF( + newConfigShortCut( + settings, "bool", + func(val string, settings *deployer.Config) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + if valParsed { + settings.Central.EarlyReadiness = true + settings.SecuredCluster.EarlyReadiness = true + } + return nil + }, + ), "early-readiness", "", "Only wait for essential workloads (central/sensor) to be ready") + flag.NoOptDefVal = "true" + + // Make --override an alias for --config, for backwards compatibility. + cmd.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { + if name == "override" { + name = "config" + } + return pflag.NormalizedName(name) + }) return cmd } func runDeploy(cmd *cobra.Command, args []string) error { log := logger.New() - if err := env.Initialize(log); err != nil { - return err + if !dryRun { + if err := env.Initialize(log); err != nil { + return err + } } if env.RunningInteractively { @@ -63,11 +365,61 @@ func runDeploy(cmd *cobra.Command, args []string) error { log.Dim("Running without a controlling terminal.") } + clusterType := env.GetCurrentClusterType() + log.Dimf("Detected cluster type: %v", clusterType) + err := clusterdefaults.ApplyClusterDefaults(log, clusterType, &deploySettings) + if err != nil { + return fmt.Errorf("applying defaults for cluster type %v: %w", clusterType, err) + } + + // Deal with the "auto" resourceProfile. + if deploySettings.Central.ResourceProfile == types.ResourceProfileAuto { + profile := clusterdefaults.ResolveAutoResourceProfile(clusterType) + log.Dimf("Selecting resource profile %v for Central", profile) + deploySettings.Central.ResourceProfile = profile + } + if deploySettings.SecuredCluster.ResourceProfile == types.ResourceProfileAuto { + profile := clusterdefaults.ResolveAutoResourceProfile(clusterType) + deploySettings.SecuredCluster.ResourceProfile = profile + } + components, err := component.FromArgs(args) if err != nil { return err } + if deploySettings.Roxie.Version != "" { + log.Dimf("Using main image tag %s", deploySettings.Roxie.Version) + } else { + mainImageTag, err := helpers.LookupMainImageTag(log) + if err != nil { + return fmt.Errorf("looking up main image tag: %w", err) + } + deploySettings.Roxie.Version = mainImageTag + } + + if !deploySettings.Operator.SkipDeployment { + if err := deploySettings.Operator.Configure(&deploySettings.Roxie); err != nil { + return fmt.Errorf("configuring operator configuration: %w", err) + } + } + + if verbose { + log.Dim("Deployment configuration:") + helpers.LogMultilineYaml(log, deploySettings) + } + + if components.IncludesCentral() { + if err := deploySettings.Central.ConfigureSpec(&deploySettings.Roxie); err != nil { + return fmt.Errorf("configuring Central spec: %w", err) + } + } + if components.IncludesSensor() { + if err := deploySettings.SecuredCluster.ConfigureSpec(&deploySettings.Roxie, &deploySettings.Central); err != nil { + return fmt.Errorf("configuring SecuredCluster spec: %w", err) + } + } + if components.IncludesCentral() && os.Getenv("ROXIE_SHELL") != "" { return errors.New("already in a roxie sub-shell (ROXIE_SHELL environment variable is set), please exit the shell and try again") } @@ -76,27 +428,30 @@ func runDeploy(cmd *cobra.Command, args []string) error { return errors.New("running without a controlling terminal requires --envrc to be set") } - if envrc != "" && portForwarding { - return errors.New("cannot use --envrc with --port-forwarding. The --envrc flag is for non-interactive mode with remote cluster access") + if envrc != "" && deploySettings.Central.PortForwardingEnabled() { + return errors.New("cannot use --envrc with central port-forwarding enabled. The --envrc flag is for non-interactive mode with remote cluster access") } - if envrc != "" && exposure == "none" { + if envrc != "" && deploySettings.Central.Exposure == types.ExposureNone { return errors.New("cannot use --envrc with --exposure=none. The --envrc flag requires a remotely accessible endpoint (e.g., --exposure=loadbalancer)") } - portForwardEnabledFinal := portForwarding || exposure == "none" + if !deploySettings.Central.PortForwardingSet() && deploySettings.Central.Exposure == types.ExposureNone { + log.Info("Enabling port-forwarding due to no exposure") + deploySettings.Central.PortForwarding = ptr.To(true) + } if env.RunningInRoxieContainer { // For running containerized we have specific requirements. - if portForwardEnabledFinal { + if deploySettings.Central.PortForwardingEnabled() { return errors.New("containerized mode does not support port-forwarding") } - if exposure == "none" { + if deploySettings.Central.Exposure == types.ExposureNone { return errors.New("containerized mode requires Central exposure") } // On infra OpenShift we already get image pull secrets for Quay automatically. - if clusterType := env.GetCurrentClusterType(); clusterType != env.InfraOpenShift4 { + if clusterType := env.GetCurrentClusterType(); clusterType != types.ClusterTypeInfraOpenShift4 { if os.Getenv("REGISTRY_USERNAME") == "" || os.Getenv("REGISTRY_PASSWORD") == "" { return fmt.Errorf("containerized mode requires REGISTRY_USERNAME and REGISTRY_PASSWORD environment variables for clusters of type %s", clusterType) } @@ -106,18 +461,18 @@ func runDeploy(cmd *cobra.Command, args []string) error { } } - if konflux { - if olm { - return errors.New("cannot use both --olm and --konflux flags together (not currently implemented)") + if deploySettings.Roxie.KonfluxImages { + if deploySettings.Operator.DeployViaOlm { + return errors.New("using Konflux images while deploying operator via OLM is not supported") } clusterType := env.GetCurrentClusterType() - if clusterType != env.InfraOpenShift4 { + if clusterType != types.ClusterTypeInfraOpenShift4 { return fmt.Errorf("--konflux flag is only supported on OpenShift 4 clusters (current cluster type: %s)", clusterType.String()) } } - if !deployOperator && olm { - return errors.New("cannot use --deploy-operator=false with --olm (OLM requires operator deployment)") + if deploySettings.Operator.SkipDeployment && deploySettings.Operator.DeployViaOlm { + return errors.New("skipping operator deployment while also requesting deploying via OLM at the same time does not make sense") } d, err := deployer.New(log) @@ -126,116 +481,29 @@ func runDeploy(cmd *cobra.Command, args []string) error { } defer d.Cleanup() - if overrideFile != "" { - var err error - if components.IncludesBothCentralAndSensor() { - err = d.SetCombinedOverrideFile(overrideFile) - } else if components.IncludesCentral() { - err = d.SetCentralOverrideFile(overrideFile) - } else if components.IncludesSensor() { - err = d.SetSecuredClusterOverrideFile(overrideFile) - } - if err != nil { - return fmt.Errorf("failed to set override file: %w", err) - } - } - - if len(overrideSetExpressions) > 0 { - var err error - if components.IncludesBothCentralAndSensor() { - err = d.SetCombinedOverrideSetExpressions(overrideSetExpressions) - } else if components.IncludesCentral() { - err = d.SetCentralOverrideSetExpressions(overrideSetExpressions) - } else if components.IncludesSensor() { - err = d.SetSecuredClusterOverrideSetExpressions(overrideSetExpressions) - } - if err != nil { - return fmt.Errorf("failed to set override set expressions: %w", err) - } - } - - if components.IncludesCentral() { - d.PrintCentralDeploymentSummary() - } - if components.IncludesSensor() { - d.PrintSecuredClusterDeploymentSummary() - } - if envrc != "" { d.SetEnvrcFile(envrc) } - if olm { - if err := d.SetUseOLM(true); err != nil { - return err - } - } - - if konflux { - if err := d.SetUseKonflux(true); err != nil { - return err - } - - } - - d.SetDeployOperator(deployOperator) - d.SetVerbose(verbose) - d.SetEarlyReadiness(earlyReadiness) - d.SetPortForwardingEnabled(portForwardEnabledFinal) - d.SetPauseReconciliation(pauseReconciliation) - d.SetSingleNamespace(singleNamespace) - - // Parse and set wait timeouts only if flags were provided - if cmd.Flags().Changed("central-wait") { - centralWaitDuration, err := time.ParseDuration(centralWait) - if err == nil && centralWaitDuration <= 0 { - err = errors.New("--central-wait duration must be positive") - } - if err != nil { - return fmt.Errorf("invalid --central-wait duration: %w", err) - } - d.SetCentralWaitTimeout(centralWaitDuration) - } - if cmd.Flags().Changed("secured-cluster-wait") { - securedClusterWaitDuration, err := time.ParseDuration(securedClusterWait) - if err == nil && securedClusterWaitDuration <= 0 { - err = errors.New("--secured-cluster-wait duration must be positive") - } - if err != nil { - return fmt.Errorf("invalid --secured-cluster-wait duration: %w", err) - } - d.SetSecuredClusterWaitTimeout(securedClusterWaitDuration) + if dryRun { + log.Info("Exiting because of enabled dry run mode.") + return nil } - var mainImageTag string - if tag != "" { - log.Dimf("Using main image tag from --tag flag: %s", tag) - mainImageTag = tag - } - if mainImageTag == "" { - mainImageTag, err = helpers.LookupMainImageTag(log) - if err != nil { - return fmt.Errorf("looking up main image tag: %w", err) - } - } - d.SetMainImageTag(mainImageTag) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() - // Parse and set feature flags (these will have highest precedence) - if err := d.SetFeatureFlags(featureFlags); err != nil { - return fmt.Errorf("failed to set feature flags: %w", err) + d.SetConfig(deploySettings) + if components.IncludesCentral() { + d.PrintCentralDeploymentSummary() } - - // TODO(#91): validate the user-supplied value earlier than here - if resources == "auto" { - resources = resolveAutoResources(env.GetCurrentClusterType(), log) + if components.IncludesSensor() { + d.PrintSecuredClusterDeploymentSummary() } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer cancel() - - if err := d.Deploy(ctx, components, resources, exposure); err != nil { + if err := d.Deploy(ctx, components); err != nil { return fmt.Errorf("deployment failed: %w", err) } @@ -254,24 +522,3 @@ func runDeploy(cmd *cobra.Command, args []string) error { return nil } - -// resolveAutoResources determines the appropriate resource tier based on cluster type -func resolveAutoResources(clusterType env.ClusterType, log *logger.Logger) string { - // TODO(#91): should probably be a first-class type, not a free-form string... - var resolvedResources string - - switch clusterType { - case env.LocalKind: - resolvedResources = "small" - case env.InfraOpenShift4: - resolvedResources = "medium" - case env.InfraGKE: - resolvedResources = "medium" - default: - resolvedResources = "acs-defaults" - } - - log.Infof("Auto-detected cluster type %s: using resource profile %q", clusterType.String(), resolvedResources) - - return resolvedResources -} diff --git a/cmd/main.go b/cmd/main.go index bb57621..ac1fa51 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,28 +5,18 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/stackrox/roxie/internal/deployer" ) var ( // Global flags - verbose bool - earlyReadiness bool - olm bool - konflux bool - deployOperator bool - portForwarding bool - pauseReconciliation bool - overrideFile string - overrideSetExpressions []string - exposure string - resources string - shell string - envrc string - singleNamespace bool - tag string - featureFlags []string - centralWait string - securedClusterWait string + verbose bool + shell string + envrc string + dryRun bool + + // We need this set up before command line flags are parsed. + deploySettings = deployer.NewConfig() ) func main() { @@ -48,9 +38,9 @@ Red Hat Advanced Cluster Security (ACS) on any Kubernetes/OpenShift cluster.`, func init() { rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output (show CRs)") - rootCmd.PersistentFlags().BoolVar(&earlyReadiness, "early-readiness", true, "Only wait for essential workloads (central/sensor) to be ready") - rootCmd.AddCommand(newDeployCmd()) - rootCmd.AddCommand(newTeardownCmd()) + rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Do not actually modify cluster") + rootCmd.AddCommand(newDeployCmd(&deploySettings)) + rootCmd.AddCommand(newTeardownCmd(&deploySettings)) rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newEnvCmd()) rootCmd.AddCommand(newLogsCmd()) diff --git a/cmd/subshell.go b/cmd/subshell.go index 4cbb898..d198f52 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -11,6 +11,7 @@ import ( "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/types" ) func spawnSubshell(d *deployer.Deployer, log *logger.Logger) error { @@ -29,45 +30,44 @@ func spawnSubshell(d *deployer.Deployer, log *logger.Logger) error { env := os.Environ() - endpoint, password, caCertFile, kubeContext, exposure := d.GetDeploymentInfo() + centralDeploymentInfo := d.GetCentralDeploymentInfo() - if endpoint != "" { - env = append(env, fmt.Sprintf("API_ENDPOINT=%s", endpoint)) - env = append(env, fmt.Sprintf("ROX_ENDPOINT=%s", endpoint)) - env = append(env, fmt.Sprintf("ROX_BASE_URL=https://%s", endpoint)) + if centralDeploymentInfo.Endpoint != "" { + env = append(env, fmt.Sprintf("API_ENDPOINT=%s", centralDeploymentInfo.Endpoint)) + env = append(env, fmt.Sprintf("ROX_ENDPOINT=%s", centralDeploymentInfo.Endpoint)) + env = append(env, fmt.Sprintf("ROX_BASE_URL=https://%s", centralDeploymentInfo.Endpoint)) } - if password != "" { - env = append(env, fmt.Sprintf("ROX_ADMIN_PASSWORD=%s", password)) + if centralDeploymentInfo.Password != "" { + env = append(env, fmt.Sprintf("ROX_ADMIN_PASSWORD=%s", centralDeploymentInfo.Password)) } - if caCertFile != "" { - env = append(env, fmt.Sprintf("ROX_CA_CERT_FILE=%s", caCertFile)) + if centralDeploymentInfo.CACertFile != "" { + env = append(env, fmt.Sprintf("ROX_CA_CERT_FILE=%s", centralDeploymentInfo.CACertFile)) } env = append(env, fmt.Sprintf("ROX_USERNAME=%s", deployer.AdminUsername)) env = append(env, "ROXIE_SHELL=1") - env = append(env, fmt.Sprintf("name=acs@%s", kubeContext)) + env = append(env, fmt.Sprintf("name=acs@%s", centralDeploymentInfo.KubeContext)) haproxyAvailable := isHAProxyAvailable() var haproxyCmd *exec.Cmd var haproxyConfigPath string - var haproxyStarted bool - if haproxyAvailable && endpoint != "" && caCertFile != "" { + if haproxyAvailable && centralDeploymentInfo.Endpoint != "" && centralDeploymentInfo.CACertFile != "" { var err error - haproxyCmd, haproxyConfigPath, err = startHAProxy(endpoint, caCertFile, log) + haproxyCmd, haproxyConfigPath, err = startHAProxy(centralDeploymentInfo.Endpoint, centralDeploymentInfo.CACertFile, log) if err != nil { log.Warningf("Failed to start HAProxy: %v", err) } else { env = append(env, fmt.Sprintf("ROXIE_HAPROXY_CFG_FILE=%s", haproxyConfigPath)) - haproxyStarted = true + centralDeploymentInfo.HAProxyStarted = true defer cleanupHAProxy(haproxyCmd, haproxyConfigPath) } } - printBanner(endpoint, exposure, haproxyAvailable, haproxyStarted) + printBanner(centralDeploymentInfo) shellCmd := exec.Command(shellPath, "-i") shellCmd.Env = env @@ -171,7 +171,7 @@ func isHAProxyAvailable() bool { return err == nil } -func printBanner(endpoint, exposure string, haproxyAvailable, haproxyStarted bool) { +func printBanner(centralDeploymentInfo deployer.CentralDeploymentInfo) { cyan := color.New(color.FgCyan, color.Bold) cyan.Println("\n[roxie] Entering a subshell with ACS environment variables set.") cyan.Println("[roxie]") @@ -181,10 +181,10 @@ func printBanner(endpoint, exposure string, haproxyAvailable, haproxyStarted boo cyan.Println("[roxie] * roxcurl /v1/clusters") cyan.Println("[roxie]") - if haproxyStarted { + if centralDeploymentInfo.HAProxyStarted { cyan.Println("[roxie] Central UI: http://localhost:8080 (username: admin, password: see $ROX_ADMIN_PASSWORD)") - } else if exposure != "none" && exposure != "" { - cyan.Printf("[roxie] Central UI: https://%s", endpoint) + } else if centralDeploymentInfo.Exposure != types.ExposureNone { + cyan.Printf("[roxie] Central UI: https://%s", centralDeploymentInfo.Endpoint) } else if !env.RunningInRoxieContainer { cyan.Println("[roxie] Note: Installing haproxy enables automatic HTTP access to Central at http://localhost:8080") } diff --git a/cmd/teardown.go b/cmd/teardown.go index 2070ab5..79d0bc5 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -10,9 +10,10 @@ import ( "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/logger" + "gopkg.in/yaml.v3" ) -func newTeardownCmd() *cobra.Command { +func newTeardownCmd(settings *deployer.Config) *cobra.Command { cmd := &cobra.Command{ Use: "teardown [component]", Short: "Teardown ACS components", @@ -22,7 +23,23 @@ func newTeardownCmd() *cobra.Command { RunE: runTeardown, } - cmd.Flags().BoolVar(&singleNamespace, "single-namespace", false, "Deploy all components in a single namespace ('stackrox' by default)") + // --single-namespace[=true/false]. + flag := cmd.Flags().VarPF( + newConfigShortCut( + settings, "bool", + func(val string, settings *deployer.Config) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + if valParsed { + settings.Central.Namespace = sharedNamespace + settings.SecuredCluster.Namespace = sharedNamespace + } + return nil + }, + ), "single-namespace", "", "Deploy all components in a single namespace ('stackrox')") + flag.NoOptDefVal = "true" return cmd } @@ -40,13 +57,18 @@ func runTeardown(cmd *cobra.Command, args []string) error { log.Infof("Tearing down %s", components) + if dryRun { + log.Infof("Exiting because of enabled dry-run mode.") + return nil + } + d, err := deployer.New(log) if err != nil { return fmt.Errorf("failed to create deployer: %w", err) } defer d.Cleanup() - d.SetSingleNamespace(singleNamespace) + d.SetConfig(deploySettings) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() diff --git a/go.mod b/go.mod index 08f2376..e11e53c 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( github.com/fatih/color v1.16.0 github.com/google/go-containerregistry v0.21.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.35.3 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 ) require ( @@ -32,7 +34,6 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/vbatts/tar-split v0.12.2 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect @@ -43,7 +44,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/internal/clusterdefaults/clusterdefaults.go b/internal/clusterdefaults/clusterdefaults.go index 6a2e9b4..5c00721 100644 --- a/internal/clusterdefaults/clusterdefaults.go +++ b/internal/clusterdefaults/clusterdefaults.go @@ -1,220 +1,105 @@ package clusterdefaults import ( - "strings" - + "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/types" ) -// ClusterType represents different types of Kubernetes clusters -type ClusterType int - -const ( - // ClusterTypeUnknown represents an unidentified cluster type - ClusterTypeUnknown ClusterType = iota - // ClusterTypeKind represents a Kind (Kubernetes in Docker) cluster - ClusterTypeKind - // ClusterTypeMinikube represents a Minikube cluster - ClusterTypeMinikube - // ClusterTypeK3s represents a K3s cluster - ClusterTypeK3s - // ClusterTypeCRC represents a CRC (CodeReady Containers) cluster - ClusterTypeCRC -) - -// String returns the string representation of a ClusterType -func (ct ClusterType) String() string { - switch ct { - case ClusterTypeKind: - return "kind" - case ClusterTypeMinikube: - return "minikube" - case ClusterTypeK3s: - return "k3s" - case ClusterTypeCRC: - return "crc" - default: - return "unknown" - } -} - -// TODO(ROX-34499): Maybe I'm missing something, but this manager/detector/applicator abstraction -// seems massively over-engineered since the is only one concrete implementation. AFAICT this -// could all be just a single function that the deployer calls with log, kubeconfig, resources, -// exposure and portForward. - -// DeploymentDefaults holds the recommended defaults for a cluster type -type DeploymentDefaults struct { - Resources string // Resource preset (e.g., "small", "default") - Exposure string // Exposure mode (e.g., "none", "loadbalancer") - PortForwardEnabled bool // Whether port-forwarding should be enabled -} - -// Detector interface for identifying cluster types -type Detector interface { - // Detect returns the cluster type based on the kube context name - Detect(kubeContext string) ClusterType -} - -// Applicator interface for applying cluster-specific defaults -type Applicator interface { - // Apply returns adjusted deployment parameters based on cluster type - Apply(clusterType ClusterType, resources, exposure string, portForwardEnabled bool) ( - adjustedResources string, - adjustedExposure string, - adjustedPortForward bool, - changed bool, - ) -} - -// Manager coordinates cluster detection and default application -type Manager struct { - detector Detector - applicator Applicator - logger *logger.Logger -} - -// NewManager creates a new cluster defaults manager -func NewManager(log *logger.Logger) *Manager { - return &Manager{ - detector: &defaultDetector{}, - applicator: &defaultApplicator{}, - logger: log, - } -} - -// ApplyConvenienceDefaults detects the cluster type and applies appropriate defaults -func (m *Manager) ApplyConvenienceDefaults(kubeContext, resources, exposure string, portForwardEnabled bool) ( - adjustedResources string, - adjustedExposure string, - adjustedPortForward bool, -) { - // Detect cluster type - clusterType := m.detector.Detect(kubeContext) - - // Apply defaults based on cluster type - adjRes, adjExp, adjPF, changed := m.applicator.Apply( - clusterType, - resources, - exposure, - portForwardEnabled, - ) - - // Log if defaults were applied - if changed { - m.logDefaultsApplied(clusterType, adjRes, adjExp, adjPF) +// ApplyClusterDefaults detects the cluster type and applies appropriate defaults. +func ApplyClusterDefaults( + log *logger.Logger, + clusterType types.ClusterType, + config *deployer.Config, +) error { + if config == nil { + panic("applying cluster defaults to nil config") } - - return adjRes, adjExp, adjPF -} - -// logDefaultsApplied logs a message when cluster-specific defaults are applied -func (m *Manager) logDefaultsApplied(clusterType ClusterType, resources, exposure string, portForward bool) { - pfStatus := "with port-forwarding" - if !portForward { - pfStatus = "without port-forwarding" + configWithDefaults := getDefaultsForClusterType(clusterType) + if configWithDefaults == nil { + return nil } - - m.logger.Warning( - "Detected " + clusterType.String() + " cluster: using --resources=" + - resources + " --exposure=" + exposure + " " + pfStatus, - ) -} - -// defaultDetector implements the Detector interface -type defaultDetector struct{} - -// Detect identifies the cluster type based on kube context name -func (d *defaultDetector) Detect(kubeContext string) ClusterType { - contextLower := strings.ToLower(kubeContext) - - // Kind clusters typically have context names starting with "kind-" - if strings.HasPrefix(contextLower, "kind") { - return ClusterTypeKind + log.Dimf("Applying the following defaults based on detected cluster type %v:", clusterType) + helpers.LogMultilineYaml(log, configWithDefaults) + configMap, err := helpers.StructToMap(config) + if err != nil { + return err } - - // Minikube clusters typically have context name "minikube" - if contextLower == "minikube" || strings.HasPrefix(contextLower, "minikube-") { - return ClusterTypeMinikube + err = helpers.DeepMerge(configWithDefaults, configMap) + if err != nil { + return err } - // K3s clusters often have "k3s" in the context name - if strings.Contains(contextLower, "k3s") { - return ClusterTypeK3s + if err := helpers.MapToStruct(configWithDefaults, config); err != nil { + return err } - - // CRC (CodeReady Containers) contexts start with "crc" or contain "-crc-"/"_crc_" as a segment - if strings.HasPrefix(contextLower, "crc") || strings.Contains(contextLower, "-crc-") || strings.Contains(contextLower, "-crc:") { - return ClusterTypeCRC - } - - return ClusterTypeUnknown + return nil } -// defaultApplicator implements the Applicator interface -type defaultApplicator struct{} - -// Apply returns adjusted deployment parameters based on cluster type -func (a *defaultApplicator) Apply( - clusterType ClusterType, - resources, exposure string, - portForwardEnabled bool, -) (string, string, bool, bool) { - defaults, ok := getDefaultsForClusterType(clusterType) - if !ok { - // No special defaults for this cluster type - return resources, exposure, portForwardEnabled, false - } - - // Check if any parameter would change - changed := resources != defaults.Resources || - exposure != defaults.Exposure || - portForwardEnabled != defaults.PortForwardEnabled +// getDefaultsForClusterType returns the recommended defaults for a given cluster type. +func getDefaultsForClusterType(clusterType types.ClusterType) map[string]interface{} { + switch clusterType { + case types.ClusterTypeKind: + // Kind clusters are local, lightweight, and don't support LoadBalancer. + return map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": types.ExposureNone.String(), + "portForwarding": true, + }, + } + + case types.ClusterTypeMinikube: + return map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": types.ExposureNone.String(), + "portForwarding": true, + }, + } + + case types.ClusterTypeK3s: + return map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": types.ExposureNone.String(), + "portForwarding": true, + }, + } + + case types.ClusterTypeCRC: + return map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": types.ExposureNone.String(), + "portForwarding": true, + }, + } - if !changed { - // User already specified the recommended defaults - return resources, exposure, portForwardEnabled, false + default: + return nil } - - // Apply the defaults - return defaults.Resources, defaults.Exposure, defaults.PortForwardEnabled, true } -// getDefaultsForClusterType returns the recommended defaults for a given cluster type -func getDefaultsForClusterType(clusterType ClusterType) (DeploymentDefaults, bool) { +// ResolveAutoResourceProfile resolves the "auto" resource profile depending on the cluster type. +func ResolveAutoResourceProfile(clusterType types.ClusterType) types.ResourceProfile { switch clusterType { - case ClusterTypeKind: - // Kind clusters are local, lightweight, and don't support LoadBalancer - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true + case types.ClusterTypeKind: + return types.ResourceProfileSmall + + case types.ClusterTypeMinikube: + return types.ResourceProfileSmall + + case types.ClusterTypeK3s: + return types.ResourceProfileSmall - case ClusterTypeMinikube: - // Minikube is also local and benefits from small resources - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true + case types.ClusterTypeCRC: + return types.ResourceProfileSmall - case ClusterTypeK3s: - // K3s can vary (local or cloud), apply conservative defaults - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true + case types.ClusterTypeInfraOpenShift4: + return types.ResourceProfileMedium - case ClusterTypeCRC: - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true + case types.ClusterTypeInfraGKE: + return types.ResourceProfileMedium default: - return DeploymentDefaults{}, false + return types.ResourceProfileAcsDefaults } } diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index 567e976..20cbe18 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -3,265 +3,79 @@ package clusterdefaults import ( "testing" + "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/types" + "github.com/stretchr/testify/require" ) -func TestDefaultDetector_Detect(t *testing.T) { +func TestClusterDefaults(t *testing.T) { tests := []struct { - name string - kubeContext string - want ClusterType + name string + clusterType types.ClusterType + wantResourceProfile types.ResourceProfile + wantExposure types.Exposure + wantPortForwarding bool }{ { - name: "kind cluster with standard prefix", - kubeContext: "kind-dev-cluster", - want: ClusterTypeKind, + name: "kind cluster with default params", + clusterType: types.ClusterTypeKind, + wantResourceProfile: types.ResourceProfileSmall, + wantExposure: types.ExposureNone, + wantPortForwarding: true, }, { - name: "kind cluster simple name", - kubeContext: "kind", - want: ClusterTypeKind, + name: "kind cluster with already correct params", + clusterType: types.ClusterTypeKind, + wantResourceProfile: types.ResourceProfileSmall, + wantExposure: types.ExposureNone, + wantPortForwarding: true, }, { - name: "kind cluster with uppercase", - kubeContext: "KIND-test", - want: ClusterTypeKind, + name: "kind cluster with partial match", + clusterType: types.ClusterTypeKind, + wantResourceProfile: types.ResourceProfileSmall, + wantExposure: types.ExposureNone, + wantPortForwarding: true, }, { - name: "crc cluster with admin context", - kubeContext: "crc-admin", - want: ClusterTypeCRC, + name: "unknown cluster type", + clusterType: types.ClusterTypeUnknown, + wantResourceProfile: types.ResourceProfileAcsDefaults, + wantExposure: types.ExposureNone, + wantPortForwarding: false, }, { - name: "crc cluster with api prefix", - kubeContext: "api-crc-testing:6443", - want: ClusterTypeCRC, + name: "minikube cluster", + clusterType: types.ClusterTypeMinikube, + wantResourceProfile: types.ResourceProfileSmall, + wantExposure: types.ExposureNone, + wantPortForwarding: true, }, { - name: "crc cluster with uppercase", - kubeContext: "CRC-admin", - want: ClusterTypeCRC, - }, - { - name: "crc cluster bare name", - kubeContext: "crc", - want: ClusterTypeCRC, - }, - { - name: "not crc - incidental substring", - kubeContext: "acrc-cluster", - want: ClusterTypeUnknown, - }, - { - name: "not crc - encrypted in name", - kubeContext: "my-encrypted-cluster", - want: ClusterTypeUnknown, - }, - } - - detector := &defaultDetector{} - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := detector.Detect(tt.kubeContext) - if got != tt.want { - t.Errorf("Detect(%q) = %v, want %v", tt.kubeContext, got, tt.want) - } - }) - } -} - -func TestDefaultApplicator_Apply(t *testing.T) { - tests := []struct { - name string - clusterType ClusterType - resources string - exposure string - portForwardEnabled bool - wantResources string - wantExposure string - wantPortForward bool - wantChanged bool - }{ - { - name: "kind cluster with default params", - clusterType: ClusterTypeKind, - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - wantChanged: true, - }, - { - name: "kind cluster with already correct params", - clusterType: ClusterTypeKind, - resources: "small", - exposure: "none", - portForwardEnabled: true, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - wantChanged: false, - }, - { - name: "kind cluster with partial match", - clusterType: ClusterTypeKind, - resources: "small", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - wantChanged: true, - }, - { - name: "unknown cluster type", - clusterType: ClusterTypeUnknown, - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "default", - wantExposure: "loadbalancer", - wantPortForward: false, - wantChanged: false, - }, - { - name: "minikube cluster", - clusterType: ClusterTypeMinikube, - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - wantChanged: true, - }, - { - name: "crc cluster", - clusterType: ClusterTypeCRC, - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - wantChanged: true, + name: "crc cluster", + clusterType: types.ClusterTypeCRC, + wantResourceProfile: types.ResourceProfileSmall, + wantExposure: types.ExposureNone, + wantPortForwarding: true, }, } - applicator := &defaultApplicator{} - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotRes, gotExp, gotPF, gotChanged := applicator.Apply( - tt.clusterType, - tt.resources, - tt.exposure, - tt.portForwardEnabled, - ) + config := deployer.Config{} + err := ApplyClusterDefaults(logger.New(), tt.clusterType, &config) + require.NoError(t, err) - if gotRes != tt.wantResources { - t.Errorf("Apply() resources = %v, want %v", gotRes, tt.wantResources) - } - if gotExp != tt.wantExposure { - t.Errorf("Apply() exposure = %v, want %v", gotExp, tt.wantExposure) + gotResourceProfile := ResolveAutoResourceProfile(tt.clusterType) + if gotResourceProfile != tt.wantResourceProfile { + t.Errorf("Apply() resources = %v, want %v", gotResourceProfile, tt.wantResourceProfile) } - if gotPF != tt.wantPortForward { - t.Errorf("Apply() portForward = %v, want %v", gotPF, tt.wantPortForward) + if config.Central.Exposure != tt.wantExposure { + t.Errorf("Apply() exposure = %v, want %v", config.Central.Exposure, tt.wantExposure) } - if gotChanged != tt.wantChanged { - t.Errorf("Apply() changed = %v, want %v", gotChanged, tt.wantChanged) - } - }) - } -} - -func TestManager_ApplyConvenienceDefaults(t *testing.T) { - tests := []struct { - name string - kubeContext string - resources string - exposure string - portForwardEnabled bool - wantResources string - wantExposure string - wantPortForward bool - }{ - { - name: "kind cluster detection and defaults", - kubeContext: "kind-local", - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - }, - { - name: "crc cluster detection and defaults", - kubeContext: "crc-admin", - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - }, - { - name: "gke cluster no changes", - kubeContext: "gke_project_zone_cluster", - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "default", - wantExposure: "loadbalancer", - wantPortForward: false, - }, - } - - log := logger.New() - manager := NewManager(log) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes, gotExp, gotPF := manager.ApplyConvenienceDefaults( - tt.kubeContext, - tt.resources, - tt.exposure, - tt.portForwardEnabled, - ) - - if gotRes != tt.wantResources { - t.Errorf("ApplyConvenienceDefaults() resources = %v, want %v", gotRes, tt.wantResources) - } - if gotExp != tt.wantExposure { - t.Errorf("ApplyConvenienceDefaults() exposure = %v, want %v", gotExp, tt.wantExposure) - } - if gotPF != tt.wantPortForward { - t.Errorf("ApplyConvenienceDefaults() portForward = %v, want %v", gotPF, tt.wantPortForward) - } - }) - } -} - -func TestClusterType_String(t *testing.T) { - tests := []struct { - clusterType ClusterType - want string - }{ - {ClusterTypeKind, "kind"}, - {ClusterTypeMinikube, "minikube"}, - {ClusterTypeK3s, "k3s"}, - {ClusterTypeCRC, "crc"}, - {ClusterTypeUnknown, "unknown"}, - } - - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - if got := tt.clusterType.String(); got != tt.want { - t.Errorf("ClusterType.String() = %v, want %v", got, tt.want) + if config.Central.PortForwardingEnabled() != tt.wantPortForwarding { + t.Errorf("Apply() portForward = %v, want %v", config.Central.PortForwardingEnabled(), tt.wantPortForwarding) } }) } diff --git a/internal/deployer/config.go b/internal/deployer/config.go new file mode 100644 index 0000000..398550f --- /dev/null +++ b/internal/deployer/config.go @@ -0,0 +1,220 @@ +package deployer + +import ( + "fmt" + "time" + + "github.com/stackrox/roxie/internal/helpers" + "github.com/stackrox/roxie/internal/types" +) + +// This is the self-contained configuration for deployments. +type Config struct { + Roxie RoxieConfig `yaml:"roxie"` + Operator OperatorConfig `yaml:"operator"` + Central CentralConfig `yaml:"central"` + SecuredCluster SecuredClusterConfig `yaml:"securedCluster"` +} + +func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { + cr := map[string]interface{}{ + "apiVersion": "platform.stackrox.io/v1alpha1", + "kind": "Central", + "metadata": map[string]interface{}{ + "name": "stackrox-central-services", + "namespace": c.Namespace, + "labels": map[string]string{ + "app": "stackrox-central", + }, + }, + "spec": map[string]interface{}{ + "central": map[string]interface{}{ + "adminPasswordSecret": map[string]interface{}{ + "name": adminPasswordSecretName, + }, + }, + }, + } + if c.ResourceProfile == types.ResourceProfileAuto { + return nil, fmt.Errorf("resource profile 'auto' must have been resolved before building the CR") + } + if c.ResourceProfile != types.ResourceProfileAcsDefaults { + if err := helpers.DeepMerge(cr, getCentralResourcesOperator(c.ResourceProfile)); err != nil { + return nil, fmt.Errorf("merging resource profile into Central CR: %w", err) + } + } + if err := helpers.DeepMerge(cr, map[string]interface{}{ + "spec": c.Spec, + }); err != nil { + return nil, fmt.Errorf("merging spec into Central CR: %w", err) + } + return cr, nil +} + +func (s *SecuredClusterConfig) CustomResource() (map[string]interface{}, error) { + cr := map[string]interface{}{ + "apiVersion": "platform.stackrox.io/v1alpha1", + "kind": "SecuredCluster", + "metadata": map[string]interface{}{ + "name": "stackrox-secured-cluster-services", + "namespace": s.Namespace, + "labels": map[string]string{ + "app": "stackrox-secured-cluster", + }, + }, + "spec": map[string]interface{}{ + "clusterName": generateClusterName(), + "imagePullSecrets": []map[string]string{ + {"name": "stackrox"}, + }, + }, + } + if s.ResourceProfile == types.ResourceProfileAuto { + return nil, fmt.Errorf("resource profile 'auto' must have been resolved before building the CR") + } + if s.ResourceProfile != types.ResourceProfileAcsDefaults { + if err := helpers.DeepMerge(cr, getSecuredClusterResourcesOperator(s.ResourceProfile)); err != nil { + return nil, fmt.Errorf("merging resource profile into SecuredCluster CR: %w", err) + } + } + + if err := helpers.DeepMerge(cr, map[string]interface{}{ + "spec": s.Spec, + }); err != nil { + return nil, fmt.Errorf("merging spec into SecuredCluster CR: %w", err) + } + return cr, nil +} + +type RoxieConfig struct { + Version string `yaml:"version"` + KonfluxImages bool `yaml:"konfluxImages"` + FeatureFlags map[string]bool `yaml:"featureFlags"` +} + +func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { + err := helpers.DeepMerge(c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)) + if err != nil { + return err + } + if err = helpers.DeepMerge(c.Spec, map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": c.Exposure.ToUnstructuredConfig(), + }, + }); err != nil { + return err + } + return nil +} + +func (c *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { + if err := helpers.DeepMerge(c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { + return err + } + + if err := helpers.DeepMerge(c.Spec, map[string]interface{}{ + "centralEndpoint": internalCentralEndpoint(centralConfig.Namespace), + }); err != nil { + return err + } + + return nil +} + +func (c *Config) MergeInUnstructured(m map[string]interface{}) error { + asMap, err := helpers.StructToMap(c) + if err != nil { + return err + } + if err := helpers.DeepMerge(asMap, m); err != nil { + return err + } + return helpers.MapToStruct(asMap, c) +} + +func (c *Config) MergeIn(other *Config) error { + if other == nil { + return nil + } + otherAsMap, err := helpers.StructToMap(other) + if err != nil { + return err + } + return c.MergeInUnstructured(otherAsMap) +} + +type OperatorConfig struct { + SkipDeployment bool `yaml:"skipDeployment"` + DeployViaOlm bool `yaml:"deployViaOlm"` + Version string `yaml:"version"` +} + +func (c *OperatorConfig) Configure(roxieConfig *RoxieConfig) error { + c.Version = helpers.ConvertMainTagToOperatorTag(roxieConfig.Version) + return nil +} + +type CentralConfig struct { + Namespace string `yaml:"namespace"` + ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` + PauseReconciliation bool `yaml:"pauseReconciliation"` + Exposure types.Exposure `yaml:"exposure"` + DeployTimeout time.Duration `yaml:"deployTimeout"` + PortForwarding *bool `yaml:"portForwarding"` + EarlyReadiness bool `yaml:"earlyReadiness"` + Spec map[string]interface{} `yaml:"spec"` +} + +func (c *CentralConfig) PortForwardingSet() bool { + return c.PortForwarding != nil +} + +func (c *CentralConfig) PortForwardingEnabled() bool { + return c.PortForwarding != nil && *c.PortForwarding +} + +type SecuredClusterConfig struct { + Namespace string `yaml:"namespace"` + ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` + PauseReconciliation bool `yaml:"pauseReconciliation"` + DeployTimeout time.Duration `yaml:"deployTimeout"` + EarlyReadiness bool `yaml:"earlyReadiness"` + Spec map[string]interface{} `yaml:"spec"` +} + +func NewConfig() Config { + return Config{ + Roxie: NewRoxieConfig(), + Central: DefaultCentralConfig(), + SecuredCluster: DefaultSecuredClusterConfig(), + } +} + +func NewRoxieConfig() RoxieConfig { + return RoxieConfig{ + FeatureFlags: make(map[string]bool), + } +} + +func DefaultCentralConfig() CentralConfig { + return CentralConfig{ + DeployTimeout: DefaultCentralWaitTimeout, + Namespace: "acs-central", + Exposure: types.ExposureLoadBalancer, + Spec: map[string]interface{}{ + "central": map[string]interface{}{ + "telemetry": map[string]interface{}{ + "enabled": false, + }, + }, + }, + } +} + +func DefaultSecuredClusterConfig() SecuredClusterConfig { + return SecuredClusterConfig{ + DeployTimeout: DefaultSecuredClusterWaitTimeout, + Namespace: "acs-sensor", + Spec: make(map[string]interface{}), + } +} diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index a1ae807..f2bb9e9 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -46,7 +46,7 @@ func (d *Deployer) applyCRS(ctx context.Context, crsContent string) error { d.logger.Info("Applying CRS to sensor namespace") result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"apply", "-n", d.sensorNamespace, "-f", "-"}, + Args: []string{"apply", "-n", d.config.SecuredCluster.Namespace, "-f", "-"}, Stdin: strings.NewReader(crsContent), }) if err != nil { diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 2fd825f..97e6674 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -13,6 +13,7 @@ import ( "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/k8s" + "github.com/stackrox/roxie/internal/types" "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -44,7 +45,7 @@ func (d *Deployer) deployOperatorOnly(ctx context.Context) error { // ensureOperatorDeployed ensures the operator is deployed with the correct version and mode func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { // Skip operator deployment/checks if flag is set to false - if !d.shouldDeployOperator { + if d.config.Operator.SkipDeployment { d.logger.Info("â„šī¸ Skipping operator deployment checks (--deploy-operator=false)") d.logger.Info(" Assuming operator is already running...") return nil @@ -61,12 +62,12 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { if !operatorExists { needsDeployment = true - } else if d.useOLM && currentMode == OperatorModeNonOLM { + } else if d.config.Operator.DeployViaOlm && currentMode == OperatorModeNonOLM { // Switching from non-OLM to OLM d.logger.Info("🔄 Switching operator from non-OLM to OLM mode...") needsTeardown = true needsDeployment = true - } else if !d.useOLM && currentMode == OperatorModeOLM { + } else if !d.config.Operator.DeployViaOlm && currentMode == OperatorModeOLM { // Switching from OLM to non-OLM d.logger.Info("🔄 Switching operator from OLM to non-OLM mode...") needsTeardown = true @@ -96,7 +97,7 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { } if needsDeployment { - if d.useOLM { + if d.config.Operator.DeployViaOlm { if err := d.deployOperatorViaOLM(ctx); err != nil { return fmt.Errorf("failed to deploy operator via OLM: %w", err) } @@ -111,10 +112,10 @@ func (d *Deployer) ensureOperatorDeployed(ctx context.Context) error { } // deployCentralOperator deploys Central using the operator -func (d *Deployer) deployCentralOperator(ctx context.Context, resources, exposure string) error { +func (d *Deployer) deployCentralOperator(ctx context.Context) error { d.logger.Info("🚀 Deploying Central via Operator...") - if err := d.prepareNamespace(ctx, d.centralNamespace); err != nil { + if err := d.prepareNamespace(ctx, d.config.Central.Namespace); err != nil { return fmt.Errorf("failed to prepare namespace: %w", err) } @@ -122,24 +123,28 @@ func (d *Deployer) deployCentralOperator(ctx context.Context, resources, exposur return fmt.Errorf("failed to create admin password secret: %w", err) } - centralCR, err := d.createCentralCR(resources, exposure) + cr, err := d.config.Central.CustomResource() if err != nil { - return fmt.Errorf("failed to create Central CR: %w", err) + return fmt.Errorf("failed to build Central CR: %w", err) } - if err := d.applyCentralCR(ctx, centralCR); err != nil { + if err := d.applyCentralCR(ctx, cr); err != nil { return fmt.Errorf("failed to apply Central CR: %w", err) } - if err := d.waitForCentralReady(ctx, d.centralWaitTimeout); err != nil { + if err := d.waitForCentralReady(ctx); err != nil { return fmt.Errorf("failed waiting for Central: %w", err) } - if err := d.maybeAddPauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.centralNamespace); err != nil { - d.logger.Warningf("failed to add pause-reconcile annotation: %v", err) + if d.config.Central.PauseReconciliation { + d.logger.Infof("Adding pause-reconcile annotation to Central") + err := d.addPauseReconcileAnnotation(ctx, "Central", centralCrName, d.config.Central.Namespace) + if err != nil { + return err + } } - return d.configureCentralEndpoint(ctx, exposure) + return d.configureCentralEndpoint(ctx) } // isOperatorVersionCorrect checks if the deployed operator matches the desired version @@ -158,10 +163,10 @@ func (d *Deployer) isOperatorVersionCorrect(ctx context.Context) bool { } currentTag := parts[1] - if currentTag != d.operatorTag { + if currentTag != d.config.Operator.Version { d.logger.Info("Operator version mismatch detected:") d.logger.Infof(" Current: %s", currentTag) - d.logger.Infof(" Desired: %s", d.operatorTag) + d.logger.Infof(" Desired: %s", d.config.Operator.Version) return false } return true @@ -189,7 +194,7 @@ func (d *Deployer) prepareNamespace(ctx context.Context, namespace string) error return err } - if env.GetCurrentClusterType() != env.InfraOpenShift4 { + if env.GetCurrentClusterType() != types.ClusterTypeInfraOpenShift4 { if err := d.ensurePullSecretExists(ctx, namespace); err != nil { return fmt.Errorf("ensuring image pull secret exists: %w", err) } @@ -220,7 +225,7 @@ func (d *Deployer) createAdminPasswordSecret(ctx context.Context) error { "kind": "Secret", "metadata": map[string]interface{}{ "name": adminPasswordSecretName, - "namespace": d.centralNamespace, + "namespace": d.config.Central.Namespace, }, "type": "Opaque", "stringData": map[string]string{ @@ -245,47 +250,9 @@ func (d *Deployer) createAdminPasswordSecret(ctx context.Context) error { return nil } -// createCentralCR creates the Central custom resource -func (d *Deployer) createCentralCR(resources, exposure string) (map[string]interface{}, error) { - base := map[string]interface{}{ - "apiVersion": "platform.stackrox.io/v1alpha1", - "kind": "Central", - "metadata": map[string]interface{}{ - "name": "stackrox-central-services", - "namespace": d.centralNamespace, - "labels": map[string]string{ - "app": "stackrox-central", - }, - }, - "spec": map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": d.getCentralExposureConfig(exposure), - "adminPasswordSecret": map[string]interface{}{ - "name": adminPasswordSecretName, - }, - "telemetry": map[string]interface{}{ - "enabled": false, - }, - }, - }, - } - - d.logger.Infof("Using Central resource profile: %s", resources) - resourcesOverlay := d.getCentralResourcesOperator(resources) - - merged := helpers.MergeMaps(base, resourcesOverlay, d.centralOverrides) - - // Apply feature flag overrides last with smart envVars merging - if d.featureFlagOverrides != nil { - merged = mergeWithEnvVarSupport(merged, d.featureFlagOverrides) - } - - return merged, nil -} - -func (d *Deployer) getCentralResourcesOperator(resourcesName string) map[string]interface{} { - switch resourcesName { - case "small": +func getCentralResourcesOperator(resourcesProfile types.ResourceProfile) map[string]interface{} { + switch resourcesProfile { + case types.ResourceProfileSmall: return map[string]interface{}{ "spec": map[string]interface{}{ "central": map[string]interface{}{ @@ -324,7 +291,7 @@ func (d *Deployer) getCentralResourcesOperator(resourcesName string) map[string] }, }, } - case "medium": + case types.ResourceProfileMedium: return map[string]interface{}{ "spec": map[string]interface{}{ "central": map[string]interface{}{ @@ -357,7 +324,7 @@ func (d *Deployer) getCentralResourcesOperator(resourcesName string) map[string] }, }, } - case "ci": + case types.ResourceProfileCI: return map[string]interface{}{ "spec": map[string]interface{}{ "central": map[string]interface{}{ @@ -390,56 +357,24 @@ func (d *Deployer) getCentralResourcesOperator(resourcesName string) map[string] } } -// getCentralExposureConfig returns the exposure configuration -func (d *Deployer) getCentralExposureConfig(exposure string) map[string]interface{} { - switch exposure { - case "loadbalancer": - return map[string]interface{}{ - "loadBalancer": map[string]interface{}{ - "enabled": true, - "port": 443, - }, - } - case "none": - return map[string]interface{}{ - "nodePort": map[string]interface{}{ - "enabled": false, - }, - "loadBalancer": map[string]interface{}{ - "enabled": false, - }, - "route": map[string]interface{}{ - "enabled": false, - }, - } - default: - return map[string]interface{}{ - "loadBalancer": map[string]interface{}{ - "enabled": true, - "port": 443, - }, - } - } -} - // applyCentralCR applies the Central CR to the cluster func (d *Deployer) applyCentralCR(ctx context.Context, cr map[string]interface{}) error { d.logger.Info("Applying Central custom resource") - yamlData, err := yaml.Marshal(cr) - if err != nil { - return fmt.Errorf("failed to marshal Central CR: %w", err) - } - if d.verbose { if env.RunningInteractively { d.logger.Dim("Central CR YAML:") - d.logger.Dim(string(yamlData)) + helpers.LogMultilineYaml(d.logger, cr) } else { d.logger.Dim("Skipping emitting Central CR in non-interactive mode, because it could leak confidential information") } } + yamlData, err := yaml.Marshal(cr) + if err != nil { + return fmt.Errorf("failed to marshal Central CR: %w", err) + } + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), @@ -455,7 +390,8 @@ func (d *Deployer) applyCentralCR(ctx context.Context, cr map[string]interface{} } // waitForCentralReady waits for Central to be ready -func (d *Deployer) waitForCentralReady(ctx context.Context, timeout time.Duration) error { +func (d *Deployer) waitForCentralReady(ctx context.Context) error { + timeout := d.config.Central.DeployTimeout d.logger.Infof("âŗ Waiting for Central to become ready (timeout: %s)...", timeout) // Track seen deployments and their states to avoid duplicate messages @@ -470,13 +406,13 @@ func (d *Deployer) waitForCentralReady(ctx context.Context, timeout time.Duratio d.checkDeploymentProgress(ctx, seenDeployments) // Check for pod events if in early readiness mode or verbose - if d.earlyReadiness || d.verbose { + if d.config.Central.EarlyReadiness || d.verbose { d.checkPodProgress(ctx, seenPods) } // Check if central deployment is ready result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "deployment", "central", "-n", d.centralNamespace, "-o", "jsonpath={.status.readyReplicas}"}, + Args: []string{"get", "deployment", "central", "-n", d.config.Central.Namespace, "-o", "jsonpath={.status.readyReplicas}"}, }) if err == nil && result.Stdout != "" { replicas := strings.TrimSpace(result.Stdout) @@ -496,12 +432,12 @@ func (d *Deployer) waitForCentralReady(ctx context.Context, timeout time.Duratio // checkDeploymentProgress checks for deployment state changes and reports them func (d *Deployer) checkDeploymentProgress(ctx context.Context, seenDeployments map[string]string) { - d.checkDeploymentProgressInNamespace(ctx, d.centralNamespace, seenDeployments) + d.checkDeploymentProgressInNamespace(ctx, d.config.Central.Namespace, seenDeployments) } // checkPodProgress checks for pod state changes and reports them func (d *Deployer) checkPodProgress(ctx context.Context, seenPods map[string]string) { - d.checkPodProgressInNamespace(ctx, d.centralNamespace, seenPods) + d.checkPodProgressInNamespace(ctx, d.config.Central.Namespace, seenPods) } // waitForLoadBalancer waits for a LoadBalancer service to get an external IP. @@ -553,7 +489,7 @@ func (d *Deployer) fetchCentralCACert(ctx context.Context) error { d.logger.Info("Fetching Central CA certificate...") result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "secret", "central-tls", "-n", d.centralNamespace, "-o", "jsonpath={.data.ca\\.pem}"}, + Args: []string{"get", "secret", "central-tls", "-n", d.config.Central.Namespace, "-o", "jsonpath={.data.ca\\.pem}"}, }) if err != nil { return fmt.Errorf("failed to get CA cert from secret: %w", err) @@ -590,13 +526,14 @@ func (d *Deployer) fetchCentralCACert(ctx context.Context) error { return nil } -// configureCentralEndpoint configures the central endpoint based on exposure settings -func (d *Deployer) configureCentralEndpoint(ctx context.Context, exposure string) error { - if d.portForwardEnabled { +// configureCentralEndpoint configures the central endpoint in the Deployer based on exposure settings. +func (d *Deployer) configureCentralEndpoint(ctx context.Context) error { + exposure := d.config.Central.Exposure + if d.config.Central.PortForwardingEnabled() { // Start port-forward for CLI tool access via localhost:8443 serviceName := "central" - if exposure == "loadbalancer" { - _, err := d.waitForLoadBalancer(ctx, d.centralNamespace, "central-loadbalancer", 300) + if exposure == types.ExposureLoadBalancer { + _, err := d.waitForLoadBalancer(ctx, d.config.Central.Namespace, "central-loadbalancer", 300) if err != nil { d.logger.Warningf("LoadBalancer not ready: %v", err) } else { @@ -604,19 +541,19 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context, exposure string } } - endpoint, err := d.portForward.Start(d.centralNamespace, serviceName, 443, 8443) + endpoint, err := d.portForward.Start(d.config.Central.Namespace, serviceName, 443, 8443) if err != nil { return fmt.Errorf("failed to start port-forward: %w", err) } d.centralEndpoint = endpoint - } else if exposure == "loadbalancer" { - endpoint, err := d.waitForLoadBalancer(ctx, d.centralNamespace, "central-loadbalancer", 300) + } else if exposure == types.ExposureLoadBalancer { + endpoint, err := d.waitForLoadBalancer(ctx, d.config.Central.Namespace, "central-loadbalancer", 300) if err != nil { return fmt.Errorf("failed to get LoadBalancer endpoint: %w", err) } d.centralEndpoint = endpoint } else { - d.centralEndpoint = "central." + centralNamespace + ".svc:443" + d.centralEndpoint = "central." + d.config.Central.Namespace + ".svc:443" } if err := d.fetchCentralCACert(ctx); err != nil { @@ -632,19 +569,19 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context, exposure string } // deploySecuredClusterOperator deploys SecuredCluster using the operator. -func (d *Deployer) deploySecuredClusterOperator(ctx context.Context, resources string) error { +func (d *Deployer) deploySecuredClusterOperator(ctx context.Context) error { d.logger.Info("🚀 Deploying SecuredCluster via Operator...") - if err := d.prepareNamespace(ctx, d.sensorNamespace); err != nil { + if err := d.prepareNamespace(ctx, d.config.SecuredCluster.Namespace); err != nil { return fmt.Errorf("failed to prepare namespace: %w", err) } - securedClusterCR, err := d.createSecuredClusterCR(resources) + cr, err := d.config.SecuredCluster.CustomResource() if err != nil { - return fmt.Errorf("failed to create SecuredCluster CR: %w", err) + return fmt.Errorf("failed to build SecuredCluster CR: %w", err) } - clusterName, found, err := unstructured.NestedString(securedClusterCR, "spec", "clusterName") + clusterName, found, err := unstructured.NestedString(cr, "spec", "clusterName") if err != nil { return fmt.Errorf("failed to get cluster name from SecuredCluster CR: %w", err) } @@ -662,58 +599,29 @@ func (d *Deployer) deploySecuredClusterOperator(ctx context.Context, resources s return fmt.Errorf("failed to apply CRS: %w", err) } - if err := d.applySecuredClusterCR(ctx, securedClusterCR); err != nil { + if err := d.applySecuredClusterCR(ctx, cr); err != nil { return fmt.Errorf("failed to apply SecuredCluster CR: %w", err) } - if err := d.waitForSecuredClusterReady(ctx, d.securedClusterWaitTimeout); err != nil { + if err := d.waitForSecuredClusterReady(ctx); err != nil { return fmt.Errorf("failed waiting for SecuredCluster: %w", err) } - if err := d.maybeAddPauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.sensorNamespace); err != nil { - d.logger.Warningf("failed to add pause-reconcile annotation: %v", err) + if d.config.SecuredCluster.PauseReconciliation { + d.logger.Infof("Adding pause-reconcile annotation to SecuredCluster") + err := d.addPauseReconcileAnnotation(ctx, "SecuredCluster", securedClusterCrName, d.config.SecuredCluster.Namespace) + if err != nil { + return err + } } d.logger.Successf("✓ SecuredCluster '%s' is ready", clusterName) return nil } -// createSecuredClusterCR creates the SecuredCluster custom resource. -func (d *Deployer) createSecuredClusterCR(resources string) (map[string]interface{}, error) { - base := map[string]interface{}{ - "apiVersion": "platform.stackrox.io/v1alpha1", - "kind": "SecuredCluster", - "metadata": map[string]interface{}{ - "name": "stackrox-secured-cluster-services", - "namespace": d.sensorNamespace, - "labels": map[string]string{ - "app": "stackrox-secured-cluster", - }, - }, - "spec": map[string]interface{}{ - "clusterName": generateClusterName(), // Just a default, can be overwritten. - "centralEndpoint": internalCentralEndpoint(d.centralNamespace), - "imagePullSecrets": []map[string]string{ - {"name": "stackrox"}, - }, - }, - } - - resourcesOverlay := d.getSecuredClusterResourcesOperator(resources) - - merged := helpers.MergeMaps(base, resourcesOverlay, d.securedClusterOverrides) - - // Apply feature flag overrides last with smart envVars merging - if d.featureFlagOverrides != nil { - merged = mergeWithEnvVarSupport(merged, d.featureFlagOverrides) - } - - return merged, nil -} - -func (d *Deployer) getSecuredClusterResourcesOperator(resourcesName string) map[string]interface{} { - switch resourcesName { - case "small": +func getSecuredClusterResourcesOperator(resourceProfile types.ResourceProfile) map[string]interface{} { + switch resourceProfile { + case types.ResourceProfileSmall: return map[string]interface{}{ "spec": map[string]interface{}{ "sensor": map[string]interface{}{ @@ -732,7 +640,7 @@ func (d *Deployer) getSecuredClusterResourcesOperator(resourcesName string) map[ }, }, } - case "medium": + case types.ResourceProfileMedium: return map[string]interface{}{ "spec": map[string]interface{}{ "admissionControl": map[string]interface{}{ @@ -753,7 +661,7 @@ func (d *Deployer) getSecuredClusterResourcesOperator(resourcesName string) map[ }, }, } - case "ci": + case types.ResourceProfileCI: return map[string]interface{}{ "spec": map[string]interface{}{ "sensor": map[string]interface{}{ @@ -785,7 +693,7 @@ func (d *Deployer) applySecuredClusterCR(ctx context.Context, cr map[string]inte } result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"apply", "-n", d.sensorNamespace, "-f", "-"}, + Args: []string{"apply", "-n", d.config.SecuredCluster.Namespace, "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) if err != nil { @@ -798,7 +706,8 @@ func (d *Deployer) applySecuredClusterCR(ctx context.Context, cr map[string]inte } // waitForSecuredClusterReady waits for SecuredCluster to be ready -func (d *Deployer) waitForSecuredClusterReady(ctx context.Context, timeout time.Duration) error { +func (d *Deployer) waitForSecuredClusterReady(ctx context.Context) error { + timeout := d.config.SecuredCluster.DeployTimeout d.logger.Infof("âŗ Waiting for SecuredCluster to become ready (timeout: %s)...", timeout) // Track seen deployments and their states to avoid duplicate messages @@ -809,17 +718,17 @@ func (d *Deployer) waitForSecuredClusterReady(ctx context.Context, timeout time. checkInterval := 3 * time.Second for time.Since(start) < timeout { - d.checkDeploymentProgressInNamespace(ctx, d.sensorNamespace, seenDeployments) + d.checkDeploymentProgressInNamespace(ctx, d.config.SecuredCluster.Namespace, seenDeployments) - if d.earlyReadiness || d.verbose { - d.checkPodProgressInNamespace(ctx, d.sensorNamespace, seenPods) + if d.config.SecuredCluster.EarlyReadiness || d.verbose { + d.checkPodProgressInNamespace(ctx, d.config.SecuredCluster.Namespace, seenPods) } allReady := true // Check sensor deployment result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "deployment", "sensor", "-n", d.sensorNamespace, "-o", "jsonpath={.status.readyReplicas}"}, + Args: []string{"get", "deployment", "sensor", "-n", d.config.SecuredCluster.Namespace, "-o", "jsonpath={.status.readyReplicas}"}, }) if err != nil || result.Stdout == "" { allReady = false @@ -831,10 +740,10 @@ func (d *Deployer) waitForSecuredClusterReady(ctx context.Context, timeout time. } // Only check additional workloads if early-readiness is not enabled - if !d.earlyReadiness { + if !d.config.SecuredCluster.EarlyReadiness { // Check admission-control deployment result, err = d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "deployment", "admission-control", "-n", d.sensorNamespace, "-o", "jsonpath={.status.readyReplicas}"}, + Args: []string{"get", "deployment", "admission-control", "-n", d.config.SecuredCluster.Namespace, "-o", "jsonpath={.status.readyReplicas}"}, }) if err != nil || result.Stdout == "" { allReady = false diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 8cb84df..500055a 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -3,7 +3,6 @@ package deployer import ( "context" "crypto/rand" - "errors" "fmt" "os" "os/exec" @@ -12,25 +11,18 @@ import ( "time" "github.com/fatih/color" - "gopkg.in/yaml.v3" - "github.com/stackrox/roxie/internal/clusterdefaults" "github.com/stackrox/roxie/internal/component" "github.com/stackrox/roxie/internal/dockerauth" "github.com/stackrox/roxie/internal/env" - "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/imagecache" "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/portforward" + "github.com/stackrox/roxie/internal/types" ) var ( - sharedNamespace = "stackrox" - centralNamespace = "acs-central" - sensorNamespace = "acs-sensor" - defaultExposure = "loadbalancer" - DefaultCentralWaitTimeout = 20 * time.Minute DefaultSecuredClusterWaitTimeout = 20 * time.Minute @@ -102,38 +94,26 @@ var ( // Deployer is the base deployer for ACS type Deployer struct { - logger *logger.Logger - startTime time.Time - dockerAuth *dockerauth.DockerAuth - imageCache *imagecache.ImageCache - portForward *portforward.Manager - clusterDefaults *clusterdefaults.Manager - roxctlVersion string - centralNamespace string - sensorNamespace string - mainImageTag string - operatorTag string - centralEndpoint string - centralPassword string - roxCACertFile string - kubeContext string - portForwardEnabled bool - pauseReconciliation bool - exposure string - centralOverrides map[string]interface{} - securedClusterOverrides map[string]interface{} - featureFlagOverrides map[string]interface{} - envrcFile string - useOLM bool - useKonflux bool - shouldDeployOperator bool - verbose bool - earlyReadiness bool - centralWaitTimeout time.Duration - securedClusterWaitTimeout time.Duration - dockerCreds *dockerauth.Credentials - clusterResourceKinds map[string]struct{} - tempDir string + // Influencing roxies mode of operation. + verbose bool + logger *logger.Logger + startTime time.Time + dockerAuth *dockerauth.DockerAuth + imageCache *imagecache.ImageCache + dockerCreds *dockerauth.Credentials + envrcFile string + + kubeContext string + clusterResourceKinds map[string]struct{} + + config Config + + // State + centralEndpoint string + centralPassword string + roxCACertFile string + tempDir string + portForward *portforward.Manager } type ResourceToDelete struct { @@ -187,16 +167,16 @@ func (d *Deployer) deleteCentralResources(ctx context.Context, wait bool) error d.logger.Info("Deleting Central resources") var crExists bool - if d.doesResourceExist(ctx, "central", "stackrox-central-services", d.centralNamespace) { + if d.doesResourceExist(ctx, "central", "stackrox-central-services", d.config.Central.Namespace) { crExists = true // Trigger async deletion of the Central CR. - err := d.deleteResource(ctx, d.centralNamespace, "central", "stackrox-central-services", "--wait=false") + err := d.deleteResource(ctx, d.config.Central.Namespace, "central", "stackrox-central-services", "--wait=false") if err != nil { return fmt.Errorf("failed to asynchronously delete Central CR: %w", err) } - err = d.deleteFinalizers(ctx, d.centralNamespace, "central", "stackrox-central-services") + err = d.deleteFinalizers(ctx, d.config.Central.Namespace, "central", "stackrox-central-services") if err != nil { return fmt.Errorf("failed to delete finalizers on Central CR: %w", err) } @@ -212,7 +192,7 @@ func (d *Deployer) deleteCentralResources(ctx context.Context, wait bool) error // Delete other resources by brute force. resourceKinds := d.filterResourceKinds(allInstallableCentralResourceKinds) - err = d.deleteResources(ctx, d.centralNamespace, resourceKinds, "-l=app.kubernetes.io/part-of=stackrox-central-services") + err = d.deleteResources(ctx, d.config.Central.Namespace, resourceKinds, "-l=app.kubernetes.io/part-of=stackrox-central-services") if err != nil { return err } @@ -227,7 +207,7 @@ func (d *Deployer) deleteCentralResources(ctx context.Context, wait bool) error if resource.OwnerName != "" { // Avoid deletion if the resource does not have the expected owner. // (e.g. in case central and secured cluster are deployed into the same namespace). - obj, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.centralNamespace, resource.Kind, resource.Name) + obj, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.config.Central.Namespace, resource.Kind, resource.Name) if err != nil { if !k8s.IsResourceNotFound(err) { d.logger.Warningf("Failed to retrieve %s/%s for owner checking: %v. Skipping deletion. Deployment might be affected.", resource.Kind, resource.Name, err) @@ -240,14 +220,14 @@ func (d *Deployer) deleteCentralResources(ctx context.Context, wait bool) error } } - if err := d.deleteResource(ctx, d.centralNamespace, resource.Kind, resource.Name); err != nil { + if err := d.deleteResource(ctx, d.config.Central.Namespace, resource.Kind, resource.Name); err != nil { return fmt.Errorf("failed to delete %s/%s: %w", resource.Kind, resource.Name, err) } } if crExists { // Now delete the Central CR synchronously. - err := d.deleteResource(ctx, d.centralNamespace, "central", "stackrox-central-services") + err := d.deleteResource(ctx, d.config.Central.Namespace, "central", "stackrox-central-services") if err != nil { return fmt.Errorf("failed to delete Central CR: %w", err) } @@ -263,14 +243,14 @@ func (d *Deployer) preventOtherControllersFromReconciling(ctx context.Context) e func (d *Deployer) preventCABundleInjection(ctx context.Context) error { configMapName := "injected-cabundle-stackrox-central-services" - if !d.doesResourceExist(ctx, "configmap", configMapName, d.centralNamespace) { + if !d.doesResourceExist(ctx, "configmap", configMapName, d.config.Central.Namespace) { return nil } d.logger.Info("Removing CNO label from injected-cabundle ConfigMap to prevent CNO from injecting the CA bundle during cleanup") _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{ - "label", "configmap", configMapName, "-n", d.centralNamespace, + "label", "configmap", configMapName, "-n", d.config.Central.Namespace, "config.openshift.io/inject-trusted-cabundle-", }, IgnoreErrors: true, @@ -287,16 +267,16 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, wait bool) d.logger.Info("Deleting SecuredCluster resources") var crExists bool - if d.doesResourceExist(ctx, "securedcluster", "stackrox-secured-cluster-services", d.sensorNamespace) { + if d.doesResourceExist(ctx, "securedcluster", "stackrox-secured-cluster-services", d.config.SecuredCluster.Namespace) { crExists = true // Trigger async deletion of the SecuredCluster CR. - err := d.deleteResource(ctx, d.sensorNamespace, "securedcluster", "stackrox-secured-cluster-services", "--wait=false") + err := d.deleteResource(ctx, d.config.SecuredCluster.Namespace, "securedcluster", "stackrox-secured-cluster-services", "--wait=false") if err != nil { return err } - err = d.deleteFinalizers(ctx, d.sensorNamespace, "securedcluster", "stackrox-secured-cluster-services") + err = d.deleteFinalizers(ctx, d.config.SecuredCluster.Namespace, "securedcluster", "stackrox-secured-cluster-services") if err != nil { return fmt.Errorf("failed to delete finalizers on SecuredCluster CR: %w", err) } @@ -304,7 +284,7 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, wait bool) // In the meantime, delete other resources by brute force. resourceKinds := d.filterResourceKinds(allInstallableSecuredClusterResourceKinds) - err := d.deleteResources(ctx, d.sensorNamespace, resourceKinds, "-l=app.kubernetes.io/part-of=stackrox-secured-cluster-services") + err := d.deleteResources(ctx, d.config.SecuredCluster.Namespace, resourceKinds, "-l=app.kubernetes.io/part-of=stackrox-secured-cluster-services") if err != nil { return err } @@ -319,7 +299,7 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, wait bool) if resource.OwnerName != "" { // Avoid deletion if the resource does not have the expected owner. // (e.g. in case central and secured cluster are deployed into the same namespace). - obj, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.sensorNamespace, resource.Kind, resource.Name) + obj, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.config.SecuredCluster.Namespace, resource.Kind, resource.Name) if err != nil { if !k8s.IsResourceNotFound(err) { d.logger.Warningf("Failed to retrieve %s/%s for owner checking: %v. Skipping deletion. Deployment might be affected.", resource.Kind, resource.Name, err) @@ -331,14 +311,14 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, wait bool) continue } } - if err := d.deleteResource(ctx, d.sensorNamespace, resource.Kind, resource.Name); err != nil { + if err := d.deleteResource(ctx, d.config.SecuredCluster.Namespace, resource.Kind, resource.Name); err != nil { return fmt.Errorf("failed to delete %s/%s: %w", resource.Kind, resource.Name, err) } } if crExists { // Now delete the SecuredCluster CR synchronously. - err := d.deleteResource(ctx, d.sensorNamespace, "securedcluster", "stackrox-secured-cluster-services") + err := d.deleteResource(ctx, d.config.SecuredCluster.Namespace, "securedcluster", "stackrox-secured-cluster-services") if err != nil { return fmt.Errorf("failed to delete SecuredCluster CR: %w", err) } @@ -347,158 +327,8 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, wait bool) return nil } -var ( - centralOverridePrefix = "central" - securedClusterOverridePrefix = "securedCluster" -) - -func unmarshalYamlFile(filePath string) (map[string]interface{}, error) { - rawContent, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read override file: %w", err) - } - var content map[string]interface{} - if err := yaml.Unmarshal(rawContent, &content); err != nil { - return nil, fmt.Errorf("failed to parse override file: %w", err) - } - return content, nil -} - -func (d *Deployer) SetCombinedOverrideFile(overrideFile string) error { - overrides, err := unmarshalYamlFile(overrideFile) - if err != nil { - return fmt.Errorf("failed to unmarshal override file: %w", err) - } - - for key, value := range overrides { - switch key { - case centralOverridePrefix: - d.centralOverrides = value.(map[string]interface{}) - case securedClusterOverridePrefix: - d.securedClusterOverrides = value.(map[string]interface{}) - default: - d.logger.Errorf("override file contains key %q; combined deployments require extra nesting under 'central' or 'securedCluster'", key) - return fmt.Errorf("unexpected key %q in override file", key) - } - } - - return nil -} - -// Returns remaining set expressions. -func setOverrideSetExpressions(overrides map[string]interface{}, prefix string, overrideSetExpressions []string) ([]string, error) { - remainingSetExpressions := make([]string, 0) - for _, expr := range overrideSetExpressions { - key, yamlValue, found := strings.Cut(expr, "=") - if !found { - return nil, fmt.Errorf("invalid override expression '%s': expected format 'key.path=value'", expr) - } - if prefix != "" { - if !strings.HasPrefix(key, prefix) { - remainingSetExpressions = append(remainingSetExpressions, expr) - continue - } - key = strings.TrimPrefix(key, prefix+".") - } - var val interface{} - if err := yaml.Unmarshal([]byte(yamlValue), &val); err != nil { - return nil, fmt.Errorf("failed to unmarshal value '%s' for key '%s': %w", yamlValue, key, err) - } - if err := setNestedValue(overrides, key, val); err != nil { - return nil, fmt.Errorf("failed to set value for key '%s': %w", key, err) - } - } - - return remainingSetExpressions, nil -} - -func (d *Deployer) SetCombinedOverrideSetExpressions(overrideSetExpressions []string) error { - if d.centralOverrides == nil { - d.centralOverrides = make(map[string]interface{}) - } - if d.securedClusterOverrides == nil { - d.securedClusterOverrides = make(map[string]interface{}) - } - - remainingSetExpressions, err := setOverrideSetExpressions(d.centralOverrides, centralOverridePrefix, overrideSetExpressions) - if err != nil { - return fmt.Errorf("failed to set central override set expressions: %w", err) - } - remainingSetExpressions, err = setOverrideSetExpressions(d.securedClusterOverrides, securedClusterOverridePrefix, remainingSetExpressions) - if err != nil { - return fmt.Errorf("failed to set secured cluster override set expressions: %w", err) - } - - if len(remainingSetExpressions) > 0 { - return fmt.Errorf("some override expressions were not properly prefixed with 'central.' or 'securedCluster.': %v", remainingSetExpressions) - } - - return nil -} - -func (d *Deployer) SetCentralOverrideFile(overrideYaml string) error { - centralOverrides, err := unmarshalYamlFile(overrideYaml) - if err != nil { - return fmt.Errorf("failed to unmarshal override file: %w", err) - } - d.centralOverrides = centralOverrides - return nil -} - -func (d *Deployer) SetCentralOverrideSetExpressions(overrideSetExpressions []string) error { - if d.centralOverrides == nil { - d.centralOverrides = make(map[string]interface{}) - } - _, err := setOverrideSetExpressions(d.centralOverrides, "", overrideSetExpressions) - if err != nil { - return fmt.Errorf("failed to set central override set expressions: %w", err) - } - return nil -} - -func (d *Deployer) SetSecuredClusterOverrideFile(overrideYaml string) error { - securedClusterOverrides, err := unmarshalYamlFile(overrideYaml) - if err != nil { - return fmt.Errorf("failed to unmarshal override file: %w", err) - } - d.securedClusterOverrides = securedClusterOverrides - return nil -} - -func (d *Deployer) SetSecuredClusterOverrideSetExpressions(overrideSetExpressions []string) error { - if d.securedClusterOverrides == nil { - d.securedClusterOverrides = make(map[string]interface{}) - } - _, err := setOverrideSetExpressions(d.securedClusterOverrides, "", overrideSetExpressions) - if err != nil { - return fmt.Errorf("failed to set secured cluster override set expressions: %w", err) - } - return nil -} - -// SetFeatureFlags parses feature flags and stores them as overrides. -// Feature flags are applied last (after file-based overrides and --set) to ensure highest precedence. -func (d *Deployer) SetFeatureFlags(featureFlags []string) error { - if len(featureFlags) == 0 { - return nil - } - - flags, err := parseFeatureFlags(featureFlags) - if err != nil { - return fmt.Errorf("failed to parse feature flags: %w", err) - } - - if len(flags) == 0 { - return nil - } - - for name, value := range flags { - d.logger.Dimf("Feature flag: %s=%t", name, value) - } - - d.featureFlagOverrides = featureFlagsToOverrides(flags) - - return nil +func (d *Deployer) SetConfig(config Config) { + d.config = config } // New creates a new Deployer instance. @@ -510,33 +340,20 @@ func New(log *logger.Logger) (*Deployer, error) { return nil, err } - roxctlVersion, err := getRoxctlVersion() - if err != nil { - return nil, err - } - tempDir, err := os.MkdirTemp("", "roxie-deployer-*") if err != nil { return nil, fmt.Errorf("failed to create temporary directory: %w", err) } d := &Deployer{ - logger: log, - startTime: time.Now(), - roxctlVersion: roxctlVersion, - centralNamespace: centralNamespace, - sensorNamespace: sensorNamespace, - exposure: defaultExposure, - shouldDeployOperator: true, - centralWaitTimeout: DefaultCentralWaitTimeout, - securedClusterWaitTimeout: DefaultSecuredClusterWaitTimeout, - tempDir: tempDir, + logger: log, + startTime: time.Now(), + tempDir: tempDir, } d.dockerAuth = dockerauth.New(log) d.imageCache = imagecache.New(log, "", 20) d.portForward = portforward.New(k8s.GetKubectl(), log) - d.clusterDefaults = clusterdefaults.NewManager(log) if password := os.Getenv("ROX_ADMIN_PASSWORD"); password != "" { d.centralPassword = password @@ -554,14 +371,15 @@ func New(log *logger.Logger) (*Deployer, error) { d.kubeContext = env.GetCurrentContext() - clusterResourceKinds, err := d.getClusterResourceKinds() - if err != nil { - return nil, fmt.Errorf("failed to get cluster resource kinds: %w", err) + if d.kubeContext != "" { + clusterResourceKinds, err := d.getClusterResourceKinds() + if err != nil { + return nil, fmt.Errorf("failed to get cluster resource kinds: %w", err) + } + d.clusterResourceKinds = clusterResourceKinds } - d.clusterResourceKinds = clusterResourceKinds log.Success("🚀 ACS Deployer initialized") - log.Infof("roxctl version: %s", d.roxctlVersion) return d, nil } @@ -599,22 +417,9 @@ func (d *Deployer) Cleanup() { } // Deploy deploys the specified components to the cluster. -func (d *Deployer) Deploy(ctx context.Context, components component.Component, resources, exposure string) error { - adjustedResources, adjustedExposure, adjustedPortForward := d.clusterDefaults.ApplyConvenienceDefaults( - d.kubeContext, - resources, - exposure, - d.portForwardEnabled, - ) - - resources = adjustedResources - exposure = adjustedExposure - d.portForwardEnabled = adjustedPortForward - d.exposure = exposure - - // Prepare and verify credentials early to fail fast - - if env.GetCurrentClusterType() != env.InfraOpenShift4 { +func (d *Deployer) Deploy(ctx context.Context, components component.Component) error { + // Prepare and verify credentials early to fail fast. + if env.GetCurrentClusterType() != types.ClusterTypeInfraOpenShift4 { if err := d.prepareCredentials(); err != nil { return fmt.Errorf("failed to prepare credentials: %w", err) } @@ -622,23 +427,23 @@ func (d *Deployer) Deploy(ctx context.Context, components component.Component, r d.logger.Infof("Initiating deployment of %s", components) - // If only deploying operator, use the operator-only flow + // If only deploying operator, use the operator-only flow. if components.IncludesOperatorExplicitly() { return d.deployOperatorOnly(ctx) } - // Deploy operator first if needed + // Deploy operator first if needed. if err := d.ensureOperatorDeployed(ctx); err != nil { return fmt.Errorf("failed to deploy operator: %w", err) } if components.IncludesCentral() { - if err := d.deployCentral(ctx, resources, exposure); err != nil { + if err := d.deployCentral(ctx); err != nil { return fmt.Errorf("failed to deploy central: %w", err) } } if components.IncludesSensor() { - if err := d.deploySecuredCluster(ctx, resources); err != nil { + if err := d.deploySecuredCluster(ctx); err != nil { return fmt.Errorf("failed to deploy secured cluster: %w", err) } } @@ -662,25 +467,23 @@ func (d *Deployer) prepareCredentials() error { return nil } -func (d *Deployer) deployCentral(ctx context.Context, resources, exposure string) error { - d.logger.Infof("Deploying Central to namespace %s", d.centralNamespace) - if d.namespaceExists(d.centralNamespace) { +func (d *Deployer) deployCentral(ctx context.Context) error { + d.logger.Infof("Deploying Central to namespace %s", d.config.Central.Namespace) + if d.namespaceExists(d.config.Central.Namespace) { d.logger.Info("Existing Central deployment found, tearing down...") if err := d.teardownCentral(ctx); err != nil { d.logger.Warningf("Error during teardown: %v", err) } } - portForwardWanted := d.portForwardEnabled - - if err := d.deployCentralOperator(ctx, resources, exposure); err != nil { + if err := d.deployCentralOperator(ctx); err != nil { return err } // envrc may be used from different processes, so use actual endpoint not port-forward if d.envrcFile != "" { d.logger.Dimf("Writing environment variables to %s", d.envrcFile) - if err := d.writeEnvrcFile(ctx, exposure, portForwardWanted); err != nil { + if err := d.writeEnvrcFile(ctx); err != nil { d.logger.Warningf("Failed to write envrc file: %v", err) } } @@ -688,16 +491,16 @@ func (d *Deployer) deployCentral(ctx context.Context, resources, exposure string return nil } -func (d *Deployer) deploySecuredCluster(ctx context.Context, resources string) error { - d.logger.Infof("Deploying SecuredCluster to namespace %s", d.sensorNamespace) - if d.namespaceExists(d.sensorNamespace) { +func (d *Deployer) deploySecuredCluster(ctx context.Context) error { + d.logger.Infof("Deploying SecuredCluster to namespace %s", d.config.SecuredCluster.Namespace) + if d.namespaceExists(d.config.SecuredCluster.Namespace) { d.logger.Info("Existing SecuredCluster deployment found, tearing down...") if err := d.teardownSecuredCluster(ctx); err != nil { d.logger.Warningf("Error during teardown: %v", err) } } - return d.deploySecuredClusterOperator(ctx, resources) + return d.deploySecuredClusterOperator(ctx) } func (d *Deployer) Teardown(ctx context.Context, components component.Component) error { @@ -746,18 +549,18 @@ func (d *Deployer) Teardown(ctx context.Context, components component.Component) } func (d *Deployer) teardownCentral(ctx context.Context) error { - d.logger.Infof("đŸ—‘ī¸ Tearing down central in namespace %s", d.centralNamespace) + d.logger.Infof("đŸ—‘ī¸ Tearing down central in namespace %s", d.config.Central.Namespace) - if !d.namespaceExists(d.centralNamespace) { - d.logger.Infof("Namespace %s doesn't exist, skipping", d.centralNamespace) + if !d.namespaceExists(d.config.Central.Namespace) { + d.logger.Infof("Namespace %s doesn't exist, skipping", d.config.Central.Namespace) return nil } d.portForward.Stop() // Add pause-reconcile annotation to not have the operator interfere during resource deletion. - if d.doesResourceExist(ctx, "central", "stackrox-central-services", d.centralNamespace) { - if err := d.addPauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.centralNamespace); err != nil { + if d.doesResourceExist(ctx, "central", "stackrox-central-services", d.config.Central.Namespace) { + if err := d.addPauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.config.Central.Namespace); err != nil { d.logger.Warningf("Error adding pause-reconcile annotation: %v", err) } } @@ -768,21 +571,21 @@ func (d *Deployer) teardownCentral(ctx context.Context) error { return fmt.Errorf("failed to delete Central resources: %w", err) } - d.logger.Successf("✓ Central resources in namespace %s have been deleted", d.centralNamespace) + d.logger.Successf("✓ Central resources in namespace %s have been deleted", d.config.Central.Namespace) return nil } func (d *Deployer) teardownSecuredCluster(ctx context.Context) error { - d.logger.Infof("đŸ—‘ī¸ Tearing down secured cluster in namespace %s", d.sensorNamespace) + d.logger.Infof("đŸ—‘ī¸ Tearing down secured cluster in namespace %s", d.config.SecuredCluster.Namespace) - if !d.namespaceExists(d.sensorNamespace) { - d.logger.Infof("Namespace %s doesn't exist, skipping", d.sensorNamespace) + if !d.namespaceExists(d.config.SecuredCluster.Namespace) { + d.logger.Infof("Namespace %s doesn't exist, skipping", d.config.SecuredCluster.Namespace) return nil } - if d.doesResourceExist(ctx, "securedcluster", "stackrox-secured-cluster-services", d.sensorNamespace) { + if d.doesResourceExist(ctx, "securedcluster", "stackrox-secured-cluster-services", d.config.SecuredCluster.Namespace) { // Add pause-reconcile annotation to not have the operator interfere during resource deletion. - if err := d.addPauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.sensorNamespace); err != nil { + if err := d.addPauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.config.SecuredCluster.Namespace); err != nil { d.logger.Warningf("Error adding pause-reconcile annotation: %v", err) } } @@ -793,7 +596,7 @@ func (d *Deployer) teardownSecuredCluster(ctx context.Context) error { return fmt.Errorf("failed to delete SecuredCluster resources: %w", err) } - d.logger.Successf("✓ SecuredCluster resources in namespace %s have been deleted", d.sensorNamespace) + d.logger.Successf("✓ SecuredCluster resources in namespace %s have been deleted", d.config.SecuredCluster.Namespace) return nil } @@ -873,27 +676,6 @@ func checkRequiredTools() error { return nil } -func getRoxctlVersion() (string, error) { - cmd := exec.Command("roxctl", "version") - output, err := cmd.Output() - if err != nil { - if _, ok := err.(*exec.Error); ok { - return "", errors.New("roxctl not found in PATH; please install roxctl and ensure it's available in your PATH") - } - if _, ok := err.(*exec.ExitError); ok { - return "", errors.New("roxctl not found in PATH; please install roxctl and ensure it's available in your PATH") - } - return "", fmt.Errorf("failed to get roxctl version: %w", err) - } - - version := strings.TrimSpace(string(output)) - if version == "" { - return "", errors.New("roxctl returned empty version") - } - - return version, nil -} - func generatePassword() string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" const passwordLength = 20 @@ -916,69 +698,10 @@ func (d *Deployer) SetEnvrcFile(path string) { d.envrcFile = path } -func (d *Deployer) SetPortForwardingEnabled(enabled bool) { - d.portForwardEnabled = enabled -} - -func (d *Deployer) SetUseOLM(useOLM bool) error { - d.useOLM = useOLM - return nil -} - -func (d *Deployer) SetUseKonflux(useKonflux bool) error { - d.useKonflux = useKonflux - return nil -} - func (d *Deployer) SetVerbose(verbose bool) { d.verbose = verbose } -func (d *Deployer) SetEarlyReadiness(enabled bool) { - d.earlyReadiness = enabled -} - -func (d *Deployer) SetCentralWaitTimeout(timeout time.Duration) { - d.centralWaitTimeout = timeout -} - -func (d *Deployer) SetSecuredClusterWaitTimeout(timeout time.Duration) { - d.securedClusterWaitTimeout = timeout -} - -func (d *Deployer) SetPauseReconciliation(enabled bool) { - d.pauseReconciliation = enabled -} - -func (d *Deployer) SetSingleNamespace(enabled bool) { - if enabled { - d.centralNamespace = sharedNamespace - d.sensorNamespace = sharedNamespace - } -} - -func (d *Deployer) SetMainImageTag(tag string) { - d.mainImageTag = tag - d.operatorTag = helpers.ConvertMainTagToOperatorTag(d.mainImageTag) -} - -// maybeAddPauseReconcileAnnotation adds the stackrox.io/pause-reconcile annotation to a custom resource -func (d *Deployer) maybeAddPauseReconcileAnnotation(ctx context.Context, resourceType, resourceName, namespace string) error { - if !d.pauseReconciliation { - return nil - } - - d.logger.Infof("Adding pause-reconcile annotation to %s/%s", resourceType, resourceName) - - err := d.addPauseReconcileAnnotation(ctx, resourceType, resourceName, namespace) - if err != nil { - return err - } - - d.logger.Successf("✓ Added pause-reconcile annotation to %s/%s", resourceType, resourceName) - return nil -} - func (d *Deployer) doesResourceExist(ctx context.Context, resourceType, resourceName, namespace string) bool { _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{ @@ -1005,14 +728,6 @@ func (d *Deployer) addPauseReconcileAnnotation(ctx context.Context, resourceType return nil } -func (d *Deployer) SetDeployOperator(deployOperator bool) { - d.shouldDeployOperator = deployOperator -} - -func (d *Deployer) GetDeploymentInfo() (endpoint, password, caCertFile, kubeContext, exposure string) { - return d.centralEndpoint, d.centralPassword, d.roxCACertFile, d.kubeContext, d.exposure -} - // WaitForCentral waits for Central to be ready and responding on its endpoint // Returns true if Central is ready, false if timeout occurs func (d *Deployer) WaitForCentral(timeout time.Duration) bool { @@ -1092,7 +807,7 @@ func (d *Deployer) cleanupTempDir(path string, description string) { } } -func (d *Deployer) writeEnvrcFile(ctx context.Context, exposure string, portForwardWanted bool) error { +func (d *Deployer) writeEnvrcFile(ctx context.Context) error { var content strings.Builder fmt.Fprintf(&content, "export API_ENDPOINT=%q\n", d.centralEndpoint) fmt.Fprintf(&content, "export ROX_ENDPOINT=%q\n", d.centralEndpoint) @@ -1111,10 +826,10 @@ func (d *Deployer) writeEnvrcFile(ctx context.Context, exposure string, portForw func (d *Deployer) PrintCentralDeploymentSummary() { component := "Central" - mainImageTag := d.mainImageTag - olm := d.useOLM - exposure := d.exposure - portForwarding := d.portForwardEnabled + mainImageTag := d.config.Roxie.Version + olm := d.config.Operator.DeployViaOlm + exposure := d.config.Central.Exposure + portForwarding := d.config.Central.PortForwardingEnabled() log := d.logger kubeContext := d.kubeContext @@ -1173,9 +888,9 @@ func (d *Deployer) PrintCentralDeploymentSummary() { log.Info(cyan.Sprint("│") + createRow("OLM", "Yes")) } - log.Info(cyan.Sprint("│") + createRow("Exposure", exposure)) + log.Info(cyan.Sprint("│") + createRow("Exposure", exposure.String())) - if portForwarding || exposure == "none" { + if portForwarding || exposure == types.ExposureNone { log.Info(cyan.Sprint("│") + createRow("Port Forwarding", "Enabled (localhost:8443)")) } @@ -1280,8 +995,8 @@ func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace st // extracted func (d *Deployer) PrintSecuredClusterDeploymentSummary() { component := "Secured Cluster" - mainImageTag := d.mainImageTag - olm := d.useOLM + mainImageTag := d.config.Roxie.Version + olm := d.config.Operator.DeployViaOlm log := d.logger kubeContext := d.kubeContext @@ -1343,3 +1058,22 @@ func (d *Deployer) PrintSecuredClusterDeploymentSummary() { log.Info(cyan.Sprint("└" + strings.Repeat("─", boxWidth) + "┘")) log.Info("") } + +type CentralDeploymentInfo struct { + Endpoint string + Password string + KubeContext string + Exposure types.Exposure + CACertFile string + HAProxyStarted bool +} + +func (d *Deployer) GetCentralDeploymentInfo() CentralDeploymentInfo { + return CentralDeploymentInfo{ + Endpoint: d.centralEndpoint, + Password: d.centralPassword, + KubeContext: d.kubeContext, + Exposure: d.config.Central.Exposure, + CACertFile: d.roxCACertFile, + } +} diff --git a/internal/deployer/feature_flags.go b/internal/deployer/feature_flags.go index d9299be..5912feb 100644 --- a/internal/deployer/feature_flags.go +++ b/internal/deployer/feature_flags.go @@ -53,9 +53,9 @@ func parseFlagWithPrefix(part string) (name string, value bool, err error) { return name, value, nil } -// parseFeatureFlags parses a slice of feature flag strings and returns a map of flag names to boolean values. +// ParseFeatureFlags parses a slice of feature flag strings and returns a map of flag names to boolean values. // Supports formats: +ROX_FOO (enable), -ROX_FOO (disable), ROX_FOO=true, ROX_FOO=false, ROX_FOO (enable) -func parseFeatureFlags(flags []string) (map[string]bool, error) { +func ParseFeatureFlags(flags []string) (map[string]bool, error) { result := make(map[string]bool) for _, flagStr := range flags { @@ -106,10 +106,8 @@ func featureFlagsToOverrides(flags map[string]bool) map[string]interface{} { } return map[string]interface{}{ - "spec": map[string]interface{}{ - "customize": map[string]interface{}{ - "envVars": featureFlagsToEnvVars(flags), - }, + "customize": map[string]interface{}{ + "envVars": featureFlagsToEnvVars(flags), }, } } @@ -143,8 +141,11 @@ func mergeEnvVars(base, overlay []interface{}) []interface{} { // mergeWithEnvVarSupport merges two maps, with special handling for spec.customize.envVars arrays. // Instead of replacing the entire envVars array, it merges individual env vars by name, // allowing overlay to override specific env vars while preserving others from base. -func mergeWithEnvVarSupport(base, overlay map[string]interface{}) map[string]interface{} { - result := helpers.MergeMaps(base, overlay) +func mergeWithEnvVarSupport(base, overlay map[string]interface{}) (map[string]interface{}, error) { + result, err := helpers.MergeMaps(base, overlay) + if err != nil { + return nil, err + } baseEnvVars, baseFound, _ := unstructured.NestedSlice(base, "spec", "customize", "envVars") overlayEnvVars, overlayFound, _ := unstructured.NestedSlice(overlay, "spec", "customize", "envVars") @@ -153,5 +154,5 @@ func mergeWithEnvVarSupport(base, overlay map[string]interface{}) map[string]int _ = unstructured.SetNestedSlice(result, mergeEnvVars(baseEnvVars, overlayEnvVars), "spec", "customize", "envVars") } - return result + return result, nil } diff --git a/internal/deployer/feature_flags_test.go b/internal/deployer/feature_flags_test.go index d316ec7..bc6c72a 100644 --- a/internal/deployer/feature_flags_test.go +++ b/internal/deployer/feature_flags_test.go @@ -3,6 +3,8 @@ package deployer import ( "reflect" "testing" + + "github.com/stretchr/testify/require" ) func TestParseFeatureFlags(t *testing.T) { @@ -123,7 +125,7 @@ func TestParseFeatureFlags(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := parseFeatureFlags(tt.input) + result, err := ParseFeatureFlags(tt.input) if tt.expectError { if err == nil { t.Errorf("expected error but got nil") @@ -356,7 +358,8 @@ func TestMergeWithEnvVarSupport(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := mergeWithEnvVarSupport(tt.base, tt.overlay) + result, err := mergeWithEnvVarSupport(tt.base, tt.overlay) + require.NoError(t, err) spec, ok := result["spec"].(map[string]interface{}) if !ok { @@ -450,12 +453,7 @@ func TestFeatureFlagsConversion(t *testing.T) { t.Fatal("result should not be nil") } - spec, ok := result["spec"].(map[string]interface{}) - if !ok { - t.Fatal("spec should be a map") - } - - customize, ok := spec["customize"].(map[string]interface{}) + customize, ok := result["customize"].(map[string]interface{}) if !ok { t.Fatal("customize should be a map") } diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index 2a04393..4d57625 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -17,6 +17,7 @@ import ( "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/ocihelper" + "github.com/stackrox/roxie/internal/types" ) const ( @@ -35,8 +36,8 @@ var requiredCRDs = []string{ // deployOperatorNonOLM deploys the RHACS operator without OLM func (d *Deployer) deployOperatorNonOLM(ctx context.Context) error { - d.logger.Infof("Operator tag: %s", d.operatorTag) - if d.useKonflux { + d.logger.Infof("Operator tag: %s", d.config.Operator.Version) + if d.config.Roxie.KonfluxImages { if err := d.ensureKonfluxImageRewriting(ctx); err != nil { return fmt.Errorf("failed to configure Konflux image rewriting: %w", err) } @@ -193,16 +194,16 @@ func (d *Deployer) ensureCRDsInstalled(ctx context.Context) error { } func (d *Deployer) getOperatorBundleImage() string { - if d.useKonflux { + if d.config.Roxie.KonfluxImages { d.logger.Infof("Using Konflux-built operator bundle image") - return fmt.Sprintf(operatorBundleImageReleaseRepo+":v%s", d.operatorTag) + return fmt.Sprintf(operatorBundleImageReleaseRepo+":v%s", d.config.Operator.Version) } - return fmt.Sprintf(operatorBundleImageRepo+":v%s", d.operatorTag) + return fmt.Sprintf(operatorBundleImageRepo+":v%s", d.config.Operator.Version) } // ensureKonfluxImageRewriting configures image rewriting for Konflux images func (d *Deployer) ensureKonfluxImageRewriting(ctx context.Context) error { - if env.GetCurrentClusterType() != env.InfraOpenShift4 { + if env.GetCurrentClusterType() != types.ClusterTypeInfraOpenShift4 { return errors.New("image rewriting for Konflux is only supported on OpenShift4 clusters") } @@ -290,7 +291,7 @@ func (d *Deployer) applyImageContentSourcePolicy(ctx context.Context) error { // removeKonfluxImageRewriting removes the ImageContentSourcePolicy for Konflux images if it exists func (d *Deployer) removeKonfluxImageRewriting(ctx context.Context) error { - if env.GetCurrentClusterType() != env.InfraOpenShift4 { + if env.GetCurrentClusterType() != types.ClusterTypeInfraOpenShift4 { return nil } diff --git a/internal/deployer/operator_olm.go b/internal/deployer/operator_olm.go index 662fbd0..51a8120 100644 --- a/internal/deployer/operator_olm.go +++ b/internal/deployer/operator_olm.go @@ -31,7 +31,7 @@ const ( // deployOperatorViaOLM deploys the RHACS operator using OLM. func (d *Deployer) deployOperatorViaOLM(ctx context.Context) error { d.logger.Info("🚀 Deploying operator via OLM...") - d.logger.Infof("Operator tag: %s", d.operatorTag) + d.logger.Infof("Operator tag: %s", d.config.Operator.Version) if err := d.checkOLMInstalled(ctx); err != nil { return err @@ -100,7 +100,7 @@ func (d *Deployer) checkOLMInstalled(ctx context.Context) error { // getOperatorIndexImage returns the operator index image reference. func (d *Deployer) getOperatorIndexImage() string { - return fmt.Sprintf(operatorIndexImage+":v%s", d.operatorTag) + return fmt.Sprintf(operatorIndexImage+":v%s", d.config.Operator.Version) } // createCatalogSource creates the CatalogSource for the operator. @@ -201,7 +201,7 @@ func (d *Deployer) createOperatorGroup(ctx context.Context) error { func (d *Deployer) createSubscription(ctx context.Context) error { d.logger.Info("Creating Subscription...") - startingCSV := fmt.Sprintf("rhacs-operator.v%s", d.operatorTag) + startingCSV := fmt.Sprintf("rhacs-operator.v%s", d.config.Operator.Version) subscription := map[string]interface{}{ "apiVersion": "operators.coreos.com/v1alpha1", @@ -263,7 +263,7 @@ func (d *Deployer) waitForAndApproveInstallPlan(ctx context.Context) error { } // Sanity check:Verify currentCSV matches expected version. - expectedCSV := fmt.Sprintf("rhacs-operator.v%s", d.operatorTag) + expectedCSV := fmt.Sprintf("rhacs-operator.v%s", d.config.Operator.Version) result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "subscription", subscriptionName, "-n", operatorNamespace, "-o", "jsonpath={.status.currentCSV}"}, }) @@ -305,7 +305,7 @@ func (d *Deployer) waitForAndApproveInstallPlan(ctx context.Context) error { // waitForCSVSuccess waits for the CSV to reach Succeeded phase. func (d *Deployer) waitForCSVSuccess(ctx context.Context) error { - csvName := fmt.Sprintf("rhacs-operator.v%s", d.operatorTag) + csvName := fmt.Sprintf("rhacs-operator.v%s", d.config.Operator.Version) d.logger.Infof("âŗ Waiting for CSV %s to succeed...", csvName) start := time.Now() diff --git a/internal/deployer/override.go b/internal/deployer/override.go deleted file mode 100644 index 3924dbc..0000000 --- a/internal/deployer/override.go +++ /dev/null @@ -1,48 +0,0 @@ -package deployer - -import ( - "fmt" - "strings" -) - -// setNestedValue sets a value at a nested path in a map -// The path is a dot-separated string like "foo.bar.baz" -// Creates intermediate maps as needed -func setNestedValue(m map[string]interface{}, path string, value interface{}) error { - if path == "" { - return fmt.Errorf("path cannot be empty") - } - - parts := strings.Split(path, ".") - current := m - - // Navigate to the parent of the target key - for i := 0; i < len(parts)-1; i++ { - key := parts[i] - - if existing, ok := current[key]; ok { - // Check if existing value is a map - if existingMap, isMap := existing.(map[string]interface{}); isMap { - current = existingMap - } else { - // TODO(ROX-34499): shouldn't this be an error instead? I think it's more likely someone - // made a typo... - // Existing value is not a map, we need to replace it - newMap := make(map[string]interface{}) - current[key] = newMap - current = newMap - } - } else { - // Key doesn't exist, create a new map - newMap := make(map[string]interface{}) - current[key] = newMap - current = newMap - } - } - - // Set the final value - finalKey := parts[len(parts)-1] - current[finalKey] = value - - return nil -} diff --git a/internal/env/env.go b/internal/env/env.go index d3ae4fa..89a4124 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -14,6 +14,7 @@ import ( "github.com/stackrox/roxie/internal/containerutil" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/types" "golang.org/x/term" ) @@ -23,24 +24,10 @@ var ( initializationMutex sync.Mutex ) -// ClusterType represents different types of Kubernetes clusters -type ClusterType int - -const ( - // ClusterTypeUnknown represents an unidentified cluster type - ClusterTypeUnknown ClusterType = iota - // InfraGKE represents a GKE (Google Kubernetes Engine) cluster - InfraGKE - // InfraOpenShift4 represents an OpenShift 4 cluster - InfraOpenShift4 - // LocalKind represents a Kind (Kubernetes in Docker) cluster - LocalKind -) - var ( // currentClusterType holds the detected cluster type for the current kubectl context // This is lazily populated on first access via GetCurrentClusterType() - currentClusterType ClusterType + currentClusterType types.ClusterType // currentContext holds the name of the current kubectl context // This is lazily populated on first access via GetCurrentContext() @@ -85,44 +72,22 @@ func ensureInitialized(log *logger.Logger) error { if err != nil { return err } - currentClusterType = detectClusterType(kubeConfig, apiResources) + currentClusterType = DetectClusterType(kubeConfig, apiResources) initialized = true } return nil } // GetCurrentClusterType returns the current cluster type, initializing if needed -func GetCurrentClusterType() ClusterType { - panicIfNotInitialized() +func GetCurrentClusterType() types.ClusterType { return currentClusterType } // GetCurrentContext returns the current kubectl context, initializing if needed func GetCurrentContext() string { - panicIfNotInitialized() return currentContext } -func panicIfNotInitialized() { - if !initialized { - panic("environment information not initialized") - } -} - -// String returns the string representation of a ClusterType -func (ct ClusterType) String() string { - switch ct { - case InfraGKE: - return "GKE" - case InfraOpenShift4: - return "OpenShift4" - case LocalKind: - return "Kind" - default: - return "Unknown" - } -} - // KubeConfig represents a simplified kubectl configuration type KubeConfig struct { CurrentContext string @@ -168,41 +133,55 @@ func Initialize(log *logger.Logger) error { return fmt.Errorf("failed to initialize environment after %d attempts: %w", maxRetries, lastErr) } -// detectClusterType implements the cluster type detection logic +// DetectClusterType implements the cluster type detection logic // This function is pure and testable - it doesn't invoke kubectl itself -func detectClusterType(config KubeConfig, apiResources []string) ClusterType { +func DetectClusterType(config KubeConfig, apiResources []string) types.ClusterType { if config.CurrentContext == "" { - return ClusterTypeUnknown + return types.ClusterTypeUnknown } contextLower := strings.ToLower(config.CurrentContext) - // Check for GKE clusters - // GKE contexts have format: gke_PROJECT_ZONE_CLUSTER + // GKE contexts have format: gke_PROJECT_ZONE_CLUSTER. if strings.HasPrefix(config.CurrentContext, "gke_acs-team-temp-dev") { - return InfraGKE + return types.ClusterTypeInfraGKE + } + + // Minikube clusters typically have context name "minikube". + if contextLower == "minikube" || strings.HasPrefix(contextLower, "minikube-") { + return types.ClusterTypeMinikube } - // Check for OpenShift 4 clusters by examining the server hostname + // Check for OpenShift 4 clusters by examining the server hostname. if serverURL := getServerURL(config); serverURL != "" { if parsedURL, err := url.Parse(serverURL); err == nil { hostname := parsedURL.Hostname() if strings.HasSuffix(hostname, ".ocp.infra.rox.systems") { // Further verify it's OpenShift 4 by checking the API resources if isOpenShift4(apiResources) { - return InfraOpenShift4 + return types.ClusterTypeInfraOpenShift4 } } } } + // K3s clusters often have "k3s" in the context name + if strings.Contains(contextLower, "k3s") { + return types.ClusterTypeK3s + } + // Check for Kind clusters // Kind clusters typically have context names starting with "kind-" or just "kind" if strings.HasPrefix(contextLower, "kind") { - return LocalKind + return types.ClusterTypeKind + } + + // CRC (CodeReady Containers) contexts start with "crc" or contain "-crc-"/"_crc_" as a segment + if strings.HasPrefix(contextLower, "crc") || strings.Contains(contextLower, "-crc-") || strings.Contains(contextLower, "-crc:") { + return types.ClusterTypeCRC } - return ClusterTypeUnknown + return types.ClusterTypeUnknown } // getServerURL retrieves the server URL from the KubeConfig diff --git a/internal/env/env_integration_test.go b/internal/env/env_integration_test.go index f583eb8..6a5677b 100644 --- a/internal/env/env_integration_test.go +++ b/internal/env/env_integration_test.go @@ -4,6 +4,8 @@ package env import ( "testing" + + "github.com/stackrox/roxie/internal/types" ) func TestDetectClusterType_Integration(t *testing.T) { @@ -19,7 +21,7 @@ func TestDetectClusterType_Integration(t *testing.T) { t.Logf("Detected cluster type: %s", clusterType) // The cluster type should never be invalid (even if Unknown) - validTypes := []ClusterType{ClusterTypeUnknown, InfraGKE, InfraOpenShift4, LocalKind} + validTypes := types.AllClusterTypes() found := false for _, valid := range validTypes { if clusterType == valid { diff --git a/internal/env/env_test.go b/internal/env/env_test.go index 149825a..6c4ea0b 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -2,6 +2,8 @@ package env import ( "testing" + + "github.com/stackrox/roxie/internal/types" ) func TestDetectClusterType_GKE(t *testing.T) { @@ -16,9 +18,9 @@ func TestDetectClusterType_GKE(t *testing.T) { } apiResources := []string{"pods", "services", "deployments"} - result := detectClusterType(config, apiResources) - if result != InfraGKE { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), InfraGKE, InfraGKE.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeInfraGKE { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeInfraGKE) } } @@ -34,9 +36,9 @@ func TestDetectClusterType_GKE_ExactMatch(t *testing.T) { } apiResources := []string{"pods", "services"} - result := detectClusterType(config, apiResources) - if result != InfraGKE { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), InfraGKE, InfraGKE.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeInfraGKE { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeInfraGKE) } } @@ -57,9 +59,9 @@ func TestDetectClusterType_OpenShift4(t *testing.T) { "clusteroperators.config.openshift.io", } - result := detectClusterType(config, apiResources) - if result != InfraOpenShift4 { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), InfraOpenShift4, InfraOpenShift4.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeInfraOpenShift4 { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeInfraOpenShift4) } } @@ -79,9 +81,9 @@ func TestDetectClusterType_OpenShift4_WrongHostname(t *testing.T) { "clusterversions.config.openshift.io", } - result := detectClusterType(config, apiResources) - if result != ClusterTypeUnknown { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), ClusterTypeUnknown, ClusterTypeUnknown.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeUnknown { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeUnknown) } } @@ -97,9 +99,9 @@ func TestDetectClusterType_OpenShift4_NoAPIResources(t *testing.T) { } apiResources := []string{"pods", "services"} - result := detectClusterType(config, apiResources) - if result != ClusterTypeUnknown { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), ClusterTypeUnknown, ClusterTypeUnknown.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeUnknown { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeUnknown) } } @@ -115,9 +117,9 @@ func TestDetectClusterType_Kind(t *testing.T) { } apiResources := []string{"pods", "services"} - result := detectClusterType(config, apiResources) - if result != LocalKind { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), LocalKind, LocalKind.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeKind { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeKind) } } @@ -133,9 +135,9 @@ func TestDetectClusterType_Kind_CaseInsensitive(t *testing.T) { } apiResources := []string{"pods"} - result := detectClusterType(config, apiResources) - if result != LocalKind { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), LocalKind, LocalKind.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeKind { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeKind) } } @@ -146,13 +148,13 @@ func TestDetectClusterType_EmptyContext(t *testing.T) { } apiResources := []string{} - result := detectClusterType(config, apiResources) - if result != ClusterTypeUnknown { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), ClusterTypeUnknown, ClusterTypeUnknown.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeUnknown { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeUnknown) } } -func TestDetectClusterType_Unknown(t *testing.T) { +func TestDetectClusterType_Minikube(t *testing.T) { config := KubeConfig{ CurrentContext: "minikube", Clusters: []KubeCluster{ @@ -164,9 +166,9 @@ func TestDetectClusterType_Unknown(t *testing.T) { } apiResources := []string{"pods", "services"} - result := detectClusterType(config, apiResources) - if result != ClusterTypeUnknown { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), ClusterTypeUnknown, ClusterTypeUnknown.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeMinikube { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeMinikube) } } @@ -182,9 +184,9 @@ func TestDetectClusterType_GKE_DifferentProject(t *testing.T) { } apiResources := []string{"pods"} - result := detectClusterType(config, apiResources) - if result != ClusterTypeUnknown { - t.Errorf("detectClusterType() = %v (%s), want %v (%s)", result, result.String(), ClusterTypeUnknown, ClusterTypeUnknown.String()) + result := DetectClusterType(config, apiResources) + if result != types.ClusterTypeUnknown { + t.Errorf("DetectClusterType() = %v (%s), want %v", result, result.String(), types.ClusterTypeUnknown) } } @@ -284,27 +286,27 @@ func TestGetServerURL(t *testing.T) { func TestClusterTypeString(t *testing.T) { tests := []struct { name string - clusterType ClusterType + clusterType types.ClusterType want string }{ { - name: "InfraGKE", - clusterType: InfraGKE, + name: "types.ClusterTypeInfraGKE", + clusterType: types.ClusterTypeInfraGKE, want: "GKE", }, { - name: "InfraOpenShift4", - clusterType: InfraOpenShift4, + name: "types.ClusterTypeInfraOpenShift4", + clusterType: types.ClusterTypeInfraOpenShift4, want: "OpenShift4", }, { name: "LocalKind", - clusterType: LocalKind, + clusterType: types.ClusterTypeKind, want: "Kind", }, { name: "ClusterTypeUnknown", - clusterType: ClusterTypeUnknown, + clusterType: types.ClusterTypeUnknown, want: "Unknown", }, } @@ -318,3 +320,69 @@ func TestClusterTypeString(t *testing.T) { }) } } + +func TestDefaultDetector_Detect(t *testing.T) { + tests := []struct { + name string + kubeContext string + want types.ClusterType + }{ + { + name: "kind cluster with standard prefix", + kubeContext: "kind-dev-cluster", + want: types.ClusterTypeKind, + }, + { + name: "kind cluster simple name", + kubeContext: "kind", + want: types.ClusterTypeKind, + }, + { + name: "kind cluster with uppercase", + kubeContext: "KIND-test", + want: types.ClusterTypeKind, + }, + { + name: "crc cluster with admin context", + kubeContext: "crc-admin", + want: types.ClusterTypeCRC, + }, + { + name: "crc cluster with api prefix", + kubeContext: "api-crc-testing:6443", + want: types.ClusterTypeCRC, + }, + { + name: "crc cluster with uppercase", + kubeContext: "CRC-admin", + want: types.ClusterTypeCRC, + }, + { + name: "crc cluster bare name", + kubeContext: "crc", + want: types.ClusterTypeCRC, + }, + { + name: "not crc - incidental substring", + kubeContext: "acrc-cluster", + want: types.ClusterTypeUnknown, + }, + { + name: "not crc - encrypted in name", + kubeContext: "my-encrypted-cluster", + want: types.ClusterTypeUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kubeConfig := KubeConfig{ + CurrentContext: tt.kubeContext, + } + got := DetectClusterType(kubeConfig, nil) + if got != tt.want { + t.Errorf("Detect(%q) = %v, want %v", tt.kubeContext, got, tt.want) + } + }) + } +} diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 99ad143..a2a28a6 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -5,7 +5,10 @@ import ( "fmt" "os" "os/exec" + "reflect" + "strings" + "github.com/stackrox/roxie/internal/logger" "gopkg.in/yaml.v3" ) @@ -66,14 +69,16 @@ func LoadYAMLFile(path string) (map[string]interface{}, error) { } // MergeMaps deeply merges multiple maps, with later maps taking precedence -func MergeMaps(base map[string]interface{}, overlays ...map[string]interface{}) map[string]interface{} { +func MergeMaps(base map[string]interface{}, overlays ...map[string]interface{}) (map[string]interface{}, error) { result := deepCopy(base) for _, overlay := range overlays { - deepMerge(result, overlay) + if err := DeepMerge(result, overlay); err != nil { + return nil, err + } } - return result + return result, nil } // deepCopy creates a deep copy of a map @@ -109,18 +114,68 @@ func deepCopySlice(s []interface{}) []interface{} { } // deepMerge recursively merges overlay into base -func deepMerge(base, overlay map[string]interface{}) { +func DeepMerge(base, overlay map[string]interface{}) error { for k, v := range overlay { + if IsNil(v) { + continue + } if baseVal, ok := base[k]; ok { // Both are maps - merge recursively if baseMap, baseIsMap := baseVal.(map[string]interface{}); baseIsMap { if overlayMap, overlayIsMap := v.(map[string]interface{}); overlayIsMap { - deepMerge(baseMap, overlayMap) + if err := DeepMerge(baseMap, overlayMap); err != nil { + return err + } continue + } else { + return fmt.Errorf("incompatible types in maps to merge (map vs. %T)", v) } } } // Override with overlay value base[k] = v } + return nil +} + +func StructToMap(v interface{}) (map[string]interface{}, error) { + bytes, err := yaml.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]interface{} + return m, yaml.Unmarshal(bytes, &m) +} + +func MapToStruct(m map[string]interface{}, out interface{}) error { + bytes, err := yaml.Marshal(m) + if err != nil { + return err + } + return yaml.Unmarshal(bytes, out) +} + +func LogMultilineYaml(log *logger.Logger, v any) error { + log.Dim("-------------------------") + bytes, err := yaml.Marshal(v) + if err != nil { + return err + } + for line := range strings.SplitSeq(string(bytes), "\n") { + log.Dim(line) + } + log.Dim("-------------------------") + return nil +} + +// IsNil uses reflection to reliably check if the provided argument is a Nil pointer. +func IsNil(i interface{}) bool { + if i == nil { + return true + } + switch reflect.TypeOf(i).Kind() { + case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: + return reflect.ValueOf(i).IsNil() + } + return false } diff --git a/internal/helpers/helpers_test.go b/internal/helpers/helpers_test.go index 67f2f1d..8ba6423 100644 --- a/internal/helpers/helpers_test.go +++ b/internal/helpers/helpers_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/require" ) func TestMergeMaps(t *testing.T) { @@ -23,7 +25,8 @@ func TestMergeMaps(t *testing.T) { "f": "value_f", } - result := MergeMaps(base, overlay) + result, err := MergeMaps(base, overlay) + require.NoError(t, err, "MergeMaps failed") // Check that base values are preserved if result["a"] != "value_a" { @@ -67,7 +70,8 @@ func TestMergeMapsMultipleOverlays(t *testing.T) { "key": "overlay2", } - result := MergeMaps(base, overlay1, overlay2) + result, err := MergeMaps(base, overlay1, overlay2) + require.NoError(t, err, "MergeMaps failed") if result["key"] != "overlay2" { t.Errorf("Expected last overlay to win, got '%v'", result["key"]) diff --git a/internal/types/cluster_type.go b/internal/types/cluster_type.go new file mode 100644 index 0000000..e65addd --- /dev/null +++ b/internal/types/cluster_type.go @@ -0,0 +1,52 @@ +package types + +// ClusterType represents different types of Kubernetes clusters +type ClusterType int + +const ( + // ClusterTypeUnknown represents an unidentified cluster type + ClusterTypeUnknown ClusterType = iota + // ClusterTypeInfraGKE represents a GKE cluster created via Infra. + ClusterTypeInfraGKE + // ClusterTypeInfraOpenShift4 represents an OpenShift 4 cluster + ClusterTypeInfraOpenShift4 + // ClusterTypeKind represents a Kind (Kubernetes in Docker) cluster + ClusterTypeKind + // ClusterTypeMinikube represents a Minikube cluster + ClusterTypeMinikube + // ClusterTypeK3s represents a K3s cluster + ClusterTypeK3s + // ClusterTypeCRC represents a CRC (CodeReady Containers) cluster + ClusterTypeCRC +) + +// String returns the string representation of a ClusterType +func (ct ClusterType) String() string { + switch ct { + case ClusterTypeInfraGKE: + return "GKE" + case ClusterTypeInfraOpenShift4: + return "OpenShift4" + case ClusterTypeKind: + return "Kind" + case ClusterTypeMinikube: + return "minikube" + case ClusterTypeK3s: + return "k3s" + case ClusterTypeCRC: + return "crc" + default: + return "Unknown" + } +} + +func AllClusterTypes() []ClusterType { + return []ClusterType{ + ClusterTypeInfraGKE, + ClusterTypeKind, + ClusterTypeMinikube, + ClusterTypeK3s, + ClusterTypeCRC, + ClusterTypeInfraOpenShift4, + } +} diff --git a/internal/types/exposure.go b/internal/types/exposure.go new file mode 100644 index 0000000..3457ab9 --- /dev/null +++ b/internal/types/exposure.go @@ -0,0 +1,81 @@ +package types + +import ( + "fmt" +) + +type Exposure int + +const ( + ExposureNone Exposure = iota + ExposureLoadBalancer +) + +var ( + exposureNames = map[Exposure]string{ + ExposureNone: "none", + ExposureLoadBalancer: "loadbalancer", + } + + exposureValues = func() map[string]Exposure { + m := make(map[string]Exposure, len(exposureNames)) + for k, v := range exposureNames { + m[v] = k + } + return m + }() +) + +func (e Exposure) String() string { + if name, ok := exposureNames[e]; ok { + return name + } + return fmt.Sprintf("Unknown(%d)", e) +} + +func (e Exposure) MarshalYAML() (interface{}, error) { + if name, ok := exposureNames[e]; ok { + return name, nil + } + return nil, fmt.Errorf("unknown exposure: %d", int(e)) +} + +func (e *Exposure) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + exposure, ok := exposureValues[s] + if !ok { + return fmt.Errorf("unknown exposure: %q", s) + } + *e = exposure + return nil +} + +// ToUnstructuredConfig returns the exposure configuration. +func (e Exposure) ToUnstructuredConfig() map[string]interface{} { + switch e { + case ExposureLoadBalancer: + return map[string]interface{}{ + "loadBalancer": map[string]interface{}{ + "enabled": true, + "port": 443, + }, + } + case ExposureNone: + return map[string]interface{}{ + "nodePort": map[string]interface{}{ + "enabled": false, + }, + "loadBalancer": map[string]interface{}{ + "enabled": false, + }, + "route": map[string]interface{}{ + "enabled": false, + }, + } + default: + return nil + } +} diff --git a/internal/types/resources.go b/internal/types/resources.go new file mode 100644 index 0000000..657b2dd --- /dev/null +++ b/internal/types/resources.go @@ -0,0 +1,76 @@ +package types + +import ( + "fmt" + "slices" + "strings" +) + +type ResourceProfile int + +const ( + ResourceProfileAcsDefaults ResourceProfile = iota + ResourceProfileAuto + ResourceProfileSmall + ResourceProfileMedium + ResourceProfileCI +) + +var ( + resourceProfileNames = map[ResourceProfile]string{ + ResourceProfileAcsDefaults: "acs-defaults", + ResourceProfileAuto: "auto", + ResourceProfileSmall: "small", + ResourceProfileMedium: "medium", + ResourceProfileCI: "ci", + } + + resourceProfileValues = func() map[string]ResourceProfile { + m := make(map[string]ResourceProfile, len(resourceProfileNames)) + for k, v := range resourceProfileNames { + m[v] = k + } + return m + }() +) + +func (r ResourceProfile) String() string { + if name, ok := resourceProfileNames[r]; ok { + return name + } + return fmt.Sprintf("ResourceProfile(%d)", int(r)) +} + +func (r ResourceProfile) MarshalYAML() (interface{}, error) { + if name, ok := resourceProfileNames[r]; ok { + return name, nil + } + return nil, fmt.Errorf("unknown resource profile: %d", int(r)) +} + +func (r *ResourceProfile) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + profile, ok := resourceProfileValues[s] + if !ok { + return fmt.Errorf("unknown resource profile: %q", s) + } + *r = profile + return nil +} + +func ResourceProfiles() []string { + resourceProfiles := make([]string, 0, len(resourceProfileNames)) + for _, name := range resourceProfileNames { + resourceProfiles = append(resourceProfiles, name) + } + return resourceProfiles +} + +func ResourceProfilesJoined() string { + profiles := ResourceProfiles() + slices.Sort(profiles) + return strings.Join(profiles, ", ") +} From 47bfdc73f31db4e83e2ccf2dc8dd874ecab56923 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 5 May 2026 22:43:27 +0200 Subject: [PATCH 02/30] Make central's exposure a pointer --- cmd/deploy.go | 10 ++-- .../clusterdefaults/clusterdefaults_test.go | 50 ++++++++++++------- internal/deployer/config.go | 18 ++++++- internal/deployer/deploy_via_operator.go | 2 +- internal/deployer/deployer.go | 4 +- 5 files changed, 55 insertions(+), 29 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index acbc085..0fb1f41 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -186,7 +186,7 @@ Examples: if err := yaml.Unmarshal([]byte(val), &exposure); err != nil { return err } - settings.Central.Exposure = exposure + settings.Central.Exposure = ptr.To(exposure) return nil }, ), "exposure", "Central exposure backend (loadbalancer, none)") @@ -432,11 +432,11 @@ func runDeploy(cmd *cobra.Command, args []string) error { return errors.New("cannot use --envrc with central port-forwarding enabled. The --envrc flag is for non-interactive mode with remote cluster access") } - if envrc != "" && deploySettings.Central.Exposure == types.ExposureNone { - return errors.New("cannot use --envrc with --exposure=none. The --envrc flag requires a remotely accessible endpoint (e.g., --exposure=loadbalancer)") + if envrc != "" && !deploySettings.Central.ExposureEnabled() { + return errors.New("cannot use --envrc without central exposure. The --envrc flag requires a remotely accessible endpoint (e.g., --exposure=loadbalancer)") } - if !deploySettings.Central.PortForwardingSet() && deploySettings.Central.Exposure == types.ExposureNone { + if !deploySettings.Central.PortForwardingSet() && !deploySettings.Central.ExposureEnabled() { log.Info("Enabling port-forwarding due to no exposure") deploySettings.Central.PortForwarding = ptr.To(true) } @@ -446,7 +446,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { if deploySettings.Central.PortForwardingEnabled() { return errors.New("containerized mode does not support port-forwarding") } - if deploySettings.Central.Exposure == types.ExposureNone { + if !deploySettings.Central.ExposureEnabled() { return errors.New("containerized mode requires Central exposure") } diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index 20cbe18..b896796 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -6,7 +6,9 @@ import ( "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/types" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" ) func TestClusterDefaults(t *testing.T) { @@ -14,56 +16,56 @@ func TestClusterDefaults(t *testing.T) { name string clusterType types.ClusterType wantResourceProfile types.ResourceProfile - wantExposure types.Exposure - wantPortForwarding bool + wantExposure *types.Exposure + wantPortForwarding *bool }{ { name: "kind cluster with default params", clusterType: types.ClusterTypeKind, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: types.ExposureNone, - wantPortForwarding: true, + wantExposure: ptr.To(types.ExposureNone), + wantPortForwarding: ptr.To(true), }, { name: "kind cluster with already correct params", clusterType: types.ClusterTypeKind, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: types.ExposureNone, - wantPortForwarding: true, + wantExposure: ptr.To(types.ExposureNone), + wantPortForwarding: ptr.To(true), }, { name: "kind cluster with partial match", clusterType: types.ClusterTypeKind, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: types.ExposureNone, - wantPortForwarding: true, + wantExposure: ptr.To(types.ExposureNone), + wantPortForwarding: ptr.To(true), }, { name: "unknown cluster type", clusterType: types.ClusterTypeUnknown, wantResourceProfile: types.ResourceProfileAcsDefaults, - wantExposure: types.ExposureNone, - wantPortForwarding: false, + wantExposure: nil, + wantPortForwarding: nil, }, { name: "minikube cluster", clusterType: types.ClusterTypeMinikube, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: types.ExposureNone, - wantPortForwarding: true, + wantExposure: ptr.To(types.ExposureNone), + wantPortForwarding: ptr.To(true), }, { name: "crc cluster", clusterType: types.ClusterTypeCRC, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: types.ExposureNone, - wantPortForwarding: true, + wantExposure: ptr.To(types.ExposureNone), + wantPortForwarding: ptr.To(true), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - config := deployer.Config{} + config := deployer.NewConfig() err := ApplyClusterDefaults(logger.New(), tt.clusterType, &config) require.NoError(t, err) @@ -71,11 +73,21 @@ func TestClusterDefaults(t *testing.T) { if gotResourceProfile != tt.wantResourceProfile { t.Errorf("Apply() resources = %v, want %v", gotResourceProfile, tt.wantResourceProfile) } - if config.Central.Exposure != tt.wantExposure { - t.Errorf("Apply() exposure = %v, want %v", config.Central.Exposure, tt.wantExposure) + + if tt.wantExposure == nil { + assert.Nil(t, config.Central.Exposure, "central exposure is not nil") + } else { + require.NotNil(t, config.Central.Exposure, "central exposure is nil") + assert.Equal(t, *tt.wantExposure, *config.Central.Exposure, + "exposure = %v, want %v", *config.Central.Exposure, *tt.wantExposure) } - if config.Central.PortForwardingEnabled() != tt.wantPortForwarding { - t.Errorf("Apply() portForward = %v, want %v", config.Central.PortForwardingEnabled(), tt.wantPortForwarding) + + if tt.wantPortForwarding == nil { + assert.Nil(t, config.Central.PortForwarding, "central port forwarding is not nil") + } else { + require.NotNil(t, config.Central.PortForwarding, "central port forwarding is nil") + assert.Equal(t, *tt.wantPortForwarding, *config.Central.PortForwarding, + "portForward = %v, want %v", *config.Central.PortForwarding, *tt.wantPortForwarding) } }) } diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 398550f..eb84c4a 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -158,7 +158,7 @@ type CentralConfig struct { Namespace string `yaml:"namespace"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` PauseReconciliation bool `yaml:"pauseReconciliation"` - Exposure types.Exposure `yaml:"exposure"` + Exposure *types.Exposure `yaml:"exposure"` DeployTimeout time.Duration `yaml:"deployTimeout"` PortForwarding *bool `yaml:"portForwarding"` EarlyReadiness bool `yaml:"earlyReadiness"` @@ -173,6 +173,21 @@ func (c *CentralConfig) PortForwardingEnabled() bool { return c.PortForwarding != nil && *c.PortForwarding } +func (c *CentralConfig) ExposureSet() bool { + return c.Exposure != nil +} + +func (c *CentralConfig) ExposureEnabled() bool { + return c.Exposure != nil && *c.Exposure != types.ExposureNone +} + +func (c *CentralConfig) GetExposure() types.Exposure { + if c.Exposure == nil { + return types.ExposureNone + } + return *c.Exposure +} + type SecuredClusterConfig struct { Namespace string `yaml:"namespace"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` @@ -200,7 +215,6 @@ func DefaultCentralConfig() CentralConfig { return CentralConfig{ DeployTimeout: DefaultCentralWaitTimeout, Namespace: "acs-central", - Exposure: types.ExposureLoadBalancer, Spec: map[string]interface{}{ "central": map[string]interface{}{ "telemetry": map[string]interface{}{ diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 97e6674..d404abd 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -528,7 +528,7 @@ func (d *Deployer) fetchCentralCACert(ctx context.Context) error { // configureCentralEndpoint configures the central endpoint in the Deployer based on exposure settings. func (d *Deployer) configureCentralEndpoint(ctx context.Context) error { - exposure := d.config.Central.Exposure + exposure := d.config.Central.GetExposure() if d.config.Central.PortForwardingEnabled() { // Start port-forward for CLI tool access via localhost:8443 serviceName := "central" diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 500055a..7e1b7b7 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -828,7 +828,7 @@ func (d *Deployer) PrintCentralDeploymentSummary() { component := "Central" mainImageTag := d.config.Roxie.Version olm := d.config.Operator.DeployViaOlm - exposure := d.config.Central.Exposure + exposure := d.config.Central.GetExposure() portForwarding := d.config.Central.PortForwardingEnabled() log := d.logger kubeContext := d.kubeContext @@ -1073,7 +1073,7 @@ func (d *Deployer) GetCentralDeploymentInfo() CentralDeploymentInfo { Endpoint: d.centralEndpoint, Password: d.centralPassword, KubeContext: d.kubeContext, - Exposure: d.config.Central.Exposure, + Exposure: d.config.Central.GetExposure(), CACertFile: d.roxCACertFile, } } From b75d4d15a70a51d51192977367b3f3d619cfbcd4 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 5 May 2026 22:49:21 +0200 Subject: [PATCH 03/30] Defaults for GKE & OpenShift --- internal/clusterdefaults/clusterdefaults.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/clusterdefaults/clusterdefaults.go b/internal/clusterdefaults/clusterdefaults.go index 5c00721..4d1fca6 100644 --- a/internal/clusterdefaults/clusterdefaults.go +++ b/internal/clusterdefaults/clusterdefaults.go @@ -73,6 +73,22 @@ func getDefaultsForClusterType(clusterType types.ClusterType) map[string]interfa }, } + case types.ClusterTypeInfraGKE: + return map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": types.ExposureLoadBalancer.String(), + "portForwarding": false, + }, + } + + case types.ClusterTypeInfraOpenShift4: + return map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": types.ExposureLoadBalancer.String(), + "portForwarding": false, + }, + } + default: return nil } From dd047d33c4050b6549d9d3a5e7a396bed42ee87d Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 5 May 2026 22:49:30 +0200 Subject: [PATCH 04/30] More test cases --- internal/clusterdefaults/clusterdefaults_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index b896796..f686c68 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -61,6 +61,20 @@ func TestClusterDefaults(t *testing.T) { wantExposure: ptr.To(types.ExposureNone), wantPortForwarding: ptr.To(true), }, + { + name: "gke cluster", + clusterType: types.ClusterTypeInfraGKE, + wantResourceProfile: types.ResourceProfileMedium, + wantExposure: ptr.To(types.ExposureLoadBalancer), + wantPortForwarding: ptr.To(false), + }, + { + name: "openshift cluster", + clusterType: types.ClusterTypeInfraGKE, + wantResourceProfile: types.ResourceProfileMedium, + wantExposure: ptr.To(types.ExposureLoadBalancer), + wantPortForwarding: ptr.To(false), + }, } for _, tt := range tests { From 9abbbcb33b852a4e88ff0a1690359baeb67f9781 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier <111092021+mclasmeier@users.noreply.github.com> Date: Wed, 6 May 2026 13:17:46 +0200 Subject: [PATCH 05/30] Update internal/deployer/config.go Co-authored-by: Marcin Owsiany --- internal/deployer/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index eb84c4a..29d7ccd 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -36,7 +36,7 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { }, } if c.ResourceProfile == types.ResourceProfileAuto { - return nil, fmt.Errorf("resource profile 'auto' must have been resolved before building the CR") + return nil, fmt.Errorf("resource profile 'auto' should have been resolved before building the CR") } if c.ResourceProfile != types.ResourceProfileAcsDefaults { if err := helpers.DeepMerge(cr, getCentralResourcesOperator(c.ResourceProfile)); err != nil { From 827cfb84c6d60b4502f3566a8248924ee0bab876 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 6 May 2026 15:18:31 +0200 Subject: [PATCH 06/30] Renamed receiver --- internal/deployer/config.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 29d7ccd..32b8d3f 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -107,12 +107,12 @@ func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { return nil } -func (c *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { - if err := helpers.DeepMerge(c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { +func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { + if err := helpers.DeepMerge(s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { return err } - if err := helpers.DeepMerge(c.Spec, map[string]interface{}{ + if err := helpers.DeepMerge(s.Spec, map[string]interface{}{ "centralEndpoint": internalCentralEndpoint(centralConfig.Namespace), }); err != nil { return err From 24b62d749ec93b81bb5133800cb087d108b0beff Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 6 May 2026 15:27:50 +0200 Subject: [PATCH 07/30] Improved code comment --- cmd/deploy.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 0fb1f41..2878741 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -28,7 +28,10 @@ var ( sharedNamespace = "stackrox" ) -// For extended short-cut parameters. +// configShortCut implements pflag.Value so that CLI flags can directly mutate +// the deployment configuration. Each flag carries its own apply function, which +// may modify one or more fields in deployer.Config, or merge in an arbitrary +// YAML overlay. type configShortCut struct { settings *deployer.Config flagType string From 1e732a591f2dbf286c7a2554a5576be47c5bbacf Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 6 May 2026 16:03:20 +0200 Subject: [PATCH 08/30] Unrelated, linter demanded it --- internal/helpers/helpers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index a2a28a6..18d5120 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -174,7 +174,7 @@ func IsNil(i interface{}) bool { return true } switch reflect.TypeOf(i).Kind() { - case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: + case reflect.Pointer, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: return reflect.ValueOf(i).IsNil() } return false From 95f43b427dce0063c2080fbb5ea6ae6d45cf5f7e Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 6 May 2026 16:07:30 +0200 Subject: [PATCH 09/30] Prevent nil dereference, e.g. when cluster defaults do not provide a default loadbalancer --- internal/types/exposure.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/types/exposure.go b/internal/types/exposure.go index 3457ab9..d35bbd5 100644 --- a/internal/types/exposure.go +++ b/internal/types/exposure.go @@ -54,8 +54,11 @@ func (e *Exposure) UnmarshalYAML(unmarshal func(interface{}) error) error { } // ToUnstructuredConfig returns the exposure configuration. -func (e Exposure) ToUnstructuredConfig() map[string]interface{} { - switch e { +func (e *Exposure) ToUnstructuredConfig() map[string]interface{} { + if e == nil { + return nil + } + switch *e { case ExposureLoadBalancer: return map[string]interface{}{ "loadBalancer": map[string]interface{}{ From ad3e62c71136614c03f2a1cb7d2ebfc326aad851 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 6 May 2026 16:21:51 +0200 Subject: [PATCH 10/30] Fix cluster type in test --- internal/clusterdefaults/clusterdefaults_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index f686c68..119f163 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -70,7 +70,7 @@ func TestClusterDefaults(t *testing.T) { }, { name: "openshift cluster", - clusterType: types.ClusterTypeInfraGKE, + clusterType: types.ClusterTypeInfraOpenShift4, wantResourceProfile: types.ResourceProfileMedium, wantExposure: ptr.To(types.ExposureLoadBalancer), wantPortForwarding: ptr.To(false), From 1e34f97d961602c3220f604144a62d9a02304b2b Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 6 May 2026 17:20:55 +0200 Subject: [PATCH 11/30] Refactor runDeploy --- cmd/deploy.go | 170 ++++++++++-------- internal/clusterdefaults/clusterdefaults.go | 38 ++-- .../clusterdefaults/clusterdefaults_test.go | 3 +- 3 files changed, 116 insertions(+), 95 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 2878741..7fb29fd 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -368,24 +368,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { log.Dim("Running without a controlling terminal.") } - clusterType := env.GetCurrentClusterType() - log.Dimf("Detected cluster type: %v", clusterType) - err := clusterdefaults.ApplyClusterDefaults(log, clusterType, &deploySettings) - if err != nil { - return fmt.Errorf("applying defaults for cluster type %v: %w", clusterType, err) - } - - // Deal with the "auto" resourceProfile. - if deploySettings.Central.ResourceProfile == types.ResourceProfileAuto { - profile := clusterdefaults.ResolveAutoResourceProfile(clusterType) - log.Dimf("Selecting resource profile %v for Central", profile) - deploySettings.Central.ResourceProfile = profile - } - if deploySettings.SecuredCluster.ResourceProfile == types.ResourceProfileAuto { - profile := clusterdefaults.ResolveAutoResourceProfile(clusterType) - deploySettings.SecuredCluster.ResourceProfile = profile - } - components, err := component.FromArgs(args) if err != nil { return err @@ -401,15 +383,86 @@ func runDeploy(cmd *cobra.Command, args []string) error { deploySettings.Roxie.Version = mainImageTag } - if !deploySettings.Operator.SkipDeployment { - if err := deploySettings.Operator.Configure(&deploySettings.Roxie); err != nil { - return fmt.Errorf("configuring operator configuration: %w", err) + if err := configureConfig(log, components, &deploySettings); err != nil { + return err + } + + if err := deployValidate(components, &deploySettings); err != nil { + return err + } + + d, err := deployer.New(log) + if err != nil { + return fmt.Errorf("failed to create deployer: %w", err) + } + defer d.Cleanup() + + if envrc != "" { + d.SetEnvrcFile(envrc) + } + d.SetVerbose(verbose) + d.SetConfig(deploySettings) + + if dryRun { + log.Info("Exiting because of enabled dry run mode.") + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + if components.IncludesCentral() { + d.PrintCentralDeploymentSummary() + } + if components.IncludesSensor() { + d.PrintSecuredClusterDeploymentSummary() + } + + if err := d.Deploy(ctx, components); err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + + log.Success("🎉 Deployment complete!") + + // If Central was deployed, wait for it to be ready before entering subshell + if components.IncludesCentral() { + d.WaitForCentral(5 * time.Minute) + } + + if components.IncludesCentral() && envrc == "" { + if err := spawnSubshell(d, log); err != nil { + return fmt.Errorf("failed to spawn subshell: %w", err) } } + return nil +} + +func configureConfig(log *logger.Logger, components component.Component, deploySettings *deployer.Config) error { + clusterType := env.GetCurrentClusterType() + log.Dimf("Detected cluster type: %v", clusterType) + defaults, err := clusterdefaults.ApplyClusterDefaults(clusterType, deploySettings) + if err != nil { + return fmt.Errorf("applying defaults for cluster type %v: %w", clusterType, err) + } if verbose { - log.Dim("Deployment configuration:") - helpers.LogMultilineYaml(log, deploySettings) + log.Dimf("Applying the following defaults based on detected cluster type %v:", clusterType) + helpers.LogMultilineYaml(log, defaults) + } + + // Deal with the "auto" resourceProfile. + if deploySettings.Central.ResourceProfile == types.ResourceProfileAuto { + profile := clusterdefaults.ResolveAutoResourceProfile(clusterType) + log.Dimf("Selecting resource profile %v for Central", profile) + deploySettings.Central.ResourceProfile = profile + } + if deploySettings.SecuredCluster.ResourceProfile == types.ResourceProfileAuto { + profile := clusterdefaults.ResolveAutoResourceProfile(clusterType) + deploySettings.SecuredCluster.ResourceProfile = profile + } + + if err := deploySettings.Operator.Configure(&deploySettings.Roxie); err != nil { + return fmt.Errorf("configuring operator configuration: %w", err) } if components.IncludesCentral() { @@ -422,7 +475,20 @@ func runDeploy(cmd *cobra.Command, args []string) error { return fmt.Errorf("configuring SecuredCluster spec: %w", err) } } + if verbose { + log.Dim("Deployment configuration:") + helpers.LogMultilineYaml(log, deploySettings) + } + + if !deploySettings.Central.PortForwardingSet() && !deploySettings.Central.ExposureEnabled() { + log.Info("Enabling port-forwarding due to no exposure") + deploySettings.Central.PortForwarding = ptr.To(true) + } + + return nil +} +func deployValidate(components component.Component, deploySettings *deployer.Config) error { if components.IncludesCentral() && os.Getenv("ROXIE_SHELL") != "" { return errors.New("already in a roxie sub-shell (ROXIE_SHELL environment variable is set), please exit the shell and try again") } @@ -439,11 +505,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { return errors.New("cannot use --envrc without central exposure. The --envrc flag requires a remotely accessible endpoint (e.g., --exposure=loadbalancer)") } - if !deploySettings.Central.PortForwardingSet() && !deploySettings.Central.ExposureEnabled() { - log.Info("Enabling port-forwarding due to no exposure") - deploySettings.Central.PortForwarding = ptr.To(true) - } - if env.RunningInRoxieContainer { // For running containerized we have specific requirements. if deploySettings.Central.PortForwardingEnabled() { @@ -464,6 +525,10 @@ func runDeploy(cmd *cobra.Command, args []string) error { } } + if deploySettings.Operator.SkipDeployment && deploySettings.Operator.DeployViaOlm { + return errors.New("skipping operator deployment while also requesting deploying via OLM at the same time does not make sense") + } + if deploySettings.Roxie.KonfluxImages { if deploySettings.Operator.DeployViaOlm { return errors.New("using Konflux images while deploying operator via OLM is not supported") @@ -474,54 +539,5 @@ func runDeploy(cmd *cobra.Command, args []string) error { } } - if deploySettings.Operator.SkipDeployment && deploySettings.Operator.DeployViaOlm { - return errors.New("skipping operator deployment while also requesting deploying via OLM at the same time does not make sense") - } - - d, err := deployer.New(log) - if err != nil { - return fmt.Errorf("failed to create deployer: %w", err) - } - defer d.Cleanup() - - if envrc != "" { - d.SetEnvrcFile(envrc) - } - - d.SetVerbose(verbose) - - if dryRun { - log.Info("Exiting because of enabled dry run mode.") - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer cancel() - - d.SetConfig(deploySettings) - if components.IncludesCentral() { - d.PrintCentralDeploymentSummary() - } - if components.IncludesSensor() { - d.PrintSecuredClusterDeploymentSummary() - } - - if err := d.Deploy(ctx, components); err != nil { - return fmt.Errorf("deployment failed: %w", err) - } - - log.Success("🎉 Deployment complete!") - - // If Central was deployed, wait for it to be ready before entering subshell - if components.IncludesCentral() { - d.WaitForCentral(5 * time.Minute) - } - - if components.IncludesCentral() && envrc == "" { - if err := spawnSubshell(d, log); err != nil { - return fmt.Errorf("failed to spawn subshell: %w", err) - } - } - return nil } diff --git a/internal/clusterdefaults/clusterdefaults.go b/internal/clusterdefaults/clusterdefaults.go index 4d1fca6..ad9f21a 100644 --- a/internal/clusterdefaults/clusterdefaults.go +++ b/internal/clusterdefaults/clusterdefaults.go @@ -3,38 +3,44 @@ package clusterdefaults import ( "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/helpers" - "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/types" + "k8s.io/apimachinery/pkg/runtime" ) -// ApplyClusterDefaults detects the cluster type and applies appropriate defaults. +// ApplyClusterDefaults detects the cluster type and applies appropriate defaults to the +// provided deployer.Config. +// Returns *just* the assembled defaults for the given cluster type for logging purposes. func ApplyClusterDefaults( - log *logger.Logger, clusterType types.ClusterType, config *deployer.Config, -) error { +) (map[string]interface{}, error) { if config == nil { panic("applying cluster defaults to nil config") } - configWithDefaults := getDefaultsForClusterType(clusterType) - if configWithDefaults == nil { - return nil + defaults := getDefaultsForClusterType(clusterType) + if defaults == nil { + return nil, nil } - log.Dimf("Applying the following defaults based on detected cluster type %v:", clusterType) - helpers.LogMultilineYaml(log, configWithDefaults) + + // Make a copy. + defaultsCopy := runtime.DeepCopyJSON(defaults) + configMap, err := helpers.StructToMap(config) if err != nil { - return err + return nil, err } - err = helpers.DeepMerge(configWithDefaults, configMap) - if err != nil { - return err + mergeResult := make(map[string]interface{}) + if err = helpers.DeepMerge(mergeResult, defaults); err != nil { + return nil, err + } + if err := helpers.DeepMerge(mergeResult, configMap); err != nil { + return nil, err } - if err := helpers.MapToStruct(configWithDefaults, config); err != nil { - return err + if err := helpers.MapToStruct(mergeResult, config); err != nil { + return nil, err } - return nil + return defaultsCopy, nil } // getDefaultsForClusterType returns the recommended defaults for a given cluster type. diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index 119f163..d3074a6 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/stackrox/roxie/internal/deployer" - "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -80,7 +79,7 @@ func TestClusterDefaults(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := deployer.NewConfig() - err := ApplyClusterDefaults(logger.New(), tt.clusterType, &config) + _, err := ApplyClusterDefaults(tt.clusterType, &config) require.NoError(t, err) gotResourceProfile := ResolveAutoResourceProfile(tt.clusterType) From 9e1d58d27587e49cef96f923a1fb4d103a276f37 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 09:09:17 +0200 Subject: [PATCH 12/30] Reorganized config.go --- internal/deployer/config.go | 272 +++++++++++++++++++----------------- 1 file changed, 144 insertions(+), 128 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 32b8d3f..243d648 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -8,7 +8,8 @@ import ( "github.com/stackrox/roxie/internal/types" ) -// This is the self-contained configuration for deployments. +// Config is the top-level deployment configuration, combining settings for +// roxie itself, the operator, Central, and SecuredCluster. type Config struct { Roxie RoxieConfig `yaml:"roxie"` Operator OperatorConfig `yaml:"operator"` @@ -16,111 +17,28 @@ type Config struct { SecuredCluster SecuredClusterConfig `yaml:"securedCluster"` } -func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { - cr := map[string]interface{}{ - "apiVersion": "platform.stackrox.io/v1alpha1", - "kind": "Central", - "metadata": map[string]interface{}{ - "name": "stackrox-central-services", - "namespace": c.Namespace, - "labels": map[string]string{ - "app": "stackrox-central", - }, - }, - "spec": map[string]interface{}{ - "central": map[string]interface{}{ - "adminPasswordSecret": map[string]interface{}{ - "name": adminPasswordSecretName, - }, - }, - }, - } - if c.ResourceProfile == types.ResourceProfileAuto { - return nil, fmt.Errorf("resource profile 'auto' should have been resolved before building the CR") - } - if c.ResourceProfile != types.ResourceProfileAcsDefaults { - if err := helpers.DeepMerge(cr, getCentralResourcesOperator(c.ResourceProfile)); err != nil { - return nil, fmt.Errorf("merging resource profile into Central CR: %w", err) - } - } - if err := helpers.DeepMerge(cr, map[string]interface{}{ - "spec": c.Spec, - }); err != nil { - return nil, fmt.Errorf("merging spec into Central CR: %w", err) +// NewConfig returns a Config populated with default values. +func NewConfig() Config { + return Config{ + Roxie: NewRoxieConfig(), + Central: DefaultCentralConfig(), + SecuredCluster: DefaultSecuredClusterConfig(), } - return cr, nil } -func (s *SecuredClusterConfig) CustomResource() (map[string]interface{}, error) { - cr := map[string]interface{}{ - "apiVersion": "platform.stackrox.io/v1alpha1", - "kind": "SecuredCluster", - "metadata": map[string]interface{}{ - "name": "stackrox-secured-cluster-services", - "namespace": s.Namespace, - "labels": map[string]string{ - "app": "stackrox-secured-cluster", - }, - }, - "spec": map[string]interface{}{ - "clusterName": generateClusterName(), - "imagePullSecrets": []map[string]string{ - {"name": "stackrox"}, - }, - }, - } - if s.ResourceProfile == types.ResourceProfileAuto { - return nil, fmt.Errorf("resource profile 'auto' must have been resolved before building the CR") - } - if s.ResourceProfile != types.ResourceProfileAcsDefaults { - if err := helpers.DeepMerge(cr, getSecuredClusterResourcesOperator(s.ResourceProfile)); err != nil { - return nil, fmt.Errorf("merging resource profile into SecuredCluster CR: %w", err) - } - } - - if err := helpers.DeepMerge(cr, map[string]interface{}{ - "spec": s.Spec, - }); err != nil { - return nil, fmt.Errorf("merging spec into SecuredCluster CR: %w", err) +// MergeIn deep-merges another Config into this one. +func (c *Config) MergeIn(other *Config) error { + if other == nil { + return nil } - return cr, nil -} - -type RoxieConfig struct { - Version string `yaml:"version"` - KonfluxImages bool `yaml:"konfluxImages"` - FeatureFlags map[string]bool `yaml:"featureFlags"` -} - -func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { - err := helpers.DeepMerge(c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)) + otherAsMap, err := helpers.StructToMap(other) if err != nil { return err } - if err = helpers.DeepMerge(c.Spec, map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": c.Exposure.ToUnstructuredConfig(), - }, - }); err != nil { - return err - } - return nil -} - -func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { - if err := helpers.DeepMerge(s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { - return err - } - - if err := helpers.DeepMerge(s.Spec, map[string]interface{}{ - "centralEndpoint": internalCentralEndpoint(centralConfig.Namespace), - }); err != nil { - return err - } - - return nil + return c.MergeInUnstructured(otherAsMap) } +// MergeInUnstructured deep-merges an unstructured map into this Config. func (c *Config) MergeInUnstructured(m map[string]interface{}) error { asMap, err := helpers.StructToMap(c) if err != nil { @@ -132,28 +50,34 @@ func (c *Config) MergeInUnstructured(m map[string]interface{}) error { return helpers.MapToStruct(asMap, c) } -func (c *Config) MergeIn(other *Config) error { - if other == nil { - return nil - } - otherAsMap, err := helpers.StructToMap(other) - if err != nil { - return err +// RoxieConfig holds roxie-level settings such as version and feature flags. +type RoxieConfig struct { + Version string `yaml:"version"` + KonfluxImages bool `yaml:"konfluxImages"` + FeatureFlags map[string]bool `yaml:"featureFlags"` +} + +// NewRoxieConfig returns a RoxieConfig with initialized defaults. +func NewRoxieConfig() RoxieConfig { + return RoxieConfig{ + FeatureFlags: make(map[string]bool), } - return c.MergeInUnstructured(otherAsMap) } +// OperatorConfig controls how the ACS operator is deployed. type OperatorConfig struct { SkipDeployment bool `yaml:"skipDeployment"` DeployViaOlm bool `yaml:"deployViaOlm"` Version string `yaml:"version"` } +// Configure derives the operator version from the roxie configuration. func (c *OperatorConfig) Configure(roxieConfig *RoxieConfig) error { c.Version = helpers.ConvertMainTagToOperatorTag(roxieConfig.Version) return nil } +// CentralConfig holds deployment settings for the Central component. type CentralConfig struct { Namespace string `yaml:"namespace"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` @@ -165,6 +89,21 @@ type CentralConfig struct { Spec map[string]interface{} `yaml:"spec"` } +// DefaultCentralConfig returns a CentralConfig with sensible defaults. +func DefaultCentralConfig() CentralConfig { + return CentralConfig{ + DeployTimeout: DefaultCentralWaitTimeout, + Namespace: "acs-central", + Spec: map[string]interface{}{ + "central": map[string]interface{}{ + "telemetry": map[string]interface{}{ + "enabled": false, + }, + }, + }, + } +} + func (c *CentralConfig) PortForwardingSet() bool { return c.PortForwarding != nil } @@ -188,6 +127,59 @@ func (c *CentralConfig) GetExposure() types.Exposure { return *c.Exposure } +// ConfigureSpec applies feature flags and exposure settings to the Central spec. +func (c *CentralConfig) ConfigureSpec(roxieConfig *RoxieConfig) error { + err := helpers.DeepMerge(c.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)) + if err != nil { + return err + } + if err = helpers.DeepMerge(c.Spec, map[string]interface{}{ + "central": map[string]interface{}{ + "exposure": c.Exposure.ToUnstructuredConfig(), + }, + }); err != nil { + return err + } + return nil +} + +// CustomResource builds an unstructured Central custom resource from this config. +func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { + cr := map[string]interface{}{ + "apiVersion": "platform.stackrox.io/v1alpha1", + "kind": "Central", + "metadata": map[string]interface{}{ + "name": "stackrox-central-services", + "namespace": c.Namespace, + "labels": map[string]string{ + "app": "stackrox-central", + }, + }, + "spec": map[string]interface{}{ + "central": map[string]interface{}{ + "adminPasswordSecret": map[string]interface{}{ + "name": adminPasswordSecretName, + }, + }, + }, + } + if c.ResourceProfile == types.ResourceProfileAuto { + return nil, fmt.Errorf("resource profile 'auto' should have been resolved before building the CR") + } + if c.ResourceProfile != types.ResourceProfileAcsDefaults { + if err := helpers.DeepMerge(cr, getCentralResourcesOperator(c.ResourceProfile)); err != nil { + return nil, fmt.Errorf("merging resource profile into Central CR: %w", err) + } + } + if err := helpers.DeepMerge(cr, map[string]interface{}{ + "spec": c.Spec, + }); err != nil { + return nil, fmt.Errorf("merging spec into Central CR: %w", err) + } + return cr, nil +} + +// SecuredClusterConfig holds deployment settings for the SecuredCluster component. type SecuredClusterConfig struct { Namespace string `yaml:"namespace"` ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` @@ -197,38 +189,62 @@ type SecuredClusterConfig struct { Spec map[string]interface{} `yaml:"spec"` } -func NewConfig() Config { - return Config{ - Roxie: NewRoxieConfig(), - Central: DefaultCentralConfig(), - SecuredCluster: DefaultSecuredClusterConfig(), +// DefaultSecuredClusterConfig returns a SecuredClusterConfig with sensible defaults. +func DefaultSecuredClusterConfig() SecuredClusterConfig { + return SecuredClusterConfig{ + DeployTimeout: DefaultSecuredClusterWaitTimeout, + Namespace: "acs-sensor", + Spec: make(map[string]interface{}), } } -func NewRoxieConfig() RoxieConfig { - return RoxieConfig{ - FeatureFlags: make(map[string]bool), +// ConfigureSpec applies feature flags and the central endpoint to the SecuredCluster spec. +func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { + if err := helpers.DeepMerge(s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { + return err + } + + if err := helpers.DeepMerge(s.Spec, map[string]interface{}{ + "centralEndpoint": internalCentralEndpoint(centralConfig.Namespace), + }); err != nil { + return err } + + return nil } -func DefaultCentralConfig() CentralConfig { - return CentralConfig{ - DeployTimeout: DefaultCentralWaitTimeout, - Namespace: "acs-central", - Spec: map[string]interface{}{ - "central": map[string]interface{}{ - "telemetry": map[string]interface{}{ - "enabled": false, - }, +// CustomResource builds an unstructured SecuredCluster custom resource from this config. +func (s *SecuredClusterConfig) CustomResource() (map[string]interface{}, error) { + cr := map[string]interface{}{ + "apiVersion": "platform.stackrox.io/v1alpha1", + "kind": "SecuredCluster", + "metadata": map[string]interface{}{ + "name": "stackrox-secured-cluster-services", + "namespace": s.Namespace, + "labels": map[string]string{ + "app": "stackrox-secured-cluster", + }, + }, + "spec": map[string]interface{}{ + "clusterName": generateClusterName(), + "imagePullSecrets": []map[string]string{ + {"name": "stackrox"}, }, }, } -} + if s.ResourceProfile == types.ResourceProfileAuto { + return nil, fmt.Errorf("resource profile 'auto' must have been resolved before building the CR") + } + if s.ResourceProfile != types.ResourceProfileAcsDefaults { + if err := helpers.DeepMerge(cr, getSecuredClusterResourcesOperator(s.ResourceProfile)); err != nil { + return nil, fmt.Errorf("merging resource profile into SecuredCluster CR: %w", err) + } + } -func DefaultSecuredClusterConfig() SecuredClusterConfig { - return SecuredClusterConfig{ - DeployTimeout: DefaultSecuredClusterWaitTimeout, - Namespace: "acs-sensor", - Spec: make(map[string]interface{}), + if err := helpers.DeepMerge(cr, map[string]interface{}{ + "spec": s.Spec, + }); err != nil { + return nil, fmt.Errorf("merging spec into SecuredCluster CR: %w", err) } + return cr, nil } From 23c117d13e7b6645d0768a251e57a62c819d3e07 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:24:29 +0200 Subject: [PATCH 13/30] Add mergo dependency --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index e11e53c..681a416 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/cli v29.2.1+incompatible // indirect diff --git a/go.sum b/go.sum index 10f5301..86ff011 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= From bf5441b57dce6aa439f2ac9c150047562022ae88 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:25:02 +0200 Subject: [PATCH 14/30] Add DeepCopy method for deployer.Config --- internal/deployer/config.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 243d648..5b07320 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -6,6 +6,7 @@ import ( "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/types" + "gopkg.in/yaml.v3" ) // Config is the top-level deployment configuration, combining settings for @@ -26,6 +27,18 @@ func NewConfig() Config { } } +func (c *Config) DeepCopy() (*Config, error) { + data, err := yaml.Marshal(c) + if err != nil { + return nil, err + } + var copy Config + if err := yaml.Unmarshal(data, ©); err != nil { + return nil, err + } + return ©, nil +} + // MergeIn deep-merges another Config into this one. func (c *Config) MergeIn(other *Config) error { if other == nil { From 5e76b9082cd3708fbb0312613914a8a059dc0f98 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:25:29 +0200 Subject: [PATCH 15/30] Let getDefaultsForClusterType return deployer.Config, not unstructured map --- internal/clusterdefaults/clusterdefaults.go | 56 ++++----------------- 1 file changed, 11 insertions(+), 45 deletions(-) diff --git a/internal/clusterdefaults/clusterdefaults.go b/internal/clusterdefaults/clusterdefaults.go index ad9f21a..1202f8c 100644 --- a/internal/clusterdefaults/clusterdefaults.go +++ b/internal/clusterdefaults/clusterdefaults.go @@ -43,55 +43,21 @@ func ApplyClusterDefaults( return defaultsCopy, nil } -// getDefaultsForClusterType returns the recommended defaults for a given cluster type. -func getDefaultsForClusterType(clusterType types.ClusterType) map[string]interface{} { +func getDefaultsForClusterType(clusterType types.ClusterType) *deployer.Config { switch clusterType { - case types.ClusterTypeKind: - // Kind clusters are local, lightweight, and don't support LoadBalancer. - return map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": types.ExposureNone.String(), - "portForwarding": true, - }, - } - - case types.ClusterTypeMinikube: - return map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": types.ExposureNone.String(), - "portForwarding": true, - }, - } - - case types.ClusterTypeK3s: - return map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": types.ExposureNone.String(), - "portForwarding": true, + case types.ClusterTypeKind, types.ClusterTypeMinikube, types.ClusterTypeK3s, types.ClusterTypeCRC: + return &deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), }, } - case types.ClusterTypeCRC: - return map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": types.ExposureNone.String(), - "portForwarding": true, - }, - } - - case types.ClusterTypeInfraGKE: - return map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": types.ExposureLoadBalancer.String(), - "portForwarding": false, - }, - } - - case types.ClusterTypeInfraOpenShift4: - return map[string]interface{}{ - "central": map[string]interface{}{ - "exposure": types.ExposureLoadBalancer.String(), - "portForwarding": false, + case types.ClusterTypeInfraGKE, types.ClusterTypeInfraOpenShift4: + return &deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureLoadBalancer), + PortForwarding: ptr.To(false), }, } From 83ef45eb868131e19bbbe3ac48316fe436d1ba55 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:30:17 +0200 Subject: [PATCH 16/30] Rewrite ApplyClusterDefaults using mergo --- internal/clusterdefaults/clusterdefaults.go | 26 ++++++++------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/internal/clusterdefaults/clusterdefaults.go b/internal/clusterdefaults/clusterdefaults.go index 1202f8c..9985f63 100644 --- a/internal/clusterdefaults/clusterdefaults.go +++ b/internal/clusterdefaults/clusterdefaults.go @@ -1,10 +1,12 @@ package clusterdefaults import ( + "fmt" + + "dario.cat/mergo" "github.com/stackrox/roxie/internal/deployer" - "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/types" - "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" ) // ApplyClusterDefaults detects the cluster type and applies appropriate defaults to the @@ -13,7 +15,7 @@ import ( func ApplyClusterDefaults( clusterType types.ClusterType, config *deployer.Config, -) (map[string]interface{}, error) { +) (*deployer.Config, error) { if config == nil { panic("applying cluster defaults to nil config") } @@ -23,23 +25,15 @@ func ApplyClusterDefaults( } // Make a copy. - defaultsCopy := runtime.DeepCopyJSON(defaults) - - configMap, err := helpers.StructToMap(config) + defaultsCopy, err := defaults.DeepCopy() if err != nil { - return nil, err - } - mergeResult := make(map[string]interface{}) - if err = helpers.DeepMerge(mergeResult, defaults); err != nil { - return nil, err - } - if err := helpers.DeepMerge(mergeResult, configMap); err != nil { - return nil, err + return nil, fmt.Errorf("deep-copying cluster defaults: %w", err) } - if err := helpers.MapToStruct(mergeResult, config); err != nil { - return nil, err + if err := mergo.Merge(config, defaultsCopy, mergo.WithoutDereference); err != nil { + return nil, fmt.Errorf("merging-in cluster defaults: %w", err) } + return defaultsCopy, nil } From 27e463c18a0a58505b1ab16e5433b94eba46cb1a Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:38:02 +0200 Subject: [PATCH 17/30] Generalize test case, using the whole deployer.Config not just hand-picked fields --- .../clusterdefaults/clusterdefaults_test.go | 74 +++++++++++++------ 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index d3074a6..576775a 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -15,64 +15,90 @@ func TestClusterDefaults(t *testing.T) { name string clusterType types.ClusterType wantResourceProfile types.ResourceProfile - wantExposure *types.Exposure - wantPortForwarding *bool + wantConfig deployer.Config }{ { name: "kind cluster with default params", clusterType: types.ClusterTypeKind, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: ptr.To(types.ExposureNone), - wantPortForwarding: ptr.To(true), + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, }, { name: "kind cluster with already correct params", clusterType: types.ClusterTypeKind, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: ptr.To(types.ExposureNone), - wantPortForwarding: ptr.To(true), + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, }, { name: "kind cluster with partial match", clusterType: types.ClusterTypeKind, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: ptr.To(types.ExposureNone), - wantPortForwarding: ptr.To(true), + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, }, { name: "unknown cluster type", clusterType: types.ClusterTypeUnknown, wantResourceProfile: types.ResourceProfileAcsDefaults, - wantExposure: nil, - wantPortForwarding: nil, + wantConfig: deployer.Config{}, }, { name: "minikube cluster", clusterType: types.ClusterTypeMinikube, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: ptr.To(types.ExposureNone), - wantPortForwarding: ptr.To(true), + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, }, { name: "crc cluster", clusterType: types.ClusterTypeCRC, wantResourceProfile: types.ResourceProfileSmall, - wantExposure: ptr.To(types.ExposureNone), - wantPortForwarding: ptr.To(true), + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, }, { name: "gke cluster", clusterType: types.ClusterTypeInfraGKE, wantResourceProfile: types.ResourceProfileMedium, - wantExposure: ptr.To(types.ExposureLoadBalancer), - wantPortForwarding: ptr.To(false), + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureLoadBalancer), + PortForwarding: ptr.To(false), + }, + }, }, { name: "openshift cluster", clusterType: types.ClusterTypeInfraOpenShift4, wantResourceProfile: types.ResourceProfileMedium, - wantExposure: ptr.To(types.ExposureLoadBalancer), - wantPortForwarding: ptr.To(false), + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureLoadBalancer), + PortForwarding: ptr.To(false), + }, + }, }, } @@ -87,20 +113,20 @@ func TestClusterDefaults(t *testing.T) { t.Errorf("Apply() resources = %v, want %v", gotResourceProfile, tt.wantResourceProfile) } - if tt.wantExposure == nil { + if tt.wantConfig.Central.Exposure == nil { assert.Nil(t, config.Central.Exposure, "central exposure is not nil") } else { require.NotNil(t, config.Central.Exposure, "central exposure is nil") - assert.Equal(t, *tt.wantExposure, *config.Central.Exposure, - "exposure = %v, want %v", *config.Central.Exposure, *tt.wantExposure) + assert.Equal(t, *tt.wantConfig.Central.Exposure, *config.Central.Exposure, + "exposure = %v, want %v", *config.Central.Exposure, *tt.wantConfig.Central.Exposure) } - if tt.wantPortForwarding == nil { + if tt.wantConfig.Central.PortForwarding == nil { assert.Nil(t, config.Central.PortForwarding, "central port forwarding is not nil") } else { require.NotNil(t, config.Central.PortForwarding, "central port forwarding is nil") - assert.Equal(t, *tt.wantPortForwarding, *config.Central.PortForwarding, - "portForward = %v, want %v", *config.Central.PortForwarding, *tt.wantPortForwarding) + assert.Equal(t, *tt.wantConfig.Central.PortForwarding, *config.Central.PortForwarding, + "portForward = %v, want %v", *config.Central.PortForwarding, *tt.wantConfig.Central.PortForwarding) } }) } From badabdbf3f753c9355f7bc87a88fcc1614a122b6 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:43:36 +0200 Subject: [PATCH 18/30] Move checking of resourceProfile into its own test --- .../clusterdefaults/clusterdefaults_test.go | 105 ++++++++++++------ 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index 576775a..05f23a5 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -12,15 +12,13 @@ import ( func TestClusterDefaults(t *testing.T) { tests := []struct { - name string - clusterType types.ClusterType - wantResourceProfile types.ResourceProfile - wantConfig deployer.Config + name string + clusterType types.ClusterType + wantConfig deployer.Config }{ { - name: "kind cluster with default params", - clusterType: types.ClusterTypeKind, - wantResourceProfile: types.ResourceProfileSmall, + name: "kind cluster with default params", + clusterType: types.ClusterTypeKind, wantConfig: deployer.Config{ Central: deployer.CentralConfig{ Exposure: ptr.To(types.ExposureNone), @@ -29,9 +27,8 @@ func TestClusterDefaults(t *testing.T) { }, }, { - name: "kind cluster with already correct params", - clusterType: types.ClusterTypeKind, - wantResourceProfile: types.ResourceProfileSmall, + name: "kind cluster with already correct params", + clusterType: types.ClusterTypeKind, wantConfig: deployer.Config{ Central: deployer.CentralConfig{ Exposure: ptr.To(types.ExposureNone), @@ -40,9 +37,8 @@ func TestClusterDefaults(t *testing.T) { }, }, { - name: "kind cluster with partial match", - clusterType: types.ClusterTypeKind, - wantResourceProfile: types.ResourceProfileSmall, + name: "kind cluster with partial match", + clusterType: types.ClusterTypeKind, wantConfig: deployer.Config{ Central: deployer.CentralConfig{ Exposure: ptr.To(types.ExposureNone), @@ -51,15 +47,13 @@ func TestClusterDefaults(t *testing.T) { }, }, { - name: "unknown cluster type", - clusterType: types.ClusterTypeUnknown, - wantResourceProfile: types.ResourceProfileAcsDefaults, - wantConfig: deployer.Config{}, + name: "unknown cluster type", + clusterType: types.ClusterTypeUnknown, + wantConfig: deployer.Config{}, }, { - name: "minikube cluster", - clusterType: types.ClusterTypeMinikube, - wantResourceProfile: types.ResourceProfileSmall, + name: "minikube cluster", + clusterType: types.ClusterTypeMinikube, wantConfig: deployer.Config{ Central: deployer.CentralConfig{ Exposure: ptr.To(types.ExposureNone), @@ -68,9 +62,8 @@ func TestClusterDefaults(t *testing.T) { }, }, { - name: "crc cluster", - clusterType: types.ClusterTypeCRC, - wantResourceProfile: types.ResourceProfileSmall, + name: "crc cluster", + clusterType: types.ClusterTypeCRC, wantConfig: deployer.Config{ Central: deployer.CentralConfig{ Exposure: ptr.To(types.ExposureNone), @@ -79,9 +72,8 @@ func TestClusterDefaults(t *testing.T) { }, }, { - name: "gke cluster", - clusterType: types.ClusterTypeInfraGKE, - wantResourceProfile: types.ResourceProfileMedium, + name: "gke cluster", + clusterType: types.ClusterTypeInfraGKE, wantConfig: deployer.Config{ Central: deployer.CentralConfig{ Exposure: ptr.To(types.ExposureLoadBalancer), @@ -90,9 +82,8 @@ func TestClusterDefaults(t *testing.T) { }, }, { - name: "openshift cluster", - clusterType: types.ClusterTypeInfraOpenShift4, - wantResourceProfile: types.ResourceProfileMedium, + name: "openshift cluster", + clusterType: types.ClusterTypeInfraOpenShift4, wantConfig: deployer.Config{ Central: deployer.CentralConfig{ Exposure: ptr.To(types.ExposureLoadBalancer), @@ -108,11 +99,6 @@ func TestClusterDefaults(t *testing.T) { _, err := ApplyClusterDefaults(tt.clusterType, &config) require.NoError(t, err) - gotResourceProfile := ResolveAutoResourceProfile(tt.clusterType) - if gotResourceProfile != tt.wantResourceProfile { - t.Errorf("Apply() resources = %v, want %v", gotResourceProfile, tt.wantResourceProfile) - } - if tt.wantConfig.Central.Exposure == nil { assert.Nil(t, config.Central.Exposure, "central exposure is not nil") } else { @@ -131,3 +117,54 @@ func TestClusterDefaults(t *testing.T) { }) } } + +func TestResolveAutoResourceProfile(t *testing.T) { + tests := []struct { + name string + clusterType types.ClusterType + want types.ResourceProfile + }{ + { + name: "kind cluster", + clusterType: types.ClusterTypeKind, + want: types.ResourceProfileSmall, + }, + { + name: "minikube cluster", + clusterType: types.ClusterTypeMinikube, + want: types.ResourceProfileSmall, + }, + { + name: "k3s cluster", + clusterType: types.ClusterTypeK3s, + want: types.ResourceProfileSmall, + }, + { + name: "crc cluster", + clusterType: types.ClusterTypeCRC, + want: types.ResourceProfileSmall, + }, + { + name: "gke cluster", + clusterType: types.ClusterTypeInfraGKE, + want: types.ResourceProfileMedium, + }, + { + name: "openshift cluster", + clusterType: types.ClusterTypeInfraOpenShift4, + want: types.ResourceProfileMedium, + }, + { + name: "unknown cluster type", + clusterType: types.ClusterTypeUnknown, + want: types.ResourceProfileAcsDefaults, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveAutoResourceProfile(tt.clusterType) + assert.Equal(t, tt.want, got) + }) + } +} From 0e90f040c48a64c903637910cb3dacce97a0a7ba Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:46:11 +0200 Subject: [PATCH 19/30] Move config pre defaulting to test cases --- internal/clusterdefaults/clusterdefaults_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index 05f23a5..5eac13d 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -14,6 +14,7 @@ func TestClusterDefaults(t *testing.T) { tests := []struct { name string clusterType types.ClusterType + config deployer.Config wantConfig deployer.Config }{ { @@ -95,7 +96,7 @@ func TestClusterDefaults(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - config := deployer.NewConfig() + config := tt.config _, err := ApplyClusterDefaults(tt.clusterType, &config) require.NoError(t, err) From d681edf173af98756f62a21ce0c7803676a032dd Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:48:35 +0200 Subject: [PATCH 20/30] New test case verifying that defaults won't overwrite explicitly set values --- internal/clusterdefaults/clusterdefaults_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/clusterdefaults/clusterdefaults_test.go b/internal/clusterdefaults/clusterdefaults_test.go index 5eac13d..f0dab56 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -92,6 +92,22 @@ func TestClusterDefaults(t *testing.T) { }, }, }, + { + name: "cluster does not override existing values", + clusterType: types.ClusterTypeInfraGKE, + config: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, + }, } for _, tt := range tests { From f154aa422674b11359e4dd74664dcb75ebb04066 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 13:54:12 +0200 Subject: [PATCH 21/30] Added omitempty --- internal/deployer/config.go | 48 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 5b07320..69eab9d 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -12,10 +12,10 @@ import ( // Config is the top-level deployment configuration, combining settings for // roxie itself, the operator, Central, and SecuredCluster. type Config struct { - Roxie RoxieConfig `yaml:"roxie"` - Operator OperatorConfig `yaml:"operator"` - Central CentralConfig `yaml:"central"` - SecuredCluster SecuredClusterConfig `yaml:"securedCluster"` + Roxie RoxieConfig `yaml:"roxie,omitempty"` + Operator OperatorConfig `yaml:"operator,omitempty"` + Central CentralConfig `yaml:"central,omitempty"` + SecuredCluster SecuredClusterConfig `yaml:"securedCluster,omitempty"` } // NewConfig returns a Config populated with default values. @@ -65,9 +65,9 @@ func (c *Config) MergeInUnstructured(m map[string]interface{}) error { // RoxieConfig holds roxie-level settings such as version and feature flags. type RoxieConfig struct { - Version string `yaml:"version"` - KonfluxImages bool `yaml:"konfluxImages"` - FeatureFlags map[string]bool `yaml:"featureFlags"` + Version string `yaml:"version,omitempty"` + KonfluxImages bool `yaml:"konfluxImages,omitempty"` + FeatureFlags map[string]bool `yaml:"featureFlags,omitempty"` } // NewRoxieConfig returns a RoxieConfig with initialized defaults. @@ -79,9 +79,9 @@ func NewRoxieConfig() RoxieConfig { // OperatorConfig controls how the ACS operator is deployed. type OperatorConfig struct { - SkipDeployment bool `yaml:"skipDeployment"` - DeployViaOlm bool `yaml:"deployViaOlm"` - Version string `yaml:"version"` + SkipDeployment bool `yaml:"skipDeployment,omitempty"` + DeployViaOlm bool `yaml:"deployViaOlm,omitempty"` + Version string `yaml:"version,omitempty"` } // Configure derives the operator version from the roxie configuration. @@ -92,14 +92,14 @@ func (c *OperatorConfig) Configure(roxieConfig *RoxieConfig) error { // CentralConfig holds deployment settings for the Central component. type CentralConfig struct { - Namespace string `yaml:"namespace"` - ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` - PauseReconciliation bool `yaml:"pauseReconciliation"` - Exposure *types.Exposure `yaml:"exposure"` - DeployTimeout time.Duration `yaml:"deployTimeout"` - PortForwarding *bool `yaml:"portForwarding"` - EarlyReadiness bool `yaml:"earlyReadiness"` - Spec map[string]interface{} `yaml:"spec"` + Namespace string `yaml:"namespace,omitempty"` + ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` + PauseReconciliation bool `yaml:"pauseReconciliation,omitempty"` + Exposure *types.Exposure `yaml:"exposure,omitempty"` + DeployTimeout time.Duration `yaml:"deployTimeout,omitempty"` + PortForwarding *bool `yaml:"portForwarding,omitempty"` + EarlyReadiness bool `yaml:"earlyReadiness,omitempty"` + Spec map[string]interface{} `yaml:"spec,omitempty"` } // DefaultCentralConfig returns a CentralConfig with sensible defaults. @@ -194,12 +194,12 @@ func (c *CentralConfig) CustomResource() (map[string]interface{}, error) { // SecuredClusterConfig holds deployment settings for the SecuredCluster component. type SecuredClusterConfig struct { - Namespace string `yaml:"namespace"` - ResourceProfile types.ResourceProfile `yaml:"resourceProfile"` - PauseReconciliation bool `yaml:"pauseReconciliation"` - DeployTimeout time.Duration `yaml:"deployTimeout"` - EarlyReadiness bool `yaml:"earlyReadiness"` - Spec map[string]interface{} `yaml:"spec"` + Namespace string `yaml:"namespace,omitempty"` + ResourceProfile types.ResourceProfile `yaml:"resourceProfile,omitempty"` + PauseReconciliation bool `yaml:"pauseReconciliation,omitempty"` + DeployTimeout time.Duration `yaml:"deployTimeout,omitempty"` + EarlyReadiness bool `yaml:"earlyReadiness,omitempty"` + Spec map[string]interface{} `yaml:"spec,omitempty"` } // DefaultSecuredClusterConfig returns a SecuredClusterConfig with sensible defaults. From d6fc8db45b422e73789aad07b8caac7f40121fcc Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 14:20:29 +0200 Subject: [PATCH 22/30] Added log message --- cmd/deploy.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/deploy.go b/cmd/deploy.go index 95bc71e..9f54ff9 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -458,6 +458,7 @@ func configureConfig(log *logger.Logger, components component.Component, deployS } if deploySettings.SecuredCluster.ResourceProfile == types.ResourceProfileAuto { profile := clusterdefaults.ResolveAutoResourceProfile(clusterType) + log.Dimf("Selecting resource profile %v for SecuredCluster", profile) deploySettings.SecuredCluster.ResourceProfile = profile } From 26eb1dd7939cf731df6b5c47cd5f5a80ef65a95a Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 14:31:52 +0200 Subject: [PATCH 23/30] go mod tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 681a416..7f00eef 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/stackrox/roxie go 1.25.6 require ( + dario.cat/mergo v1.0.2 github.com/fatih/color v1.16.0 github.com/google/go-containerregistry v0.21.0 github.com/spf13/cobra v1.10.2 @@ -15,7 +16,6 @@ require ( ) require ( - dario.cat/mergo v1.0.2 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/cli v29.2.1+incompatible // indirect From 5fb113017278c23a41438e60c4dcbc403fe5dfb9 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 14:33:59 +0200 Subject: [PATCH 24/30] Accidentally left out during manual merge --- internal/env/env.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/env/env.go b/internal/env/env.go index 1773c3c..9d85814 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -147,6 +147,11 @@ func DetectClusterType(config KubeConfig, apiResources []string) types.ClusterTy return types.ClusterTypeInfraGKE } + // Minikube clusters typically have context name "minikube". + if contextLower == "minikube" || strings.HasPrefix(contextLower, "minikube-") { + return types.ClusterTypeMinikube + } + // Check for OpenShift 4 clusters. if isOpenShift4(apiResources) { if isInfraOpenShift4(config) { From 9d862ea7fbe84eb32711e1f150c0bcee22c34302 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 11 May 2026 12:03:27 +0200 Subject: [PATCH 25/30] Reorganize flag parsing setup. Also: * Switch to using mergo (& removing dead code after the switch). * Add tests for CLI flag parsing. --- cmd/deploy.go | 422 ++++++++++++++---------------------- cmd/deploy_test.go | 207 ++++++++++++++++++ cmd/flags.go | 97 +++++++++ cmd/teardown.go | 28 +-- internal/deployer/config.go | 24 -- 5 files changed, 471 insertions(+), 307 deletions(-) create mode 100644 cmd/deploy_test.go create mode 100644 cmd/flags.go diff --git a/cmd/deploy.go b/cmd/deploy.go index 9f54ff9..42b9f1f 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "dario.cat/mergo" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/stackrox/roxie/internal/clusterdefaults" @@ -28,62 +29,7 @@ var ( sharedNamespace = "stackrox" ) -// configShortCut implements pflag.Value so that CLI flags can directly mutate -// the deployment configuration. Each flag carries its own apply function, which -// may modify one or more fields in deployer.Config, or merge in an arbitrary -// YAML overlay. -type configShortCut struct { - settings *deployer.Config - flagType string - applyFn func(val string, settings *deployer.Config) error -} - -func newConfigShortCut( - settings *deployer.Config, - flagType string, - applyFn func(val string, settings *deployer.Config) error, -) *configShortCut { - return &configShortCut{ - flagType: flagType, - settings: settings, - applyFn: applyFn, - } -} - -func (y *configShortCut) Set(val string) error { - return y.applyFn(val, y.settings) -} - -func (y *configShortCut) String() string { - return "" // Not sure what to return here. -} - -func (y *configShortCut) Type() string { - return y.flagType -} - -func newConfigShortCutBool(settings *deployer.Config, path string) *configShortCut { - pathElements := strings.Split(path, ".") - applyFn := func(val string, settings *deployer.Config) error { - var valParsed bool - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - u, err := helpers.StructToMap(settings) - if err != nil { - return err - } - if err := unstructured.SetNestedField(u, valParsed, pathElements...); err != nil { - return err - } - return helpers.MapToStruct(u, settings) - } - return newConfigShortCut(settings, "bool", applyFn) -} - func newDeployCmd(settings *deployer.Config) *cobra.Command { - var flag *pflag.Flag - cmd := &cobra.Command{ Use: "deploy [component]", Short: "Deploy ACS components", @@ -105,113 +51,93 @@ Examples: // --envrc . cmd.Flags().StringVar(&envrc, "envrc", "", "Write environment to file instead of spawning sub-shell") - // --olm[=true/false]. - flag = cmd.Flags().VarPF(newConfigShortCutBool(settings, "operator.deployViaOlm"), "olm", "", "Deploy operator via OLM (requires OLM installed)") - flag.NoOptDefVal = "true" - - // --konflux[=true/false] - flag = cmd.Flags().VarPF(newConfigShortCutBool(settings, "roxie.konfluxImages"), "konflux", "", "Use Konflux images") - flag.NoOptDefVal = "true" - - // --deploy-operator[=true/false]. - flag = cmd.Flags().VarPF( - newConfigShortCut( - settings, - "bool", - func(val string, settings *deployer.Config) error { - var valParsed bool - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - settings.Operator.SkipDeployment = !valParsed - return nil - }, - ), "deploy-operator", "", "Whether to deploy and manage the operator") - flag.NoOptDefVal = "true" - - // --port-forward[=true/false]. - flag = cmd.Flags().VarPF( - newConfigShortCut( - settings, "bool", - func(val string, settings *deployer.Config) error { - var valParsed bool - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - settings.Central.PortForwarding = ptr.To(valParsed) - return nil - }, - ), "port-forwarding", "", "Enable localhost port-forward for Central") - flag.NoOptDefVal = "true" - - // --pause-reconciliation[=true/false]. - flag = cmd.Flags().VarPF( - newConfigShortCut( - settings, "bool", - func(val string, settings *deployer.Config) error { - var valParsed bool - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - settings.Central.PauseReconciliation = valParsed - settings.SecuredCluster.PauseReconciliation = valParsed - return nil - }, - ), "pause-reconciliation", "", "Pause reconciliation after deployment") - flag.NoOptDefVal = "true" - - // --config/-c . - cmd.Flags().VarP( - newConfigShortCut( - settings, "file", - func(filename string, settings *deployer.Config) error { - if filename == "-" { - filename = "/dev/stdin" - } - data, err := os.ReadFile(filename) - if err != nil { - return fmt.Errorf("failed to read config file %q: %w", filename, err) - } - var obj map[string]interface{} - if err := yaml.Unmarshal(data, &obj); err != nil { - return fmt.Errorf("failed to decode config file %q: %w", filename, err) - } - return settings.MergeInUnstructured(obj) - }, - ), "config", "c", "Path to YAML config file") - - // --exposure loadbalancer/none. - cmd.Flags().Var( - newConfigShortCut( - settings, "exposure", - func(val string, settings *deployer.Config) error { - var exposure types.Exposure - if err := yaml.Unmarshal([]byte(val), &exposure); err != nil { - return err - } - settings.Central.Exposure = ptr.To(exposure) - return nil - }, - ), "exposure", "Central exposure backend (loadbalancer, none)") - - // --resources . - cmd.Flags().Var( - newConfigShortCut( - settings, "resource-profile", - func(val string, settings *deployer.Config) error { - var valParsed types.ResourceProfile - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - settings.Central.ResourceProfile = valParsed - settings.SecuredCluster.ResourceProfile = valParsed - return nil - }, - ), "resources", fmt.Sprintf("Resource sizing preset (%s)", types.ResourceProfilesJoined())) - - // --set . - cmd.Flags().Var(newConfigShortCut(settings, "set-expression", - func(expr string, settings *deployer.Config) error { + registerFlag(cmd, settings, "olm", "Deploy operator via OLM (requires OLM installed)", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + config.Operator.DeployViaOlm = val + return nil + }), + ) + + registerFlag(cmd, settings, "konflux", "Use Konflux images", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + config.Roxie.KonfluxImages = val + return nil + }), + ) + + registerFlag(cmd, settings, "deploy-operator", "Whether to deploy and manage the operator", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + config.Operator.SkipDeployment = !val + return nil + }), + ) + + registerFlag(cmd, settings, "port-forwarding", "Enable localhost port-forward for Central", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + config.Central.PortForwarding = ptr.To(val) + return nil + }), + ) + + registerFlag(cmd, settings, "pause-reconciliation", "Pause reconciliation after deployment", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + config.Central.PauseReconciliation = val + config.SecuredCluster.PauseReconciliation = val + return nil + }), + ) + + registerFlag(cmd, settings, "config", "Path to YAML config file", + WithShortName("c"), + WithApplyFn("filename", func(config *deployer.Config, filename string) error { + if filename == "-" { + filename = "/dev/stdin" + } + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read config file %q: %w", filename, err) + } + var configFromFile deployer.Config + if err := yaml.Unmarshal(data, &configFromFile); err != nil { + return fmt.Errorf("failed to unmarshal config file %q: %w", filename, err) + } + if err := mergo.Merge(config, configFromFile, mergo.WithOverride, mergo.WithoutDereference); err != nil { + return fmt.Errorf("mergin config file %q into deployer Config: %w", filename, err) + } + return nil + }), + ) + + registerFlag(cmd, settings, "exposure", "Central exposure backend (loadbalancer, none)", + WithApplyFn("exposure", func(config *deployer.Config, val string) error { + var exposure types.Exposure + if err := yaml.Unmarshal([]byte(val), &exposure); err != nil { + return err + } + config.Central.Exposure = ptr.To(exposure) + return nil + }), + ) + + registerFlag(cmd, settings, "resources", fmt.Sprintf("Resource sizing preset (%s)", types.ResourceProfilesJoined()), + WithApplyFn("resource-profile", func(config *deployer.Config, val string) error { + var valParsed types.ResourceProfile + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + config.Central.ResourceProfile = valParsed + config.SecuredCluster.ResourceProfile = valParsed + return nil + }), + ) + + registerFlag(cmd, settings, "set", "Set expressions, e.g. securedCluster.spec.clusterName=sensor", + WithApplyFn("set-expression", func(config *deployer.Config, expr string) error { key, yamlValue, found := strings.Cut(expr, "=") if !found { return fmt.Errorf("invalid set expression '%s': expected format 'key.path=value'", expr) @@ -221,6 +147,7 @@ Examples: return fmt.Errorf("failed to unmarshal value '%s' for key '%s': %w", yamlValue, key, err) } // SetNestedField requires JSON-compatible types: float64 for numbers, not int. + // Fix types if needed. switch v := val.(type) { case int: val = float64(v) @@ -229,119 +156,84 @@ Examples: } pathElements := strings.Split(key, ".") if len(pathElements) > 0 && pathElements[0] == "spec" { - // Special error reporting for this case, because it was supported previously. - return errors.New("set expression begin with 'spec.' -- it must be prefixed with 'central.' or 'securedCluster.'") + return errors.New("set expression begins with 'spec.' -- it must be prefixed with 'central.' or 'securedCluster.'") } - u, err := helpers.StructToMap(settings) - if err != nil { + unstructuredPatch := make(map[string]interface{}) + if err := unstructured.SetNestedField(unstructuredPatch, val, pathElements...); err != nil { return err } - if err := unstructured.SetNestedField(u, val, pathElements...); err != nil { + var patch deployer.Config + if err := helpers.MapToStruct(unstructuredPatch, &patch); err != nil { return err } - var updatedSettings deployer.Config - if err := helpers.MapToStruct(u, &updatedSettings); err != nil { - return err + if reflect.DeepEqual(patch, deployer.Config{}) { + return fmt.Errorf("set expression %q had no effect -- typo?", expr) } - if reflect.DeepEqual(settings, &updatedSettings) { - return fmt.Errorf("Set expression %q had no effect -- typo?", expr) + + if err := mergo.Merge(config, &patch, mergo.WithOverride, mergo.WithoutDereference); err != nil { + return fmt.Errorf("merging set-expression %q into deployer Config: %w", expr, err) + } + + return nil + }), + ) + + registerFlag(cmd, settings, "single-namespace", "Deploy all components in a single namespace ('stackrox')", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + if val { + config.Central.Namespace = sharedNamespace + config.SecuredCluster.Namespace = sharedNamespace } - *settings = updatedSettings + return nil + }), + ) + registerFlag(cmd, settings, "tag", "Main image tag to use for deployment (takes precedence over MAIN_IMAGE_TAG environment variable)", + WithShortName("t"), + WithApplyFn("version", func(config *deployer.Config, mainImageTag string) error { + config.Roxie.Version = mainImageTag return nil + }), + ) - }, - ), "set", "Set expressions, e.g. securedCluster.spec.clusterName=sensor") - - // --single-namespace[=true/false]. - flag = cmd.Flags().VarPF( - newConfigShortCut( - settings, "bool", - func(val string, settings *deployer.Config) error { - var valParsed bool - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - if valParsed { - settings.Central.Namespace = sharedNamespace - settings.SecuredCluster.Namespace = sharedNamespace - } - return nil - }, - ), "single-namespace", "", "Deploy all components in a single namespace ('stackrox')") - flag.NoOptDefVal = "true" - - // --tag/-t
. - cmd.Flags().VarP( - newConfigShortCut( - settings, "version", - func(mainImageTag string, settings *deployer.Config) error { - settings.Roxie.Version = mainImageTag - return nil - }, - ), "tag", "t", "Main image tag to use for deployment (takes precedence over MAIN_IMAGE_TAG environment variable)") - - // --features - cmd.Flags().Var( - newConfigShortCut( - settings, "feature-flags", - func(featureFlagExpr string, settings *deployer.Config) error { - featureFlags, err := deployer.ParseFeatureFlags([]string{featureFlagExpr}) - if err != nil { - return fmt.Errorf("parsing feature flags: %w", err) - } - for k, v := range featureFlags { - settings.Roxie.FeatureFlags[k] = v - } - return nil - }, - ), "features", "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)") - - // --central-wait . - cmd.Flags().Var( - newConfigShortCut( - settings, "duration", - func(val string, settings *deployer.Config) error { - duration, err := time.ParseDuration(val) - if err != nil { - return err - } - settings.Central.DeployTimeout = duration - return nil - }, - ), "central-wait", "maximum wait time for central to become ready (e.g., 5m, 10m)") - - // --secured-cluster-wait . - cmd.Flags().Var( - newConfigShortCut( - settings, "duration", - func(val string, settings *deployer.Config) error { - duration, err := time.ParseDuration(val) - if err != nil { - return err - } - settings.SecuredCluster.DeployTimeout = duration - return nil - }, - ), "secured-cluster-wait", "maximum wait time for secured cluster to become ready (e.g., 5m, 10m)") - - // --early-readiness[=true/false]. - flag = cmd.Flags().VarPF( - newConfigShortCut( - settings, "bool", - func(val string, settings *deployer.Config) error { - var valParsed bool - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - if valParsed { - settings.Central.EarlyReadiness = true - settings.SecuredCluster.EarlyReadiness = true - } - return nil - }, - ), "early-readiness", "", "Only wait for essential workloads (central/sensor) to be ready") - flag.NoOptDefVal = "true" + registerFlag(cmd, settings, "features", "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)", + WithApplyFn("feature-flags", func(config *deployer.Config, featureFlagExpr string) error { + featureFlags, err := deployer.ParseFeatureFlags([]string{featureFlagExpr}) + if err != nil { + return fmt.Errorf("parsing feature flags: %w", err) + } + for k, v := range featureFlags { + config.Roxie.FeatureFlags[k] = v + } + return nil + }), + ) + + registerFlag(cmd, settings, "central-wait", "maximum wait time for central to become ready (e.g., 5m, 10m)", + WithApplyFnDuration(func(config *deployer.Config, duration time.Duration) error { + config.Central.DeployTimeout = duration + return nil + }), + ) + + registerFlag(cmd, settings, "secured-cluster-wait", "maximum wait time for secured cluster to become ready (e.g., 5m, 10m)", + WithApplyFnDuration(func(config *deployer.Config, duration time.Duration) error { + config.SecuredCluster.DeployTimeout = duration + return nil + }), + ) + + registerFlag(cmd, settings, "early-readiness", "Only wait for essential workloads (central/sensor) to be ready", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + if val { + config.Central.EarlyReadiness = true + config.SecuredCluster.EarlyReadiness = true + } + return nil + }), + ) // Make --override an alias for --config, for backwards compatibility. cmd.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go new file mode 100644 index 0000000..945d3a4 --- /dev/null +++ b/cmd/deploy_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDeployCmd_Flags(t *testing.T) { + configFilePath := filepath.Join(t.TempDir(), "config.yaml") + + tests := []struct { + name string + config string + args []string + assert func(t *testing.T, cfg deployer.Config) + }{ + { + name: "exposure loadbalancer", + args: []string{"--exposure", "loadbalancer"}, + assert: func(t *testing.T, cfg deployer.Config) { + require.NotNil(t, cfg.Central.Exposure, "Central.Exposure should be set") + assert.Equal(t, types.ExposureLoadBalancer, *cfg.Central.Exposure, "Central.Exposure mismatch") + }, + }, + { + name: "resources small", + args: []string{"--resources", "small"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, types.ResourceProfileSmall, cfg.Central.ResourceProfile, "Central.ResourceProfile mismatch") + assert.Equal(t, types.ResourceProfileSmall, cfg.SecuredCluster.ResourceProfile, "SecuredCluster.ResourceProfile mismatch") + }, + }, + { + name: "tag short flag", + args: []string{"-t", "4.7.0"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, "4.7.0", cfg.Roxie.Version, "Roxie.Version mismatch") + }, + }, + { + name: "tag long flag", + args: []string{"--tag", "4.7.0"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, "4.7.0", cfg.Roxie.Version, "Roxie.Version mismatch") + }, + }, + { + name: "port-forwarding enabled", + args: []string{"--port-forwarding"}, + assert: func(t *testing.T, cfg deployer.Config) { + require.NotNil(t, cfg.Central.PortForwarding, "Central.PortForwarding should be set") + assert.True(t, *cfg.Central.PortForwarding, "Central.PortForwarding mismatch") + }, + }, + { + name: "port-forwarding disabled", + args: []string{"--port-forwarding=false"}, + assert: func(t *testing.T, cfg deployer.Config) { + require.NotNil(t, cfg.Central.PortForwarding, "Central.PortForwarding should be set") + assert.False(t, *cfg.Central.PortForwarding, "Central.PortForwarding mismatch") + }, + }, + { + name: "single-namespace", + args: []string{"--single-namespace"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, "stackrox", cfg.Central.Namespace, "Central.Namespace mismatch") + assert.Equal(t, "stackrox", cfg.SecuredCluster.Namespace, "SecuredCluster.Namespace mismatch") + }, + }, + { + name: "central-wait", + args: []string{"--central-wait", "10m"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, 10*time.Minute, cfg.Central.DeployTimeout, "Central.DeployTimeout mismatch") + }, + }, + { + name: "secured-cluster-wait", + args: []string{"--secured-cluster-wait", "7m"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, 7*time.Minute, cfg.SecuredCluster.DeployTimeout, "SecuredCluster.DeployTimeout mismatch") + }, + }, + { + name: "early-readiness", + args: []string{"--early-readiness"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.True(t, cfg.Central.EarlyReadiness, "Central.EarlyReadiness mismatch") + assert.True(t, cfg.SecuredCluster.EarlyReadiness, "SecuredCluster.EarlyReadiness mismatch") + }, + }, + { + name: "disable early-readiness", + args: []string{"--early-readiness=false"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.False(t, cfg.Central.EarlyReadiness, "Central.EarlyReadiness mismatch") + assert.False(t, cfg.SecuredCluster.EarlyReadiness, "SecuredCluster.EarlyReadiness mismatch") + }, + }, + { + name: "pause-reconciliation", + args: []string{"--pause-reconciliation"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.True(t, cfg.Central.PauseReconciliation, "Central.PauseReconciliation mismatch") + assert.True(t, cfg.SecuredCluster.PauseReconciliation, "SecuredCluster.PauseReconciliation mismatch") + }, + }, + { + name: "olm", + args: []string{"--olm"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.True(t, cfg.Operator.DeployViaOlm, "Operator.DeployViaOlm mismatch") + }, + }, + { + name: "disable deploy-operator", + args: []string{"--deploy-operator=false"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.True(t, cfg.Operator.SkipDeployment, "Operator.SkipDeployment mismatch") + }, + }, + { + name: "multiple flags combined", + args: []string{"--tag", "4.7.0", "--exposure", "loadbalancer", "--early-readiness", "--resources", "small"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, "4.7.0", cfg.Roxie.Version, "Roxie.Version mismatch") + require.NotNil(t, cfg.Central.Exposure, "Central.Exposure should be set") + assert.Equal(t, types.ExposureLoadBalancer, *cfg.Central.Exposure, "Central.Exposure mismatch") + assert.True(t, cfg.Central.EarlyReadiness, "Central.EarlyReadiness mismatch") + assert.Equal(t, types.ResourceProfileSmall, cfg.Central.ResourceProfile, "Central.ResourceProfile mismatch") + }, + }, + { + name: "config file can be used", + config: ` +roxie: + version: 1.2.3 +securedCluster: + spec: + foo: bar +`, + args: []string{"--config", configFilePath}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, "1.2.3", cfg.Roxie.Version, "Roxie.Version mismatch") + assert.True(t, + reflect.DeepEqual(cfg.SecuredCluster.Spec, + map[string]interface{}{ + "foo": "bar", + }), + "SecuredCluster.Spec mismatch", + ) + }, + }, + + { + name: "flags can override earlier specified config file", + config: ` +central: + resourceProfile: small + portForwarding: true +`, + args: []string{"--config", configFilePath, "--port-forwarding=false", "--resources=medium"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, types.ResourceProfileMedium, cfg.Central.ResourceProfile, "Central.ResourceProfile mismatch") + require.NotNil(t, cfg.Central.PortForwarding, "Central.PortForwarding should be set") + assert.False(t, *cfg.Central.PortForwarding, "Central.PortForwarding mismatch") + }, + }, + + { + name: "set expressions can be used", + args: []string{"--set", "roxie.version=0.99.1", "--set", "central.deployTimeout=4m", "--set", "securedCluster.spec.clusterName=foo"}, + assert: func(t *testing.T, cfg deployer.Config) { + assert.Equal(t, "0.99.1", cfg.Roxie.Version, "version mismatch") + assert.Equal(t, 4*time.Minute, cfg.Central.DeployTimeout, "Central.DeployTimeout mismatch") + assert.Equal(t, + map[string]interface{}{ + "clusterName": "foo", + }, + cfg.SecuredCluster.Spec, + "SecuredCluster.Spec mismatch", + ) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.config != "" { + require.NoError(t, os.WriteFile(configFilePath, []byte(tt.config), 0o644)) + } + cfg := deployer.NewConfig() + cmd := newDeployCmd(&cfg) + require.NoError(t, cmd.ParseFlags(tt.args)) + tt.assert(t, cfg) + }) + } +} diff --git a/cmd/flags.go b/cmd/flags.go new file mode 100644 index 0000000..41fcf40 --- /dev/null +++ b/cmd/flags.go @@ -0,0 +1,97 @@ +package main + +import ( + "time" + + "github.com/spf13/cobra" + "github.com/stackrox/roxie/internal/deployer" + "gopkg.in/yaml.v3" +) + +type CliFlag struct { + config *deployer.Config + longName string + shortName string + flagType string + applyFn func(config *deployer.Config, val string) error + noOptDefVal string + description string +} + +type FlagOpt func(opts *CliFlag) + +func (f *CliFlag) Set(val string) error { + return f.applyFn(f.config, val) +} + +func (f *CliFlag) String() string { + return "" // Not sure what to return here. +} + +func (f *CliFlag) Type() string { + return f.flagType +} + +func WithApplyFnBool(boolApplyFn func(config *deployer.Config, val bool) error) FlagOpt { + return func(opts *CliFlag) { + opts.flagType = "bool" + opts.applyFn = func(config *deployer.Config, val string) error { + var valParsed bool + if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { + return err + } + return boolApplyFn(config, valParsed) + } + } +} + +func WithApplyFn(flagType string, stringApplyFn func(config *deployer.Config, val string) error) FlagOpt { + return func(opts *CliFlag) { + opts.flagType = flagType + opts.applyFn = func(config *deployer.Config, val string) error { + return stringApplyFn(config, val) + } + } +} + +func WithApplyFnDuration(durationApplyFn func(config *deployer.Config, duration time.Duration) error) FlagOpt { + return func(opts *CliFlag) { + opts.flagType = "duration" + opts.applyFn = func(config *deployer.Config, val string) error { + var duration time.Duration + duration, err := time.ParseDuration(val) + if err != nil { + return err + } + return durationApplyFn(config, duration) + } + } +} + +func WithNoOptDefVal(defVal string) FlagOpt { + return func(opts *CliFlag) { + opts.noOptDefVal = defVal + } +} + +func WithShortName(shortName string) FlagOpt { + return func(opts *CliFlag) { + opts.shortName = shortName + } +} + +func registerFlag(cmd *cobra.Command, settings *deployer.Config, longName string, description string, flagOpts ...FlagOpt) { + cliFlag := CliFlag{ + config: settings, + longName: longName, + description: description, + } + for _, applyOpt := range flagOpts { + applyOpt(&cliFlag) + + } + flag := cmd.Flags().VarPF(&cliFlag, cliFlag.longName, cliFlag.shortName, cliFlag.description) + if cliFlag.noOptDefVal != "" { + flag.NoOptDefVal = cliFlag.noOptDefVal + } +} diff --git a/cmd/teardown.go b/cmd/teardown.go index 79d0bc5..1c1c5d3 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -10,7 +10,6 @@ import ( "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/logger" - "gopkg.in/yaml.v3" ) func newTeardownCmd(settings *deployer.Config) *cobra.Command { @@ -23,23 +22,16 @@ func newTeardownCmd(settings *deployer.Config) *cobra.Command { RunE: runTeardown, } - // --single-namespace[=true/false]. - flag := cmd.Flags().VarPF( - newConfigShortCut( - settings, "bool", - func(val string, settings *deployer.Config) error { - var valParsed bool - if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { - return err - } - if valParsed { - settings.Central.Namespace = sharedNamespace - settings.SecuredCluster.Namespace = sharedNamespace - } - return nil - }, - ), "single-namespace", "", "Deploy all components in a single namespace ('stackrox')") - flag.NoOptDefVal = "true" + registerFlag(cmd, settings, "single-namespace", "Deploy all components in a single namespace ('stackrox')", + WithNoOptDefVal("true"), + WithApplyFnBool(func(config *deployer.Config, val bool) error { + if val { + config.Central.Namespace = sharedNamespace + config.SecuredCluster.Namespace = sharedNamespace + } + return nil + }), + ) return cmd } diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 69eab9d..a390f85 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -39,30 +39,6 @@ func (c *Config) DeepCopy() (*Config, error) { return ©, nil } -// MergeIn deep-merges another Config into this one. -func (c *Config) MergeIn(other *Config) error { - if other == nil { - return nil - } - otherAsMap, err := helpers.StructToMap(other) - if err != nil { - return err - } - return c.MergeInUnstructured(otherAsMap) -} - -// MergeInUnstructured deep-merges an unstructured map into this Config. -func (c *Config) MergeInUnstructured(m map[string]interface{}) error { - asMap, err := helpers.StructToMap(c) - if err != nil { - return err - } - if err := helpers.DeepMerge(asMap, m); err != nil { - return err - } - return helpers.MapToStruct(asMap, c) -} - // RoxieConfig holds roxie-level settings such as version and feature flags. type RoxieConfig struct { Version string `yaml:"version,omitempty"` From 40a0a1668e60a73de97f104a7db279a074e55a4b Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 11 May 2026 14:10:20 +0200 Subject: [PATCH 26/30] Claude review feedback --- cmd/deploy.go | 58 ++++++++++++++++++------------------- cmd/flags.go | 43 ++++++++++++++------------- cmd/teardown.go | 5 ++-- internal/helpers/helpers.go | 9 ------ 4 files changed, 52 insertions(+), 63 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 42b9f1f..0626c49 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -52,40 +52,40 @@ Examples: cmd.Flags().StringVar(&envrc, "envrc", "", "Write environment to file instead of spawning sub-shell") registerFlag(cmd, settings, "olm", "Deploy operator via OLM (requires OLM installed)", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { config.Operator.DeployViaOlm = val return nil }), ) registerFlag(cmd, settings, "konflux", "Use Konflux images", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { config.Roxie.KonfluxImages = val return nil }), ) registerFlag(cmd, settings, "deploy-operator", "Whether to deploy and manage the operator", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { config.Operator.SkipDeployment = !val return nil }), ) registerFlag(cmd, settings, "port-forwarding", "Enable localhost port-forward for Central", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { config.Central.PortForwarding = ptr.To(val) return nil }), ) registerFlag(cmd, settings, "pause-reconciliation", "Pause reconciliation after deployment", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { config.Central.PauseReconciliation = val config.SecuredCluster.PauseReconciliation = val return nil @@ -93,8 +93,8 @@ Examples: ) registerFlag(cmd, settings, "config", "Path to YAML config file", - WithShortName("c"), - WithApplyFn("filename", func(config *deployer.Config, filename string) error { + withShortName("c"), + withApplyFn("filename", func(config *deployer.Config, filename string) error { if filename == "-" { filename = "/dev/stdin" } @@ -107,14 +107,14 @@ Examples: return fmt.Errorf("failed to unmarshal config file %q: %w", filename, err) } if err := mergo.Merge(config, configFromFile, mergo.WithOverride, mergo.WithoutDereference); err != nil { - return fmt.Errorf("mergin config file %q into deployer Config: %w", filename, err) + return fmt.Errorf("merging config file %q into deployer Config: %w", filename, err) } return nil }), ) registerFlag(cmd, settings, "exposure", "Central exposure backend (loadbalancer, none)", - WithApplyFn("exposure", func(config *deployer.Config, val string) error { + withApplyFn("exposure", func(config *deployer.Config, val string) error { var exposure types.Exposure if err := yaml.Unmarshal([]byte(val), &exposure); err != nil { return err @@ -125,7 +125,7 @@ Examples: ) registerFlag(cmd, settings, "resources", fmt.Sprintf("Resource sizing preset (%s)", types.ResourceProfilesJoined()), - WithApplyFn("resource-profile", func(config *deployer.Config, val string) error { + withApplyFn("resource-profile", func(config *deployer.Config, val string) error { var valParsed types.ResourceProfile if err := yaml.Unmarshal([]byte(val), &valParsed); err != nil { return err @@ -137,7 +137,7 @@ Examples: ) registerFlag(cmd, settings, "set", "Set expressions, e.g. securedCluster.spec.clusterName=sensor", - WithApplyFn("set-expression", func(config *deployer.Config, expr string) error { + withApplyFn("set-expression", func(config *deployer.Config, expr string) error { key, yamlValue, found := strings.Cut(expr, "=") if !found { return fmt.Errorf("invalid set expression '%s': expected format 'key.path=value'", expr) @@ -147,7 +147,6 @@ Examples: return fmt.Errorf("failed to unmarshal value '%s' for key '%s': %w", yamlValue, key, err) } // SetNestedField requires JSON-compatible types: float64 for numbers, not int. - // Fix types if needed. switch v := val.(type) { case int: val = float64(v) @@ -179,8 +178,9 @@ Examples: ) registerFlag(cmd, settings, "single-namespace", "Deploy all components in a single namespace ('stackrox')", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { + // We do not support --single-namespace=false as of now. if val { config.Central.Namespace = sharedNamespace config.SecuredCluster.Namespace = sharedNamespace @@ -190,15 +190,15 @@ Examples: ) registerFlag(cmd, settings, "tag", "Main image tag to use for deployment (takes precedence over MAIN_IMAGE_TAG environment variable)", - WithShortName("t"), - WithApplyFn("version", func(config *deployer.Config, mainImageTag string) error { + withShortName("t"), + withApplyFn("version", func(config *deployer.Config, mainImageTag string) error { config.Roxie.Version = mainImageTag return nil }), ) registerFlag(cmd, settings, "features", "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)", - WithApplyFn("feature-flags", func(config *deployer.Config, featureFlagExpr string) error { + withApplyFn("feature-flags", func(config *deployer.Config, featureFlagExpr string) error { featureFlags, err := deployer.ParseFeatureFlags([]string{featureFlagExpr}) if err != nil { return fmt.Errorf("parsing feature flags: %w", err) @@ -211,26 +211,24 @@ Examples: ) registerFlag(cmd, settings, "central-wait", "maximum wait time for central to become ready (e.g., 5m, 10m)", - WithApplyFnDuration(func(config *deployer.Config, duration time.Duration) error { + withApplyFnDuration(func(config *deployer.Config, duration time.Duration) error { config.Central.DeployTimeout = duration return nil }), ) registerFlag(cmd, settings, "secured-cluster-wait", "maximum wait time for secured cluster to become ready (e.g., 5m, 10m)", - WithApplyFnDuration(func(config *deployer.Config, duration time.Duration) error { + withApplyFnDuration(func(config *deployer.Config, duration time.Duration) error { config.SecuredCluster.DeployTimeout = duration return nil }), ) registerFlag(cmd, settings, "early-readiness", "Only wait for essential workloads (central/sensor) to be ready", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { - if val { - config.Central.EarlyReadiness = true - config.SecuredCluster.EarlyReadiness = true - } + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { + config.Central.EarlyReadiness = val + config.SecuredCluster.EarlyReadiness = val return nil }), ) diff --git a/cmd/flags.go b/cmd/flags.go index 41fcf40..ede5d81 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -8,7 +8,7 @@ import ( "gopkg.in/yaml.v3" ) -type CliFlag struct { +type cliFlag struct { config *deployer.Config longName string shortName string @@ -18,22 +18,22 @@ type CliFlag struct { description string } -type FlagOpt func(opts *CliFlag) +type flagOpt func(opts *cliFlag) -func (f *CliFlag) Set(val string) error { +func (f *cliFlag) Set(val string) error { return f.applyFn(f.config, val) } -func (f *CliFlag) String() string { +func (f *cliFlag) String() string { return "" // Not sure what to return here. } -func (f *CliFlag) Type() string { +func (f *cliFlag) Type() string { return f.flagType } -func WithApplyFnBool(boolApplyFn func(config *deployer.Config, val bool) error) FlagOpt { - return func(opts *CliFlag) { +func withApplyFnBool(boolApplyFn func(config *deployer.Config, val bool) error) flagOpt { + return func(opts *cliFlag) { opts.flagType = "bool" opts.applyFn = func(config *deployer.Config, val string) error { var valParsed bool @@ -45,8 +45,8 @@ func WithApplyFnBool(boolApplyFn func(config *deployer.Config, val bool) error) } } -func WithApplyFn(flagType string, stringApplyFn func(config *deployer.Config, val string) error) FlagOpt { - return func(opts *CliFlag) { +func withApplyFn(flagType string, stringApplyFn func(config *deployer.Config, val string) error) flagOpt { + return func(opts *cliFlag) { opts.flagType = flagType opts.applyFn = func(config *deployer.Config, val string) error { return stringApplyFn(config, val) @@ -54,8 +54,8 @@ func WithApplyFn(flagType string, stringApplyFn func(config *deployer.Config, va } } -func WithApplyFnDuration(durationApplyFn func(config *deployer.Config, duration time.Duration) error) FlagOpt { - return func(opts *CliFlag) { +func withApplyFnDuration(durationApplyFn func(config *deployer.Config, duration time.Duration) error) flagOpt { + return func(opts *cliFlag) { opts.flagType = "duration" opts.applyFn = func(config *deployer.Config, val string) error { var duration time.Duration @@ -68,30 +68,29 @@ func WithApplyFnDuration(durationApplyFn func(config *deployer.Config, duration } } -func WithNoOptDefVal(defVal string) FlagOpt { - return func(opts *CliFlag) { +func withNoOptDefVal(defVal string) flagOpt { + return func(opts *cliFlag) { opts.noOptDefVal = defVal } } -func WithShortName(shortName string) FlagOpt { - return func(opts *CliFlag) { +func withShortName(shortName string) flagOpt { + return func(opts *cliFlag) { opts.shortName = shortName } } -func registerFlag(cmd *cobra.Command, settings *deployer.Config, longName string, description string, flagOpts ...FlagOpt) { - cliFlag := CliFlag{ +func registerFlag(cmd *cobra.Command, settings *deployer.Config, longName string, description string, flagOpts ...flagOpt) { + f := cliFlag{ config: settings, longName: longName, description: description, } for _, applyOpt := range flagOpts { - applyOpt(&cliFlag) - + applyOpt(&f) } - flag := cmd.Flags().VarPF(&cliFlag, cliFlag.longName, cliFlag.shortName, cliFlag.description) - if cliFlag.noOptDefVal != "" { - flag.NoOptDefVal = cliFlag.noOptDefVal + flag := cmd.Flags().VarPF(&f, f.longName, f.shortName, f.description) + if f.noOptDefVal != "" { + flag.NoOptDefVal = f.noOptDefVal } } diff --git a/cmd/teardown.go b/cmd/teardown.go index 1c1c5d3..fbda147 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -23,8 +23,9 @@ func newTeardownCmd(settings *deployer.Config) *cobra.Command { } registerFlag(cmd, settings, "single-namespace", "Deploy all components in a single namespace ('stackrox')", - WithNoOptDefVal("true"), - WithApplyFnBool(func(config *deployer.Config, val bool) error { + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { + // We do not support --single-namespace=false as of now. if val { config.Central.Namespace = sharedNamespace config.SecuredCluster.Namespace = sharedNamespace diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 18d5120..857873b 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -138,15 +138,6 @@ func DeepMerge(base, overlay map[string]interface{}) error { return nil } -func StructToMap(v interface{}) (map[string]interface{}, error) { - bytes, err := yaml.Marshal(v) - if err != nil { - return nil, err - } - var m map[string]interface{} - return m, yaml.Unmarshal(bytes, &m) -} - func MapToStruct(m map[string]interface{}, out interface{}) error { bytes, err := yaml.Marshal(m) if err != nil { From df863d1f47f7ed51a3f65130085639271c0cc917 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 11 May 2026 18:01:27 +0200 Subject: [PATCH 27/30] Remove comments --- cmd/deploy.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 0626c49..ed040d3 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -45,10 +45,8 @@ Examples: RunE: runDeploy, } - // --shell . cmd.Flags().StringVar(&shell, "shell", "", "Shell to spawn after Central deployment") - // --envrc . cmd.Flags().StringVar(&envrc, "envrc", "", "Write environment to file instead of spawning sub-shell") registerFlag(cmd, settings, "olm", "Deploy operator via OLM (requires OLM installed)", From a995af03d82ca285e8fecbece9f56cfd0ba65951 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 12 May 2026 12:34:16 +0200 Subject: [PATCH 28/30] Added another retry error condition --- internal/deployer/roxctl.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/deployer/roxctl.go b/internal/deployer/roxctl.go index 274c683..5439006 100644 --- a/internal/deployer/roxctl.go +++ b/internal/deployer/roxctl.go @@ -49,6 +49,7 @@ func (d *Deployer) runRoxctl(ctx context.Context, opts RoxctlOptions) (*RoxctlRe "bad gateway", "service unavailable", "context deadline exceeded", + "no such host", } var lastStderr string From 563992b4e02ad819a849b04ea8a5436070ced528 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 12 May 2026 13:27:28 +0200 Subject: [PATCH 29/30] Fix accidental logic inversion --- internal/deployer/deploy_via_operator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 31bb9c3..43cbc3f 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -558,7 +558,7 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context) error { } d.centralEndpoint = endpoint } - } else if exposure == types.ExposureNone { + } else if exposure == types.ExposureLoadBalancer { endpoint, err := d.waitForLoadBalancer(ctx, d.config.Central.Namespace, "central-loadbalancer", 300) if err != nil { return fmt.Errorf("failed to get LoadBalancer endpoint: %w", err) From 20676d00a43ae84edbd788b3d29dbb19315e541d Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 13 May 2026 09:47:08 +0200 Subject: [PATCH 30/30] Added comments for clarity --- internal/clusterdefaults/clusterdefaults.go | 6 ++++++ internal/deployer/config.go | 3 +++ 2 files changed, 9 insertions(+) diff --git a/internal/clusterdefaults/clusterdefaults.go b/internal/clusterdefaults/clusterdefaults.go index 9985f63..588c8e9 100644 --- a/internal/clusterdefaults/clusterdefaults.go +++ b/internal/clusterdefaults/clusterdefaults.go @@ -37,6 +37,12 @@ func ApplyClusterDefaults( return defaultsCopy, nil } +// getDefaultsForClusterType returns a deployer.Config filled with the defaults for the given +// cluster type. +// Note that to be able to differentiate "not set" from "set specifically to the empty value", +// any fields set specifically to the empty value (e.g. false booleans), must be of pointer type +// in the Config struct. Otherwise, `ApplyClusterDefaults` would not apply those to the caller-provided +// configuration. func getDefaultsForClusterType(clusterType types.ClusterType) *deployer.Config { switch clusterType { case types.ClusterTypeKind, types.ClusterTypeMinikube, types.ClusterTypeK3s, types.ClusterTypeCRC: diff --git a/internal/deployer/config.go b/internal/deployer/config.go index a390f85..9645b16 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -27,6 +27,9 @@ func NewConfig() Config { } } +// DeepCopy creates a deep-copy of the provided config using a YAML marshaling/unmarshaling roundtrip. +// Due the `omitempty`, this causes empty values (e.g. empty maps) from being discarded (replace with nil +// in the resulting copy). func (c *Config) DeepCopy() (*Config, error) { data, err := yaml.Marshal(c) if err != nil {