diff --git a/cmd/deploy.go b/cmd/deploy.go index b8b225d..c866cb9 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -32,6 +32,7 @@ Examples: cmd.Flags().BoolVar(&helm, "helm", false, "Deploy using Helm charts instead of operator") cmd.Flags().BoolVar(&olm, "olm", false, "Deploy operator via OLM (requires OLM installed)") cmd.Flags().BoolVar(&portForwarding, "port-forwarding", false, "Enable localhost port-forward for Central") + cmd.Flags().BoolVar(&pauseReconciliation, "pause-reconciliation", false, "Pause reconciliation after deployment") cmd.Flags().StringVar(&overrideFile, "override", "", "Path to YAML file with overrides") cmd.Flags().StringArrayVar(&overrideSetExpressions, "set", []string{}, "Set override values (can specify multiple times, e.g., --set foo.bar=val)") cmd.Flags().StringVar(&exposure, "exposure", "loadbalancer", "Central exposure backend (loadbalancer, none)") @@ -125,6 +126,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { d.SetVerbose(verbose) d.SetEarlyReadiness(earlyReadiness) d.SetPortForwardingEnabled(portForwardEnabledFinal) + d.SetPauseReconciliation(pauseReconciliation) // Resolve "auto" resources based on cluster type resolvedResources := resources diff --git a/cmd/main.go b/cmd/main.go index 23c976b..7c96185 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -14,6 +14,7 @@ var ( helm bool olm bool portForwarding bool + pauseReconciliation bool overrideFile string overrideSetExpressions []string exposure string diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 4b55849..abe953a 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -105,6 +105,10 @@ func (d *Deployer) deployCentralOperator(ctx context.Context, resources, exposur return fmt.Errorf("failed waiting for Central: %w", err) } + if err := d.maybeAddPauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.centralNamespace); err != nil { + d.logger.Warningf("failed to add pause-reconcile annotation: %v", err) + } + return d.configureCentralEndpoint(ctx, exposure) } @@ -589,6 +593,10 @@ func (d *Deployer) deploySecuredClusterOperator(ctx context.Context, resources s return fmt.Errorf("failed waiting for SecuredCluster: %w", err) } + if err := d.maybeAddPauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.sensorNamespace); err != nil { + d.logger.Warningf("failed to add pause-reconcile annotation: %v", err) + } + d.logger.Successf("✓ SecuredCluster '%s' is ready", clusterName) return nil } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 788b327..e64cf4e 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -25,6 +25,8 @@ var ( centralNamespace = "acs-central" sensorNamespace = "acs-sensor" defaultExposure = "loadbalancer" + + pauseReconcileAnnotationKey = "stackrox.io/pause-reconcile" ) // Deployer is the base deployer for ACS @@ -46,6 +48,7 @@ type Deployer struct { roxCACertFile string kubeContext string portForwardEnabled bool + pauseReconciliation bool exposure string overrideFile string overrideSetExpressions []string @@ -260,6 +263,9 @@ func (d *Deployer) teardownCentral(ctx context.Context) error { d.portForward.Stop() + // Remove pause-reconcile annotation before deleting to allow operator to clean up properly + d.removePauseReconcileAnnotation(ctx, "central", "stackrox-central-services", d.centralNamespace) + d.logger.Info("Deleting Central custom resource") d.runKubectl(ctx, KubectlOptions{ Args: []string{"delete", "central", "stackrox-central-services", "-n", d.centralNamespace, "--wait=false"}, @@ -292,6 +298,9 @@ func (d *Deployer) teardownSecuredCluster(ctx context.Context) error { return nil } + // Remove pause-reconcile annotation before deleting to allow operator to clean up properly + d.removePauseReconcileAnnotation(ctx, "securedcluster", "stackrox-secured-cluster-services", d.sensorNamespace) + d.logger.Info("Deleting SecuredCluster custom resource") d.runKubectl(ctx, KubectlOptions{ Args: []string{"delete", "securedcluster", "stackrox-secured-cluster-services", "-n", d.sensorNamespace, "--wait=false"}, @@ -500,6 +509,50 @@ func (d *Deployer) SetEarlyReadiness(enabled bool) { d.earlyReadiness = enabled } +func (d *Deployer) SetPauseReconciliation(enabled bool) { + d.pauseReconciliation = enabled +} + +// maybeAddPauseReconcileAnnotation adds the stackrox.io/pause-reconcile annotation to a custom resource +func (d *Deployer) maybeAddPauseReconcileAnnotation(ctx context.Context, resourceType, resourceName, namespace string) error { + if !d.pauseReconciliation { + return nil + } + + d.logger.Infof("Adding pause-reconcile annotation to %s/%s", resourceType, resourceName) + + _, err := d.runKubectl(ctx, KubectlOptions{ + Args: []string{ + "annotate", resourceType, resourceName, + "-n", namespace, + pauseReconcileAnnotationKey, + "--overwrite", + }, + }) + if err != nil { + return fmt.Errorf("failed to add pause-reconcile annotation: %w", err) + } + + d.logger.Successf("✓ Added pause-reconcile annotation to %s/%s", resourceType, resourceName) + return nil +} + +// removePauseReconcileAnnotation removes the stackrox.io/pause-reconcile annotation from a custom resource +func (d *Deployer) removePauseReconcileAnnotation(ctx context.Context, resourceType, resourceName, namespace string) { + d.logger.Dimf("Removing pause-reconcile annotation from %s/%s", resourceType, resourceName) + + _, err := d.runKubectl(ctx, KubectlOptions{ + Args: []string{ + "annotate", resourceType, resourceName, + "-n", namespace, + fmt.Sprintf("%s-", pauseReconcileAnnotationKey), + }, + }) + if err != nil { + d.logger.Dimf("Could not remove pause-reconcile annotation (expected if CR does not exist): %v", err) + } +} + func (d *Deployer) GetDeploymentInfo() (endpoint, password, caCertFile, kubeContext, exposure string) { return d.centralEndpoint, d.centralPassword, d.roxCACertFile, d.kubeContext, d.exposure } diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index d4291f7..9a365a2 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -6,6 +6,7 @@ package e2e import ( "bytes" "context" + "encoding/json" "fmt" "os" "os/exec" @@ -296,7 +297,8 @@ func TestDeployBothComponentsTogether(t *testing.T) { defer os.Remove(envrcPath) t.Log("=== Deploying both components ===") - args := append([]string{roxieBinary, "deploy", "both", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + // We also test --pause-reconciliation flag here. + args := append([]string{roxieBinary, "deploy", "both", "--early-readiness", "--pause-reconciliation", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) runCommand(t, deployTimeout*2, nil, args...) t.Log("Verifying namespace: acs-central") @@ -304,6 +306,15 @@ func TestDeployBothComponentsTogether(t *testing.T) { t.Log("Verifying namespace: acs-sensor") verifyNamespaceExists(t, "acs-sensor") + + // Verify Central has the pause-reconcile annotation. + t.Log("Verifying pause-reconcile annotation on Central CR") + verifyAnnotation(t, "central", "stackrox-central-services", "acs-central", "stackrox.io/pause-reconcile", "true") + + // Verify SecuredCluster has the pause-reconcile annotation. + t.Log("Verifying pause-reconcile annotation on SecuredCluster CR") + verifyAnnotation(t, "securedcluster", "stackrox-secured-cluster-services", "acs-sensor", "stackrox.io/pause-reconcile", "true") + } func TestDeployCentralAndSecuredClusterViaHelm(t *testing.T) { @@ -337,3 +348,26 @@ func TestDeployCentralAndSecuredClusterViaHelm(t *testing.T) { t.Log("Verifying namespace: acs-sensor") verifyNamespaceExists(t, "acs-sensor") } + +func verifyAnnotation(t *testing.T, resourceType, resourceName, namespace, annotationKey, expectedValue string) { + t.Helper() + + cmd := exec.Command("kubectl", "get", resourceType, resourceName, "-n", namespace, "-o", "jsonpath={.metadata.annotations}") + output, err := cmd.Output() + if err != nil { + t.Fatalf("Failed to get annotation %s on %s/%s in namespace %s: %v", annotationKey, resourceType, resourceName, namespace, err) + } + + annotations := make(map[string]string) + err = json.Unmarshal(output, &annotations) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + currentValue := annotations[annotationKey] + if currentValue != expectedValue { + t.Fatalf("Annotation %s on %s/%s has incorrect value. Expected: %s, Got: %s", annotationKey, resourceType, resourceName, expectedValue, currentValue) + } + + t.Logf("✓ Annotation %s=%s verified on %s/%s", annotationKey, expectedValue, resourceType, resourceName) +}