diff --git a/cmd/deploy.go b/cmd/deploy.go index a324a5c..e03b3ee 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -5,17 +5,31 @@ import ( "errors" "fmt" "os" + "reflect" + "strings" "time" + "dario.cat/mergo" "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 { +func newDeployCmd(settings *deployer.Config) *cobra.Command { cmd := &cobra.Command{ Use: "deploy [component]", Short: "Deploy ACS components", @@ -31,30 +45,209 @@ 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)") cmd.Flags().StringVar(&shell, "shell", "", "Shell to spawn after Central deployment") + 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)") + + 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("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 { + 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) + } + 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" { + return errors.New("set expression begins with 'spec.' -- it must be prefixed with 'central.' or 'securedCluster.'") + } + unstructuredPatch := make(map[string]interface{}) + if err := unstructured.SetNestedField(unstructuredPatch, val, pathElements...); err != nil { + return err + } + var patch deployer.Config + if err := helpers.MapToStruct(unstructuredPatch, &patch); err != nil { + return err + } + if reflect.DeepEqual(patch, deployer.Config{}) { + 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 { + // We do not support --single-namespace=false as of now. + if val { + config.Central.Namespace = sharedNamespace + config.SecuredCluster.Namespace = sharedNamespace + } + 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 + }), + ) + + 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 { + config.Central.EarlyReadiness = val + config.SecuredCluster.EarlyReadiness = val + return nil + }), + ) + + // 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 { @@ -68,48 +261,22 @@ func runDeploy(cmd *cobra.Command, args []string) error { return 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") - } - - if components.IncludesCentral() && !env.RunningInteractively && envrc == "" { - return errors.New("running without a controlling terminal requires --envrc to be set") - } - - portForwardEnabledFinal := portForwarding || exposure == "none" - - if env.RunningInRoxieContainer { - // For running containerized we have specific requirements. - if portForwardEnabledFinal { - return errors.New("containerized mode does not support port-forwarding") - } - if exposure == "none" { - 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 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) - } - if _, err := os.Stat("/kubeconfig"); err != nil { - return fmt.Errorf("containerized mode requires /kubeconfig file: %w", 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 konflux { - if olm { - return errors.New("cannot use both --olm and --konflux flags together (not currently implemented)") - } - clusterType := env.GetCurrentClusterType() - if !clusterType.IsOpenShift() { - return fmt.Errorf("--konflux flag is only supported on OpenShift 4 clusters (current cluster type: %s)", clusterType.String()) - } + if err := configureConfig(log, components, &deploySettings); err != nil { + return err } - if !deployOperator && olm { - return errors.New("cannot use --deploy-operator=false with --olm (OLM requires operator deployment)") + if err := deployValidate(components, &deploySettings); err != nil { + return err } d, err := deployer.New(log) @@ -118,34 +285,20 @@ 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 envrc != "" { + d.SetEnvrcFile(envrc) } + d.SetVerbose(verbose) + d.SetConfig(deploySettings) - 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 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() } @@ -153,117 +306,119 @@ func runDeploy(cmd *cobra.Command, args []string) error { d.PrintSecuredClusterDeploymentSummary() } - if envrc != "" { - d.SetEnvrcFile(envrc) + if err := d.Deploy(ctx, components); err != nil { + return fmt.Errorf("deployment failed: %w", err) } - if olm { - if err := d.SetUseOLM(true); err != nil { - return 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 konflux { - if err := d.SetUseKonflux(true); err != nil { - return err + if components.IncludesCentral() && envrc == "" { + if err := spawnSubshell(d, log); err != nil { + return fmt.Errorf("failed to spawn subshell: %w", err) } - } - d.SetDeployOperator(deployOperator) + return nil +} - 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) +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.Dimf("Applying the following defaults based on detected cluster type %v:", clusterType) + helpers.LogMultilineYaml(log, defaults) } - 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) + // 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) + log.Dimf("Selecting resource profile %v for SecuredCluster", profile) + deploySettings.SecuredCluster.ResourceProfile = profile } - var mainImageTag string - if tag != "" { - log.Dimf("Using main image tag from --tag flag: %s", tag) - mainImageTag = tag + if err := deploySettings.Operator.Configure(&deploySettings.Roxie); err != nil { + return fmt.Errorf("configuring operator configuration: %w", err) } - if mainImageTag == "" { - mainImageTag, err = helpers.LookupMainImageTag(log) - if err != nil { - return fmt.Errorf("looking up main image tag: %w", err) + + if components.IncludesCentral() { + if err := deploySettings.Central.ConfigureSpec(&deploySettings.Roxie); err != nil { + return fmt.Errorf("configuring Central spec: %w", err) } } - d.SetMainImageTag(mainImageTag) - - // 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) + if components.IncludesSensor() { + if err := deploySettings.SecuredCluster.ConfigureSpec(&deploySettings.Roxie, &deploySettings.Central); err != nil { + return fmt.Errorf("configuring SecuredCluster spec: %w", err) + } + } + if verbose { + log.Dim("Deployment configuration:") + helpers.LogMultilineYaml(log, deploySettings) } - // TODO(#91): validate the user-supplied value earlier than here - if resources == "auto" { - resources = resolveAutoResources(env.GetCurrentClusterType(), log) + if !deploySettings.Central.PortForwardingSet() && !deploySettings.Central.ExposureEnabled() { + log.Info("Enabling port-forwarding due to no exposure") + deploySettings.Central.PortForwarding = ptr.To(true) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer cancel() + return nil +} - if err := d.Deploy(ctx, components, resources, exposure); err != nil { - return fmt.Errorf("deployment failed: %w", err) +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") } - 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() && !env.RunningInteractively && envrc == "" { + return errors.New("running without a controlling terminal requires --envrc to be set") } - if components.IncludesCentral() && envrc == "" { - if err := spawnSubshell(d, log); err != nil { - return fmt.Errorf("failed to spawn subshell: %w", err) + if env.RunningInRoxieContainer { + // For running containerized we have specific requirements. + if deploySettings.Central.PortForwardingEnabled() { + return errors.New("containerized mode does not support port-forwarding") + } + if !deploySettings.Central.ExposureEnabled() { + return errors.New("containerized mode requires Central exposure") } - } - return nil -} + // On infra OpenShift we already get image pull secrets for Quay automatically. + 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) + } + if _, err := os.Stat("/kubeconfig"); err != nil { + return fmt.Errorf("containerized mode requires /kubeconfig file: %w", err) + } + } + } -// 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" + 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") } - log.Infof("Auto-detected cluster type %s: using resource profile %q", clusterType.String(), resolvedResources) + 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.IsOpenShift() { + return fmt.Errorf("--konflux flag is only supported on OpenShift 4 clusters (current cluster type: %s)", clusterType.String()) + } + } - return resolvedResources + return nil } 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..ede5d81 --- /dev/null +++ b/cmd/flags.go @@ -0,0 +1,96 @@ +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) { + f := cliFlag{ + config: settings, + longName: longName, + description: description, + } + for _, applyOpt := range flagOpts { + applyOpt(&f) + } + flag := cmd.Flags().VarPF(&f, f.longName, f.shortName, f.description) + if f.noOptDefVal != "" { + flag.NoOptDefVal = f.noOptDefVal + } +} 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..fbda147 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -12,7 +12,7 @@ import ( "github.com/stackrox/roxie/internal/logger" ) -func newTeardownCmd() *cobra.Command { +func newTeardownCmd(settings *deployer.Config) *cobra.Command { cmd := &cobra.Command{ Use: "teardown [component]", Short: "Teardown ACS components", @@ -22,7 +22,17 @@ func newTeardownCmd() *cobra.Command { RunE: runTeardown, } - cmd.Flags().BoolVar(&singleNamespace, "single-namespace", false, "Deploy all components in a single namespace ('stackrox' by default)") + registerFlag(cmd, settings, "single-namespace", "Deploy all components in a single namespace ('stackrox')", + 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 + } + return nil + }), + ) return cmd } @@ -40,13 +50,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 7802b8a..3506bcb 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,16 @@ 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.5 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 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 ( @@ -31,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.4 // indirect - github.com/spf13/pflag v1.0.10 // 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 +45,6 @@ require ( gotest.tools/v3 v3.5.2 // 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/go.sum b/go.sum index 2583651..420a50e 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= diff --git a/internal/clusterdefaults/clusterdefaults.go b/internal/clusterdefaults/clusterdefaults.go index 6a2e9b4..588c8e9 100644 --- a/internal/clusterdefaults/clusterdefaults.go +++ b/internal/clusterdefaults/clusterdefaults.go @@ -1,220 +1,93 @@ package clusterdefaults import ( - "strings" + "fmt" - "github.com/stackrox/roxie/internal/logger" + "dario.cat/mergo" + "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/types" + "k8s.io/utils/ptr" ) -// 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" +// 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( + clusterType types.ClusterType, + config *deployer.Config, +) (*deployer.Config, error) { + if config == nil { + panic("applying cluster defaults to nil config") } -} - -// 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, + defaults := getDefaultsForClusterType(clusterType) + if defaults == nil { + return nil, nil } -} -// 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) + // Make a copy. + defaultsCopy, err := defaults.DeepCopy() + if err != nil { + return nil, fmt.Errorf("deep-copying cluster defaults: %w", err) } - 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" + if err := mergo.Merge(config, defaultsCopy, mergo.WithoutDereference); err != nil { + return nil, fmt.Errorf("merging-in cluster defaults: %w", err) } - m.logger.Warning( - "Detected " + clusterType.String() + " cluster: using --resources=" + - resources + " --exposure=" + exposure + " " + pfStatus, - ) + return defaultsCopy, nil } -// 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 - } - - // Minikube clusters typically have context name "minikube" - if contextLower == "minikube" || strings.HasPrefix(contextLower, "minikube-") { - return ClusterTypeMinikube - } - - // K3s clusters often have "k3s" in the context name - if strings.Contains(contextLower, "k3s") { - return ClusterTypeK3s - } +// 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: + return &deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + } + + case types.ClusterTypeInfraGKE, types.ClusterTypeInfraOpenShift4: + return &deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureLoadBalancer), + PortForwarding: ptr.To(false), + }, + } - // 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 + default: + return nil } - - return ClusterTypeUnknown } -// 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 - } +// ResolveAutoResourceProfile resolves the "auto" resource profile depending on the cluster type. +func ResolveAutoResourceProfile(clusterType types.ClusterType) types.ResourceProfile { + switch clusterType { + case types.ClusterTypeKind: + return types.ResourceProfileSmall - // Check if any parameter would change - changed := resources != defaults.Resources || - exposure != defaults.Exposure || - portForwardEnabled != defaults.PortForwardEnabled + case types.ClusterTypeMinikube: + return types.ResourceProfileSmall - if !changed { - // User already specified the recommended defaults - return resources, exposure, portForwardEnabled, false - } + case types.ClusterTypeK3s: + return types.ResourceProfileSmall - // Apply the defaults - return defaults.Resources, defaults.Exposure, defaults.PortForwardEnabled, true -} + case types.ClusterTypeCRC: + return types.ResourceProfileSmall -// getDefaultsForClusterType returns the recommended defaults for a given cluster type -func getDefaultsForClusterType(clusterType ClusterType) (DeploymentDefaults, bool) { - switch clusterType { - case ClusterTypeKind: - // Kind clusters are local, lightweight, and don't support LoadBalancer - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true - - case ClusterTypeMinikube: - // Minikube is also local and benefits from small resources - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true - - case ClusterTypeK3s: - // K3s can vary (local or cloud), apply conservative defaults - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true - - case ClusterTypeCRC: - return DeploymentDefaults{ - Resources: "small", - Exposure: "none", - PortForwardEnabled: true, - }, true + case types.ClusterTypeInfraOpenShift4: + return types.ResourceProfileMedium + + 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..f0dab56 100644 --- a/internal/clusterdefaults/clusterdefaults_test.go +++ b/internal/clusterdefaults/clusterdefaults_test.go @@ -3,266 +3,185 @@ package clusterdefaults import ( "testing" - "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" ) -func TestDefaultDetector_Detect(t *testing.T) { +func TestClusterDefaults(t *testing.T) { tests := []struct { name string - kubeContext string - want ClusterType + clusterType types.ClusterType + config deployer.Config + wantConfig deployer.Config }{ { - name: "kind cluster with standard prefix", - kubeContext: "kind-dev-cluster", - want: ClusterTypeKind, - }, - { - name: "kind cluster simple name", - kubeContext: "kind", - want: ClusterTypeKind, - }, - { - name: "kind cluster with uppercase", - kubeContext: "KIND-test", - want: ClusterTypeKind, - }, - { - name: "crc cluster with admin context", - kubeContext: "crc-admin", - want: ClusterTypeCRC, - }, - { - name: "crc cluster with api prefix", - kubeContext: "api-crc-testing:6443", - want: ClusterTypeCRC, - }, - { - 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, + name: "kind cluster with default params", + clusterType: types.ClusterTypeKind, + 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, + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, + }, + { + name: "kind cluster with partial match", + clusterType: types.ClusterTypeKind, + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, + }, + { + name: "unknown cluster type", + clusterType: types.ClusterTypeUnknown, + wantConfig: deployer.Config{}, + }, + { + name: "minikube cluster", + clusterType: types.ClusterTypeMinikube, + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, + }, + { + name: "crc cluster", + clusterType: types.ClusterTypeCRC, + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureNone), + PortForwarding: ptr.To(true), + }, + }, + }, + { + name: "gke cluster", + clusterType: types.ClusterTypeInfraGKE, + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureLoadBalancer), + PortForwarding: ptr.To(false), + }, + }, + }, + { + name: "openshift cluster", + clusterType: types.ClusterTypeInfraOpenShift4, + wantConfig: deployer.Config{ + Central: deployer.CentralConfig{ + Exposure: ptr.To(types.ExposureLoadBalancer), + PortForwarding: ptr.To(false), + }, + }, + }, + { + 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), + }, + }, }, } - 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) + config := tt.config + _, err := ApplyClusterDefaults(tt.clusterType, &config) + require.NoError(t, err) + + 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.wantConfig.Central.Exposure, *config.Central.Exposure, + "exposure = %v, want %v", *config.Central.Exposure, *tt.wantConfig.Central.Exposure) + } + + 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.wantConfig.Central.PortForwarding, *config.Central.PortForwarding, + "portForward = %v, want %v", *config.Central.PortForwarding, *tt.wantConfig.Central.PortForwarding) } }) } } -func TestDefaultApplicator_Apply(t *testing.T) { +func TestResolveAutoResourceProfile(t *testing.T) { tests := []struct { - name string - clusterType ClusterType - resources string - exposure string - portForwardEnabled bool - wantResources string - wantExposure string - wantPortForward bool - wantChanged bool + name string + clusterType types.ClusterType + want types.ResourceProfile }{ { - 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", + clusterType: types.ClusterTypeKind, + want: types.ResourceProfileSmall, }, { - name: "kind cluster with already correct params", - clusterType: ClusterTypeKind, - resources: "small", - exposure: "none", - portForwardEnabled: true, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - wantChanged: false, + name: "minikube cluster", + clusterType: types.ClusterTypeMinikube, + want: types.ResourceProfileSmall, }, { - name: "kind cluster with partial match", - clusterType: ClusterTypeKind, - resources: "small", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, - wantChanged: true, + name: "k3s cluster", + clusterType: types.ClusterTypeK3s, + want: types.ResourceProfileSmall, }, { - name: "unknown cluster type", - clusterType: ClusterTypeUnknown, - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "default", - wantExposure: "loadbalancer", - wantPortForward: false, - wantChanged: false, + name: "crc cluster", + clusterType: types.ClusterTypeCRC, + want: types.ResourceProfileSmall, }, { - 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, - }, - } - - 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, - ) - - 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) - } - if gotPF != tt.wantPortForward { - t.Errorf("Apply() portForward = %v, want %v", gotPF, tt.wantPortForward) - } - 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: "gke cluster", + clusterType: types.ClusterTypeInfraGKE, + want: types.ResourceProfileMedium, }, { - name: "crc cluster detection and defaults", - kubeContext: "crc-admin", - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "small", - wantExposure: "none", - wantPortForward: true, + name: "openshift cluster", + clusterType: types.ClusterTypeInfraOpenShift4, + want: types.ResourceProfileMedium, }, { - name: "gke cluster no changes", - kubeContext: "gke_project_zone_cluster", - resources: "default", - exposure: "loadbalancer", - portForwardEnabled: false, - wantResources: "default", - wantExposure: "loadbalancer", - wantPortForward: false, + name: "unknown cluster type", + clusterType: types.ClusterTypeUnknown, + want: types.ResourceProfileAcsDefaults, }, } - 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) - } + got := ResolveAutoResourceProfile(tt.clusterType) + assert.Equal(t, tt.want, got) }) } } diff --git a/internal/deployer/config.go b/internal/deployer/config.go new file mode 100644 index 0000000..9645b16 --- /dev/null +++ b/internal/deployer/config.go @@ -0,0 +1,242 @@ +package deployer + +import ( + "fmt" + "time" + + "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 +// roxie itself, the operator, Central, and SecuredCluster. +type Config struct { + 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. +func NewConfig() Config { + return Config{ + Roxie: NewRoxieConfig(), + Central: DefaultCentralConfig(), + SecuredCluster: DefaultSecuredClusterConfig(), + } +} + +// 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 { + return nil, err + } + var copy Config + if err := yaml.Unmarshal(data, ©); err != nil { + return nil, err + } + return ©, nil +} + +// RoxieConfig holds roxie-level settings such as version and feature flags. +type RoxieConfig struct { + Version string `yaml:"version,omitempty"` + KonfluxImages bool `yaml:"konfluxImages,omitempty"` + FeatureFlags map[string]bool `yaml:"featureFlags,omitempty"` +} + +// NewRoxieConfig returns a RoxieConfig with initialized defaults. +func NewRoxieConfig() RoxieConfig { + return RoxieConfig{ + FeatureFlags: make(map[string]bool), + } +} + +// OperatorConfig controls how the ACS operator is deployed. +type OperatorConfig struct { + SkipDeployment bool `yaml:"skipDeployment,omitempty"` + DeployViaOlm bool `yaml:"deployViaOlm,omitempty"` + Version string `yaml:"version,omitempty"` +} + +// 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,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. +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 +} + +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 +} + +// 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,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. +func DefaultSecuredClusterConfig() SecuredClusterConfig { + return SecuredClusterConfig{ + DeployTimeout: DefaultSecuredClusterWaitTimeout, + Namespace: "acs-sensor", + Spec: make(map[string]interface{}), + } +} + +// 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 +} + +// 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) + } + } + + 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 +} 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 99d4755..c80b1c7 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 @@ -64,12 +65,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 @@ -99,7 +100,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) } @@ -114,11 +115,11 @@ 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...") - needPullSecrets := env.GetCurrentClusterType() != env.InfraOpenShift4 - if err := d.prepareNamespace(ctx, d.centralNamespace, needPullSecrets); err != nil { + needPullSecrets := env.GetCurrentClusterType() != types.ClusterTypeInfraOpenShift4 + if err := d.prepareNamespace(ctx, d.config.Central.Namespace, needPullSecrets); err != nil { return fmt.Errorf("failed to prepare namespace: %w", err) } @@ -126,24 +127,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 @@ -162,10 +167,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 @@ -226,7 +231,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{ @@ -251,47 +256,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{}{ @@ -330,7 +297,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{}{ @@ -363,7 +330,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{}{ @@ -396,56 +363,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), @@ -461,7 +396,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 @@ -476,13 +412,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) @@ -502,12 +438,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. @@ -559,7 +495,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) @@ -596,13 +532,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.GetExposure() + 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 { @@ -611,27 +548,27 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context, exposure string } if d.envrcFile != "" { - endpoint, pid, err := d.portForward.StartDetached(d.centralNamespace, serviceName, 443, 8443) + endpoint, pid, err := d.portForward.StartDetached(d.config.Central.Namespace, serviceName, 443, 8443) if err != nil { return fmt.Errorf("failed to start detached port-forward: %w", err) } d.centralEndpoint = endpoint d.portForwardPID = pid } else { - 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 { @@ -647,20 +584,20 @@ 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...") - needPullSecrets := env.GetCurrentClusterType() != env.InfraOpenShift4 - if err := d.prepareNamespace(ctx, d.sensorNamespace, needPullSecrets); err != nil { + needPullSecrets := env.GetCurrentClusterType() != types.ClusterTypeInfraOpenShift4 + if err := d.prepareNamespace(ctx, d.config.SecuredCluster.Namespace, needPullSecrets); 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) } @@ -678,58 +615,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{}{ @@ -748,7 +656,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{}{ @@ -769,7 +677,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{}{ @@ -801,7 +709,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 { @@ -814,7 +722,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 @@ -825,17 +734,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 @@ -847,10 +756,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 faeb3f0..2f9aebd 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" @@ -14,25 +13,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 @@ -44,40 +36,28 @@ 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 - portForwardPID int - 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 - useOperatorPullSecrets bool + // 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 + portForwardPID int + useOperatorPullSecrets bool } type ResourceToDelete struct { @@ -110,7 +90,7 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) error { d.logger.Info("Deleting Central resources") crExists := true - if _, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.centralNamespace, "central", "stackrox-central-services"); err != nil { + if _, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.config.Central.Namespace, "central", "stackrox-central-services"); err != nil { if !k8s.IsResourceNotFound(err) { return fmt.Errorf("retrieving Central CR: %w", err) } @@ -119,14 +99,14 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) error { if crExists { d.logger.Info("Removing any pause-reconcile annotation from Central") - if err := d.removePauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.centralNamespace); err != nil { + if err := d.removePauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.config.Central.Namespace); err != nil { return err } if d.verbose { d.logger.Dim("Removed any pause-reconcile annotation from Central") } - err := d.deleteResource(ctx, d.centralNamespace, "central", "stackrox-central-services", "--wait") + err := d.deleteResource(ctx, d.config.Central.Namespace, "central", "stackrox-central-services", "--wait") if err != nil { return err } @@ -151,7 +131,7 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) 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) @@ -164,7 +144,7 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) 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) } } @@ -176,7 +156,7 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { d.logger.Info("Deleting SecuredCluster resources") crExists := true - if _, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.sensorNamespace, "securedcluster", "stackrox-secured-cluster-services"); err != nil { + if _, err := k8s.RetrieveResourceFromCluster(ctx, d.logger, d.config.SecuredCluster.Namespace, "securedcluster", "stackrox-secured-cluster-services"); err != nil { if !k8s.IsResourceNotFound(err) { return fmt.Errorf("retrieving SecuredCluster CR: %w", err) } @@ -185,14 +165,14 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { if crExists { d.logger.Info("Removing any pause-reconcile annotation from SecuredCluster") - if err := d.removePauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.sensorNamespace); err != nil { + if err := d.removePauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.config.SecuredCluster.Namespace); err != nil { return err } if d.verbose { d.logger.Dim("Removed any pause-reconcile annotation from SecuredCluster") } - err := d.deleteResource(ctx, d.sensorNamespace, "securedcluster", "stackrox-secured-cluster-services", "--wait") + err := d.deleteResource(ctx, d.config.SecuredCluster.Namespace, "securedcluster", "stackrox-secured-cluster-services", "--wait") if err != nil { return err } @@ -214,7 +194,7 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) 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.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) @@ -226,7 +206,7 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { 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) } } @@ -234,158 +214,8 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { 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. @@ -397,33 +227,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 @@ -447,14 +264,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 } @@ -508,22 +326,9 @@ func (d *Deployer) stopDetachedPortForward() { } // 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) } @@ -531,23 +336,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) } } @@ -571,24 +376,22 @@ 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 } 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) } } @@ -596,16 +399,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 { @@ -654,10 +457,10 @@ 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 } @@ -665,8 +468,8 @@ func (d *Deployer) teardownCentral(ctx context.Context) error { d.stopDetachedPortForward() // 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) } } @@ -677,21 +480,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) } } @@ -702,7 +505,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 } @@ -782,27 +585,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 @@ -825,69 +607,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{ @@ -930,14 +653,6 @@ func (d *Deployer) removePauseReconcileAnnotation(ctx context.Context, resourceT 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 { @@ -1017,7 +732,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) @@ -1039,10 +754,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.GetExposure() + portForwarding := d.config.Central.PortForwardingEnabled() log := d.logger kubeContext := d.kubeContext @@ -1101,9 +816,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)")) } @@ -1208,8 +923,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 @@ -1271,3 +986,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.GetExposure(), + 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 e14a7e5..8f451c4 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) } @@ -192,11 +193,11 @@ 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 @@ -319,7 +320,7 @@ func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string) } serviceAccountName := deploymentSpec["service_account"].(string) - d.useOperatorPullSecrets = d.useKonflux && env.GetCurrentClusterType() != env.InfraOpenShift4 + d.useOperatorPullSecrets = d.config.Roxie.KonfluxImages && env.GetCurrentClusterType() != types.ClusterTypeInfraOpenShift4 d.logger.Info("📋 Operator deployment plan:") d.logger.Dim(fmt.Sprintf(" • Namespace: %s", operatorNamespace)) diff --git a/internal/deployer/operator_olm.go b/internal/deployer/operator_olm.go index df2686e..99fc63f 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 @@ -120,7 +120,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. @@ -197,7 +197,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", @@ -259,7 +259,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}"}, }) @@ -301,7 +301,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/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 diff --git a/internal/env/env.go b/internal/env/env.go index 6b125b1..9d85814 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,26 +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 - // Generic OpenShift4 cluster (e.g. for prow CI) - OpenShift4 - // 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() @@ -87,50 +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 (infra)" - case OpenShift4: - return "OpenShift4" - case LocalKind: - return "Kind" - default: - return "Unknown" - } -} - -func (ct ClusterType) IsOpenShift() bool { - return ct == InfraOpenShift4 || ct == OpenShift4 -} - // KubeConfig represents a simplified kubectl configuration type KubeConfig struct { CurrentContext string @@ -176,36 +133,50 @@ 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. if isOpenShift4(apiResources) { if isInfraOpenShift4(config) { - return InfraOpenShift4 + return types.ClusterTypeInfraOpenShift4 } - return OpenShift4 + return types.ClusterTypeOpenShift4 + } + + // 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 } func isInfraOpenShift4(config KubeConfig) bool { diff --git a/internal/env/env_integration_test.go b/internal/env/env_integration_test.go index e07000f..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, OpenShift4, 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 abb0a7c..8e0fed7 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/stackrox/roxie/internal/types" ) func TestDetectClusterType_GKE(t *testing.T) { @@ -18,9 +20,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) } } @@ -36,9 +38,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) } } @@ -59,8 +61,8 @@ func TestDetectClusterType_InfraOpenShift4(t *testing.T) { "clusteroperators.config.openshift.io", } - result := detectClusterType(config, apiResources) - assert.Equal(t, InfraOpenShift4, result) + result := DetectClusterType(config, apiResources) + assert.Equal(t, types.ClusterTypeInfraOpenShift4, result) } func TestDetectClusterType_OpenShift4(t *testing.T) { @@ -80,8 +82,8 @@ func TestDetectClusterType_OpenShift4(t *testing.T) { "clusteroperators.config.openshift.io", } - result := detectClusterType(config, apiResources) - assert.Equal(t, OpenShift4, result) + result := DetectClusterType(config, apiResources) + assert.Equal(t, types.ClusterTypeOpenShift4, result) } func TestDetectClusterType_OpenShift4_NoAPIResources(t *testing.T) { @@ -96,9 +98,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) } } @@ -114,9 +116,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) } } @@ -132,9 +134,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) } } @@ -145,13 +147,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{ @@ -163,9 +165,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) } } @@ -181,9 +183,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) } } @@ -283,32 +285,32 @@ 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, + clusterType: types.ClusterTypeInfraOpenShift4, want: "OpenShift4 (infra)", }, { name: "OpenShift4", - clusterType: OpenShift4, + clusterType: types.ClusterTypeOpenShift4, want: "OpenShift4", }, { name: "LocalKind", - clusterType: LocalKind, + clusterType: types.ClusterTypeKind, want: "Kind", }, { name: "ClusterTypeUnknown", - clusterType: ClusterTypeUnknown, + clusterType: types.ClusterTypeUnknown, want: "Unknown", }, } @@ -322,3 +324,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..857873b 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,59 @@ 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 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.Pointer, 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..47146a2 --- /dev/null +++ b/internal/types/cluster_type.go @@ -0,0 +1,61 @@ +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 + // Generic OpenShift4 cluster (e.g. for prow CI) + ClusterTypeOpenShift4 + // 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 +) + +func (ct ClusterType) IsOpenShift() bool { + return ct == ClusterTypeInfraOpenShift4 || ct == ClusterTypeOpenShift4 +} + +// String returns the string representation of a ClusterType +func (ct ClusterType) String() string { + switch ct { + case ClusterTypeInfraGKE: + return "GKE" + case ClusterTypeInfraOpenShift4: + return "OpenShift4 (infra)" + case ClusterTypeOpenShift4: + 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, + ClusterTypeOpenShift4, + } +} diff --git a/internal/types/exposure.go b/internal/types/exposure.go new file mode 100644 index 0000000..d35bbd5 --- /dev/null +++ b/internal/types/exposure.go @@ -0,0 +1,84 @@ +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{} { + if e == nil { + return nil + } + 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, ", ") +}