diff --git a/cmd/deploy.go b/cmd/deploy.go index 72a3567..3a1d2b6 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -18,6 +18,8 @@ import ( "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/manifest" + "github.com/stackrox/roxie/internal/roxieenv" "github.com/stackrox/roxie/internal/types" "github.com/stackrox/roxie/internal/stackroxversions" @@ -337,8 +339,19 @@ func runDeploy(cmd *cobra.Command, args []string) error { d.WaitForCentral(5 * time.Minute) } + if components.IncludesCentral() && !dryRun { + roxieEnv := roxieenv.AssembleRoxieEnvironment(d.GetCentralDeploymentInfo()) + m := manifest.RoxieManifest{ + RoxieEnvironment: roxieEnv, + Config: deploySettings, + } + if err := manifest.CreateManifestSecretOnCluster(ctx, log, m); err != nil { + log.Warningf("Failed to save roxie manifest: %v", err) + } + } + if components.IncludesCentral() && envrc == "" { - if err := spawnSubshell(d, log); err != nil { + if err := spawnSubshellForDeployerEnv(d, log); err != nil { return fmt.Errorf("failed to spawn subshell: %w", err) } } diff --git a/cmd/main.go b/cmd/main.go index ac1fa51..81b92f7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -41,6 +41,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Do not actually modify cluster") rootCmd.AddCommand(newDeployCmd(&deploySettings)) rootCmd.AddCommand(newTeardownCmd(&deploySettings)) + rootCmd.AddCommand(newShellCmd()) rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newEnvCmd()) rootCmd.AddCommand(newLogsCmd()) diff --git a/cmd/shell.go b/cmd/shell.go new file mode 100644 index 0000000..695b45d --- /dev/null +++ b/cmd/shell.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" + + "github.com/spf13/cobra" + "github.com/stackrox/roxie/internal/env" + "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/manifest" +) + +func newShellCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "shell [-- command [args...]]", + Short: "Open a subshell for an existing ACS Central deployment", + Long: `Open an interactive subshell with ACS environment variables +set for an existing ACS Central deployment. + +This command reads the roxie manifest secret from the cluster, +re-fetches the CA certificate, and spawns an interactive subshell +with the environment variables set. + +If a command is given after "--", it is executed in the modified environment +instead of spawning a subshell. + +Examples: + roxie shell + roxie shell -- roxctl central whoami + roxie shell -- bash -c 'echo $ROX_ENDPOINT'`, + Run: func(cmd *cobra.Command, args []string) { + err := runShell(cmd, args) + if err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + // Propagate exit error from the child process. + os.Exit(exitErr.ExitCode()) + } + cmd.PrintErrln(err) + os.Exit(1) + } + }, + DisableFlagParsing: false, + } + + cmd.Flags().StringVar(&shell, "shell", "", "Shell to spawn") + + return cmd +} + +func runShell(cmd *cobra.Command, args []string) error { + log := logger.New() + if err := env.Initialize(log); err != nil { + return err + } + + if 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.Info("Loading manifest from cluster...") + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + m, err := manifest.LoadManifestSecret(ctx, log) + if err != nil { + return fmt.Errorf("failed to load roxie manifest: %w", err) + } + log.Dim("roxie manifest loaded") + + // We need this for the setup of the CA cert. + tempDir, err := os.MkdirTemp("", "roxie-shell-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + centralDeploymentInfo, err := manifest.ManifestToCentralDeploymentInfo(ctx, log, tempDir, m) + if err != nil { + return fmt.Errorf("extracting central deployment info from manifest: %w", err) + } + + return runCommandOrSubshell(centralDeploymentInfo, log, args) +} diff --git a/cmd/subshell.go b/cmd/subshell.go index d198f52..19462dc 100644 --- a/cmd/subshell.go +++ b/cmd/subshell.go @@ -11,94 +11,100 @@ import ( "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/roxieenv" "github.com/stackrox/roxie/internal/types" ) -func spawnSubshell(d *deployer.Deployer, log *logger.Logger) error { - shellPath := shell - if shellPath == "" { - shellPath = os.Getenv("ROXIE_USER_SHELL") - } - if shellPath == "" { - shellPath = os.Getenv("SHELL") - } - if shellPath == "" { - shellPath = "/bin/bash" - } - - log.Infof("Spawning sub-shell: %s", shellPath) - - env := os.Environ() - - centralDeploymentInfo := d.GetCentralDeploymentInfo() - - 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 centralDeploymentInfo.Password != "" { - env = append(env, fmt.Sprintf("ROX_ADMIN_PASSWORD=%s", centralDeploymentInfo.Password)) - } +// 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) +} - if centralDeploymentInfo.CACertFile != "" { - env = append(env, fmt.Sprintf("ROX_CA_CERT_FILE=%s", centralDeploymentInfo.CACertFile)) +// 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 { + cmdEnv := os.Environ() + for name, val := range roxieenv.AssembleRoxieEnvironment(centralDeploymentInfo).Export() { + cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", name, val)) } - - env = append(env, fmt.Sprintf("ROX_USERNAME=%s", deployer.AdminUsername)) - env = append(env, "ROXIE_SHELL=1") - env = append(env, fmt.Sprintf("name=acs@%s", centralDeploymentInfo.KubeContext)) + cmdEnv = append(cmdEnv, "ROXIE_SHELL=1") + cmdEnv = append(cmdEnv, fmt.Sprintf("name=acs@%s", centralDeploymentInfo.KubeContext)) haproxyAvailable := isHAProxyAvailable() - var haproxyCmd *exec.Cmd - var haproxyConfigPath string - if haproxyAvailable && centralDeploymentInfo.Endpoint != "" && centralDeploymentInfo.CACertFile != "" { - var err error - haproxyCmd, haproxyConfigPath, err = startHAProxy(centralDeploymentInfo.Endpoint, centralDeploymentInfo.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)) + cmdEnv = append(cmdEnv, "ROXIE_HAPROXY_CFG_FILE="+haproxyConfigPath) centralDeploymentInfo.HAProxyStarted = true defer cleanupHAProxy(haproxyCmd, haproxyConfigPath) } } - printBanner(centralDeploymentInfo) - - shellCmd := exec.Command(shellPath, "-i") - shellCmd.Env = env - shellCmd.Stdin = os.Stdin - shellCmd.Stdout = os.Stdout - shellCmd.Stderr = os.Stderr - - err := shellCmd.Run() + var cmd *exec.Cmd - // Print exit message - cyan := color.New(color.FgCyan, color.Bold) - cyan.Println("\n[roxie] Exited subshell. You are now back in your original shell.") - cyan.Println("") - - // Don't treat shell exit as an error - shells can exit with non-zero status - // for various reasons (like the last command failing) which is normal behavior - if err != nil { - // Check if it's a normal exit (exit code from the shell) - if exitErr, ok := err.(*exec.ExitError); ok { - // Shell exited (could be normal exit or last command failed) - // This is not an error condition for roxie - the subshell worked fine - _ = exitErr // Acknowledge we handled this - return nil + if subShellMode(args) { + shellPath := resolveShellPath() + log.Infof("Spawning sub-shell: %s", shellPath) + printBanner(centralDeploymentInfo) + cmd = exec.Command(shellPath, "-i") + } else { + // args is non-empty. + cmd = exec.Command(args[0], args[1:]...) + } + cmd.Env = cmdEnv + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + + if subShellMode(args) { + cyan := color.New(color.FgCyan, color.Bold) + cyan.Println("") + cyan.Println("[roxie] Exited subshell. You are now back in your original shell.") + cyan.Println("[roxie] If you accidentally closed the roxie subshell, you can use `roxie shell` to re-open it.") + cyan.Println("") + + // Don't treat shell exit as an error - shells can exit with non-zero status + // for various reasons (like the last command failing) which is normal behavior + if err != nil { + // Check if it's a normal exit (exit code from the shell) + if _, ok := err.(*exec.ExitError); ok { + return nil + } + // Only return error if we couldn't even start the shell + return fmt.Errorf("failed to run subshell: %w", err) + } + } else { + if err != nil { + return fmt.Errorf("failed to execute command: %w", err) } - // Only return error if we couldn't even start the shell - return fmt.Errorf("failed to run subshell: %w", err) } return nil } +func subShellMode(args []string) bool { + return len(args) == 0 +} + +func resolveShellPath() string { + if shell != "" { + return shell + } + if s := os.Getenv("ROXIE_USER_SHELL"); s != "" { + return s + } + if s := os.Getenv("SHELL"); s != "" { + return s + } + return "/bin/bash" +} + func startHAProxy(endpoint, caCertFile string, log *logger.Logger) (*exec.Cmd, string, error) { configFile, err := os.CreateTemp("", "roxie-haproxy-*.cfg") if err != nil { @@ -171,7 +177,7 @@ func isHAProxyAvailable() bool { return err == nil } -func printBanner(centralDeploymentInfo deployer.CentralDeploymentInfo) { +func printBanner(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]") diff --git a/cmd/teardown.go b/cmd/teardown.go index fbda147..2b8a834 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -10,6 +10,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/manifest" ) func newTeardownCmd(settings *deployer.Config) *cobra.Command { @@ -70,6 +71,17 @@ func runTeardown(cmd *cobra.Command, args []string) error { return fmt.Errorf("teardown failed: %w", err) } + if components.IncludesCentral() { + if err := manifest.DeleteManifestSecret(ctx, log); err != nil { + log.Warningf("Failed to delete roxie manifest: %v", err) + } + } + if components == component.All { + if err := manifest.DeleteRoxieNamespace(ctx, log); err != nil { + log.Warningf("Failed to delete roxie namespace: %v", err) + } + } + log.Success("🎉 Teardown complete!") return nil diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 44565b8..9e40ea3 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -21,6 +21,7 @@ import ( "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/portforward" + "github.com/stackrox/roxie/internal/roxieenv" "github.com/stackrox/roxie/internal/types" ) @@ -704,12 +705,10 @@ func (d *Deployer) cleanupTempDir(path string, description string) { 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) - fmt.Fprintf(&content, "export ROX_BASE_URL='https://%s'\n", d.centralEndpoint) - fmt.Fprintf(&content, "export ROX_USERNAME=%q\n", AdminUsername) - fmt.Fprintf(&content, "export ROX_ADMIN_PASSWORD=%q\n", d.centralPassword) - fmt.Fprintf(&content, "export ROX_CA_CERT_FILE=%q\n", d.roxCACertFile) + for name, val := range roxieenv.AssembleRoxieEnvironment(d.GetCentralDeploymentInfo()).Export() { + fmt.Fprintf(&content, "export %s=%q\n", name, val) + } + if d.portForwardPID != 0 { fmt.Fprintf(&content, "export ROXIE_PORT_FORWARD_PID=%d\n", d.portForwardPID) } @@ -969,18 +968,10 @@ func (d *Deployer) PrintSecuredClusterDeploymentSummary() { 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{ +func (d *Deployer) GetCentralDeploymentInfo() types.CentralDeploymentInfo { + return types.CentralDeploymentInfo{ Endpoint: d.centralEndpoint, + Username: AdminUsername, Password: d.centralPassword, KubeContext: d.kubeContext, Exposure: d.config.Central.GetExposure(), diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..ae4b625 --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,214 @@ +package manifest + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/env" + "github.com/stackrox/roxie/internal/k8s" + "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/types" + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + roxieNamespace = "roxie" + manifestSecretName = "roxie-manifest" + manifestKey = "manifest" +) + +// RoxieManifest represents the data stored in the roxie manifest secret. +// We include the whole deployer Config for reproducibility purposes. +type RoxieManifest struct { + RoxieEnvironment types.RoxieEnvironment `yaml:"roxieEnvironment"` + Config deployer.Config `yaml:"config"` +} + +func manifestToSecret(m RoxieManifest) (*unstructured.Unstructured, error) { + manifestYAML, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to marshal manifest: %w", err) + } + + secret := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": manifestSecretName, + "namespace": roxieNamespace, + "labels": map[string]any{ + "app.kubernetes.io/managed-by": "roxie", + }, + }, + "type": "Opaque", + "stringData": map[string]any{ + manifestKey: string(manifestYAML), + }, + }, + } + + return secret, nil +} + +func CreateManifestSecretOnCluster(ctx context.Context, log *logger.Logger, m RoxieManifest) error { + secret, err := manifestToSecret(m) + if err != nil { + return fmt.Errorf("failed to convert manifest to secret: %w", err) + } + + yamlData, err := yaml.Marshal(secret.Object) + if err != nil { + return fmt.Errorf("failed to marshal manifest secret: %w", err) + } + + if err := ensureRoxieNamespace(ctx, log); err != nil { + return fmt.Errorf("failed to ensure roxie namespace exists: %w", err) + } + + _, err = k8s.RunKubectl(ctx, log, k8s.KubectlOptions{ + Args: []string{"apply", "-f", "-"}, + Stdin: bytes.NewReader(yamlData), + }) + if err != nil { + return fmt.Errorf("failed to apply manifest secret to cluster: %w", err) + } + + log.Dim("roxie manifest secret applied") + return nil +} + +func LoadManifestSecret(ctx context.Context, log *logger.Logger) (*RoxieManifest, error) { + obj, err := k8s.RetrieveResourceFromCluster(ctx, log, roxieNamespace, "secret", manifestSecretName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve manifest secret: %w", err) + } + + manifestB64, found, err := unstructured.NestedString(obj.Object, "data", manifestKey) + if err != nil { + return nil, fmt.Errorf("failed to extract secret data: %w", err) + } + if !found { + return nil, fmt.Errorf("manifest secret missing key %q", manifestKey) + } + + manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) + if err != nil { + return nil, fmt.Errorf("failed to decode %s: %w", manifestKey, err) + } + + var m RoxieManifest + if err := yaml.Unmarshal(manifestBytes, &m); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + + return &m, nil +} + +func DeleteManifestSecret(ctx context.Context, log *logger.Logger) error { + _, err := k8s.RunKubectl(ctx, log, k8s.KubectlOptions{ + Args: []string{"delete", "secret", manifestSecretName, "-n", roxieNamespace, "--ignore-not-found=true"}, + IgnoreErrors: true, + }) + return err +} + +func DeleteRoxieNamespace(ctx context.Context, log *logger.Logger) error { + _, err := k8s.RunKubectl(ctx, log, k8s.KubectlOptions{ + Args: []string{"delete", "namespace", roxieNamespace, "--ignore-not-found=true"}, + IgnoreErrors: true, + }) + return err +} + +func ensureRoxieNamespace(ctx context.Context, log *logger.Logger) error { + ns := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]any{ + "name": roxieNamespace, + "labels": map[string]any{ + "app.kubernetes.io/managed-by": "roxie", + }, + }, + }, + } + nsYAML, err := yaml.Marshal(ns.Object) + if err != nil { + return fmt.Errorf("failed to marshal namespace: %w", err) + } + _, err = k8s.RunKubectl(ctx, log, k8s.KubectlOptions{ + Args: []string{"apply", "-f", "-"}, + Stdin: bytes.NewReader(nsYAML), + }) + if err != nil { + return fmt.Errorf("failed to create namespace %s: %w", roxieNamespace, err) + } + + return nil +} + +func ManifestToCentralDeploymentInfo(ctx context.Context, log *logger.Logger, tempDir string, m *RoxieManifest) (types.CentralDeploymentInfo, error) { + roxieEnv := m.RoxieEnvironment + + caCertFile, err := fetchCACertForShell(ctx, log, m.Config.Central.Namespace, tempDir) + if err != nil { + // Nothing we expect to happen, but in any case, don't let the deployment fail here. + log.Warningf("Could not fetch CA cert: %v", err) + } + + return types.CentralDeploymentInfo{ + Endpoint: roxieEnv.RoxEndpoint, + Username: roxieEnv.RoxUsername, + Password: roxieEnv.RoxAdminPassword, + KubeContext: env.GetCurrentContext(), + Exposure: m.Config.Central.GetExposure(), + CACertFile: caCertFile, + }, nil +} + +func fetchCACertForShell(ctx context.Context, log *logger.Logger, centralNamespace, tempDir string) (string, error) { + log.Info("Fetching Central CA certificate...") + + result, err := k8s.RunKubectl(ctx, log, k8s.KubectlOptions{ + Args: []string{"get", "secret", "central-tls", "-n", centralNamespace, "-o", "jsonpath={.data.ca\\.pem}"}, + }) + if err != nil { + return "", fmt.Errorf("failed to get CA cert from secret: %w", err) + } + + caCertBase64 := strings.TrimSpace(result.Stdout) + if caCertBase64 == "" { + return "", errors.New("CA certificate is empty") + } + + caCert, err := base64.StdEncoding.DecodeString(caCertBase64) + if err != nil { + return "", fmt.Errorf("failed to decode CA cert: %w", err) + } + + caCertFile, err := os.CreateTemp(tempDir, "roxie-ca-*.pem") + if err != nil { + return "", fmt.Errorf("failed to create temp file for CA cert: %w", err) + } + + if _, err := caCertFile.Write(caCert); err != nil { + _ = caCertFile.Close() + _ = os.Remove(caCertFile.Name()) + return "", fmt.Errorf("failed to write CA cert: %w", err) + } + if err := caCertFile.Close(); err != nil { + return "", fmt.Errorf("failed to close CA cert file: %w", err) + } + + log.Successf("✓ CA certificate saved to: %s", caCertFile.Name()) + return caCertFile.Name(), nil +} diff --git a/internal/manifest/manifest_integration_test.go b/internal/manifest/manifest_integration_test.go new file mode 100644 index 0000000..d47afdf --- /dev/null +++ b/internal/manifest/manifest_integration_test.go @@ -0,0 +1,111 @@ +//go:build integration + +package manifest + +import ( + "context" + "testing" + "time" + + "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/k8s" + "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func cleanupRoxieNamespace(t *testing.T) { + t.Helper() + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + err := DeleteRoxieNamespace(ctx, log) + assert.NoError(t, err, "deleting roxie namespace failed") +} + +func TestCreateAndLoadManifest_Integration(t *testing.T) { + t.Cleanup(func() { cleanupRoxieNamespace(t) }) + cleanupRoxieNamespace(t) + + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + original := RoxieManifest{ + RoxieEnvironment: types.RoxieEnvironment{ + APIEndpoint: "localhost:8443", + RoxAdminPassword: "testpassword", + RoxBaseURL: "https://localhost:8443", + RoxEndpoint: "localhost:8443", + RoxUsername: "admin", + }, + Config: deployer.Config{ + Central: deployer.CentralConfig{ + Namespace: "acs-central", + }, + }, + } + + err := CreateManifestSecretOnCluster(ctx, log, original) + require.NoError(t, err) + + loaded, err := LoadManifestSecret(ctx, log) + require.NoError(t, err) + + assert.Empty(t, loaded.RoxieEnvironment.RoxCaCertFile) + assert.Equal(t, original, *loaded) +} + +func TestDeleteManifest_Integration(t *testing.T) { + t.Cleanup(func() { cleanupRoxieNamespace(t) }) + cleanupRoxieNamespace(t) + + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + m := RoxieManifest{ + RoxieEnvironment: types.RoxieEnvironment{ + RoxUsername: "admin", + }, + } + + err := CreateManifestSecretOnCluster(ctx, log, m) + require.NoError(t, err) + + err = DeleteManifestSecret(ctx, log) + assert.NoError(t, err) +} + +func TestDeleteRoxieNamespace_Integration(t *testing.T) { + t.Cleanup(func() { cleanupRoxieNamespace(t) }) + cleanupRoxieNamespace(t) + + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + err := ensureRoxieNamespace(ctx, log) + require.NoError(t, err) + + err = DeleteRoxieNamespace(ctx, log) + require.NoError(t, err) + + _, err = k8s.RunKubectl(ctx, log, k8s.KubectlOptions{ + Args: []string{"get", "namespace", roxieNamespace}, + }) + assert.Error(t, err, "namespace should no longer exist") +} + +func TestLoadManifest_NotFound_Integration(t *testing.T) { + t.Cleanup(func() { cleanupRoxieNamespace(t) }) + cleanupRoxieNamespace(t) + + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err := LoadManifestSecret(ctx, log) + assert.Error(t, err) +} diff --git a/internal/roxieenv/env.go b/internal/roxieenv/env.go new file mode 100644 index 0000000..17459c9 --- /dev/null +++ b/internal/roxieenv/env.go @@ -0,0 +1,31 @@ +package roxieenv + +import ( + "github.com/stackrox/roxie/internal/types" +) + +// AssembleRoxieEnvironment returns a roxie environment for interacting with an ACS Central deployment. +// This is used for +// * writing envrc files +// * spawning sub-shells as part of the deployer +// * spawning sub-shells/executing commands for the 'shell' command +func AssembleRoxieEnvironment(info types.CentralDeploymentInfo) types.RoxieEnvironment { + var env types.RoxieEnvironment + + if info.Endpoint != "" { + env.APIEndpoint = info.Endpoint + env.RoxEndpoint = info.Endpoint + env.RoxBaseURL = "https://" + info.Endpoint + } + if info.Username != "" { + env.RoxUsername = info.Username + } + if info.Password != "" { + env.RoxAdminPassword = info.Password + } + if info.CACertFile != "" { + env.RoxCaCertFile = info.CACertFile + } + + return env +} diff --git a/internal/roxieenv/env_test.go b/internal/roxieenv/env_test.go new file mode 100644 index 0000000..cea9f70 --- /dev/null +++ b/internal/roxieenv/env_test.go @@ -0,0 +1,86 @@ +package roxieenv + +import ( + "maps" + "testing" + + "github.com/stackrox/roxie/internal/types" + "github.com/stretchr/testify/assert" +) + +func TestAssembleRoxieEnvironment(t *testing.T) { + tests := []struct { + name string + info types.CentralDeploymentInfo + wantKeys map[string]string + absentKeys []string + }{ + { + name: "all fields populated", + info: types.CentralDeploymentInfo{ + Endpoint: "localhost:8443", + Username: "admin", + Password: "secret123", + KubeContext: "kind-kind", + CACertFile: "/tmp/ca.pem", + }, + wantKeys: map[string]string{ + "API_ENDPOINT": "localhost:8443", + "ROX_ENDPOINT": "localhost:8443", + "ROX_BASE_URL": "https://localhost:8443", + "ROX_ADMIN_PASSWORD": "secret123", + "ROX_CA_CERT_FILE": "/tmp/ca.pem", + "ROX_USERNAME": "admin", + }, + }, + { + name: "empty endpoint omits endpoint keys", + info: types.CentralDeploymentInfo{ + Username: "admin", + Password: "secret123", + CACertFile: "/tmp/ca.pem", + }, + wantKeys: map[string]string{ + "ROX_ADMIN_PASSWORD": "secret123", + "ROX_CA_CERT_FILE": "/tmp/ca.pem", + "ROX_USERNAME": "admin", + }, + absentKeys: []string{"API_ENDPOINT", "ROX_ENDPOINT", "ROX_BASE_URL"}, + }, + { + name: "empty password omits password key", + info: types.CentralDeploymentInfo{ + Endpoint: "localhost:8443", + }, + wantKeys: map[string]string{ + "API_ENDPOINT": "localhost:8443", + }, + absentKeys: []string{"ROX_ADMIN_PASSWORD"}, + }, + { + name: "empty ca cert file omits cert key", + info: types.CentralDeploymentInfo{ + Endpoint: "localhost:8443", + }, + absentKeys: []string{"ROX_CA_CERT_FILE"}, + }, + { + name: "all fields empty", + info: types.CentralDeploymentInfo{}, + absentKeys: []string{"API_ENDPOINT", "ROX_ENDPOINT", "ROX_BASE_URL", "ROX_ADMIN_PASSWORD", "ROX_CA_CERT_FILE"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := maps.Collect(AssembleRoxieEnvironment(tt.info).Export()) + for key, want := range tt.wantKeys { + assert.Equal(t, want, env[key], "key %s", key) + } + for _, key := range tt.absentKeys { + _, exists := env[key] + assert.False(t, exists, "key %s should be absent", key) + } + }) + } +} diff --git a/internal/types/central_deployment_info.go b/internal/types/central_deployment_info.go new file mode 100644 index 0000000..c04e0f5 --- /dev/null +++ b/internal/types/central_deployment_info.go @@ -0,0 +1,12 @@ +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 +} diff --git a/internal/types/roxie_environment.go b/internal/types/roxie_environment.go new file mode 100644 index 0000000..aa918ca --- /dev/null +++ b/internal/types/roxie_environment.go @@ -0,0 +1,49 @@ +package types + +import ( + "iter" + "reflect" + "strings" +) + +// RoxieEnvironment is the environment used during runtime. It includes another field, +// which shall not be stored on the cluster, since it is a local path. +type RoxieEnvironment struct { + APIEndpoint string `yaml:"API_ENDPOINT,omitempty"` + RoxAdminPassword string `yaml:"ROX_ADMIN_PASSWORD,omitempty"` + RoxBaseURL string `yaml:"ROX_BASE_URL,omitempty"` + RoxEndpoint string `yaml:"ROX_ENDPOINT,omitempty"` + RoxUsername string `yaml:"ROX_USERNAME,omitempty"` + RoxCaCertFile string `yaml:"ROX_CA_CERT_FILE,omitempty"` +} + +// We use this type for standard YAML marshaling. +type roxieEnvironmentStd RoxieEnvironment + +// MarshalYAML skips local-only fields (ROX_CA_CERT_FILE). +func (r RoxieEnvironment) MarshalYAML() (any, error) { + shallowCopy := roxieEnvironmentStd(r) + shallowCopy.RoxCaCertFile = "" // Filter out local fields. + return shallowCopy, nil +} + +// Export() returns an iterator, which produces key,val pairs for the roxie environment. +// This includes local-only fields unlike YAML serialization, which only produces those +// values which shall actually be persisted. +func (r RoxieEnvironment) Export() iter.Seq2[string, string] { + return func(yield func(string, string) bool) { + v := reflect.ValueOf(r) + for _, f := range reflect.VisibleFields(v.Type()) { + if tag, ok := f.Tag.Lookup("yaml"); ok && tag != ",inline" { + val := v.FieldByIndex(f.Index).String() + if val == "" { + continue + } + name, _, _ := strings.Cut(tag, ",") + if !yield(name, val) { + return + } + } + } + } +} diff --git a/internal/types/roxie_environment_test.go b/internal/types/roxie_environment_test.go new file mode 100644 index 0000000..54b8a20 --- /dev/null +++ b/internal/types/roxie_environment_test.go @@ -0,0 +1,27 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestMarshalYAMLSkipsCACertFile(t *testing.T) { + roxieEnv := RoxieEnvironment{ + RoxUsername: "davinci", + RoxCaCertFile: "/some/file.pem", + } + + roxieEnvBytes, err := yaml.Marshal(roxieEnv) + require.NoError(t, err, "YAML marshaling failed") + + var roxieEnvUnmarshaled map[string]string + err = yaml.Unmarshal(roxieEnvBytes, &roxieEnvUnmarshaled) + require.NoError(t, err, "YAML unmarshaling failed") + + assert.NotEmpty(t, roxieEnvUnmarshaled["ROX_USERNAME"], "ROX_USERNAME missing") + _, ok := roxieEnvUnmarshaled["ROX_CA_CERT_FILE"] + assert.False(t, ok, "ROX_CA_CERT_FILE present") +}