diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 00000000..2107c34f --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + + "dario.cat/mergo" + "github.com/stackrox/roxie/internal/deployer" +) + +func assembleConfigForCommand(configFromArgs deployer.Config, skipUserConfig bool) (deployer.Config, error) { + // Start with default configuration. + config := deployer.DefaultConfig() + + // 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 +} diff --git a/cmd/deploy.go b/cmd/deploy.go index 8dad7650..f839dc97 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -95,27 +95,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 @@ -264,19 +243,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(deploySettingsFromArgs, skipUserConfig) + if err != nil { + return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) @@ -396,7 +365,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/deploy_test.go b/cmd/deploy_test.go index 71c6a6df..71c63a75 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) }) } } diff --git a/cmd/flags.go b/cmd/flags.go index ede5d818..fe4fce88 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 ffe30dd0..bf83dcbc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -76,6 +76,28 @@ 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", + withPersistent(), + 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 + }), + ) + rootCmd.AddCommand(newDeployCmd(&deploySettingsFromArgs)) rootCmd.AddCommand(newTeardownCmd(&deploySettingsFromArgs)) rootCmd.AddCommand(newShellCmd()) diff --git a/cmd/shell.go b/cmd/shell.go index 695b45d4..69de909f 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 19462dc4..973719b1 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -10,20 +10,22 @@ 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) +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)) @@ -31,17 +33,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 @@ -60,7 +57,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) @@ -88,6 +85,35 @@ 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 + } + + return func() { + cleanupHAProxy(haproxyCmd, haproxyConfigPath) + }, nil +} + func subShellMode(args []string) bool { return len(args) == 0 } @@ -105,32 +131,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) @@ -138,8 +148,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 @@ -148,6 +157,8 @@ backend https_back return nil, "", fmt.Errorf("failed to start haproxy: %w", err) } + centralDeploymentInfo.HAProxyPort = roxieConfig.HAProxy.BindPort + return cmd, configPath, nil } @@ -187,12 +198,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("") diff --git a/cmd/teardown.go b/cmd/teardown.go index 90644753..68cbcaf8 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(deploySettingsFromArgs, skipUserConfig) + if err != nil { + return err } d, err := deployer.New(log) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index ea0bd827..fa8fd5af 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 = 8080 + return cfg +} + // DefaultCentralConfig returns a CentralConfig with sensible defaults. func DefaultCentralConfig() CentralConfig { cfg := NewCentralConfig() @@ -356,3 +363,7 @@ func (s *SecuredClusterConfig) CustomResource() (map[string]interface{}, error) } return cr, nil } + +type HAProxyConfig struct { + BindPort int `yaml:"bindPort"` +} diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go new file mode 100644 index 00000000..d20eb00d --- /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 00000000..0e46127a --- /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") +} diff --git a/internal/types/central_deployment_info.go b/internal/types/central_deployment_info.go index c04e0f59..fac41815 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. }