package deployer import ( "context" "crypto/rand" "fmt" "os" "os/exec" "slices" "strconv" "strings" "sync" "syscall" "time" "github.com/fatih/color" "github.com/stackrox/roxie/internal/component" "github.com/stackrox/roxie/internal/containerrt" "github.com/stackrox/roxie/internal/dockerauth" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/imagecache" "github.com/stackrox/roxie/internal/k8s" log "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/portforward" "github.com/stackrox/roxie/internal/roxieenv" "github.com/stackrox/roxie/internal/types" ) var ( DefaultCentralWaitTimeout = 20 * time.Minute DefaultSecuredClusterWaitTimeout = 20 * time.Minute pauseReconcileAnnotationKey = "stackrox.io/pause-reconcile" // AdminUsername is the default admin username for StackRox Central AdminUsername = "admin" ) // Deployer is the base deployer for ACS type Deployer struct { // Influencing roxies mode of operation. startTime time.Time dockerAuth *dockerauth.DockerAuth imageCache *imagecache.ImageCache dockerCreds *dockerauth.Credentials envrcFile string kubeContext string containerRuntimeSocket string config Config // State centralEndpoint string centralPassword string roxCACertFile string tempDir string portForward *portforward.Manager portForwardPID int useOperatorPullSecrets bool repoAuthCache map[string]bool } type ResourceToDelete struct { Kind string Name string OwnerName string } func (d *Deployer) deleteResource(ctx context.Context, namespace, resourceType, resourceName string, args ...string) error { return d.deleteResources(ctx, namespace, []string{resourceType}, append([]string{resourceName}, args...)...) } func (d *Deployer) deleteResources(ctx context.Context, namespace string, resourceTypes []string, args ...string) error { resourceTypesString := strings.Join(resourceTypes, ",") finalArgs := []string{ "-n", namespace, "delete", resourceTypesString, "--ignore-not-found", "--force", "--grace-period=0", } finalArgs = append(finalArgs, args...) _, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{Args: finalArgs}) return err } // Expects that reconciliation for the RHACS operator is paused. func (d *Deployer) deleteCentralResources(ctx context.Context) error { log.Info("Deleting Central resources") crExists := true if _, err := k8s.RetrieveResourceFromCluster(ctx, d.config.Central.Namespace, "central", "stackrox-central-services"); err != nil { if !k8s.IsResourceNotFound(err) { return fmt.Errorf("retrieving Central CR: %w", err) } crExists = false } 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 { return err } log.Debug("Removed any pause-reconcile annotation from Central") err := d.deleteResource(ctx, d.config.Central.Namespace, "central", "stackrox-central-services", "--wait") if err != nil { return err } log.Debug("Deleted Central CR") } else { log.Info("Deletion of Central resources requested, but Central CR is not present anymore") } for _, resource := range []ResourceToDelete{ {Name: "central-db", Kind: "pvc"}, {Name: "central-db-backup", Kind: "pvc"}, {Name: "admin-password", Kind: "secret"}, {Name: "scanner-db-password", Kind: "secret", OwnerName: centralCrName}, {Name: "stackrox-central-helm", Kind: "configmap"}, } { log.Dimf("Attempting to delete %s/%s", resource.Kind, resource.Name) 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) 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) } continue } if k8s.ResourceNotOwnedByName(obj, resource.OwnerName) { log.Dimf("Skipping deletion of %s/%s: not owned by %s", resource.Kind, resource.Name, resource.OwnerName) continue } } if err := d.deleteResource(ctx, d.config.Central.Namespace, resource.Kind, resource.Name); err != nil { return fmt.Errorf("failed to delete %s/%s: %w", resource.Kind, resource.Name, err) } } return nil } func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, namespace string) error { log.Info("Deleting SecuredCluster resources") crExists := true if _, err := k8s.RetrieveResourceFromCluster(ctx, namespace, "securedcluster", securedClusterCrName); err != nil { if !k8s.IsResourceNotFound(err) { return fmt.Errorf("retrieving SecuredCluster CR: %w", err) } crExists = false } if crExists { log.Info("Removing any pause-reconcile annotation from SecuredCluster") 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, namespace, "securedcluster", securedClusterCrName, "--wait") if err != nil { return err } log.Debug("Deleted SecuredCluster CR") } else { log.Info("Deletion of SecuredCluster resources requested, but SecuredCluster CR is not present anymore") } // Delete resources, which are treated special. for _, resource := range []ResourceToDelete{ {Name: "cluster-registration-secret", Kind: "secret"}, // We need to make sure that don't accidentally delete a scanner-db-password belonging to the central CR, // when both are deployed into the same namespace. {Name: "scanner-db-password", Kind: "secret", OwnerName: securedClusterCrName}, } { log.Dimf("Attempting to delete %s/%s", resource.Kind, resource.Name) if resource.OwnerName != "" { 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) } continue } if k8s.ResourceNotOwnedByName(obj, resource.OwnerName) { log.Dimf("Skipping deletion of %s/%s: not owned by %s", resource.Kind, resource.Name, resource.OwnerName) continue } } 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) } } return nil } func (d *Deployer) SetConfig(config Config) { d.config = config } // New creates a new Deployer instance. // It verifies that the current environment contains necessary tools. // It creates a temporary directory for the deployer to use during deployment, // and it is the caller's responsibility to clean it up using the Cleanup() method when not used anymore. func New() (*Deployer, error) { if err := checkRequiredTools(); err != nil { return nil, err } imageCache, err := imagecache.New("", 20) if err != nil { return nil, err } tempDir, err := os.MkdirTemp("", "roxie-deployer-*") if err != nil { return nil, fmt.Errorf("failed to create temporary directory: %w", err) } d := &Deployer{ startTime: time.Now(), tempDir: tempDir, imageCache: imageCache, dockerAuth: dockerauth.New(), portForward: portforward.New(k8s.GetKubectl()), kubeContext: env.GetCurrentContext(), containerRuntimeSocket: containerrt.ResolveSocket(), } if password := os.Getenv("ROX_ADMIN_PASSWORD"); password != "" { d.centralPassword = password } else { d.centralPassword = generatePassword() } if endpoint := os.Getenv("API_ENDPOINT"); endpoint != "" { d.centralEndpoint = endpoint } if caCert := os.Getenv("ROX_CA_CERT_FILE"); caCert != "" { d.roxCACertFile = caCert } if pidStr := os.Getenv("ROXIE_PORT_FORWARD_PID"); pidStr != "" { if pid, err := strconv.Atoi(pidStr); err == nil { d.portForwardPID = pid } } log.Success("🚀 ACS Deployer initialized") return d, nil } // Cleanup cleans up any temporary resources created by the deployer, such as temporary files. func (d *Deployer) Cleanup() { if d.tempDir != "" && d.envrcFile == "" { // In the case of envrc file usage, we need to keep temporary files around after deployment. // (It contains CA certificates, for example.) if err := os.RemoveAll(d.tempDir); err != nil { log.Warningf("Deployer Cleanup failed to remove %q: %v", d.tempDir, err) } } } func (d *Deployer) stopDetachedPortForward() { if d.portForwardPID == 0 { return } proc, err := os.FindProcess(d.portForwardPID) if err != nil { return } if err := proc.Signal(syscall.SIGKILL); err != nil { log.Dimf("Detached port-forward (pid %d) already gone", d.portForwardPID) return } log.Dimf("Stopped detached port-forward (pid %d)", d.portForwardPID) d.portForwardPID = 0 } // NeedsPullSecrets reports whether roxie needs to set up image pull secrets for workload images. func (d *Deployer) NeedsPullSecrets(ctx context.Context) bool { // We assume that the other repositories (scanner, collector etc) have the same auth requirements as "main" return d.needsPullSecrets(ctx, d.config.Roxie.ImageRegistry+"/main") } // needsPullSecrets reports whether a pull secret is needed for the given repository. func (d *Deployer) needsPullSecrets(ctx context.Context, repository string) bool { if !d.repoRequiresAuth(ctx, repository) { // Repo is public. return false } // Repo is private. if d.config.Roxie.UsesCustomRegistry() { // Cluster never has pre-configured creds for custom registries. return true } // Standard registry. Does the cluster already have credentials? return d.config.Roxie.ClusterType.NeedsDefaultRegistryPullSecrets() } // repoRequiresAuth probes whether the given repository requires authentication, // caching the result per repository. func (d *Deployer) repoRequiresAuth(ctx context.Context, repo string) bool { if cached, ok := d.repoAuthCache[repo]; ok { return cached } ctx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() requiresAuth, err := d.dockerAuth.RepositoryRequiresAuth(ctx, repo) if err != nil { log.Warningf("Could not determine if %s requires auth, will require credentials: %v", repo, err) requiresAuth = true } else if requiresAuth { log.Dimf("Repository %s requires authentication", repo) } else { log.Dimf("Repository %s is public, no authentication required", repo) } if d.repoAuthCache == nil { d.repoAuthCache = make(map[string]bool) } d.repoAuthCache[repo] = requiresAuth return requiresAuth } // Deploy deploys the specified components to the cluster. func (d *Deployer) Deploy(ctx context.Context, components component.Component) error { // Prepare and verify credentials early to fail fast. needPullSecrets := d.NeedsPullSecrets(ctx) if needPullSecrets { if err := d.prepareCredentials(ctx); err != nil { return fmt.Errorf("failed to prepare credentials: %w", err) } } var enabledAddOns []AddOn var err error if components.IncludesAddOns() { enabledAddOns, err = d.ResolveEnabledAddOns() if err != nil { return fmt.Errorf("resolving enabled add-ons: %w", err) } } log.Infof("Initiating deployment of %s", components) // If only deploying operator, use the operator-only flow. if components.IncludesOperatorExplicitly() { return d.deployOperatorOnly(ctx) } if components.IncludesOperator() { // Deploy operator first if needed. if err := d.ensureOperatorDeployed(ctx); err != nil { return fmt.Errorf("failed to deploy operator: %w", err) } } if components.IncludesCentral() { if err := d.deployCentral(ctx); err != nil { return fmt.Errorf("failed to deploy central: %w", err) } } if components.IncludesAddOns() { if err := d.deployAddOns(ctx, enabledAddOns); err != nil { return fmt.Errorf("deploying add-ons: %w", err) } } if components.IncludesSensor() { if err := d.deploySecuredCluster(ctx); err != nil { return fmt.Errorf("failed to deploy secured cluster: %w", err) } } return nil } // prepareCredentials prepares and verifies Docker credentials early to allow failing fast. // The verified credentials are stored in the Deployer object for later use. func (d *Deployer) prepareCredentials(ctx context.Context) error { log.Dimf("Preparing and verifying Docker credentials...") // This will retrieve and verify credentials, returning error if invalid creds, err := d.dockerAuth.GetAndVerifyCredentials(ctx, d.config.Roxie.ImageRegistry) if err != nil { return err } d.dockerCreds = creds log.Dimf("Docker credentials verified successfully") return nil } func (d *Deployer) deployCentral(ctx context.Context) error { log.Infof("Deploying Central to namespace %s", d.config.Central.Namespace) if d.namespaceExists(d.config.Central.Namespace) { log.Info("Existing Central deployment found, tearing down...") if err := d.teardownCentral(ctx); err != nil { log.Warningf("Error during teardown: %v", err) } } if err := d.deployCentralOperator(ctx); err != nil { return err } if d.envrcFile != "" { log.Dimf("Writing environment variables to %s", d.envrcFile) if err := d.writeEnvrcFile(ctx); err != nil { log.Warningf("Failed to write envrc file: %v", err) } } return nil } func (d *Deployer) deploySecuredCluster(ctx context.Context) error { log.Infof("Deploying SecuredCluster to namespace %s", d.config.SecuredCluster.Namespace) if d.namespaceExists(d.config.SecuredCluster.Namespace) { log.Info("Existing SecuredCluster deployment found, tearing down...") if err := d.teardownSecuredCluster(ctx); err != nil { log.Warningf("Error during teardown: %v", err) } } return d.deploySecuredClusterOperator(ctx) } func (d *Deployer) Teardown(ctx context.Context, components component.Component) error { 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) } switch components { case component.Central: return d.teardownCentral(ctx) case component.SecuredCluster: return d.teardownSecuredCluster(ctx) case component.Both, component.All: // Tear down components in parallel for better performance var wg sync.WaitGroup // Always tear down central and sensor wg.Add(2) go func() { defer wg.Done() if err := d.teardownSecuredCluster(ctx); err != nil { log.Warningf("Error tearing down secured cluster: %v", err) } }() go func() { defer wg.Done() if err := d.teardownCentral(ctx); err != nil { log.Warningf("Error tearing down central: %v", err) } }() wg.Wait() // Tear down the operator strictly after Central/SecuredCluster are gone, // because the operator manages finalizers on their custom resources. if components == component.All { if err := d.teardownOperator(ctx); err != nil { log.Warningf("Error tearing down operator: %v", err) } } return nil case component.AddOns: return nil default: return fmt.Errorf("unknown component: %s", components) } } func (d *Deployer) teardownCentral(ctx context.Context) error { namespaces, err := d.discoverCRNamespaces(ctx, "central", centralCrName) if err != nil { log.Infof("No Central CR found on cluster, skipping Central teardown") return nil } d.config.Central.Namespace = namespaces[0] log.Infof("🗑️ Tearing down central in namespace %s", d.config.Central.Namespace) d.portForward.Stop() d.stopDetachedPortForward() // Add pause-reconcile annotation to not have the operator interfere during resource deletion. if d.doesResourceExist(ctx, "central", centralCrName, d.config.Central.Namespace) { if err := d.addPauseReconcileAnnotation(ctx, "central", centralCrName, d.config.Central.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 { return fmt.Errorf("failed to delete Central resources: %w", err) } log.Successf("✓ Central resources in namespace %s have been deleted", d.config.Central.Namespace) return nil } func (d *Deployer) teardownSecuredCluster(ctx context.Context) error { namespaces, err := d.discoverCRNamespaces(ctx, "securedcluster", securedClusterCrName) if err != nil { log.Infof("No SecuredCluster CR found on cluster, skipping SecuredCluster teardown") return 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...") 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", namespace) return nil } func (d *Deployer) ensureNamespaceExists(namespace string) error { if d.namespaceExists(namespace) { return nil } log.Infof("Creating namespace %s", namespace) _, err := k8s.RunKubectl(context.Background(), k8s.KubectlOptions{ Args: []string{"create", "namespace", namespace}, }) if err != nil { return fmt.Errorf("failed to create namespace: %w", err) } // Label namespace as managed by roxie since we just created it _, err = k8s.RunKubectl(context.Background(), k8s.KubectlOptions{ Args: []string{"label", "namespace", namespace, "app.kubernetes.io/managed-by=roxie", "--overwrite"}, }) if err != nil { log.Warningf("failed to label namespace %s: %v", namespace, err) } return nil } func (d *Deployer) namespaceExists(namespace string) bool { _, err := k8s.RunKubectl(context.Background(), k8s.KubectlOptions{ Args: []string{"get", "namespace", namespace}, }) return err == nil } func (d *Deployer) waitForNamespaceDeletion(namespace string) error { timeout := 5 * time.Minute checkInterval := 2 * time.Second progressInterval := 10 * time.Second // Report progress every 10 seconds deadline := time.Now().Add(timeout) lastProgressReport := time.Now() for time.Now().Before(deadline) { if !d.namespaceExists(namespace) { log.Infof("Namespace %s has been deleted", namespace) return nil } // Report progress periodically if time.Since(lastProgressReport) >= progressInterval { elapsed := time.Since(deadline.Add(-timeout)) log.Dim(fmt.Sprintf(" ⋯ Still waiting for namespace deletion... (%.0fs elapsed)", elapsed.Seconds())) lastProgressReport = time.Now() } time.Sleep(checkInterval) } return fmt.Errorf("timeout waiting for namespace %s to be deleted", namespace) } // checkRequiredTools verifies that required CLI tools are available func checkRequiredTools() error { requiredTools := []string{"kubectl"} var missing []string for _, tool := range requiredTools { if _, err := exec.LookPath(tool); err != nil { missing = append(missing, tool) } } if len(missing) > 0 { return fmt.Errorf("required tools not found in PATH: %s\nPlease install these tools and ensure they are available in your PATH", strings.Join(missing, ", ")) } return nil } func generatePassword() string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" const passwordLength = 20 password := make([]byte, passwordLength) randomBytes := make([]byte, passwordLength) if _, err := rand.Read(randomBytes); err != nil { return fmt.Sprintf("admin-%d", time.Now().Unix()) } for i := 0; i < passwordLength; i++ { password[i] = charset[int(randomBytes[i])%len(charset)] } return string(password) } 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 { return nil, fmt.Errorf("discovering %s/%s: %w", crType, crName, err) } raw := strings.TrimSpace(result.Stdout) if raw == "" { return nil, fmt.Errorf("%s/%s not found on cluster", crType, crName) } return strings.Fields(raw), nil } func (d *Deployer) doesResourceExist(ctx context.Context, resourceType, resourceName, namespace string) bool { _, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ Args: []string{ "get", resourceType, resourceName, "-n", namespace, }, }) return err == nil } func (d *Deployer) addPauseReconcileAnnotation(ctx context.Context, resourceType, resourceName, namespace string) error { _, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ Args: []string{ "annotate", resourceType, resourceName, "-n", namespace, fmt.Sprintf("%s=%s", pauseReconcileAnnotationKey, "true"), "--overwrite", }, }) if err != nil { return fmt.Errorf("failed to add pause-reconcile annotation: %w", err) } return nil } func (d *Deployer) removePauseReconcileAnnotation(ctx context.Context, resourceType, resourceName, namespace string) error { _, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ Args: []string{ "annotate", resourceType, resourceName, "-n", namespace, fmt.Sprintf("%s-", pauseReconcileAnnotationKey), }, IgnoreErrors: true, }) if err != nil { return fmt.Errorf("failed to remove pause-reconcile annotation: %w", err) } return nil } // WaitForCentral waits for Central to be ready and responding on its endpoint // Returns true if Central is ready, false if timeout occurs func (d *Deployer) WaitForCentral(timeout time.Duration) bool { if d.centralEndpoint == "" { log.Dim("No Central endpoint configured, skipping readiness check") return false } if env.RunningInteractively { log.Infof("⏳ Waiting for Central to be ready at %s (timeout: %v)", d.centralEndpoint, timeout) } else { log.Infof("⏳ Waiting for Central to be ready (timeout: %v)", timeout) } deadline := time.Now().Add(timeout) checkInterval := 5 * time.Second progressInterval := 30 * time.Second lastProgressReport := time.Now() for time.Now().Before(deadline) { // Try to connect to Central if d.isCentralReady() { log.Success("✓ Central is ready and responding!") return true } // Report progress periodically if time.Since(lastProgressReport) >= progressInterval { elapsed := time.Since(deadline.Add(-timeout)) remaining := timeout - elapsed log.Dim(fmt.Sprintf(" ⋯ Still waiting for Central... (%v elapsed, %v remaining)", elapsed.Round(time.Second), remaining.Round(time.Second))) lastProgressReport = time.Now() } time.Sleep(checkInterval) } log.Warning("⚠️ Central did not become ready within the timeout period") log.Warning(" This is not necessarily an error - Central may still be initializing") log.Warning(" You can check Central status manually or wait a bit longer") return false } // isCentralReady checks if Central is responding to HTTP requests func (d *Deployer) isCentralReady() bool { // Use exec to run curl with a short timeout // We use -k to skip TLS verification endpoint := fmt.Sprintf("https://%s", d.centralEndpoint) cmd := exec.Command("curl", "-k", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "3", "--max-time", "5", endpoint) output, _ := cmd.Output() // Even if curl exits with error, we might have gotten a status code statusCode := strings.TrimSpace(string(output)) if len(statusCode) == 0 { return false } // Central returns 200 for the UI root, or possibly 401/403 if auth is required // We consider any successful HTTP response (2xx, 3xx, 4xx) as "ready" // Only connection failures (empty response or network errors) mean "not ready" firstChar := statusCode[0] return firstChar >= '2' && firstChar <= '4' } // cleanupTempDir safely removes a temporary directory with logging func (d *Deployer) cleanupTempDir(path string, description string) { if path == "" { return } if err := os.RemoveAll(path); err != nil { log.Warningf("Failed to cleanup %s at %s: %v", description, path, err) } else { log.Dim(fmt.Sprintf("Cleaned up %s: %s", description, path)) } } func (d *Deployer) writeEnvrcFile(ctx context.Context) error { var content strings.Builder for name, val := range roxieenv.AssembleRoxieEnvironment(d.GetCentralDeploymentInfo()).Export() { fmt.Fprintf(&content, "export %s=%q\n", name, val) } if d.portForwardPID != 0 { fmt.Fprintf(&content, "export ROXIE_PORT_FORWARD_PID=%d\n", d.portForwardPID) } if err := os.WriteFile(d.envrcFile, []byte(content.String()), 0600); err != nil { return fmt.Errorf("failed to write envrc file: %w", err) } log.Successf("✓ Environment variables written to %s", d.envrcFile) return nil } func (d *Deployer) PrintCentralDeploymentSummary() { component := "Central" imageTag := d.config.CentralVersion() olm := d.config.Operator.DeployViaOlmEnabled() exposure := d.config.Central.GetExposure() portForwarding := d.config.Central.PortForwardingEnabled() kubeContext := d.kubeContext // Calculate box width boxWidth := 60 // Helper function to truncate long values truncate := func(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } // Helper function to create a row with alignment on colon createRow := func(label, value string) string { // Fixed label width for alignment (adjust this to fit longest label) labelWidth := 22 // Maximum value length to keep total under boxWidth maxValueLen := boxWidth - labelWidth - 4 // 4 = space + colon + space + border truncatedValue := truncate(value, maxValueLen) // Right-align label, then colon, then value labelPadding := labelWidth - len(label) if labelPadding < 0 { labelPadding = 0 } content := fmt.Sprintf(" %s%s: %s", strings.Repeat(" ", labelPadding), label, truncatedValue) // Pad to box width padding := boxWidth - len(content) - 1 if padding < 0 { padding = 0 } return content + strings.Repeat(" ", padding) + " │" } // Print the box cyan := color.New(color.FgCyan, color.Bold) log.Info("") log.Info(cyan.Sprint("┌" + strings.Repeat("─", boxWidth) + "┐")) title := " Deployment Configuration" titlePadding := boxWidth - len(title) log.Info(cyan.Sprint("│") + title + strings.Repeat(" ", titlePadding) + cyan.Sprint("│")) log.Info(cyan.Sprint("├" + strings.Repeat("─", boxWidth) + "┤")) // Deployment details log.Info(cyan.Sprint("│") + createRow("Component", component)) log.Info(cyan.Sprint("│") + createRow("Cluster Type", d.config.Roxie.ClusterType.String())) log.Info(cyan.Sprint("│") + createRow("Image Tag", imageTag.String())) log.Info(cyan.Sprint("│") + createRow("Kubernetes Context", kubeContext)) if olm { log.Info(cyan.Sprint("│") + createRow("OLM", "Yes")) } log.Info(cyan.Sprint("│") + createRow("Exposure", exposure.String())) if portForwarding || exposure == types.ExposureNone { log.Info(cyan.Sprint("│") + createRow("Port Forwarding", "Enabled (localhost:8443)")) } log.Info(cyan.Sprint("└" + strings.Repeat("─", boxWidth) + "┘")) log.Info("") } // 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 := k8s.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 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 } parts := strings.Split(line, "|") if len(parts) < 4 { continue } name := parts[0] replicas := parts[1] ready := parts[2] available := parts[3] // Create state key stateKey := fmt.Sprintf("%s:%s:%s:%s", name, replicas, ready, available) // Check if this is a new deployment or state change if prevState, exists := seenDeployments[name]; !exists { // New deployment detected log.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 { log.Dimf(" ✓ Deployment '%s' is available (%s/%s replicas)", name, available, replicas) } else if ready != prevState[len(name)+1:] { log.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. // 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 := k8s.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 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 } parts := strings.Split(line, "|") if len(parts) < 2 { continue } name := parts[0] phase := parts[1] ready := "" if len(parts) > 2 { ready = parts[2] } stateKey := fmt.Sprintf("%s:%s", phase, ready) // Only report significant state changes if prevState, exists := seenPods[name]; !exists { if phase == "Pending" { log.Dim(fmt.Sprintf(" • Pod '%s' starting...", name)) } else if phase == "Running" && ready == "true" { log.Dim(fmt.Sprintf(" • Pod '%s' running", name)) } seenPods[name] = stateKey updated = true } else if prevState != stateKey { if phase == "Running" && ready == "true" { log.Dim(fmt.Sprintf(" • Pod '%s' is ready", name)) } else if phase == "Running" && ready == "false" { log.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 // extracted func (d *Deployer) PrintSecuredClusterDeploymentSummary() { component := "Secured Cluster" imageTag := d.config.SecuredClusterVersion() olm := d.config.Operator.DeployViaOlmEnabled() kubeContext := d.kubeContext // Calculate box width boxWidth := 60 // Helper function to truncate long values truncate := func(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } // Helper function to create a row with alignment on colon createRow := func(label, value string) string { // Fixed label width for alignment (adjust this to fit longest label) labelWidth := 22 // Maximum value length to keep total under boxWidth maxValueLen := boxWidth - labelWidth - 4 // 4 = space + colon + space + border truncatedValue := truncate(value, maxValueLen) // Right-align label, then colon, then value labelPadding := labelWidth - len(label) if labelPadding < 0 { labelPadding = 0 } content := fmt.Sprintf(" %s%s: %s", strings.Repeat(" ", labelPadding), label, truncatedValue) // Pad to box width padding := boxWidth - len(content) - 1 if padding < 0 { padding = 0 } return content + strings.Repeat(" ", padding) + " │" } // Print the box cyan := color.New(color.FgCyan, color.Bold) log.Info("") log.Info(cyan.Sprint("┌" + strings.Repeat("─", boxWidth) + "┐")) title := " Deployment Configuration" titlePadding := boxWidth - len(title) log.Info(cyan.Sprint("│") + title + strings.Repeat(" ", titlePadding) + cyan.Sprint("│")) log.Info(cyan.Sprint("├" + strings.Repeat("─", boxWidth) + "┤")) // Deployment details log.Info(cyan.Sprint("│") + createRow("Component", component)) log.Info(cyan.Sprint("│") + createRow("Cluster Type", d.config.Roxie.ClusterType.String())) log.Info(cyan.Sprint("│") + createRow("Image Tag", imageTag.String())) log.Info(cyan.Sprint("│") + createRow("Kubernetes Context", kubeContext)) if olm { log.Info(cyan.Sprint("│") + createRow("OLM", "Yes")) } if ep, ok := d.config.SecuredCluster.Spec["centralEndpoint"].(string); ok && ep != internalCentralEndpoint(d.config.Central.Namespace) { log.Info(cyan.Sprint("│") + createRow("Central Endpoint", ep)) } log.Info(cyan.Sprint("└" + strings.Repeat("─", boxWidth) + "┘")) log.Info("") } func (d *Deployer) GetCentralDeploymentInfo() types.CentralDeploymentInfo { return types.CentralDeploymentInfo{ Endpoint: d.centralEndpoint, Username: AdminUsername, Password: d.centralPassword, KubeContext: d.kubeContext, Exposure: d.config.Central.GetExposure(), CACertFile: d.roxCACertFile, } } func (d *Deployer) GetKubeContext() string { return d.kubeContext } func (d *Deployer) GetContainerRuntimeSocket() string { return d.containerRuntimeSocket }