diff --git a/cmd/deploy.go b/cmd/deploy.go index 4089b12..3c31be5 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -212,6 +212,19 @@ this flag can be used to tell roxie how to pre-load images for the current clust }), ) + registerFlag(cmd, settings, "eval", "Print environment as eval-able export statements to stdout", + withNoOptDefVal("true"), + withApplyFnBool(func(config *deployer.Config, val bool) error { + if val { + evalMode = true + config.Roxie.Envrc = new("/dev/stdout") + } + return nil + }), + ) + + cmd.MarkFlagsMutuallyExclusive("eval", "envrc") + // Make --override an alias for --config, for backwards compatibility. cmd.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { if name == "override" { @@ -361,11 +374,28 @@ func runDeploy(cmd *cobra.Command, args []string) error { if err := manifest.CreateManifestSecretOnCluster(ctx, m); err != nil { log.Warningf("Failed to save roxie manifest: %v", err) } - } - if components.IncludesCentral() && !deploySettings.Roxie.EnvrcSet() { - if err := spawnSubshellForDeployerEnv(deploySettings.Roxie, d); err != nil { - return fmt.Errorf("failed to spawn subshell: %w", err) + if components.IncludesCentral() { + if evalMode { + tempDir, err := os.MkdirTemp("", "roxie-shell-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + centralDeploymentInfo, err := manifest.ManifestToCentralDeploymentInfo(ctx, tempDir, &m) + if err != nil { + return fmt.Errorf("extracting central deployment info from manifest: %w", err) + } + + printBanner(os.Stderr, centralDeploymentInfo) + printExports(centralDeploymentInfo) + return nil + } + + if !deploySettings.Roxie.EnvrcSet() { + if err := spawnSubshellForDeployerEnv(deploySettings.Roxie, d); err != nil { + return fmt.Errorf("failed to spawn subshell: %w", err) + } + } } } @@ -497,7 +527,7 @@ func deployValidate(components component.Component, deploySettings *deployer.Con } if components.IncludesCentral() && !env.RunningInteractively && !deploySettings.Roxie.EnvrcSet() { - return errors.New("running without a controlling terminal requires --envrc to be set") + return errors.New("running without a controlling terminal requires --envrc or --eval to be set") } if deploySettings.Roxie.UsesCustomRegistry() { diff --git a/cmd/main.go b/cmd/main.go index 99c35bb..466fb8d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,6 +9,7 @@ import ( "dario.cat/mergo" "github.com/fatih/color" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/helpers" @@ -24,6 +25,8 @@ var ( shell string dryRun bool + evalMode bool + skipUserConfig bool // We need this set up before command line flags are parsed. @@ -78,6 +81,12 @@ Red Hat Advanced Cluster Security (ACS) on any Kubernetes/OpenShift cluster.`, } func init() { + // All log output goes to stderr, so base the color decision on stderr's TTY status + // instead of the fatih/color default (stdout). This keeps colors working when stdout + // is captured, e.g. eval "$(roxie deploy --eval)". + _, noColorSet := os.LookupEnv("NO_COLOR") + color.NoColor = noColorSet || os.Getenv("TERM") == "dumb" || !isatty.IsTerminal(os.Stderr.Fd()) + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output (show CRs)") rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Do not actually modify cluster") rootCmd.PersistentFlags().BoolVar(&skipUserConfig, "skip-user-config", false, diff --git a/cmd/shell.go b/cmd/shell.go index a403332..121242e 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -10,8 +10,10 @@ import ( "github.com/spf13/cobra" "github.com/stackrox/roxie/internal/env" + "github.com/stackrox/roxie/internal/k8s" log "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/manifest" + "github.com/stackrox/roxie/internal/portforward" ) func newShellCmd() *cobra.Command { @@ -47,11 +49,16 @@ Examples: } cmd.Flags().StringVar(&shell, "shell", "", "Shell to spawn") + cmd.Flags().BoolVar(&evalMode, "eval", false, "Print environment as eval-able export statements to stdout") return cmd } func runShell(args []string) error { + if evalMode && len(args) > 0 { + return errors.New("--eval cannot be combined with command arguments") + } + if err := env.Initialize(); err != nil { return err } @@ -82,12 +89,44 @@ func runShell(args []string) error { if err != nil { return fmt.Errorf("failed to create temp dir: %w", err) } - defer os.RemoveAll(tempDir) + if !evalMode { + defer os.RemoveAll(tempDir) + } centralDeploymentInfo, err := manifest.ManifestToCentralDeploymentInfo(ctx, tempDir, m) if err != nil { return fmt.Errorf("extracting central deployment info from manifest: %w", err) } + var portForwardPID int + if config.Central.PortForwardingEnabled() { + portForwarder := portforward.New(k8s.GetKubectl()) + if evalMode { + endpoint, pid, err := portForwarder.StartDetached(config.Central.Namespace, "central", 443, 8443) + if err != nil { + return fmt.Errorf("failed to start port-forward: %w", err) + } + centralDeploymentInfo.Endpoint = endpoint + portForwardPID = pid + } else { + endpoint, err := portForwarder.Start(config.Central.Namespace, "central", 443, 8443) + if err != nil { + return fmt.Errorf("failed to start port-forward: %w", err) + } + defer portForwarder.Stop() + centralDeploymentInfo.Endpoint = endpoint + } + log.Infof("Port-forwarding Central at %s", centralDeploymentInfo.Endpoint) + } + + if evalMode { + printBanner(os.Stderr, centralDeploymentInfo) + printExports(centralDeploymentInfo) + if portForwardPID != 0 { + fmt.Fprintf(os.Stdout, "export ROXIE_PORT_FORWARD_PID=%d\n", portForwardPID) + } + return nil + } + return runCommandOrSubshell(config.Roxie, centralDeploymentInfo, args) } diff --git a/cmd/subshell.go b/cmd/subshell.go index 7652468..1495b4e 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "os" "os/exec" "syscall" @@ -11,6 +12,7 @@ import ( "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/haproxy" + "github.com/stackrox/roxie/internal/helpers" log "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/roxieenv" "github.com/stackrox/roxie/internal/types" @@ -49,7 +51,7 @@ func runCommandOrSubshell(roxieConfig deployer.RoxieConfig, centralDeploymentInf if subShellMode(args) { shellPath := resolveShellPath() log.Infof("Spawning sub-shell: %s", shellPath) - printBanner(centralDeploymentInfo) + printBanner(color.Output, centralDeploymentInfo) cmd = exec.Command(shellPath, "-l", "-i") } else { // args is non-empty. @@ -192,23 +194,27 @@ func isHAProxyAvailable() bool { return err == nil } -func printBanner(centralDeploymentInfo types.CentralDeploymentInfo) { +func printExports(centralDeploymentInfo types.CentralDeploymentInfo) { + for name, val := range roxieenv.AssembleRoxieEnvironment(centralDeploymentInfo).Export() { + fmt.Fprintf(os.Stdout, "export %s=%s\n", name, helpers.ShellQuote(val)) + } +} + +func printBanner(w io.Writer, centralDeploymentInfo types.CentralDeploymentInfo) { cyan := color.New(color.FgCyan, color.Bold) - cyan.Println("\n[roxie] Entering a subshell with ACS environment variables set.") - cyan.Println("[roxie]") - cyan.Println("[roxie] Environment is set up for talking to ACS Central. Examples:") - cyan.Println("[roxie]") - cyan.Println("[roxie] * roxctl central whoami") - cyan.Println("[roxie] * roxcurl /v1/clusters") - cyan.Println("[roxie]") + cyan.Fprintln(w, "\n[roxie] Environment is set up for talking to ACS Central. Examples:") + cyan.Fprintln(w, "[roxie]") + cyan.Fprintln(w, "[roxie] * roxctl central whoami") + cyan.Fprintln(w, "[roxie] * roxcurl /v1/clusters") + cyan.Fprintln(w, "[roxie]") if port := centralDeploymentInfo.HAProxyPort; port > 0 { - cyan.Printf("[roxie] Central UI: http://localhost:%d (username: admin, password: see $ROX_ADMIN_PASSWORD)\n", port) + cyan.Fprintf(w, "[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) + cyan.Fprintf(w, "[roxie] Central UI: https://%s\n", centralDeploymentInfo.Endpoint) } else if !env.RunningInRoxieContainer { - cyan.Println("[roxie] Note: Installing haproxy enables automatic HTTP access to Central through a forwarded local port") + cyan.Fprintln(w, "[roxie] Note: Installing haproxy enables automatic HTTP access to Central through a forwarded local port") } - cyan.Println("") + cyan.Fprintln(w, "") } diff --git a/go.mod b/go.mod index af447ad..86aad35 100644 --- a/go.mod +++ b/go.mod @@ -114,7 +114,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.19.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.20 github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/moby/api v1.56.1 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index a54b240..19c71b2 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -19,6 +19,7 @@ import ( "github.com/stackrox/roxie/internal/containerrt" "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" log "github.com/stackrox/roxie/internal/logger" @@ -775,7 +776,7 @@ func (d *Deployer) cleanupTempDir(path string, description string) { func (d *Deployer) writeEnvrcFile(ctx context.Context, filename string) error { var content strings.Builder for name, val := range roxieenv.AssembleRoxieEnvironment(d.GetCentralDeploymentInfo()).Export() { - fmt.Fprintf(&content, "export %s=%q\n", name, val) + fmt.Fprintf(&content, "export %s=%s\n", name, helpers.ShellQuote(val)) } if d.portForwardPID != 0 { diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 4c08605..2a4602d 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "reflect" + "strings" "gopkg.in/yaml.v3" ) @@ -150,3 +151,8 @@ func IsNil(i interface{}) bool { } return false } + +// ShellQuote wraps s in POSIX single quotes, safe for use in shell eval contexts. +func ShellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 6df7a2f..4e7658c 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -31,19 +31,18 @@ type Logger struct { // New creates a new roxie logger. func New() *Logger { - return NewWithWriters(os.Stdout, os.Stderr) + return NewWithWriter(os.Stderr) } -// NewWithWriters creates a new roxie logger with configurable writers for stdout/stderr. -func NewWithWriters(stdout, stderr io.Writer) *Logger { +// NewWithWriter creates a new roxie logger that writes all output to w. +func NewWithWriter(w io.Writer) *Logger { level := &slog.LevelVar{} level.Set(LevelDim) h := &handler{ level: level, startTime: time.Now(), - stdout: stdout, - stderr: stderr, + stderr: w, } return &Logger{ @@ -172,7 +171,6 @@ type handler struct { mu sync.Mutex level *slog.LevelVar startTime time.Time - stdout io.Writer stderr io.Writer } @@ -192,14 +190,9 @@ func (h *handler) Handle(_ context.Context, r slog.Record) error { timestamp := color.GreenString("%02d:%02d", minutes, seconds) message := styleForLevel(r.Level).Sprint(r.Message) - w := h.stdout - if r.Level >= slog.LevelError { - w = h.stderr - } - h.mu.Lock() defer h.mu.Unlock() - fmt.Fprintf(w, "%s %s\n", timestamp, message) + fmt.Fprintf(h.stderr, "%s %s\n", timestamp, message) return nil } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index f5628b7..9a3fe53 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -9,28 +9,28 @@ import ( ) func TestDebugOnlyEmitsWhenVerbose(t *testing.T) { - var stdout, stderr bytes.Buffer - l := NewWithWriters(&stdout, &stderr) + var buf bytes.Buffer + l := NewWithWriter(&buf) // Debug is suppressed at the default level (LevelDim). l.Debug("hidden") - if stdout.Len() != 0 { - t.Fatalf("expected no output before SetVerbose(true), got %q", stdout.String()) + if buf.Len() != 0 { + t.Fatalf("expected no output before SetVerbose(true), got %q", buf.String()) } // Enabling verbose lowers the level to slog.LevelDebug, so Debug emits. l.SetVerbose(true) l.Debug("visible") - if !strings.Contains(stdout.String(), "visible") { - t.Fatalf("expected debug output to contain %q, got %q", "visible", stdout.String()) + if !strings.Contains(buf.String(), "visible") { + t.Fatalf("expected debug output to contain %q, got %q", "visible", buf.String()) } // Disabling verbose restores the default level and suppresses Debug again. - before := stdout.Len() + before := buf.Len() l.SetVerbose(false) l.Debug("hidden again") - if stdout.Len() != before { - t.Fatalf("expected no new output after SetVerbose(false), got %q", stdout.String()[before:]) + if buf.Len() != before { + t.Fatalf("expected no new output after SetVerbose(false), got %q", buf.String()[before:]) } } diff --git a/tests/e2e/basic_test.go b/tests/e2e/basic_test.go index 99eed02..7e5c842 100644 --- a/tests/e2e/basic_test.go +++ b/tests/e2e/basic_test.go @@ -58,8 +58,7 @@ func TestDetachedPortForwarding(t *testing.T) { args := append([]string{roxieBinary, "deploy", "--early-readiness", "central", "--exposure=none", "--port-forwarding", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) - env, err := loadEnvrcFile(envrcPath) - require.NoError(t, err, "Failed to load envrc file") + env := loadEnvrcFile(t, envrcPath) pidStr, ok := env["ROXIE_PORT_FORWARD_PID"] require.True(t, ok, "ROXIE_PORT_FORWARD_PID not set in envrc") pid, err := strconv.Atoi(pidStr) diff --git a/tests/e2e/custom_tls_test.go b/tests/e2e/custom_tls_test.go index 3bb9fff..0085bf6 100644 --- a/tests/e2e/custom_tls_test.go +++ b/tests/e2e/custom_tls_test.go @@ -48,8 +48,7 @@ func TestDeployWithCustomTLSCert(t *testing.T) { verifyCentralInstalled(t, sharedNamespace) verifySecuredClusterInstalled(t, sharedNamespace) - env, err := loadEnvrcFile(envrcPath) - require.NoError(t, err, "Failed to load envrc file") + env := loadEnvrcFile(t, envrcPath) require.NotEmpty(t, env["ROX_CA_CERT_FILE"], "ROX_CA_CERT_FILE should be set in envrc") verifyCACertFileContainsCustomCA(t, env["ROX_CA_CERT_FILE"], caCertPEM) diff --git a/tests/e2e/helpers.go b/tests/e2e/helpers.go index 05fb226..6ed694d 100644 --- a/tests/e2e/helpers.go +++ b/tests/e2e/helpers.go @@ -223,39 +223,25 @@ func doesDeploymentExist(t *testing.T, namespace string, name string) bool { return strings.Contains(string(output), name) } -func loadEnvrcFile(path string) (map[string]string, error) { - content, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - env := make(map[string]string) - lines := strings.Split(string(content), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || !strings.HasPrefix(line, "export ") { - continue - } +// loadEnvrcFile sources the envrc file in a real shell and returns the +// exported variables. +func loadEnvrcFile(t *testing.T, path string) map[string]string { + t.Helper() - // Remove "export " prefix - line = strings.TrimPrefix(line, "export ") + // Extract shell's environment after loading roxie's envrc file. + cmd := exec.Command("sh", "-c", `. "$1" >&2 && env`, "sh", path) + out, err := cmd.Output() + require.NoError(t, err, "Failed to source envrc file %s", path) - // Split on first = + // Collect the environment set by the shell. + env := make(map[string]string) + for _, line := range strings.Split(string(out), "\n") { parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue + if len(parts) == 2 { + env[parts[0]] = parts[1] } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - // Remove quotes - value = strings.Trim(value, "\"") - - env[key] = value } - - return env, nil + return env } func verifyCentralInstalled(t *testing.T, namespace string) { diff --git a/tests/e2e/olm_switch_test.go b/tests/e2e/olm_switch_test.go index 850ad75..d7103a4 100644 --- a/tests/e2e/olm_switch_test.go +++ b/tests/e2e/olm_switch_test.go @@ -215,10 +215,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) { verifyNamespaceExists(t, centralNamespace) // Load envrc for secured-cluster - envrcEnv, err := loadEnvrcFile(envrcPath) - if err != nil { - t.Fatalf("Failed to load envrc file: %v", err) - } + envrcEnv := loadEnvrcFile(t, envrcPath) // Step 2: Deploy secured-cluster (should reuse OLM operator) t.Log("=== Step 2: Deploy secured-cluster (should reuse OLM operator) ===")