From eb3116ca7577df24259ce745fe07ab0633d31df5 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:27:09 +0200 Subject: [PATCH 01/16] Turn --config and --set into top-level flags. --- cmd/deploy.go | 49 ------------------------------------------------- cmd/main.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 8dad765..ce4630a 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -5,10 +5,8 @@ import ( "errors" "fmt" "os" - "reflect" "time" - "dario.cat/mergo" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/stackrox/roxie/internal/clusterdefaults" @@ -22,7 +20,6 @@ import ( "github.com/stackrox/roxie/internal/stackroxversions" "github.com/stackrox/roxie/internal/types" "gopkg.in/yaml.v3" - "helm.sh/helm/v3/pkg/strvals" ) var ( @@ -95,27 +92,6 @@ this flag can be used to tell roxie how to pre-load images for the current clust }), ) - 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 @@ -139,31 +115,6 @@ this flag can be used to tell roxie how to pre-load images for the current clust }), ) - registerFlag(cmd, settings, "set", "Set expressions, e.g. securedCluster.spec.clusterName=sensor", - withApplyFn("set-expression", func(config *deployer.Config, expr string) error { - unstructuredPatch, err := strvals.Parse(expr) - if err != nil { - return fmt.Errorf("parsing set expression %q: %w", expr, err) - } - if _, forbidden := unstructuredPatch["spec"]; forbidden { - return errors.New("set expression must not set top-level 'spec'; prefix with 'central.' or 'securedCluster.'") - } - 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 { diff --git a/cmd/main.go b/cmd/main.go index ffe30dd..fa6f750 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,16 +1,20 @@ package main import ( + "errors" "fmt" "os" + "reflect" "dario.cat/mergo" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/paths" "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/strvals" ) var ( @@ -76,6 +80,52 @@ func init() { rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Do not actually modify cluster") rootCmd.PersistentFlags().BoolVar(&skipUserConfig, "skip-user-config", false, fmt.Sprintf("Skips reading of user's configuration (%s)", paths.UserConfigPathString())) + registerFlag(rootCmd, &deploySettingsFromArgs, "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(rootCmd, &deploySettingsFromArgs, "set", "Set expressions, e.g. securedCluster.spec.clusterName=sensor", + withPersistent(), + withApplyFn("set-expression", func(config *deployer.Config, expr string) error { + unstructuredPatch, err := strvals.Parse(expr) + if err != nil { + return fmt.Errorf("parsing set expression %q: %w", expr, err) + } + if _, forbidden := unstructuredPatch["spec"]; forbidden { + return errors.New("set expression must not set top-level 'spec'; prefix with 'central.' or 'securedCluster.'") + } + 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 + }), + ) + rootCmd.AddCommand(newDeployCmd(&deploySettingsFromArgs)) rootCmd.AddCommand(newTeardownCmd(&deploySettingsFromArgs)) rootCmd.AddCommand(newShellCmd()) From 537fa9dac6fdae7b6b399df5248bb0e4aa70a094 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:42:31 +0200 Subject: [PATCH 02/16] Add HAProxyConfig to RoxieConfig --- internal/deployer/config.go | 18 +++++++++++++++++- internal/deployer/constants.go | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index ea0bd82..0b37ebe 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -21,7 +21,7 @@ type Config struct { // DefaultConfig returns a Config populated with default values. func DefaultConfig() Config { return Config{ - Roxie: NewRoxieConfig(), + Roxie: DefaultRoxieConfig(), Central: DefaultCentralConfig(), SecuredCluster: DefaultSecuredClusterConfig(), } @@ -57,6 +57,7 @@ type RoxieConfig struct { KonfluxImages *bool `yaml:"konfluxImages,omitempty"` FeatureFlags map[string]bool `yaml:"featureFlags,omitempty"` ClusterType types.ClusterType `yaml:"clusterType,omitempty"` + HAProxy HAProxyConfig `yaml:"haProxy,omitempty"` } func (c *RoxieConfig) KonfluxImagesSet() bool { @@ -135,6 +136,12 @@ func NewCentralConfig() CentralConfig { } } +func DefaultRoxieConfig() RoxieConfig { + cfg := NewRoxieConfig() + cfg.HAProxy.BindPort = defaultHAProxyBindPort + return cfg +} + // DefaultCentralConfig returns a CentralConfig with sensible defaults. func DefaultCentralConfig() CentralConfig { cfg := NewCentralConfig() @@ -356,3 +363,12 @@ func (s *SecuredClusterConfig) CustomResource() (map[string]interface{}, error) } return cr, nil } + +type HAProxyConfig struct { + Disabled *bool `yaml:"disabled"` + BindPort int `yaml:"bindPort"` +} + +func (c *HAProxyConfig) Enabled() bool { + return (c.Disabled == nil || !*c.Disabled) && c.BindPort > 0 +} diff --git a/internal/deployer/constants.go b/internal/deployer/constants.go index 8d1afba..3152d35 100644 --- a/internal/deployer/constants.go +++ b/internal/deployer/constants.go @@ -8,6 +8,8 @@ var ( centralDbPVCSizeSmall = "30Gi" + defaultHAProxyBindPort = 8080 + centralResourcesSmall = map[string]interface{}{ "requests": map[string]string{ "memory": "1Gi", From 3aade7a9fe94784dbbe0b765c31028332a0e3208 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:28:30 +0200 Subject: [PATCH 03/16] Add assembleConfigForCommand helper --- cmd/config.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 cmd/config.go diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..e722c40 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + + "dario.cat/mergo" + "github.com/stackrox/roxie/internal/deployer" +) + +func assembleConfigForCommand(configBase *deployer.Config, configFromArgs deployer.Config, skipUserConfig bool) (deployer.Config, error) { + var config deployer.Config + if configBase == nil { + // Start with default configuration. + config = deployer.DefaultConfig() + } else { + config = *configBase + } + + // Apply user config on top (overriding defaults). + if !skipUserConfig { + if err := tryApplyUserDefaults(globalLogger, &config); err != nil { + return deployer.Config{}, fmt.Errorf("applying user config: %w", err) + } + } + + // Apply changes from arg parsing. + if err := mergo.Merge(&config, &configFromArgs, mergo.WithOverride, mergo.WithoutDereference); err != nil { + return deployer.Config{}, fmt.Errorf("applying config patches from command line argument: %w", err) + } + + return config, nil +} From f8d3653950a4db5b7d74cf397045195a518b2788 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:31:46 +0200 Subject: [PATCH 04/16] Use assembleConfigForCommand helper. --- cmd/deploy.go | 16 +++------------- cmd/teardown.go | 17 +++-------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index ce4630a..81a9cf8 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -215,19 +215,9 @@ func runDeploy(cmd *cobra.Command, args []string) error { return err } - // Start with default configuration. - deploySettings := deployer.DefaultConfig() - - // Apply user config on top (overriding defaults). - if !skipUserConfig { - if err := tryApplyUserDefaults(globalLogger, &deploySettings); err != nil { - return fmt.Errorf("applying user config: %w", err) - } - } - - // Apply changes from arg parsing. - if err := mergo.Merge(&deploySettings, &deploySettingsFromArgs, mergo.WithOverride, mergo.WithoutDereference); err != nil { - return fmt.Errorf("applying config patches from command line argument: %w", err) + deploySettings, err := assembleConfigForCommand(nil, deploySettingsFromArgs, skipUserConfig) + if err != nil { + return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) diff --git a/cmd/teardown.go b/cmd/teardown.go index 9064475..120c836 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "dario.cat/mergo" "github.com/spf13/cobra" "github.com/stackrox/roxie/internal/component" "github.com/stackrox/roxie/internal/deployer" @@ -56,19 +55,9 @@ func runTeardown(cmd *cobra.Command, args []string) error { return nil } - // Start with default configuration. - deploySettings := deployer.DefaultConfig() - - // Apply user config on top (overriding defaults). - if !skipUserConfig { - if err := tryApplyUserDefaults(globalLogger, &deploySettings); err != nil { - return fmt.Errorf("applying user config: %w", err) - } - } - - // Apply changes from arg parsing. - if err := mergo.Merge(&deploySettings, &deploySettingsFromArgs, mergo.WithOverride, mergo.WithoutDereference); err != nil { - return fmt.Errorf("applying config patches from command line argument: %w", err) + deploySettings, err := assembleConfigForCommand(nil, deploySettingsFromArgs, skipUserConfig) + if err != nil { + return err } d, err := deployer.New(log) From cc8a2e4beec2d3edb4d3f72558be20e629bc0636 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:34:21 +0200 Subject: [PATCH 05/16] Disable stdin input for spawned command --- cmd/subshell.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/subshell.go b/cmd/subshell.go index 19462dc..aaaa23b 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -138,8 +138,7 @@ backend https_back configFile.Close() cmd := exec.Command("haproxy", "-f", configPath) - // TODO(#91): What about stdin? We probably should prevent haproxy from reading from - // the terminal just in case... + cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil From 0359821af15f8513d62b9cabfb8018d9b324bfe9 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:38:32 +0200 Subject: [PATCH 06/16] Replace boolean indicator for HAProxy with port integer --- internal/types/central_deployment_info.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/types/central_deployment_info.go b/internal/types/central_deployment_info.go index c04e0f5..fac4181 100644 --- a/internal/types/central_deployment_info.go +++ b/internal/types/central_deployment_info.go @@ -2,11 +2,11 @@ package types // CentralDeploymentInfo holds the state of a Central deployment. type CentralDeploymentInfo struct { - Endpoint string - Username string - Password string - KubeContext string - Exposure Exposure - CACertFile string - HAProxyStarted bool + Endpoint string + Username string + Password string + KubeContext string + Exposure Exposure + CACertFile string + HAProxyPort int // If positive, HAProxy was started and listens on that port. } From 0da2f60b517ef9edb730c8ab31850ea9ab230c29 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 16:14:11 +0200 Subject: [PATCH 07/16] HAProxy configuration rendering --- internal/haproxy/config.go | 32 ++++++++++++++++++++++++++++++++ internal/haproxy/config_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 internal/haproxy/config.go create mode 100644 internal/haproxy/config_test.go diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go new file mode 100644 index 0000000..d20eb00 --- /dev/null +++ b/internal/haproxy/config.go @@ -0,0 +1,32 @@ +package haproxy + +import ( + "fmt" + "strings" + + "github.com/stackrox/roxie/internal/deployer" +) + +func RenderConfig(haproxyConfig deployer.HAProxyConfig, endpoint, caCertFile string) string { + return strings.NewReplacer( + "${ENDPOINT}", endpoint, + "${CA_CERT_FILE}", caCertFile, + "${BIND_PORT}", fmt.Sprintf("%d", haproxyConfig.BindPort), + ).Replace(`global + log /dev/null local0 + +defaults + log global + mode http + timeout connect 5s + timeout client 30s + timeout server 30s + +frontend http_front + bind *:${BIND_PORT} + default_backend https_back + +backend https_back + server srv1 ${ENDPOINT} ssl verify required ca-file ${CA_CERT_FILE} +`) +} diff --git a/internal/haproxy/config_test.go b/internal/haproxy/config_test.go new file mode 100644 index 0000000..0e46127 --- /dev/null +++ b/internal/haproxy/config_test.go @@ -0,0 +1,32 @@ +package haproxy + +import ( + "strings" + "testing" + + "github.com/stackrox/roxie/internal/deployer" + "github.com/stretchr/testify/assert" +) + +func TestRenderConfig(t *testing.T) { + cfg := deployer.HAProxyConfig{BindPort: 8080} + result := RenderConfig(cfg, "central.acs:443", "/tmp/ca.pem") + + assert.Contains(t, result, "bind *:8080") + assert.Contains(t, result, "server srv1 central.acs:443 ssl verify required ca-file /tmp/ca.pem") +} + +func TestRenderConfig_CustomPort(t *testing.T) { + cfg := deployer.HAProxyConfig{BindPort: 9090} + result := RenderConfig(cfg, "central.acs:443", "/tmp/ca.pem") + + assert.Contains(t, result, "bind *:9090") + assert.NotContains(t, result, "8080") +} + +func TestRenderConfig_NoUnresolvedPlaceholders(t *testing.T) { + cfg := deployer.HAProxyConfig{BindPort: 8080} + result := RenderConfig(cfg, "central.acs:443", "/tmp/ca.pem") + + assert.False(t, strings.Contains(result, "${"), "config should not contain unresolved placeholders") +} From aa9494176c3877af31366539f8b4d90f4776626a Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:36:58 +0200 Subject: [PATCH 08/16] Parameterize startHAProxy with RoxieConfig --- cmd/subshell.go | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/cmd/subshell.go b/cmd/subshell.go index aaaa23b..6a28c39 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -10,11 +10,13 @@ import ( "github.com/fatih/color" "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" + "github.com/stackrox/roxie/internal/haproxy" "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/roxieenv" "github.com/stackrox/roxie/internal/types" ) + // spawnSubshellForDeployerEnv assembles the roxie environment from a Deployer and invokes an interactive subshell. func spawnSubshellForDeployerEnv(d *deployer.Deployer, log *logger.Logger) error { return runCommandOrSubshell(d.GetCentralDeploymentInfo(), log, nil) @@ -105,32 +107,16 @@ func resolveShellPath() string { return "/bin/bash" } -func startHAProxy(endpoint, caCertFile string, log *logger.Logger) (*exec.Cmd, string, error) { +func startHAProxy(roxieConfig deployer.RoxieConfig, centralDeploymentInfo *types.CentralDeploymentInfo) (*exec.Cmd, string, error) { configFile, err := os.CreateTemp("", "roxie-haproxy-*.cfg") if err != nil { return nil, "", fmt.Errorf("failed to create temp config: %w", err) } configPath := configFile.Name() - haproxyConfig := fmt.Sprintf(`global - log /dev/null local0 - -defaults - log global - mode http - timeout connect 5s - timeout client 30s - timeout server 30s - -frontend http_front - bind *:8080 # TODO(#91): this should probably be configurable? - default_backend https_back + haproxyCfg := haproxy.RenderConfig(roxieConfig.HAProxy, centralDeploymentInfo.Endpoint, centralDeploymentInfo.CACertFile) -backend https_back - server srv1 %s ssl verify required ca-file %s -`, endpoint, caCertFile) - - if _, err := configFile.WriteString(haproxyConfig); err != nil { + if _, err := configFile.WriteString(haproxyCfg); err != nil { configFile.Close() os.Remove(configPath) return nil, "", fmt.Errorf("failed to write haproxy config: %w", err) @@ -147,6 +133,8 @@ backend https_back return nil, "", fmt.Errorf("failed to start haproxy: %w", err) } + centralDeploymentInfo.HAProxyPort = roxieConfig.HAProxy.BindPort + return cmd, configPath, nil } From 07af7e51f958c57e8e48269ee10218b2222329d4 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:37:57 +0200 Subject: [PATCH 09/16] New helper tryStartHAProxy --- cmd/subshell.go | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/cmd/subshell.go b/cmd/subshell.go index 6a28c39..d3ae0f6 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -16,7 +16,6 @@ import ( "github.com/stackrox/roxie/internal/types" ) - // spawnSubshellForDeployerEnv assembles the roxie environment from a Deployer and invokes an interactive subshell. func spawnSubshellForDeployerEnv(d *deployer.Deployer, log *logger.Logger) error { return runCommandOrSubshell(d.GetCentralDeploymentInfo(), log, nil) @@ -90,6 +89,37 @@ func runCommandOrSubshell(centralDeploymentInfo types.CentralDeploymentInfo, log return nil } +func tryStartHAProxy( + log *logger.Logger, + roxieConfig deployer.RoxieConfig, + centralDeploymentInfo *types.CentralDeploymentInfo) (func(), error) { + + if !isHAProxyAvailable() { + log.Dim("No HAProxy available, skipping") + return nil, nil + } + + if centralDeploymentInfo.Endpoint == "" { + log.Warning("No Central endpoint available, skipping") + return nil, nil + } + if centralDeploymentInfo.CACertFile == "" { + log.Warning("No Central CA Cert available, skipping") + return nil, nil + } + + haproxyCmd, haproxyConfigPath, err := startHAProxy(roxieConfig, centralDeploymentInfo) + if err != nil { + return nil, err + } + + log.Infof("Started HAProxy at localhost:%v", centralDeploymentInfo.HAProxyPort) + + return func() { + cleanupHAProxy(haproxyCmd, haproxyConfigPath) + }, nil +} + func subShellMode(args []string) bool { return len(args) == 0 } From 739951ad2a4599e355b6f806cd0bb231eda79f9f Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:40:04 +0200 Subject: [PATCH 10/16] Let spawnSubshellForDeployerEnv and runCommandOrSubshell propagate RoxieConfig --- cmd/deploy.go | 2 +- cmd/shell.go | 6 +++--- cmd/subshell.go | 25 ++++++++++--------------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 81a9cf8..99db4b1 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -337,7 +337,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { } if components.IncludesCentral() && envrc == "" { - if err := spawnSubshellForDeployerEnv(d, log); err != nil { + if err := spawnSubshellForDeployerEnv(deploySettings.Roxie, d, log); err != nil { return fmt.Errorf("failed to spawn subshell: %w", err) } } diff --git a/cmd/shell.go b/cmd/shell.go index 695b45d..69de909 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -33,7 +33,7 @@ Examples: roxie shell -- roxctl central whoami roxie shell -- bash -c 'echo $ROX_ENDPOINT'`, Run: func(cmd *cobra.Command, args []string) { - err := runShell(cmd, args) + err := runShell(args) if err != nil { if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { // Propagate exit error from the child process. @@ -51,7 +51,7 @@ Examples: return cmd } -func runShell(cmd *cobra.Command, args []string) error { +func runShell(args []string) error { log := logger.New() if err := env.Initialize(log); err != nil { return err @@ -84,5 +84,5 @@ func runShell(cmd *cobra.Command, args []string) error { return fmt.Errorf("extracting central deployment info from manifest: %w", err) } - return runCommandOrSubshell(centralDeploymentInfo, log, args) + return runCommandOrSubshell(m.Config.Roxie, centralDeploymentInfo, log, args) } diff --git a/cmd/subshell.go b/cmd/subshell.go index d3ae0f6..b819b79 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -17,14 +17,14 @@ import ( ) // spawnSubshellForDeployerEnv assembles the roxie environment from a Deployer and invokes an interactive subshell. -func spawnSubshellForDeployerEnv(d *deployer.Deployer, log *logger.Logger) error { - return runCommandOrSubshell(d.GetCentralDeploymentInfo(), log, nil) +func spawnSubshellForDeployerEnv(roxieConfig deployer.RoxieConfig, d *deployer.Deployer, log *logger.Logger) error { + return runCommandOrSubshell(roxieConfig, d.GetCentralDeploymentInfo(), log, nil) } // runCommandOrSubshell spawns an interactive subshell or runs the provided command using the given // central deployment info. // It handles HAProxy setup, prints the connection banner, and manages shell lifecycle. -func runCommandOrSubshell(centralDeploymentInfo types.CentralDeploymentInfo, log *logger.Logger, args []string) error { +func runCommandOrSubshell(roxieConfig deployer.RoxieConfig, centralDeploymentInfo types.CentralDeploymentInfo, log *logger.Logger, args []string) error { cmdEnv := os.Environ() for name, val := range roxieenv.AssembleRoxieEnvironment(centralDeploymentInfo).Export() { cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", name, val)) @@ -32,17 +32,12 @@ func runCommandOrSubshell(centralDeploymentInfo types.CentralDeploymentInfo, log cmdEnv = append(cmdEnv, "ROXIE_SHELL=1") cmdEnv = append(cmdEnv, fmt.Sprintf("name=acs@%s", centralDeploymentInfo.KubeContext)) - haproxyAvailable := isHAProxyAvailable() - - if haproxyAvailable && centralDeploymentInfo.Endpoint != "" && centralDeploymentInfo.CACertFile != "" { - haproxyCmd, haproxyConfigPath, err := startHAProxy(centralDeploymentInfo.Endpoint, centralDeploymentInfo.CACertFile, log) - if err != nil { - log.Warningf("Failed to start HAProxy: %v", err) - } else { - cmdEnv = append(cmdEnv, "ROXIE_HAPROXY_CFG_FILE="+haproxyConfigPath) - centralDeploymentInfo.HAProxyStarted = true - defer cleanupHAProxy(haproxyCmd, haproxyConfigPath) - } + cleanupFunc, err := tryStartHAProxy(log, roxieConfig, ¢ralDeploymentInfo) + if err != nil { + log.Warningf("Failed to start HAProxy: %v", err) + } + if cleanupFunc != nil { + defer cleanupFunc() } var cmd *exec.Cmd @@ -61,7 +56,7 @@ func runCommandOrSubshell(centralDeploymentInfo types.CentralDeploymentInfo, log cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - err := cmd.Run() + err = cmd.Run() if subShellMode(args) { cyan := color.New(color.FgCyan, color.Bold) From 3e45164d5ade4a21ffa3ae83f62ff4306a1bae5a Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 14 Jul 2026 10:09:41 +0200 Subject: [PATCH 11/16] Conditional execution of tryStartHAProxy --- cmd/subshell.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd/subshell.go b/cmd/subshell.go index b819b79..61f7beb 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -32,12 +32,16 @@ func runCommandOrSubshell(roxieConfig deployer.RoxieConfig, centralDeploymentInf cmdEnv = append(cmdEnv, "ROXIE_SHELL=1") cmdEnv = append(cmdEnv, fmt.Sprintf("name=acs@%s", centralDeploymentInfo.KubeContext)) - cleanupFunc, err := tryStartHAProxy(log, roxieConfig, ¢ralDeploymentInfo) - if err != nil { - log.Warningf("Failed to start HAProxy: %v", err) - } - if cleanupFunc != nil { - defer cleanupFunc() + if roxieConfig.HAProxy.Enabled() { + cleanupFunc, err := tryStartHAProxy(log, roxieConfig, ¢ralDeploymentInfo) + if err != nil { + log.Warningf("Failed to start HAProxy: %v", err) + } + if cleanupFunc != nil { + defer cleanupFunc() + } + } else { + log.Dim("spawning of HAProxy is disabled") } var cmd *exec.Cmd @@ -56,7 +60,7 @@ func runCommandOrSubshell(roxieConfig deployer.RoxieConfig, centralDeploymentInf cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - err = cmd.Run() + err := cmd.Run() if subShellMode(args) { cyan := color.New(color.FgCyan, color.Bold) From 2bc9e18c4f892bb955a722c4b56543ef0df33f09 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 14 Jul 2026 09:48:25 +0200 Subject: [PATCH 12/16] Let shell command also benefit from ad-hoc configurability. e.g., enables things like roxie shell --set roxie.haProxy.bindPort=1234 --- cmd/shell.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/shell.go b/cmd/shell.go index 69de909..a5a4b5d 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -71,6 +71,12 @@ func runShell(args []string) error { return fmt.Errorf("failed to load roxie manifest: %w", err) } log.Dim("roxie manifest loaded") + configBase := m.Config + + config, err := assembleConfigForCommand(&configBase, deploySettingsFromArgs, skipUserConfig) + if err != nil { + return err + } // We need this for the setup of the CA cert. tempDir, err := os.MkdirTemp("", "roxie-shell-*") @@ -84,5 +90,5 @@ func runShell(args []string) error { return fmt.Errorf("extracting central deployment info from manifest: %w", err) } - return runCommandOrSubshell(m.Config.Roxie, centralDeploymentInfo, log, args) + return runCommandOrSubshell(config.Roxie, centralDeploymentInfo, log, args) } From e1714f9598fd1f558f7839f148b9adc330d2c76e Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 10:43:20 +0200 Subject: [PATCH 13/16] Adjust logging --- cmd/subshell.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/subshell.go b/cmd/subshell.go index 61f7beb..2a06823 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -203,12 +203,12 @@ func printBanner(centralDeploymentInfo types.CentralDeploymentInfo) { cyan.Println("[roxie] * roxcurl /v1/clusters") cyan.Println("[roxie]") - if centralDeploymentInfo.HAProxyStarted { - cyan.Println("[roxie] Central UI: http://localhost:8080 (username: admin, password: see $ROX_ADMIN_PASSWORD)") + if port := centralDeploymentInfo.HAProxyPort; port > 0 { + cyan.Printf("[roxie] Central UI: http://localhost:%d (username: admin, password: see $ROX_ADMIN_PASSWORD)\n", port) } 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") + cyan.Println("[roxie] Note: Installing haproxy enables automatic HTTP access to Central through a forwarded local port") } cyan.Println("") From 2eff914d7801ce20f63f90e38f0eee8d31aac083 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 13 Jul 2026 16:01:45 +0200 Subject: [PATCH 14/16] Fix unit tests cmd/flags.go, cmd/main.go: Enable flag inheritance for --config, so that it is available on subcommands. cmd/deploy_test.go: Extract subcommand from root command so that they related a parent/child and therefore parsing of `--config` works as expected. --- cmd/deploy_test.go | 16 +++++++++------- cmd/flags.go | 13 ++++++++++++- cmd/main.go | 1 + 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 71c6a6d..da754c3 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -278,10 +278,11 @@ central: 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) + deploySettingsFromArgs = deployer.NewConfig() // Need to reset this global after each test. + deployCmd, _, err := rootCmd.Find([]string{"deploy"}) + require.NoError(t, err) + require.NoError(t, deployCmd.ParseFlags(tt.args)) + tt.assert(t, deploySettingsFromArgs) }) } } @@ -302,9 +303,10 @@ func TestNewDeployCmd_SetRejectsSpec(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := deployer.NewConfig() - cmd := newDeployCmd(&cfg) - err := cmd.ParseFlags(tt.args) + deploySettingsFromArgs = deployer.NewConfig() // Need to reset this global after each test. + cmd, _, err := rootCmd.Find([]string{"deploy"}) + require.NoError(t, err) + err = cmd.ParseFlags(tt.args) require.Error(t, err) assert.Contains(t, err.Error(), "spec") }) diff --git a/cmd/flags.go b/cmd/flags.go index ede5d81..fe4fce8 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -16,6 +16,7 @@ type cliFlag struct { applyFn func(config *deployer.Config, val string) error noOptDefVal string description string + persistent bool } type flagOpt func(opts *cliFlag) @@ -80,6 +81,12 @@ func withShortName(shortName string) flagOpt { } } +func withPersistent() flagOpt { + return func(opts *cliFlag) { + opts.persistent = true + } +} + func registerFlag(cmd *cobra.Command, settings *deployer.Config, longName string, description string, flagOpts ...flagOpt) { f := cliFlag{ config: settings, @@ -89,7 +96,11 @@ func registerFlag(cmd *cobra.Command, settings *deployer.Config, longName string for _, applyOpt := range flagOpts { applyOpt(&f) } - flag := cmd.Flags().VarPF(&f, f.longName, f.shortName, f.description) + flagSet := cmd.Flags() + if f.persistent { + flagSet = cmd.PersistentFlags() + } + flag := flagSet.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 fa6f750..429be2b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -81,6 +81,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&skipUserConfig, "skip-user-config", false, fmt.Sprintf("Skips reading of user's configuration (%s)", paths.UserConfigPathString())) registerFlag(rootCmd, &deploySettingsFromArgs, "config", "Path to YAML config file", + withPersistent(), withShortName("c"), withApplyFn("filename", func(config *deployer.Config, filename string) error { if filename == "-" { From 7aec0ee288544b167ab02d0f6b3d9b45a2c28fcb Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 14 Jul 2026 10:42:11 +0200 Subject: [PATCH 15/16] Pass second struct as pointer to mergo.Merge --- cmd/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index 429be2b..b3f3ed5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -95,7 +95,7 @@ func init() { 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 { + 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 From d140a979c08c23b0269eb50fd3fb52d6447e776c Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 14 Jul 2026 10:47:48 +0200 Subject: [PATCH 16/16] Portable stdin reading --- cmd/main.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index b3f3ed5..5810f4e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "io" "os" "reflect" @@ -84,19 +85,24 @@ func init() { withPersistent(), withShortName("c"), withApplyFn("filename", func(config *deployer.Config, filename string) error { + var data []byte + var err error if filename == "-" { - filename = "/dev/stdin" + data, err = io.ReadAll(os.Stdin) + filename = "stdin" + } else { + data, err = os.ReadFile(filename) } - data, err := os.ReadFile(filename) if err != nil { - return fmt.Errorf("failed to read config file %q: %w", filename, err) + return fmt.Errorf("failed to read config from %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) + return fmt.Errorf("failed to unmarshal config from %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 fmt.Errorf("merging config from %q into deployer Config: %w", filename, err) } return nil }),