From 4b1316ed4135743e14b3606bcbeb056eead40740 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 21 Oct 2026 17:05:04 +0200 Subject: [PATCH 01/15] Discover namespaces for teardown --- cmd/main.go | 2 +- cmd/teardown.go | 33 ++++------ internal/deployer/deployer.go | 111 ++++++++++++++++++++++++---------- 3 files changed, 93 insertions(+), 53 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 52acea8..84eb976 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -136,7 +136,7 @@ func init() { ) rootCmd.AddCommand(newDeployCmd(&deploySettingsFromArgs)) - rootCmd.AddCommand(newTeardownCmd(&deploySettingsFromArgs)) + rootCmd.AddCommand(newTeardownCmd()) rootCmd.AddCommand(newShellCmd()) rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newEnvCmd()) diff --git a/cmd/teardown.go b/cmd/teardown.go index 0b5c09d..ef44418 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -15,7 +15,7 @@ import ( "github.com/stackrox/roxie/internal/manifest" ) -func newTeardownCmd(settings *deployer.Config) *cobra.Command { +func newTeardownCmd() *cobra.Command { cmd := &cobra.Command{ Use: "teardown [component]", Short: "Teardown ACS components", @@ -25,17 +25,9 @@ func newTeardownCmd(settings *deployer.Config) *cobra.Command { RunE: runTeardown, } - registerFlag(cmd, settings, "single-namespace", "Deploy all components in a single namespace ('stackrox')", - withNoOptDefVal("true"), - withApplyFnBool(func(config *deployer.Config, val bool) error { - // We do not support --single-namespace=false as of now. - if val { - config.Central.Namespace = sharedNamespace - config.SecuredCluster.Namespace = sharedNamespace - } - return nil - }), - ) + // Accepted for backwards compatibility but ignored — teardown now discovers namespaces from the cluster. + cmd.Flags().Bool("single-namespace", false, "Deprecated: teardown discovers namespaces automatically") + _ = cmd.Flags().MarkDeprecated("single-namespace", "teardown now discovers namespaces automatically") return cmd } @@ -60,19 +52,18 @@ func runTeardown(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() - var clusterConfig *deployer.Config - clusterManifest, err := manifest.LoadManifestSecret(ctx) + // Load manifest from cluster for add-on config. Namespaces are discovered + // directly from the cluster, but add-on definitions live in the Central + // config and are not discoverable — we need the manifest to know which + // add-ons were deployed so we can tear them down. + var config deployer.Config + m, err := manifest.LoadManifestSecret(ctx) if errors.Is(err, k8s.ErrResourceNotFound) { log.Infof("No roxie manifest found in cluster, proceeding without it") } else if err != nil { log.Warningf("Failed to load manifest from cluster: %v", err) } else { - clusterConfig = &clusterManifest.Config - } - - deploySettings, err := assembleConfigForCommand(clusterConfig, deploySettingsFromArgs, skipUserConfig) - if err != nil { - return err + config = *m.Config.AsConfig() } d, err := deployer.New() @@ -81,7 +72,7 @@ func runTeardown(cmd *cobra.Command, args []string) error { } defer d.Cleanup() - d.SetConfig(deploySettings) + d.SetConfig(config) if err := d.Teardown(ctx, components); err != nil { return fmt.Errorf("teardown failed: %w", err) diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 84dab48..dd6e506 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -3,6 +3,7 @@ package deployer import ( "context" "crypto/rand" + "errors" "fmt" "os" "os/exec" @@ -88,11 +89,11 @@ func (d *Deployer) deleteResources(ctx context.Context, namespace string, resour } // Expects that reconciliation for the RHACS operator is paused. -func (d *Deployer) deleteCentralResources(ctx context.Context) error { +func (d *Deployer) deleteCentralResources(ctx context.Context, namespace string) error { log.Info("Deleting Central resources") crExists := true - if _, err := k8s.RetrieveResourceFromCluster(ctx, d.config.Central.Namespace, "central", "stackrox-central-services"); err != nil { + if _, err := k8s.RetrieveResourceFromCluster(ctx, namespace, "central", "stackrox-central-services"); err != nil { if !k8s.IsResourceNotFound(err) { return fmt.Errorf("retrieving Central CR: %w", err) } @@ -101,12 +102,12 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) error { if crExists { log.Info("Removing any pause-reconcile annotation from Central") - if err := d.removePauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.config.Central.Namespace); err != nil { + if err := d.removePauseReconcileAnnotation(ctx, "central", "stackrox-central-services", namespace); err != nil { return err } log.Debug("Removed any pause-reconcile annotation from Central") - err := d.deleteResource(ctx, d.config.Central.Namespace, "central", "stackrox-central-services", "--wait") + err := d.deleteResource(ctx, namespace, "central", "stackrox-central-services", "--wait") if err != nil { return err } @@ -126,7 +127,7 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) error { if resource.OwnerName != "" { // Avoid deletion if the resource does not have the expected owner. // (e.g. in case central and secured cluster are deployed into the same namespace). - obj, err := k8s.RetrieveResourceFromCluster(ctx, d.config.Central.Namespace, resource.Kind, resource.Name) + obj, err := k8s.RetrieveResourceFromCluster(ctx, namespace, resource.Kind, resource.Name) if err != nil { if !k8s.IsResourceNotFound(err) { log.Warningf("Failed to retrieve %s/%s for owner checking: %v. Skipping deletion. Deployment might be affected.", resource.Kind, resource.Name, err) @@ -139,7 +140,7 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) error { } } - if err := d.deleteResource(ctx, d.config.Central.Namespace, resource.Kind, resource.Name); err != nil { + if err := d.deleteResource(ctx, namespace, resource.Kind, resource.Name); err != nil { return fmt.Errorf("failed to delete %s/%s: %w", resource.Kind, resource.Name, err) } } @@ -147,11 +148,11 @@ func (d *Deployer) deleteCentralResources(ctx context.Context) error { return nil } -func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { +func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, namespace string) error { log.Info("Deleting SecuredCluster resources") crExists := true - if _, err := k8s.RetrieveResourceFromCluster(ctx, d.config.SecuredCluster.Namespace, "securedcluster", "stackrox-secured-cluster-services"); err != nil { + if _, err := k8s.RetrieveResourceFromCluster(ctx, namespace, "securedcluster", securedClusterCrName); err != nil { if !k8s.IsResourceNotFound(err) { return fmt.Errorf("retrieving SecuredCluster CR: %w", err) } @@ -160,12 +161,12 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { if crExists { log.Info("Removing any pause-reconcile annotation from SecuredCluster") - if err := d.removePauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.config.SecuredCluster.Namespace); err != nil { + if err := d.removePauseReconcileAnnotation(ctx, "securedcluster", securedClusterCrName, namespace); err != nil { return err } log.Debug("Removed any pause-reconcile annotation from SecuredCluster") - err := d.deleteResource(ctx, d.config.SecuredCluster.Namespace, "securedcluster", "stackrox-secured-cluster-services", "--wait") + err := d.deleteResource(ctx, namespace, "securedcluster", securedClusterCrName, "--wait") if err != nil { return err } @@ -185,7 +186,7 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { if resource.OwnerName != "" { // Avoid deletion if the resource does not have the expected owner. // (e.g. in case central and secured cluster are deployed into the same namespace). - obj, err := k8s.RetrieveResourceFromCluster(ctx, d.config.SecuredCluster.Namespace, resource.Kind, resource.Name) + obj, err := k8s.RetrieveResourceFromCluster(ctx, namespace, resource.Kind, resource.Name) if err != nil { if !k8s.IsResourceNotFound(err) { log.Warningf("Failed to retrieve %s/%s for owner checking: %v. Skipping deletion. Deployment might be affected.", resource.Kind, resource.Name, err) @@ -197,7 +198,7 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context) error { continue } } - if err := d.deleteResource(ctx, d.config.SecuredCluster.Namespace, resource.Kind, resource.Name); err != nil { + if err := d.deleteResource(ctx, namespace, resource.Kind, resource.Name); err != nil { return fmt.Errorf("failed to delete %s/%s: %w", resource.Kind, resource.Name, err) } } @@ -497,54 +498,80 @@ func (d *Deployer) Teardown(ctx context.Context, components component.Component) } func (d *Deployer) teardownCentral(ctx context.Context) error { - log.Infof("🗑️ Tearing down central in namespace %s", d.config.Central.Namespace) - - if !d.namespaceExists(d.config.Central.Namespace) { - log.Infof("Namespace %s doesn't exist, skipping", d.config.Central.Namespace) + namespaces, err := d.discoverCRNamespaces(ctx, "central", centralCrName) + if err != nil { + return fmt.Errorf("discovering Central namespace: %w", err) + } + if len(namespaces) > 1 { + log.Errorf("Unexpected situation: Central seems to be installed in multiple namespaces: %s", strings.Join(namespaces, ", ")) + return errors.New("cannot teardown multiple centrals") + } + if len(namespaces) == 0 { + log.Infof("No Central CR found on cluster, skipping Central teardown") return nil } + namespace := namespaces[0] + + log.Infof("🗑️ Tearing down central in namespace %s", namespace) d.portForward.Stop() d.stopDetachedPortForward() // Add pause-reconcile annotation to not have the operator interfere during resource deletion. - if d.doesResourceExist(ctx, "central", "stackrox-central-services", d.config.Central.Namespace) { - if err := d.addPauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.config.Central.Namespace); err != nil { + if d.doesResourceExist(ctx, "central", centralCrName, namespace) { + if err := d.addPauseReconcileAnnotation(ctx, "central", centralCrName, namespace); err != nil { log.Warningf("Error adding pause-reconcile annotation: %v", err) } } log.Info("⏳ Waiting for Central resources to be fully deleted...") - if err := d.deleteCentralResources(ctx); err != nil { + if err := d.deleteCentralResources(ctx, namespace); err != nil { return fmt.Errorf("failed to delete Central resources: %w", err) } - log.Successf("✓ Central resources in namespace %s have been deleted", d.config.Central.Namespace) + log.Successf("✓ Central resources in namespace %s have been deleted", namespace) return nil } func (d *Deployer) teardownSecuredCluster(ctx context.Context) error { - log.Infof("🗑️ Tearing down secured cluster in namespace %s", d.config.SecuredCluster.Namespace) - - if !d.namespaceExists(d.config.SecuredCluster.Namespace) { - log.Infof("Namespace %s doesn't exist, skipping", d.config.SecuredCluster.Namespace) + namespaces, err := d.discoverCRNamespaces(ctx, "securedcluster", securedClusterCrName) + if err != nil { + return fmt.Errorf("discovering SecuredCluster namespaces: %w", err) + } + if len(namespaces) == 0 { + log.Infof("No SecuredCluster CRs found on cluster, skipping SecuredCluster teardown") return nil } - if d.doesResourceExist(ctx, "securedcluster", "stackrox-secured-cluster-services", d.config.SecuredCluster.Namespace) { - // Add pause-reconcile annotation to not have the operator interfere during resource deletion. - if err := d.addPauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.config.SecuredCluster.Namespace); err != nil { + var wg sync.WaitGroup + wg.Add(len(namespaces)) + for _, ns := range namespaces { + go func() { + defer wg.Done() + if err := d.teardownSecuredClusterInNamespace(ctx, ns); err != nil { + log.Warningf("Error tearing down SecuredCluster in namespace %s: %v", ns, err) + } + }() + } + wg.Wait() + return nil +} + +func (d *Deployer) teardownSecuredClusterInNamespace(ctx context.Context, namespace string) error { + log.Infof("🗑️ Tearing down secured cluster in namespace %s", namespace) + + if d.doesResourceExist(ctx, "securedcluster", securedClusterCrName, namespace) { + if err := d.addPauseReconcileAnnotation(ctx, "securedcluster", securedClusterCrName, namespace); err != nil { log.Warningf("Error adding pause-reconcile annotation: %v", err) } } log.Info("⏳ Waiting for SecuredCluster resources to be fully deleted...") - err := d.deleteSecuredClusterResources(ctx) - if err != nil { - return fmt.Errorf("failed to delete SecuredCluster resources: %w", err) + if err := d.deleteSecuredClusterResources(ctx, namespace); err != nil { + return fmt.Errorf("failed to delete SecuredCluster resources in namespace %s: %w", namespace, err) } - log.Successf("✓ SecuredCluster resources in namespace %s have been deleted", d.config.SecuredCluster.Namespace) + log.Successf("✓ SecuredCluster resources in namespace %s have been deleted", namespace) return nil } @@ -646,6 +673,28 @@ func (d *Deployer) SetEnvrcFile(path string) { d.envrcFile = path } +func (d *Deployer) discoverCRNamespaces(ctx context.Context, crType, crName string) ([]string, error) { + result, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"get", crType, crName, "-A", "-o", "jsonpath={.items[*].metadata.namespace}"}, + MaxAttempts: 1, + }) + if err != nil { + stderr := strings.ToLower(result.Stderr) + if strings.Contains(stderr, "the server doesn't have a resource type") || + strings.Contains(stderr, "not found") { + return nil, nil + } + return nil, fmt.Errorf("discovering %s/%s: %w", crType, crName, err) + } + output := strings.TrimSpace(result.Stdout) + if output == "" { + return nil, nil + } + namespaces := strings.Fields(output) + log.Infof("Discovered %s/%s in namespace(s): %s", crType, crName, strings.Join(namespaces, ", ")) + return namespaces, nil +} + func (d *Deployer) doesResourceExist(ctx context.Context, resourceType, resourceName, namespace string) bool { _, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ Args: []string{ From 713030a5c326a842628f526ad6dcdb4d811de794 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 21 Oct 2026 17:05:22 +0200 Subject: [PATCH 02/15] Introduxe new manifest data types. --- internal/manifest/manifest.go | 38 ++++++++++++++++++- .../manifest/manifest_integration_test.go | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 09386cf..5470993 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -28,7 +28,43 @@ const ( // We include the whole deployer Config for reproducibility purposes. type RoxieManifest struct { RoxieEnvironment types.RoxieEnvironment `yaml:"roxieEnvironment"` - Config deployer.Config `yaml:"config"` + Config RoxieManifestConfig `yaml:"config"` +} + +// RoxieManifestConfig stores the subset of deployer.Config that is persisted +// in the cluster manifest secret. Namespaces are NOT consumed from here — +// teardown discovers them directly from CRs on the cluster. The fields that +// are actively used: +// +// - Central.AddOns / Central.AvailableAddOns — teardown needs these to know +// which add-ons were deployed so it can tear them down (add-ons are +// lifecycle-aligned with Central and not discoverable from the cluster). +// - Central.Namespace — used by 'roxie shell' to fetch the CA cert from the +// central-tls secret, and to resolve the Central endpoint. +// - Central.Exposure — used by 'roxie shell' to determine the base URL shown +// in the subshell banner. +// - Roxie.HAProxy — used by 'roxie shell' to re-spawn the HAProxy sidecar +// for local HTTP access to Central. +type RoxieManifestConfig struct { + Roxie deployer.RoxieConfig `yaml:"roxie"` + Central deployer.CentralConfig `yaml:"central"` +} + +func FromConfig(config deployer.Config) RoxieManifestConfig { + return RoxieManifestConfig{ + Roxie: config.Roxie, + Central: config.Central, + } +} + +func (c *RoxieManifestConfig) AsConfig() *deployer.Config { + if c == nil { + return nil + } + return &deployer.Config{ + Roxie: c.Roxie, + Central: c.Central, + } } func manifestToSecret(m RoxieManifest) (*unstructured.Unstructured, error) { diff --git a/internal/manifest/manifest_integration_test.go b/internal/manifest/manifest_integration_test.go index c8e69fe..5a302a9 100644 --- a/internal/manifest/manifest_integration_test.go +++ b/internal/manifest/manifest_integration_test.go @@ -37,7 +37,7 @@ func TestCreateAndLoadManifest_Integration(t *testing.T) { RoxEndpoint: "localhost:8443", RoxUsername: "admin", }, - Config: deployer.Config{ + Config: RoxieManifestConfig{ Central: deployer.CentralConfig{ Namespace: "acs-central", }, From 88b7bb052fb9f25e0f9a1b22c11ff5fc033fe568 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 21 Oct 2026 17:05:16 +0200 Subject: [PATCH 03/15] Don't use on-cluster manifest for new deployments. Lifecycle-align the stored manifest with central. --- cmd/deploy.go | 45 ++++++++------------------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 4106850..cff3db6 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -20,7 +20,6 @@ import ( "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/imagetag" - "github.com/stackrox/roxie/internal/k8s" log "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/manifest" "github.com/stackrox/roxie/internal/roxieenv" @@ -235,12 +234,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { return err } - // Cluster-config retrieval happens before the full config (and thus the configured deploy - // wait timeouts) is assembled, so give it its own short bounded context. - setupCtx, setupCancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer setupCancel() - - clusterConfig := retrieveClusterConfigForComponents(setupCtx, components) + clusterConfig := new(deployer.DefaultConfig()) deploySettings, err := assembleConfigForCommand(clusterConfig, deploySettingsFromArgs, skipUserConfig) if err != nil { @@ -344,20 +338,21 @@ func runDeploy(cmd *cobra.Command, args []string) error { d.WaitForCentral(5 * time.Minute) } - if !dryRun { + // The on-cluster manifest is lifecylce-aligned with Central. + if components.IncludesCentral() { roxieEnv := roxieenv.AssembleRoxieEnvironment(d.GetCentralDeploymentInfo()) m := manifest.RoxieManifest{ RoxieEnvironment: roxieEnv, - Config: deploySettings, + Config: manifest.FromConfig(deploySettings), } if err := manifest.CreateManifestSecretOnCluster(ctx, m); err != nil { log.Warningf("Failed to save roxie manifest: %v", err) } - } - if components.IncludesCentral() && envrc == "" { - if err := spawnSubshellForDeployerEnv(deploySettings.Roxie, d); err != nil { - return fmt.Errorf("failed to spawn subshell: %w", err) + if envrc == "" { + if err := spawnSubshellForDeployerEnv(deploySettings.Roxie, d); err != nil { + return fmt.Errorf("failed to spawn subshell: %w", err) + } } } @@ -402,30 +397,6 @@ func validateContainerizedCredentials(registry string, clusterType types.Cluster return nil } -func retrieveClusterConfigForComponents( - ctx context.Context, - components component.Component, -) *deployer.Config { - clusterManifest, err := manifest.LoadManifestSecret(ctx) - if err != nil { - if errors.Is(err, k8s.ErrResourceNotFound) { - log.Dim("No existing manifest found on cluster, starting from defaults") - } else { - log.Warningf("Failed to load manifest from cluster: %v", err) - } - return nil // Let's try to continue without manifest - } - - if components == component.SecuredCluster && clusterManifest.Config.SecuredCluster.Spec != nil { - // Reusing these fields from the config stored on the cluster would cause problems - // during repeating sensor deploy & teardown cycles. - delete(clusterManifest.Config.SecuredCluster.Spec, "clusterName") - delete(clusterManifest.Config.SecuredCluster.Spec, "centralEndpoint") - } - - return &clusterManifest.Config -} - func configureConfig(components component.Component, deploySettings *deployer.Config) error { if deploySettings.Roxie.ClusterType == "" { clusterType := env.GetAutoDetectedClusterType() From efcffcea092a6ceb35746502394c9129d03196e1 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 21 Oct 2026 17:05:11 +0200 Subject: [PATCH 04/15] Adjust shell command to new manifest data types. --- cmd/shell.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/shell.go b/cmd/shell.go index a403332..33f704f 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -72,7 +72,7 @@ func runShell(args []string) error { log.Dim("roxie manifest loaded") configBase := m.Config - config, err := assembleConfigForCommand(&configBase, deploySettingsFromArgs, skipUserConfig) + config, err := assembleConfigForCommand(configBase.AsConfig(), deploySettingsFromArgs, skipUserConfig) if err != nil { return err } From 9a69aed21b281c404bd983c760248a19109b1e87 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 22 Oct 2026 10:08:30 +0200 Subject: [PATCH 05/15] Add debug logs to kubectl invocations for easier debugging, only fires on --verbose --- internal/k8s/kubectl.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go index 25f87bd..9450f9c 100644 --- a/internal/k8s/kubectl.go +++ b/internal/k8s/kubectl.go @@ -67,6 +67,7 @@ func RunKubectl(ctx context.Context, opts KubectlOptions) (KubectlResult, error) time.Sleep(waitTime) } + log.Debugf("Executing: %s %s", kubectl, strings.Join(opts.Args, " ")) cmd := exec.CommandContext(ctx, kubectl, opts.Args...) if opts.Stdin != nil { @@ -92,6 +93,11 @@ func RunKubectl(ctx context.Context, opts KubectlOptions) (KubectlResult, error) }, nil } + log.Debugf("exit code: %v", err) + for line := range strings.SplitSeq(strings.TrimSpace(lastStderr), "\n") { + log.Debug("stderr output: " + line) + } + if opts.IgnoreErrors { return KubectlResult{ Stdout: stdout.String(), From 61bbc10314fd1cad983e02a87c2f555ab622f7b5 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 22 Oct 2026 10:08:42 +0200 Subject: [PATCH 06/15] json expr fix for discovery --- internal/deployer/deployer.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index dd6e506..ce5eace 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -674,8 +674,9 @@ func (d *Deployer) SetEnvrcFile(path string) { } func (d *Deployer) discoverCRNamespaces(ctx context.Context, crType, crName string) ([]string, error) { + jsonpathExpr := fmt.Sprintf(`{.items[?(@.metadata.name=="%s")].metadata.namespace}`, crName) result, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", crType, crName, "-A", "-o", "jsonpath={.items[*].metadata.namespace}"}, + Args: []string{"get", crType, "-A", "-o", "jsonpath=" + jsonpathExpr}, MaxAttempts: 1, }) if err != nil { From 13fcb667db4b9bd0d4e92eed90c4e84b34d4ba68 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:53:34 +0100 Subject: [PATCH 07/15] cmd/deploy.go --- cmd/deploy.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index cff3db6..d47604b 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -341,10 +341,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { // The on-cluster manifest is lifecylce-aligned with Central. if components.IncludesCentral() { roxieEnv := roxieenv.AssembleRoxieEnvironment(d.GetCentralDeploymentInfo()) - m := manifest.RoxieManifest{ - RoxieEnvironment: roxieEnv, - Config: manifest.FromConfig(deploySettings), - } + m := manifest.NewManifest(roxieEnv, deploySettings) if err := manifest.CreateManifestSecretOnCluster(ctx, m); err != nil { log.Warningf("Failed to save roxie manifest: %v", err) } From 13a39aa33d0c72792af7a06331773d1e96ad633b Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:53:40 +0100 Subject: [PATCH 08/15] cmd/shell.go --- cmd/shell.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/shell.go b/cmd/shell.go index 33f704f..9b0ab65 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -9,6 +9,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" log "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/manifest" @@ -70,9 +71,14 @@ 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.AsConfig(), deploySettingsFromArgs, skipUserConfig) + // Seed the merge pipeline with the fields the manifest persists (HAProxy and + // Exposure), so they are overridable by user config and CLI args while still + // defaulting to what was deployed. + var base deployer.Config + base.Roxie.HAProxy = m.HAProxy + base.Central.Exposure = m.Exposure + config, err := assembleConfigForCommand(&base, deploySettingsFromArgs, skipUserConfig) if err != nil { return err } From 9e1c18aeb3e0ba280f6daea3f9f7beed95409551 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:53:47 +0100 Subject: [PATCH 09/15] internal/manifest/manifest.go --- internal/manifest/manifest.go | 70 +++++++++++++++++------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 5470993..43e65ae 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -24,47 +24,39 @@ const ( manifestKey = "manifest" ) -// RoxieManifest represents the data stored in the roxie manifest secret. -// We include the whole deployer Config for reproducibility purposes. +// RoxieManifest represents the data stored in the roxie manifest secret. It +// holds only the fields that cannot be discovered from the cluster: +// +// - Exposure — used by 'roxie shell' to determine the base URL shown in the +// subshell banner. +// - HAProxy — used by 'roxie shell' to re-spawn the HAProxy sidecar for local +// HTTP access to Central. +// +// Everything else (the Central namespace, deployed add-ons) is discovered +// directly from the cluster at teardown/shell time. type RoxieManifest struct { RoxieEnvironment types.RoxieEnvironment `yaml:"roxieEnvironment"` - Config RoxieManifestConfig `yaml:"config"` -} - -// RoxieManifestConfig stores the subset of deployer.Config that is persisted -// in the cluster manifest secret. Namespaces are NOT consumed from here — -// teardown discovers them directly from CRs on the cluster. The fields that -// are actively used: -// -// - Central.AddOns / Central.AvailableAddOns — teardown needs these to know -// which add-ons were deployed so it can tear them down (add-ons are -// lifecycle-aligned with Central and not discoverable from the cluster). -// - Central.Namespace — used by 'roxie shell' to fetch the CA cert from the -// central-tls secret, and to resolve the Central endpoint. -// - Central.Exposure — used by 'roxie shell' to determine the base URL shown -// in the subshell banner. -// - Roxie.HAProxy — used by 'roxie shell' to re-spawn the HAProxy sidecar -// for local HTTP access to Central. -type RoxieManifestConfig struct { - Roxie deployer.RoxieConfig `yaml:"roxie"` - Central deployer.CentralConfig `yaml:"central"` + Exposure *types.Exposure `yaml:"exposure,omitempty"` + HAProxy deployer.HAProxyConfig `yaml:"haProxy,omitempty"` } -func FromConfig(config deployer.Config) RoxieManifestConfig { - return RoxieManifestConfig{ - Roxie: config.Roxie, - Central: config.Central, +// NewManifest builds a RoxieManifest from a RoxieEnvironment and the deploy +// config, extracting only the fields that are persisted on the cluster. +func NewManifest(roxieEnv types.RoxieEnvironment, config deployer.Config) RoxieManifest { + return RoxieManifest{ + RoxieEnvironment: roxieEnv, + Exposure: config.Central.Exposure, + HAProxy: config.Roxie.HAProxy, } } -func (c *RoxieManifestConfig) AsConfig() *deployer.Config { - if c == nil { - return nil - } - return &deployer.Config{ - Roxie: c.Roxie, - Central: c.Central, +// GetExposure returns the configured exposure, defaulting to ExposureNone when +// unset. Mirrors CentralConfig.GetExposure. +func (m *RoxieManifest) GetExposure() types.Exposure { + if m.Exposure == nil { + return types.ExposureNone } + return *m.Exposure } func manifestToSecret(m RoxieManifest) (*unstructured.Unstructured, error) { @@ -195,7 +187,15 @@ func ensureRoxieNamespace(ctx context.Context) error { func ManifestToCentralDeploymentInfo(ctx context.Context, tempDir string, m *RoxieManifest) (types.CentralDeploymentInfo, error) { roxieEnv := m.RoxieEnvironment - caCertFile, err := fetchCACertForShell(ctx, m.Config.Central.Namespace, tempDir) + namespace, err := k8s.DiscoverCRNamespace(ctx, "central", "stackrox-central-services") + if err != nil { + return types.CentralDeploymentInfo{}, fmt.Errorf("discovering Central namespace: %w", err) + } + if namespace == "" { + return types.CentralDeploymentInfo{}, errors.New("no Central CR found on cluster") + } + + caCertFile, err := fetchCACertForShell(ctx, 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) @@ -206,7 +206,7 @@ func ManifestToCentralDeploymentInfo(ctx context.Context, tempDir string, m *Rox Username: roxieEnv.RoxUsername, Password: roxieEnv.RoxAdminPassword, KubeContext: env.GetCurrentContext(), - Exposure: m.Config.Central.GetExposure(), + Exposure: m.GetExposure(), CACertFile: caCertFile, }, nil } From 64ae932eabc80ff4cb1e81c65529a56076e73e17 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:53:53 +0100 Subject: [PATCH 10/15] internal/manifest/manifest_integration_test.go --- internal/manifest/manifest_integration_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/manifest/manifest_integration_test.go b/internal/manifest/manifest_integration_test.go index 5a302a9..4a0c5f8 100644 --- a/internal/manifest/manifest_integration_test.go +++ b/internal/manifest/manifest_integration_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/types" "github.com/stretchr/testify/assert" @@ -29,6 +28,7 @@ func TestCreateAndLoadManifest_Integration(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() + exposure := types.ExposureLoadBalancer original := RoxieManifest{ RoxieEnvironment: types.RoxieEnvironment{ APIEndpoint: "localhost:8443", @@ -37,11 +37,7 @@ func TestCreateAndLoadManifest_Integration(t *testing.T) { RoxEndpoint: "localhost:8443", RoxUsername: "admin", }, - Config: RoxieManifestConfig{ - Central: deployer.CentralConfig{ - Namespace: "acs-central", - }, - }, + Exposure: &exposure, } err := CreateManifestSecretOnCluster(ctx, original) From 957252a3e6f34c799c64f60a4063dc6d28b38314 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:54:00 +0100 Subject: [PATCH 11/15] cmd/teardown.go --- cmd/teardown.go | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/cmd/teardown.go b/cmd/teardown.go index ef44418..afa03b4 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "fmt" "time" @@ -10,7 +9,6 @@ import ( "github.com/stackrox/roxie/internal/component" "github.com/stackrox/roxie/internal/deployer" "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" ) @@ -52,28 +50,12 @@ func runTeardown(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() - // Load manifest from cluster for add-on config. Namespaces are discovered - // directly from the cluster, but add-on definitions live in the Central - // config and are not discoverable — we need the manifest to know which - // add-ons were deployed so we can tear them down. - var config deployer.Config - m, err := manifest.LoadManifestSecret(ctx) - if errors.Is(err, k8s.ErrResourceNotFound) { - log.Infof("No roxie manifest found in cluster, proceeding without it") - } else if err != nil { - log.Warningf("Failed to load manifest from cluster: %v", err) - } else { - config = *m.Config.AsConfig() - } - d, err := deployer.New() if err != nil { return fmt.Errorf("failed to create deployer: %w", err) } defer d.Cleanup() - d.SetConfig(config) - if err := d.Teardown(ctx, components); err != nil { return fmt.Errorf("teardown failed: %w", err) } From 8c5dddf3a03a5f2495a3192adb9d2ecde2711bc0 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:54:30 +0100 Subject: [PATCH 12/15] internal/deployer/addons.go --- internal/deployer/addons.go | 38 ++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/internal/deployer/addons.go b/internal/deployer/addons.go index 8123d8f..3d3b1be 100644 --- a/internal/deployer/addons.go +++ b/internal/deployer/addons.go @@ -8,6 +8,8 @@ import ( "regexp" "slices" + "github.com/stackrox/roxie/internal/helm" + "github.com/stackrox/roxie/internal/k8s" log "github.com/stackrox/roxie/internal/logger" ) @@ -52,22 +54,36 @@ func (d *Deployer) deployAddOns(ctx context.Context, addOns []AddOn) error { return nil } -func (d *Deployer) teardownAddOns(ctx context.Context, addOns []AddOn) { - if len(addOns) == 0 { +// teardownDiscoveredAddOns discovers add-on Helm releases deployed alongside +// Central directly from the cluster and uninstalls them. Add-ons are +// lifecycle-aligned with Central, so we locate the Central namespace and +// uninstall every Helm release matching the roxie add-on prefix. +func (d *Deployer) teardownDiscoveredAddOns(ctx context.Context) { + namespace, err := k8s.DiscoverCRNamespace(ctx, "central", centralCrName) + if err != nil { + log.Warningf("Could not discover Central namespace for add-on teardown: %v", err) + return + } + if namespace == "" { + log.Infof("No Central CR found on cluster, skipping add-on teardown") return } - log.Infof("Tearing down %d add-on(s)...", len(addOns)) + releases, err := helm.ListByPrefix(ctx, addOnReleasePrefix, namespace) + if err != nil { + log.Warningf("Could not list add-on Helm releases in namespace %s: %v", namespace, err) + return + } + if len(releases) == 0 { + return + } - for _, addon := range addOns { - if err := addon.Teardown(ctx); err != nil { - if !addon.IsOptional() { - log.Errorf("Failed to tear down non-optional add-on %q: %v", addon.Name(), err) - } else { - log.Warningf("Failed to tear down optional add-on %q: %v", addon.Name(), err) - } + log.Infof("Tearing down %d add-on(s)...", len(releases)) + for _, release := range releases { + if err := helm.Uninstall(ctx, release, namespace); err != nil { + log.Warningf("Failed to tear down add-on release %q: %v", release, err) } else { - log.Successf("Add-on %q torn down", addon.Name()) + log.Successf("Add-on release %q torn down", release) } } } From a87d71427593358d1f27e82917d229f2ef35df55 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:54:30 +0100 Subject: [PATCH 13/15] internal/deployer/deployer.go --- internal/deployer/deployer.go | 43 ++++------------------------------- 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index ce5eace..4ceb893 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -3,11 +3,9 @@ package deployer import ( "context" "crypto/rand" - "errors" "fmt" "os" "os/exec" - "slices" "strconv" "strings" "sync" @@ -446,12 +444,7 @@ func (d *Deployer) Teardown(ctx context.Context, components component.Component) log.Infof("Starting teardown of %s", components) if components.IncludesAddOns() { - enabledAddOns, err := d.ResolveEnabledAddOns() - if err != nil { - return fmt.Errorf("resolving enabled add-ons: %w", err) - } - slices.Reverse(enabledAddOns) // Tearing down in opposite order as deploying. - d.teardownAddOns(ctx, enabledAddOns) + d.teardownDiscoveredAddOns(ctx) } switch components { @@ -498,19 +491,14 @@ func (d *Deployer) Teardown(ctx context.Context, components component.Component) } func (d *Deployer) teardownCentral(ctx context.Context) error { - namespaces, err := d.discoverCRNamespaces(ctx, "central", centralCrName) + namespace, err := k8s.DiscoverCRNamespace(ctx, "central", centralCrName) if err != nil { return fmt.Errorf("discovering Central namespace: %w", err) } - if len(namespaces) > 1 { - log.Errorf("Unexpected situation: Central seems to be installed in multiple namespaces: %s", strings.Join(namespaces, ", ")) - return errors.New("cannot teardown multiple centrals") - } - if len(namespaces) == 0 { + if namespace == "" { log.Infof("No Central CR found on cluster, skipping Central teardown") return nil } - namespace := namespaces[0] log.Infof("🗑️ Tearing down central in namespace %s", namespace) @@ -534,7 +522,7 @@ func (d *Deployer) teardownCentral(ctx context.Context) error { } func (d *Deployer) teardownSecuredCluster(ctx context.Context) error { - namespaces, err := d.discoverCRNamespaces(ctx, "securedcluster", securedClusterCrName) + namespaces, err := k8s.DiscoverCRNamespaces(ctx, "securedcluster", securedClusterCrName) if err != nil { return fmt.Errorf("discovering SecuredCluster namespaces: %w", err) } @@ -673,29 +661,6 @@ func (d *Deployer) SetEnvrcFile(path string) { d.envrcFile = path } -func (d *Deployer) discoverCRNamespaces(ctx context.Context, crType, crName string) ([]string, error) { - jsonpathExpr := fmt.Sprintf(`{.items[?(@.metadata.name=="%s")].metadata.namespace}`, crName) - result, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", crType, "-A", "-o", "jsonpath=" + jsonpathExpr}, - MaxAttempts: 1, - }) - if err != nil { - stderr := strings.ToLower(result.Stderr) - if strings.Contains(stderr, "the server doesn't have a resource type") || - strings.Contains(stderr, "not found") { - return nil, nil - } - return nil, fmt.Errorf("discovering %s/%s: %w", crType, crName, err) - } - output := strings.TrimSpace(result.Stdout) - if output == "" { - return nil, nil - } - namespaces := strings.Fields(output) - log.Infof("Discovered %s/%s in namespace(s): %s", crType, crName, strings.Join(namespaces, ", ")) - return namespaces, nil -} - func (d *Deployer) doesResourceExist(ctx context.Context, resourceType, resourceName, namespace string) bool { _, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ Args: []string{ From b067ad0f2b1141830e501d6b7f18e9946cb9a238 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:54:31 +0100 Subject: [PATCH 14/15] internal/k8s/resource.go --- internal/k8s/resource.go | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/k8s/resource.go b/internal/k8s/resource.go index 67a04ae..050b602 100644 --- a/internal/k8s/resource.go +++ b/internal/k8s/resource.go @@ -62,6 +62,49 @@ func IsResourceNotFound(err error) bool { return errors.Is(err, ErrResourceNotFound) } +// DiscoverCRNamespaces returns the namespaces that contain a custom resource of +// the given type and name. It returns (nil, nil) when the CR type is not +// registered on the cluster or no matching instances are found. +func DiscoverCRNamespaces(ctx context.Context, crType, crName string) ([]string, error) { + jsonpathExpr := fmt.Sprintf(`{.items[?(@.metadata.name=="%s")].metadata.namespace}`, crName) + result, err := RunKubectl(ctx, KubectlOptions{ + Args: []string{"get", crType, "-A", "-o", "jsonpath=" + jsonpathExpr}, + MaxAttempts: 1, + }) + if err != nil { + stderr := strings.ToLower(result.Stderr) + if strings.Contains(stderr, "the server doesn't have a resource type") || + strings.Contains(stderr, "not found") { + return nil, nil + } + return nil, fmt.Errorf("discovering %s/%s: %w", crType, crName, err) + } + output := strings.TrimSpace(result.Stdout) + if output == "" { + return nil, nil + } + namespaces := strings.Fields(output) + log.Infof("Discovered %s/%s in namespace(s): %s", crType, crName, strings.Join(namespaces, ", ")) + return namespaces, nil +} + +// DiscoverCRNamespace discovers the single namespace containing the named custom +// resource. It returns ("", nil) when no instance is found, and an error when +// the CR exists in more than one namespace. +func DiscoverCRNamespace(ctx context.Context, crType, crName string) (string, error) { + namespaces, err := DiscoverCRNamespaces(ctx, crType, crName) + if err != nil { + return "", err + } + if len(namespaces) > 1 { + return "", fmt.Errorf("found %s/%s in multiple namespaces: %s", crType, crName, strings.Join(namespaces, ", ")) + } + if len(namespaces) == 0 { + return "", nil + } + return namespaces[0], nil +} + // ResourceNotOwnedByName checks if the given unstructured object does not have an owner reference with the specified owner name. // The negative version of this function is better suited for our needs, because there can be multiple owner references and // we are only interested in checking if a given resource is NOT owned by a specific owner. From 2f957eaeba33537a37b17f8bdbeb8f15b5a6fc7b Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 26 Oct 2026 15:58:26 +0100 Subject: [PATCH 15/15] Simplify tests --- tests/e2e/olm_switch_test.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/e2e/olm_switch_test.go b/tests/e2e/olm_switch_test.go index 850ad75..f92794f 100644 --- a/tests/e2e/olm_switch_test.go +++ b/tests/e2e/olm_switch_test.go @@ -64,9 +64,7 @@ func TestOLMToNonOLMSwitch(t *testing.T) { // Step 2: Deploy central again without OLM (should switch modes) t.Log("=== Step 2: Redeploy central without OLM (triggering mode switch) ===") - // Note: we need to *explicitly* set OLM because of an pre-existing issue where the on-cluster manifest influences - // the effective deployment configuration on a re-deployment. - args = append([]string{roxieBinary, "deploy", "central", "--olm=false", "--envrc", envrcPath}, commonDeployArgs...) + args = append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator switched to non-OLM mode @@ -93,9 +91,7 @@ func TestNonOLMToOLMSwitch(t *testing.T) { // Step 1: Deploy central without OLM (non-OLM operator) t.Log("=== Step 1: Deploy central with non-OLM operator ===") - // Note: we need to *explicitly* set OLM because of an pre-existing issue where the on-cluster manifest influences - // the effective deployment configuration on a re-deployment. - args := append([]string{roxieBinary, "deploy", "central", "--olm=false", "--envrc", envrcPath}, commonDeployArgs...) + args := append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator is in non-OLM mode @@ -231,9 +227,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) { // Step 3: Switch to non-OLM by redeploying secured-cluster without --olm t.Log("=== Step 3: Redeploy secured-cluster without OLM (triggering mode switch) ===") - // Note: we need to *explicitly* set OLM because of an pre-existing issue where the on-cluster manifest influences - // the effective deployment configuration on a re-deployment. - args = append([]string{roxieBinary, "deploy", "--early-readiness", "--olm=false", "secured-cluster"}, commonDeployArgs...) + args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster"}, commonDeployArgs...) runCommand(t, deployTimeout, envrcEnv, args...) // Verify operator switched to non-OLM mode