From d3db6d738caa742a6b273ad687ccfa4fec487de7 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 28 Apr 2026 22:29:15 +0200 Subject: [PATCH 01/17] New dependency: semver --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 3506bcba..03e0c77a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/cli v29.4.0+incompatible // indirect diff --git a/go.sum b/go.sum index 420a50e4..9c9eb73a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= From 27a89eec35c8305799606919f3e96c8437d96f41 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 28 Apr 2026 22:30:10 +0200 Subject: [PATCH 02/17] Introduce stackrox version constraint for additional printer columns --- internal/stackroxversions/constraints.go | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 internal/stackroxversions/constraints.go diff --git a/internal/stackroxversions/constraints.go b/internal/stackroxversions/constraints.go new file mode 100644 index 00000000..a70909dd --- /dev/null +++ b/internal/stackroxversions/constraints.go @@ -0,0 +1,28 @@ +package stackroxversions + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" +) + +var ( + SupportsAdditionalPrinterColumnsConstraint = func() *semver.Constraints { + constraint, err := semver.NewConstraint(">= 4.9.0") + if err != nil { + panic("invalid semver constraint") + } + return constraint + }() +) + +// SupportsAdditionalPrinterColumns checks if the provided main image tag supports +// the additional printer columns (i.e., >= 4.9.0). +func SupportsAdditionalPrinterColumns(version string) (bool, error) { + semVer, err := semver.NewVersion(version) + if err != nil { + return false, fmt.Errorf("failed to parse operator tag %q as semantic version: %w", version, err) + } + + return SupportsAdditionalPrinterColumnsConstraint.Check(semVer), nil +} From 11413314eff81f66390c7dc15ede35d4037179cd Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 28 Apr 2026 22:36:28 +0200 Subject: [PATCH 03/17] Enable --early-readiness after checking version constraints --- cmd/deploy.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmd/deploy.go b/cmd/deploy.go index e03b3ee5..7aca5707 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -20,6 +20,7 @@ import ( "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/types" + "github.com/stackrox/roxie/internal/stackroxversions" "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/utils/ptr" @@ -279,6 +280,25 @@ func runDeploy(cmd *cobra.Command, args []string) error { return err } + if !deploySettings.Central.EarlyReadiness || !deploySettings.SecuredCluster.EarlyReadiness { + // Explanation on the versions involved here: + // Deploying StackRox begins with picking a "main image tag" -- this is a version identifier, which cannot be reliably parsed as a semver. + // But there is a derived version from that -- the operator version -- which can be parsed as a semver. + // + // The invocation of deploySettings.Operator.Configure() above in this function prepares the operator deployment config in the sense + // that top-level roxie configuration options are propagated to the concrete operator deployment configuration. This includes also + // storing of the derived operator version within the operator configuration. + // + // This is why we use the operator version here when checking version constraints. + hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(deploySettings.Operator.Version) + if err != nil { + return fmt.Errorf("checking version constraint on main image tag %s", deploySettings.Roxie.Version) + } + if !hasSupport { + return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s", stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String()) + } + } + d, err := deployer.New(log) if err != nil { return fmt.Errorf("failed to create deployer: %w", err) From ff72c84c121bc8f9db8ab9a652eed3c8b5dd6fcc Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 4 May 2026 22:23:18 +0200 Subject: [PATCH 04/17] New waiting functions --- internal/deployer/deploy_via_operator.go | 149 +++++++++++++++++++++++ internal/deployer/deployer.go | 24 +++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index c80b1c78..c29f495a 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/stackrox/roxie/internal/component" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/k8s" @@ -446,6 +447,154 @@ func (d *Deployer) checkPodProgress(ctx context.Context, seenPods map[string]str d.checkPodProgressInNamespace(ctx, d.config.Central.Namespace, seenPods) } +// waitForAvailableCondition waits for the specified namespace/resource +func (d *Deployer) waitForAvailableCondition(ctx context.Context, resource, namespace string) error { + deadline, ok := ctx.Deadline() + if !ok { + return errors.New("waitForAvailableCondition requires a deadline be set on the provided context") + } + + // We need this extra step, because a "kubectl wait" fails immediately, when waiting for readiness of a deployment + // which does not exist (yet). + if err := d.waitForResourceToExist(ctx, resource, namespace); err != nil { + return fmt.Errorf("error waiting for resource %s in namespace %s to exist: %v", resource, namespace, err) + } + + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ + Args: []string{ + "wait", + "--for=condition=Available", + resource, + "-n", namespace, + "--timeout=" + time.Until(deadline).String(), + }, + }) + if err != nil { + return fmt.Errorf("error waiting for resource %s in namespace %s to become Available: %v", resource, namespace, err) + } + d.logger.Successf("✓ Resource %s in namespace %s is ready", resource, namespace) + return nil +} + +// waitForResourceToExist polls until the given resource exists in the namespace. +func (d *Deployer) waitForResourceToExist(ctx context.Context, resource, namespace string) error { + d.logger.Infof("Waiting for resource %s to exist in namespace %s...", resource, namespace) + for { + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"get", resource, "-n", namespace}, + }) + if err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +type componentWaitConfig struct { + namespace string + earlyReadiness bool + waitFor string + timeout time.Duration +} + +// Only supports component.Central or component.SecuredCluster to be provided. +func (d *Deployer) constructComponentWaitConfig(comp component.Component) componentWaitConfig { + // Without earlyReadiness we wait for the component's CR's Available condition to be True. This indicates all deployments are ready. + // With earlyReadiness we just wait for the core workload's Available condition of that component to be True. + switch comp { + case component.Central: + waitFor := "central/" + centralCrName + if d.config.Central.EarlyReadiness { + waitFor = "deployment/central" + } + return componentWaitConfig{ + namespace: d.config.Central.Namespace, + earlyReadiness: d.config.Central.EarlyReadiness, + waitFor: waitFor, + timeout: d.config.Central.DeployTimeout, + } + case component.SecuredCluster: + waitFor := "securedcluster/" + securedClusterCrName + if d.config.SecuredCluster.EarlyReadiness { + waitFor = "deployment/sensor" + } + return componentWaitConfig{ + namespace: d.config.SecuredCluster.Namespace, + earlyReadiness: d.config.SecuredCluster.EarlyReadiness, + waitFor: waitFor, + timeout: d.config.SecuredCluster.DeployTimeout, + } + default: + return componentWaitConfig{} + } +} + +// waitForComponentReady waits for a component to be ready. +func (d *Deployer) waitForComponentReady(ctx context.Context, comp component.Component) error { + if comp != component.Central && comp != component.SecuredCluster { + return errors.New("waitForComponentReady only supports Central or SecuredCluster components") + } + waitCfg := d.constructComponentWaitConfig(comp) + d.logger.Infof("⏳ Waiting for %s to become ready (timeout: %s)...", comp, waitCfg.timeout) + + waitCtx, cancel := context.WithTimeout(ctx, waitCfg.timeout) + defer cancel() + + // Spawn a goroutine, which waits until some success condition, sending the result (nil or error) through a dedicated channel. + waitChannel := make(chan error, 1) + + go func() { + err := d.waitForAvailableCondition(waitCtx, waitCfg.waitFor, waitCfg.namespace) + if err != nil { + waitChannel <- fmt.Errorf("error waiting for %s deployment to become Available: %v", comp, err) + return + } + d.logger.Infof("Resource %s is now ready.", waitCfg.waitFor) + waitChannel <- nil + }() + + // Show progress while waiting for the wait-goroutine above to terminate. + seenDeployments := make(map[string]string) + seenPods := make(map[string]string) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + // We will log a generic message when we haven't seen pod/deployment progress + // for longer than this duration. + progressUpdatePeriod := 1 * time.Minute + lastUpdate := time.Now() + + for { + select { + case err := <-waitChannel: + return err + case <-waitCtx.Done(): + return fmt.Errorf("timeout reached") + case <-ticker.C: + // Track seen deployments and their states to avoid duplicate messages. + deploymentsProgressed, err := d.checkDeploymentProgressInNamespace(ctx, waitCfg.namespace, seenDeployments) + if err != nil { + d.logger.Warningf("failed to check for deployment progress in namespace %s: %v", waitCfg.namespace, err) + } + podsProgressed, err := d.checkPodProgressInNamespace(ctx, waitCfg.namespace, seenPods) + if err != nil { + d.logger.Warningf("failed to check for pod progress in namespace %s: %v", waitCfg.namespace, err) + } + if deploymentsProgressed || podsProgressed { + lastUpdate = time.Now() + } else { + if time.Since(lastUpdate) > progressUpdatePeriod { + d.logger.Dimf("Still waiting for component %s in namespace %s", comp, waitCfg.namespace) + lastUpdate = time.Now() + } + } + } + } +} + // waitForLoadBalancer waits for a LoadBalancer service to get an external IP. // Returns the endpoint as "host:port", with no https:// prefix. func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceName string, timeout int) (string, error) { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 2f9aebd1..2c602e21 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -826,16 +826,18 @@ func (d *Deployer) PrintCentralDeploymentSummary() { log.Info("") } -// checkDeploymentProgressInNamespace checks for deployment state changes in a specific namespace and reports them -func (d *Deployer) checkDeploymentProgressInNamespace(ctx context.Context, namespace string, seenDeployments map[string]string) { +// checkDeploymentProgressInNamespace checks for deployment state changes in a specific namespace and reports them. +// Returns true, if relevant state changes have been observed, false otherwise. +func (d *Deployer) checkDeploymentProgressInNamespace(ctx context.Context, namespace string, seenDeployments map[string]string) (bool, error) { result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployments", "-n", namespace, "-o", "jsonpath={range .items[*]}{.metadata.name}{'|'}{.status.replicas}{'|'}{.status.readyReplicas}{'|'}{.status.availableReplicas}{'\\n'}{end}"}, }) if err != nil { - return + return false, fmt.Errorf("retrieving deployment information: %w", err) } lines := strings.Split(strings.TrimSpace(result.Stdout), "\n") + updated := false for _, line := range lines { if line == "" { continue @@ -859,6 +861,7 @@ func (d *Deployer) checkDeploymentProgressInNamespace(ctx context.Context, names // New deployment detected d.logger.Dimf(" → Deployment '%s' created (%s/%s replicas ready)", name, ready, replicas) seenDeployments[name] = stateKey + updated = true } else if prevState != stateKey { // State changed if available != "" && available != "0" && available == replicas { @@ -867,20 +870,25 @@ func (d *Deployer) checkDeploymentProgressInNamespace(ctx context.Context, names d.logger.Dimf(" ⋯ Deployment '%s' progressing (%s/%s replicas ready)", name, ready, replicas) } seenDeployments[name] = stateKey + updated = true } } + + return updated, nil } -// checkPodProgressInNamespace checks for pod state changes in a specific namespace and reports them -func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace string, seenPods map[string]string) { +// checkPodProgressInNamespace checks for pod state changes in a specific namespace and reports them. +// Returns true, if relevant state changes have been observed, false otherwise. +func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace string, seenPods map[string]string) (bool, error) { result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "pods", "-n", namespace, "-o", "jsonpath={range .items[*]}{.metadata.name}{'|'}{.status.phase}{'|'}{.status.containerStatuses[0].ready}{'\\n'}{end}"}, }) if err != nil { - return + return false, fmt.Errorf("retrieving pod information: %w", err) } lines := strings.Split(strings.TrimSpace(result.Stdout), "\n") + updated := false for _, line := range lines { if line == "" { continue @@ -908,6 +916,7 @@ func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace st d.logger.Dim(fmt.Sprintf(" • Pod '%s' running", name)) } seenPods[name] = stateKey + updated = true } else if prevState != stateKey { if phase == "Running" && ready == "true" { d.logger.Dim(fmt.Sprintf(" • Pod '%s' is ready", name)) @@ -915,8 +924,11 @@ func (d *Deployer) checkPodProgressInNamespace(ctx context.Context, namespace st d.logger.Dim(fmt.Sprintf(" • Pod '%s' running (not ready yet)", name)) } seenPods[name] = stateKey + updated = true } } + + return updated, nil } // TODO(#91): plenty of code in common with the central variant that should probably be From ee7c55a324ceed550515cd5020c4a49d3a538fd0 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 28 Apr 2026 22:42:03 +0200 Subject: [PATCH 05/17] Remove dead code --- internal/deployer/deploy_via_operator.go | 115 ----------------------- 1 file changed, 115 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index c29f495a..8e1e645b 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -396,57 +396,6 @@ func (d *Deployer) applyCentralCR(ctx context.Context, cr map[string]interface{} return nil } -// waitForCentralReady waits for Central to be ready -func (d *Deployer) waitForCentralReady(ctx context.Context) error { - timeout := d.config.Central.DeployTimeout - d.logger.Infof("⏳ Waiting for Central to become ready (timeout: %s)...", timeout) - - // Track seen deployments and their states to avoid duplicate messages - seenDeployments := make(map[string]string) - seenPods := make(map[string]string) - - start := time.Now() - checkInterval := 3 * time.Second - - for time.Since(start) < timeout { - // Check for new deployments - d.checkDeploymentProgress(ctx, seenDeployments) - - // Check for pod events if in early readiness mode or verbose - if d.config.Central.EarlyReadiness || d.verbose { - d.checkPodProgress(ctx, seenPods) - } - - // Check if central deployment is ready - result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "deployment", "central", "-n", d.config.Central.Namespace, "-o", "jsonpath={.status.readyReplicas}"}, - }) - if err == nil && result.Stdout != "" { - replicas := strings.TrimSpace(result.Stdout) - if replicas != "0" && replicas != "" { - d.logger.Successf("✓ Central is ready (%s replicas)", replicas) - return nil - } - } - - // TODO(ROX-34499): using `kubectl wait` (which in turn - I hope - uses a watch) instead of - // polling would allow us to not waste time here - time.Sleep(checkInterval) - } - - return errors.New("timeout waiting for Central to become ready") -} - -// checkDeploymentProgress checks for deployment state changes and reports them -func (d *Deployer) checkDeploymentProgress(ctx context.Context, seenDeployments map[string]string) { - d.checkDeploymentProgressInNamespace(ctx, d.config.Central.Namespace, seenDeployments) -} - -// checkPodProgress checks for pod state changes and reports them -func (d *Deployer) checkPodProgress(ctx context.Context, seenPods map[string]string) { - d.checkPodProgressInNamespace(ctx, d.config.Central.Namespace, seenPods) -} - // waitForAvailableCondition waits for the specified namespace/resource func (d *Deployer) waitForAvailableCondition(ctx context.Context, resource, namespace string) error { deadline, ok := ctx.Deadline() @@ -869,67 +818,3 @@ func (d *Deployer) applySecuredClusterCR(ctx context.Context, cr map[string]inte d.logger.Success("✓ SecuredCluster CR applied") return nil } - -// waitForSecuredClusterReady waits for SecuredCluster to be ready -func (d *Deployer) waitForSecuredClusterReady(ctx context.Context) error { - timeout := d.config.SecuredCluster.DeployTimeout - d.logger.Infof("⏳ Waiting for SecuredCluster to become ready (timeout: %s)...", timeout) - - // Track seen deployments and their states to avoid duplicate messages - seenDeployments := make(map[string]string) - seenPods := make(map[string]string) - - start := time.Now() - checkInterval := 3 * time.Second - - for time.Since(start) < timeout { - d.checkDeploymentProgressInNamespace(ctx, d.config.SecuredCluster.Namespace, seenDeployments) - - if d.config.SecuredCluster.EarlyReadiness || d.verbose { - d.checkPodProgressInNamespace(ctx, d.config.SecuredCluster.Namespace, seenPods) - } - - allReady := true - - // Check sensor deployment - result, err := d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "deployment", "sensor", "-n", d.config.SecuredCluster.Namespace, "-o", "jsonpath={.status.readyReplicas}"}, - }) - if err != nil || result.Stdout == "" { - allReady = false - } else { - replicas := strings.TrimSpace(result.Stdout) - if replicas == "0" || replicas == "" { - allReady = false - } - } - - // Only check additional workloads if early-readiness is not enabled - if !d.config.SecuredCluster.EarlyReadiness { - // Check admission-control deployment - result, err = d.runKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "deployment", "admission-control", "-n", d.config.SecuredCluster.Namespace, "-o", "jsonpath={.status.readyReplicas}"}, - }) - if err != nil || result.Stdout == "" { - allReady = false - } else { - replicas := strings.TrimSpace(result.Stdout) - if replicas == "0" || replicas == "" { - allReady = false - } - } - - // collector seems to be crashing on some local cluster types/versions. - // TODO(ROX-34499): skip the check only on local clusters, then? - } - - if allReady { - d.logger.Success("✓ SecuredCluster is ready") - return nil - } - - time.Sleep(checkInterval) - } - - return errors.New("timeout waiting for SecuredCluster to become ready") -} From 6f77179a5f817644d8da48bf32198ddfd9027330 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 5 May 2026 22:56:55 +0200 Subject: [PATCH 06/17] Wrap error --- cmd/deploy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 7aca5707..3b085550 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -292,7 +292,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { // This is why we use the operator version here when checking version constraints. hasSupport, err := stackroxversions.SupportsAdditionalPrinterColumns(deploySettings.Operator.Version) if err != nil { - return fmt.Errorf("checking version constraint on main image tag %s", deploySettings.Roxie.Version) + return fmt.Errorf("checking version constraint on main image tag %s: %w", deploySettings.Roxie.Version, err) } if !hasSupport { return fmt.Errorf("--early-readiness=false can only be used for StackRox versions satisfying %s", stackroxversions.SupportsAdditionalPrinterColumnsConstraint.String()) From 2fde2e416fa845bbde206a905503d0c084211082 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 5 May 2026 22:58:00 +0200 Subject: [PATCH 07/17] Pass waitCtx instead of ctx to progress functions --- internal/deployer/deploy_via_operator.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 8e1e645b..a4b8c826 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -524,11 +524,11 @@ func (d *Deployer) waitForComponentReady(ctx context.Context, comp component.Com return fmt.Errorf("timeout reached") case <-ticker.C: // Track seen deployments and their states to avoid duplicate messages. - deploymentsProgressed, err := d.checkDeploymentProgressInNamespace(ctx, waitCfg.namespace, seenDeployments) + deploymentsProgressed, err := d.checkDeploymentProgressInNamespace(waitCtx, waitCfg.namespace, seenDeployments) if err != nil { d.logger.Warningf("failed to check for deployment progress in namespace %s: %v", waitCfg.namespace, err) } - podsProgressed, err := d.checkPodProgressInNamespace(ctx, waitCfg.namespace, seenPods) + podsProgressed, err := d.checkPodProgressInNamespace(waitCtx, waitCfg.namespace, seenPods) if err != nil { d.logger.Warningf("failed to check for pod progress in namespace %s: %v", waitCfg.namespace, err) } From ed947f81e47a4f00a9c5bf53b7c4a3af64a7629f Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 5 May 2026 23:13:21 +0200 Subject: [PATCH 08/17] Unconditionally invoke deploySettings.Operator.Configure() --- cmd/deploy.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/deploy.go b/cmd/deploy.go index 3b085550..7609e9fb 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -370,6 +370,9 @@ func configureConfig(log *logger.Logger, components component.Component, deployS deploySettings.SecuredCluster.ResourceProfile = profile } + // We need to do this irregardless of wether the operator is deployed or not, because + // this includes the transformation of StackRox main image tags to semver compatibles versions, + // which we will make use of later for checking version constraints. if err := deploySettings.Operator.Configure(&deploySettings.Roxie); err != nil { return fmt.Errorf("configuring operator configuration: %w", err) } From 7f05d9bd9514147a9e29497f4c2c23eeba78a411 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 10:31:22 +0200 Subject: [PATCH 09/17] Typo --- cmd/deploy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 7609e9fb..5114073a 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -370,7 +370,7 @@ func configureConfig(log *logger.Logger, components component.Component, deployS deploySettings.SecuredCluster.ResourceProfile = profile } - // We need to do this irregardless of wether the operator is deployed or not, because + // We need to do this regardless of whether the operator is deployed or not, because // this includes the transformation of StackRox main image tags to semver compatibles versions, // which we will make use of later for checking version constraints. if err := deploySettings.Operator.Configure(&deploySettings.Roxie); err != nil { From b8f680a295cdfc6bd482698d498af1746fbbff24 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 10:31:57 +0200 Subject: [PATCH 10/17] Typo --- cmd/deploy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 5114073a..87d7caaf 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -371,7 +371,7 @@ func configureConfig(log *logger.Logger, components component.Component, deployS } // We need to do this regardless of whether the operator is deployed or not, because - // this includes the transformation of StackRox main image tags to semver compatibles versions, + // this includes the transformation of StackRox main image tags to semver compatible versions, // which we will make use of later for checking version constraints. if err := deploySettings.Operator.Configure(&deploySettings.Roxie); err != nil { return fmt.Errorf("configuring operator configuration: %w", err) From 421035169361f4fea853011f82dcdf8622074ae4 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier <111092021+mclasmeier@users.noreply.github.com> Date: Thu, 7 May 2026 13:11:05 +0200 Subject: [PATCH 11/17] Update internal/deployer/deploy_via_operator.go Co-authored-by: Marcin Owsiany --- internal/deployer/deploy_via_operator.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index a4b8c826..a5bb9c74 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -452,8 +452,8 @@ type componentWaitConfig struct { // Only supports component.Central or component.SecuredCluster to be provided. func (d *Deployer) constructComponentWaitConfig(comp component.Component) componentWaitConfig { - // Without earlyReadiness we wait for the component's CR's Available condition to be True. This indicates all deployments are ready. - // With earlyReadiness we just wait for the core workload's Available condition of that component to be True. + // Without earlyReadiness we wait for the Available condition of component's CR to be True. This indicates all deployments are ready. + // With earlyReadiness we just wait for the Available condition of that component's core Deployment to be True. switch comp { case component.Central: waitFor := "central/" + centralCrName From 270d73437e7382e69227be292a03c81cc17c611b Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 10:31:57 +0200 Subject: [PATCH 12/17] Pad context deadline to avoid racing with kubectl timeout Pass the kubectl wait timeout explicitly so it's independent of the context deadline. The context gets 5s of padding so kubectl can exit gracefully before the context kills it. Time spent in waitForResourceToExist is naturally subtracted via time.Until(deadline). Co-Authored-By: Claude Opus 4.6 --- internal/deployer/deploy_via_operator.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index a5bb9c74..736f7524 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -397,11 +397,8 @@ func (d *Deployer) applyCentralCR(ctx context.Context, cr map[string]interface{} } // waitForAvailableCondition waits for the specified namespace/resource -func (d *Deployer) waitForAvailableCondition(ctx context.Context, resource, namespace string) error { - deadline, ok := ctx.Deadline() - if !ok { - return errors.New("waitForAvailableCondition requires a deadline be set on the provided context") - } +func (d *Deployer) waitForAvailableCondition(ctx context.Context, resource, namespace string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) // We need this extra step, because a "kubectl wait" fails immediately, when waiting for readiness of a deployment // which does not exist (yet). @@ -490,14 +487,15 @@ func (d *Deployer) waitForComponentReady(ctx context.Context, comp component.Com waitCfg := d.constructComponentWaitConfig(comp) d.logger.Infof("⏳ Waiting for %s to become ready (timeout: %s)...", comp, waitCfg.timeout) - waitCtx, cancel := context.WithTimeout(ctx, waitCfg.timeout) + const padding = 5 * time.Second + waitCtx, cancel := context.WithTimeout(ctx, waitCfg.timeout+padding) defer cancel() // Spawn a goroutine, which waits until some success condition, sending the result (nil or error) through a dedicated channel. waitChannel := make(chan error, 1) go func() { - err := d.waitForAvailableCondition(waitCtx, waitCfg.waitFor, waitCfg.namespace) + err := d.waitForAvailableCondition(waitCtx, waitCfg.waitFor, waitCfg.namespace, waitCfg.timeout) if err != nil { waitChannel <- fmt.Errorf("error waiting for %s deployment to become Available: %v", comp, err) return From f640511a57c02f2068a4e3bc7f625ea3ef093145 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 15:40:18 +0200 Subject: [PATCH 13/17] Move wait config construction to CentralConfig and SecuredClusterConfig Add WaitConfig struct and GetWaitConfig() methods so each component config owns its readiness-wait logic. Co-Authored-By: Claude Opus 4.6 --- internal/deployer/config.go | 34 ++++++++++++++ internal/deployer/deploy_via_operator.go | 56 ++++++------------------ 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 9645b164..17890e5d 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -69,6 +69,14 @@ func (c *OperatorConfig) Configure(roxieConfig *RoxieConfig) error { return nil } +// WaitConfig describes how to wait for a component to become ready. +type WaitConfig struct { + Namespace string + EarlyReadiness bool + WaitFor string + Timeout time.Duration +} + // CentralConfig holds deployment settings for the Central component. type CentralConfig struct { Namespace string `yaml:"namespace,omitempty"` @@ -96,6 +104,19 @@ func DefaultCentralConfig() CentralConfig { } } +func (c *CentralConfig) GetWaitConfig() WaitConfig { + waitFor := "central/" + centralCrName + if c.EarlyReadiness { + waitFor = "deployment/central" + } + return WaitConfig{ + Namespace: c.Namespace, + EarlyReadiness: c.EarlyReadiness, + WaitFor: waitFor, + Timeout: c.DeployTimeout, + } +} + func (c *CentralConfig) PortForwardingSet() bool { return c.PortForwarding != nil } @@ -190,6 +211,19 @@ func DefaultSecuredClusterConfig() SecuredClusterConfig { } } +func (s *SecuredClusterConfig) GetWaitConfig() WaitConfig { + waitFor := "securedcluster/" + securedClusterCrName + if s.EarlyReadiness { + waitFor = "deployment/sensor" + } + return WaitConfig{ + Namespace: s.Namespace, + EarlyReadiness: s.EarlyReadiness, + WaitFor: waitFor, + Timeout: s.DeployTimeout, + } +} + // ConfigureSpec applies feature flags and the central endpoint to the SecuredCluster spec. func (s *SecuredClusterConfig) ConfigureSpec(roxieConfig *RoxieConfig, centralConfig *CentralConfig) error { if err := helpers.DeepMerge(s.Spec, featureFlagsToOverrides(roxieConfig.FeatureFlags)); err != nil { diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 736f7524..dc3e3fd0 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -440,42 +440,14 @@ func (d *Deployer) waitForResourceToExist(ctx context.Context, resource, namespa } } -type componentWaitConfig struct { - namespace string - earlyReadiness bool - waitFor string - timeout time.Duration -} - -// Only supports component.Central or component.SecuredCluster to be provided. -func (d *Deployer) constructComponentWaitConfig(comp component.Component) componentWaitConfig { - // Without earlyReadiness we wait for the Available condition of component's CR to be True. This indicates all deployments are ready. - // With earlyReadiness we just wait for the Available condition of that component's core Deployment to be True. +func (d *Deployer) getWaitConfig(comp component.Component) WaitConfig { switch comp { case component.Central: - waitFor := "central/" + centralCrName - if d.config.Central.EarlyReadiness { - waitFor = "deployment/central" - } - return componentWaitConfig{ - namespace: d.config.Central.Namespace, - earlyReadiness: d.config.Central.EarlyReadiness, - waitFor: waitFor, - timeout: d.config.Central.DeployTimeout, - } + return d.config.Central.GetWaitConfig() case component.SecuredCluster: - waitFor := "securedcluster/" + securedClusterCrName - if d.config.SecuredCluster.EarlyReadiness { - waitFor = "deployment/sensor" - } - return componentWaitConfig{ - namespace: d.config.SecuredCluster.Namespace, - earlyReadiness: d.config.SecuredCluster.EarlyReadiness, - waitFor: waitFor, - timeout: d.config.SecuredCluster.DeployTimeout, - } + return d.config.SecuredCluster.GetWaitConfig() default: - return componentWaitConfig{} + return WaitConfig{} } } @@ -484,23 +456,23 @@ func (d *Deployer) waitForComponentReady(ctx context.Context, comp component.Com if comp != component.Central && comp != component.SecuredCluster { return errors.New("waitForComponentReady only supports Central or SecuredCluster components") } - waitCfg := d.constructComponentWaitConfig(comp) - d.logger.Infof("⏳ Waiting for %s to become ready (timeout: %s)...", comp, waitCfg.timeout) + waitCfg := d.getWaitConfig(comp) + d.logger.Infof("⏳ Waiting for %s to become ready (timeout: %s)...", comp, waitCfg.Timeout) const padding = 5 * time.Second - waitCtx, cancel := context.WithTimeout(ctx, waitCfg.timeout+padding) + waitCtx, cancel := context.WithTimeout(ctx, waitCfg.Timeout+padding) defer cancel() // Spawn a goroutine, which waits until some success condition, sending the result (nil or error) through a dedicated channel. waitChannel := make(chan error, 1) go func() { - err := d.waitForAvailableCondition(waitCtx, waitCfg.waitFor, waitCfg.namespace, waitCfg.timeout) + err := d.waitForAvailableCondition(waitCtx, waitCfg.WaitFor, waitCfg.Namespace, waitCfg.Timeout) if err != nil { waitChannel <- fmt.Errorf("error waiting for %s deployment to become Available: %v", comp, err) return } - d.logger.Infof("Resource %s is now ready.", waitCfg.waitFor) + d.logger.Infof("Resource %s is now ready.", waitCfg.WaitFor) waitChannel <- nil }() @@ -522,19 +494,19 @@ func (d *Deployer) waitForComponentReady(ctx context.Context, comp component.Com return fmt.Errorf("timeout reached") case <-ticker.C: // Track seen deployments and their states to avoid duplicate messages. - deploymentsProgressed, err := d.checkDeploymentProgressInNamespace(waitCtx, waitCfg.namespace, seenDeployments) + deploymentsProgressed, err := d.checkDeploymentProgressInNamespace(waitCtx, waitCfg.Namespace, seenDeployments) if err != nil { - d.logger.Warningf("failed to check for deployment progress in namespace %s: %v", waitCfg.namespace, err) + d.logger.Warningf("failed to check for deployment progress in namespace %s: %v", waitCfg.Namespace, err) } - podsProgressed, err := d.checkPodProgressInNamespace(waitCtx, waitCfg.namespace, seenPods) + podsProgressed, err := d.checkPodProgressInNamespace(waitCtx, waitCfg.Namespace, seenPods) if err != nil { - d.logger.Warningf("failed to check for pod progress in namespace %s: %v", waitCfg.namespace, err) + d.logger.Warningf("failed to check for pod progress in namespace %s: %v", waitCfg.Namespace, err) } if deploymentsProgressed || podsProgressed { lastUpdate = time.Now() } else { if time.Since(lastUpdate) > progressUpdatePeriod { - d.logger.Dimf("Still waiting for component %s in namespace %s", comp, waitCfg.namespace) + d.logger.Dimf("Still waiting for component %s in namespace %s", comp, waitCfg.Namespace) lastUpdate = time.Now() } } From 1bf205d8945b5207f2a0db877fd71294b88adcbe Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 15:43:22 +0200 Subject: [PATCH 14/17] Move unsupported component check into getWaitConfig default case Co-Authored-By: Claude Opus 4.6 --- internal/deployer/deploy_via_operator.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index dc3e3fd0..45a01f42 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -440,23 +440,23 @@ func (d *Deployer) waitForResourceToExist(ctx context.Context, resource, namespa } } -func (d *Deployer) getWaitConfig(comp component.Component) WaitConfig { +func (d *Deployer) getWaitConfig(comp component.Component) (WaitConfig, error) { switch comp { case component.Central: - return d.config.Central.GetWaitConfig() + return d.config.Central.GetWaitConfig(), nil case component.SecuredCluster: - return d.config.SecuredCluster.GetWaitConfig() + return d.config.SecuredCluster.GetWaitConfig(), nil default: - return WaitConfig{} + return WaitConfig{}, fmt.Errorf("unsupported component for wait config: %s", comp) } } // waitForComponentReady waits for a component to be ready. func (d *Deployer) waitForComponentReady(ctx context.Context, comp component.Component) error { - if comp != component.Central && comp != component.SecuredCluster { - return errors.New("waitForComponentReady only supports Central or SecuredCluster components") + waitCfg, err := d.getWaitConfig(comp) + if err != nil { + return err } - waitCfg := d.getWaitConfig(comp) d.logger.Infof("⏳ Waiting for %s to become ready (timeout: %s)...", comp, waitCfg.Timeout) const padding = 5 * time.Second From 70eb68fe334482a6f171338a4a1d62f071194233 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 4 May 2026 22:23:48 +0200 Subject: [PATCH 15/17] Use new waiting functions --- internal/deployer/deploy_via_operator.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 45a01f42..6b7705f3 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -137,7 +137,7 @@ func (d *Deployer) deployCentralOperator(ctx context.Context) error { return fmt.Errorf("failed to apply Central CR: %w", err) } - if err := d.waitForCentralReady(ctx); err != nil { + if err := d.waitForComponentReady(ctx, component.Central); err != nil { return fmt.Errorf("failed waiting for Central: %w", err) } @@ -687,7 +687,7 @@ func (d *Deployer) deploySecuredClusterOperator(ctx context.Context) error { return fmt.Errorf("failed to apply SecuredCluster CR: %w", err) } - if err := d.waitForSecuredClusterReady(ctx); err != nil { + if err := d.waitForComponentReady(ctx, component.SecuredCluster); err != nil { return fmt.Errorf("failed waiting for SecuredCluster: %w", err) } From 824d85e1bdacb5f23bf5271b9ad7a723d515b258 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 15:45:25 +0200 Subject: [PATCH 16/17] Use unbuffered channel for wait result Co-Authored-By: Claude Opus 4.6 --- internal/deployer/deploy_via_operator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 6b7705f3..f97a9233 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -464,7 +464,7 @@ func (d *Deployer) waitForComponentReady(ctx context.Context, comp component.Com defer cancel() // Spawn a goroutine, which waits until some success condition, sending the result (nil or error) through a dedicated channel. - waitChannel := make(chan error, 1) + waitChannel := make(chan error) go func() { err := d.waitForAvailableCondition(waitCtx, waitCfg.WaitFor, waitCfg.Namespace, waitCfg.Timeout) From e68b849673ed6ad26f3daa805b2c119e8a6492a7 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 13 May 2026 10:36:55 +0200 Subject: [PATCH 17/17] Readd comment --- internal/deployer/config.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 17890e5d..1a6b2ca9 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -105,6 +105,10 @@ func DefaultCentralConfig() CentralConfig { } func (c *CentralConfig) GetWaitConfig() WaitConfig { + // Without earlyReadiness we wait for the Available condition of component's CR to be True. + // This indicates all deployments are ready. + // With earlyReadiness we just wait for the Available condition of that component's core + // Deployment to be True. waitFor := "central/" + centralCrName if c.EarlyReadiness { waitFor = "deployment/central"