diff --git a/internal/deployer/constants.go b/internal/deployer/constants.go index cd464929..8b1739cd 100644 --- a/internal/deployer/constants.go +++ b/internal/deployer/constants.go @@ -3,6 +3,9 @@ package deployer import "fmt" var ( + centralCrName = "stackrox-central-services" + securedClusterCrName = "stackrox-secured-cluster-services" + centralDbPVCSizeSmall = "30Gi" centralResourcesSmall = map[string]interface{}{ diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index c31549a2..a1ae807b 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + "github.com/stackrox/roxie/internal/k8s" ) // generateCRS generates the Central Resource Secret using roxctl @@ -43,7 +45,7 @@ func (d *Deployer) generateCRS(ctx context.Context, clusterName string) (string, func (d *Deployer) applyCRS(ctx context.Context, crsContent string) error { d.logger.Info("Applying CRS to sensor namespace") - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-n", d.sensorNamespace, "-f", "-"}, Stdin: strings.NewReader(crsContent), }) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index a794b966..2c15e5ff 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -11,6 +11,7 @@ import ( "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" + "github.com/stackrox/roxie/internal/k8s" "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -167,7 +168,7 @@ func (d *Deployer) isOperatorVersionCorrect(ctx context.Context) bool { // getDeployedOperatorImage gets the image of the currently deployed operator func (d *Deployer) getDeployedOperatorImage(ctx context.Context) (string, error) { - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployment", operatorDeploymentName, "-n", operatorNamespace, "-o", "jsonpath={.spec.template.spec.containers[0].image}"}, }) @@ -200,7 +201,7 @@ func (d *Deployer) ensurePullSecretExists(ctx context.Context, namespace string) // Assemble pull secret YAML from pre-verified credentials pullSecretYAML := d.dockerAuth.CreatePullSecretYAMLFromCredentials(d.dockerCreds, namespace) - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: strings.NewReader(pullSecretYAML), }) @@ -231,7 +232,7 @@ func (d *Deployer) createAdminPasswordSecret(ctx context.Context) error { return fmt.Errorf("failed to marshal secret: %w", err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -438,7 +439,7 @@ func (d *Deployer) applyCentralCR(ctx context.Context, cr map[string]interface{} } } - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -473,7 +474,7 @@ func (d *Deployer) waitForCentralReady(ctx context.Context, timeout time.Duratio } // Check if central deployment is ready - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployment", "central", "-n", d.centralNamespace, "-o", "jsonpath={.status.readyReplicas}"}, }) if err == nil && result.Stdout != "" { @@ -508,7 +509,7 @@ func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceNa start := time.Now() for time.Since(start) < time.Duration(timeout)*time.Second { - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "svc", serviceName, "-n", namespace, "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}"}, }) if err == nil && result.Stdout != "" { @@ -524,7 +525,7 @@ func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceNa } // Also check for hostname (some cloud providers use hostname instead of IP) - result, err = d.runKubectl(ctx, KubectlOptions{ + result, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "svc", serviceName, "-n", namespace, "-o", "jsonpath={.status.loadBalancer.ingress[0].hostname}"}, }) if err == nil && result.Stdout != "" { @@ -549,7 +550,7 @@ func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceNa func (d *Deployer) fetchCentralCACert(ctx context.Context) error { d.logger.Info("Fetching Central CA certificate...") - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "secret", "central-tls", "-n", d.centralNamespace, "-o", "jsonpath={.data.ca\\.pem}"}, }) if err != nil { @@ -771,7 +772,7 @@ func (d *Deployer) applySecuredClusterCR(ctx context.Context, cr map[string]inte } } - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-n", d.sensorNamespace, "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -805,7 +806,7 @@ func (d *Deployer) waitForSecuredClusterReady(ctx context.Context, timeout time. allReady := true // Check sensor deployment - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployment", "sensor", "-n", d.sensorNamespace, "-o", "jsonpath={.status.readyReplicas}"}, }) if err != nil || result.Stdout == "" { @@ -820,7 +821,7 @@ func (d *Deployer) waitForSecuredClusterReady(ctx context.Context, timeout time. // Only check additional workloads if early-readiness is not enabled if !d.earlyReadiness { // Check admission-control deployment - result, err = d.runKubectl(ctx, KubectlOptions{ + result, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployment", "admission-control", "-n", d.sensorNamespace, "-o", "jsonpath={.status.readyReplicas}"}, }) if err != nil || result.Stdout == "" { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 31f204ce..2d50d3d2 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -20,6 +20,7 @@ import ( "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/imagecache" + "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/portforward" ) @@ -107,7 +108,6 @@ type Deployer struct { imageCache *imagecache.ImageCache portForward *portforward.Manager clusterDefaults *clusterdefaults.Manager - kubectl string roxctlVersion string centralNamespace string sensorNamespace string @@ -135,9 +135,10 @@ type Deployer struct { clusterResourceKinds map[string]struct{} } -type ResourceKindWithName struct { - Kind string - Name string +type ResourceToDelete struct { + Kind string + Name string + OwnerName string } func (d *Deployer) filterResourceKinds(resourceKinds []string) []string { @@ -165,12 +166,12 @@ func (d *Deployer) deleteResources(ctx context.Context, namespace string, resour "--grace-period=0", } finalArgs = append(finalArgs, args...) - _, err := d.runKubectl(ctx, KubectlOptions{Args: finalArgs}) + _, err := d.runKubectl(ctx, k8s.KubectlOptions{Args: finalArgs}) return err } func (d *Deployer) deleteFinalizers(ctx context.Context, namespace, resourceType, resourceName string) error { - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{ "-n", namespace, "patch", resourceType, resourceName, "-p", `{"metadata":{"finalizers":null}}`, @@ -215,13 +216,30 @@ func (d *Deployer) deleteCentralResources(ctx context.Context, wait bool) error return err } - for _, resource := range []ResourceKindWithName{ - {Name: "central-db", Kind: "pvc"}, - {Name: "central-db-backup", Kind: "pvc"}, + for _, resource := range []ResourceToDelete{ + {Name: "central-db", Kind: "pvc", OwnerName: centralCrName}, + {Name: "central-db-backup", Kind: "pvc", OwnerName: centralCrName}, {Name: "admin-password", Kind: "secret"}, + {Name: "scanner-db-password", Kind: "secret", OwnerName: centralCrName}, } { - err := d.deleteResource(ctx, d.centralNamespace, resource.Kind, resource.Name) - if err != nil { + d.logger.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.logger, d.centralNamespace, resource.Kind, resource.Name) + if err != nil { + if !k8s.IsResourceNotFound(err) { + d.logger.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) { + d.logger.Dimf("Skipping deletion of %s/%s: not owned by %s", resource.Kind, resource.Name, resource.OwnerName) + continue + } + } + + if err := d.deleteResource(ctx, d.centralNamespace, resource.Kind, resource.Name); err != nil { return fmt.Errorf("failed to delete %s/%s: %w", resource.Kind, resource.Name, err) } } @@ -249,7 +267,7 @@ func (d *Deployer) preventCABundleInjection(ctx context.Context) error { } d.logger.Info("Removing CNO label from injected-cabundle ConfigMap to prevent CNO from injecting the CA bundle during cleanup") - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{ "label", "configmap", configMapName, "-n", d.centralNamespace, "config.openshift.io/inject-trusted-cabundle-", @@ -290,12 +308,29 @@ func (d *Deployer) deleteSecuredClusterResources(ctx context.Context, wait bool) return err } - for _, resource := range []ResourceKindWithName{ + for _, resource := range []ResourceToDelete{ {Name: "cluster-registration-secret", Kind: "secret"}, - {Name: "scanner-db-password", 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}, } { - err := d.deleteResource(ctx, d.sensorNamespace, resource.Kind, resource.Name) - if err != nil { + d.logger.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.logger, d.sensorNamespace, resource.Kind, resource.Name) + if err != nil { + if !k8s.IsResourceNotFound(err) { + d.logger.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) { + d.logger.Dimf("Skipping deletion of %s/%s: not owned by %s", resource.Kind, resource.Name, resource.OwnerName) + continue + } + } + if err := d.deleteResource(ctx, d.sensorNamespace, resource.Kind, resource.Name); err != nil { return fmt.Errorf("failed to delete %s/%s: %w", resource.Kind, resource.Name, err) } } @@ -477,12 +512,9 @@ func New(log *logger.Logger) (*Deployer, error) { return nil, err } - kubectl := getKubectl() - d := &Deployer{ logger: log, startTime: time.Now(), - kubectl: kubectl, roxctlVersion: roxctlVersion, centralNamespace: centralNamespace, sensorNamespace: sensorNamespace, @@ -494,7 +526,7 @@ func New(log *logger.Logger) (*Deployer, error) { d.dockerAuth = dockerauth.New(log) d.imageCache = imagecache.New(log, "", 20) - d.portForward = portforward.New(kubectl, log) + d.portForward = portforward.New(k8s.GetKubectl(), log) d.clusterDefaults = clusterdefaults.NewManager(log) if password := os.Getenv("ROX_ADMIN_PASSWORD"); password != "" { @@ -511,11 +543,7 @@ func New(log *logger.Logger) (*Deployer, error) { d.roxCACertFile = caCert } - ctx, err := getCurrentContext(kubectl) - if err != nil { - return nil, err - } - d.kubeContext = ctx + d.kubeContext = env.GetCurrentContext() clusterResourceKinds, err := d.getClusterResourceKinds() if err != nil { @@ -530,7 +558,7 @@ func New(log *logger.Logger) (*Deployer, error) { } func (d *Deployer) getClusterResourceKinds() (map[string]struct{}, error) { - result, err := d.runKubectl(context.Background(), KubectlOptions{ + result, err := d.runKubectl(context.Background(), k8s.KubectlOptions{ Args: []string{"api-resources", "-o", "name"}, }) if err != nil { @@ -754,7 +782,7 @@ func (d *Deployer) ensureNamespaceExists(namespace string) error { } d.logger.Infof("Creating namespace %s", namespace) - _, err := d.runKubectl(context.Background(), KubectlOptions{ + _, err := d.runKubectl(context.Background(), k8s.KubectlOptions{ Args: []string{"create", "namespace", namespace}, }) if err != nil { @@ -762,7 +790,7 @@ func (d *Deployer) ensureNamespaceExists(namespace string) error { } // Label namespace as managed by roxie since we just created it - _, err = d.runKubectl(context.Background(), KubectlOptions{ + _, err = d.runKubectl(context.Background(), k8s.KubectlOptions{ Args: []string{"label", "namespace", namespace, "app.kubernetes.io/managed-by=roxie", "--overwrite"}, }) @@ -774,7 +802,7 @@ func (d *Deployer) ensureNamespaceExists(namespace string) error { } func (d *Deployer) namespaceExists(namespace string) bool { - _, err := d.runKubectl(context.Background(), KubectlOptions{ + _, err := d.runKubectl(context.Background(), k8s.KubectlOptions{ Args: []string{"get", "namespace", namespace}, }) return err == nil @@ -824,13 +852,6 @@ func checkRequiredTools() error { return nil } -func getKubectl() string { - if kubectl := os.Getenv("ORCH_CMD"); kubectl != "" { - return kubectl - } - return "kubectl" -} - func getRoxctlVersion() (string, error) { cmd := exec.Command("roxctl", "version") output, err := cmd.Output() @@ -852,16 +873,6 @@ func getRoxctlVersion() (string, error) { return version, nil } -func getCurrentContext(kubectl string) (string, error) { - cmd := exec.Command(kubectl, "config", "current-context") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get current context: %w", err) - } - - return strings.TrimSpace(string(output)), nil -} - func generatePassword() string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" const passwordLength = 20 @@ -948,7 +959,7 @@ func (d *Deployer) maybeAddPauseReconcileAnnotation(ctx context.Context, resourc } func (d *Deployer) doesResourceExist(ctx context.Context, resourceType, resourceName, namespace string) bool { - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{ "get", resourceType, resourceName, "-n", namespace, @@ -958,7 +969,7 @@ func (d *Deployer) doesResourceExist(ctx context.Context, resourceType, resource } func (d *Deployer) addPauseReconcileAnnotation(ctx context.Context, resourceType, resourceName, namespace string) error { - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{ "annotate", resourceType, resourceName, "-n", namespace, @@ -1155,7 +1166,7 @@ func (d *Deployer) PrintCentralDeploymentSummary() { // 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) { - result, err := d.runKubectl(ctx, KubectlOptions{ + 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 { @@ -1200,7 +1211,7 @@ func (d *Deployer) checkDeploymentProgressInNamespace(ctx context.Context, names // 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) { - result, err := d.runKubectl(ctx, KubectlOptions{ + 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 { diff --git a/internal/deployer/kubectl.go b/internal/deployer/kubectl.go index 1205253d..5f9a8c5f 100644 --- a/internal/deployer/kubectl.go +++ b/internal/deployer/kubectl.go @@ -1,125 +1,15 @@ package deployer import ( - "bytes" "context" - "fmt" - "io" - "os/exec" - "strings" - "time" -) - -// KubectlOptions contains options for running kubectl commands -type KubectlOptions struct { - Args []string // Command arguments (e.g., ["get", "pod", "my-pod"]) - Stdin io.Reader // Optional stdin for commands like "apply -f -" - MaxAttempts int // Maximum number of retry attempts (default: 3) - RetryDelay int // Base retry delay in seconds (default: 2) - IgnoreErrors bool // If true, return nil on errors (useful for cleanup operations) -} -// KubectlResult contains the result of a kubectl command execution -type KubectlResult struct { - Stdout string - Stderr string -} + "github.com/stackrox/roxie/internal/k8s" +) -// runKubectl executes a kubectl command with automatic retries on transient errors +// runKubectl is a thin wrapper around k8s.RunKubectl that injects the deployer's logger. +// // TODO(#91): perhaps this should not return a pointer so that the lack of nil checks elsewhere // is robust? -func (d *Deployer) runKubectl(ctx context.Context, opts KubectlOptions) (*KubectlResult, error) { - if opts.MaxAttempts <= 0 { - opts.MaxAttempts = 3 - } - if opts.RetryDelay <= 0 { - opts.RetryDelay = 2 - } - - // List of transient/retryable errors for kubectl - retryableErrors := []string{ - "connection refused", - "connection reset", - "timed out", - "timeout", - "temporary failure", - "network is unreachable", - "no route to host", - "tls handshake timeout", - "eof", - "broken pipe", - "context deadline exceeded", - "unable to connect to the server", - "server is currently unable to handle the request", - "the server is currently unable to handle the request", - "etcdserver: request timed out", - "etcdserver: leader changed", - "transport is closing", - } - - var lastStderr string - var lastErr error - - for attempt := 1; attempt <= opts.MaxAttempts; attempt++ { - if attempt > 1 { - waitTime := time.Duration(attempt*opts.RetryDelay) * time.Second - d.logger.Infof("Retrying kubectl command (attempt %d/%d) after %v...", attempt, opts.MaxAttempts, waitTime) - time.Sleep(waitTime) - } - - cmd := exec.CommandContext(ctx, d.kubectl, opts.Args...) - - if opts.Stdin != nil { - // For retry attempts, we need to reset the reader if it's a bytes.Reader - if reader, ok := opts.Stdin.(*bytes.Reader); ok { - reader.Seek(0, io.SeekStart) - } - cmd.Stdin = opts.Stdin - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - lastStderr = stderr.String() - lastErr = err - - if err == nil { - return &KubectlResult{ - Stdout: stdout.String(), - Stderr: lastStderr, - }, nil - } - - if opts.IgnoreErrors { - return &KubectlResult{ - Stdout: stdout.String(), - Stderr: lastStderr, - }, nil - } - - isRetryable := false - stderrLower := strings.ToLower(lastStderr) - for _, retryErr := range retryableErrors { - if strings.Contains(stderrLower, strings.ToLower(retryErr)) { - isRetryable = true - break - } - } - - if !isRetryable || attempt == opts.MaxAttempts { - return &KubectlResult{ - Stdout: stdout.String(), - Stderr: lastStderr, - }, fmt.Errorf("kubectl command failed: %w", err) - } - - d.logger.Warningf("Transient error in kubectl command: %s", lastStderr) - } - - return &KubectlResult{ - Stdout: "", - Stderr: lastStderr, - }, fmt.Errorf("kubectl command failed after %d attempts: %w", opts.MaxAttempts, lastErr) +func (d *Deployer) runKubectl(ctx context.Context, opts k8s.KubectlOptions) (*k8s.KubectlResult, error) { + return k8s.RunKubectl(ctx, d.logger, opts) } diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index d76d2246..1e51c068 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -15,6 +15,7 @@ import ( "gopkg.in/yaml.v3" "github.com/stackrox/roxie/internal/env" + "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/ocihelper" ) @@ -142,7 +143,7 @@ func (d *Deployer) applyCRDsToCluster(ctx context.Context, crdFiles []string) er d.logger.Infof("Applying %d CRD(s) to cluster", len(crdFiles)) for _, crdFile := range crdFiles { - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", crdFile}, }) if err != nil { @@ -161,7 +162,7 @@ func (d *Deployer) applyCRDsToCluster(ctx context.Context, crdFiles []string) er func (d *Deployer) ensureCRDsInstalled(ctx context.Context) error { var missing []string for _, crd := range requiredCRDs { - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "crd", crd}, }) if err != nil { @@ -273,7 +274,7 @@ func (d *Deployer) applyImageContentSourcePolicy(ctx context.Context) error { } d.logger.Dim("Applying ImageContentSourcePolicy...") - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -294,7 +295,7 @@ func (d *Deployer) removeKonfluxImageRewriting(ctx context.Context) error { } d.logger.Dim("Removing Konflux ImageContentSourcePolicy if present...") - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", "imagecontentsourcepolicy", "acs-konflux-builds", "--ignore-not-found=true"}, }) if err != nil { @@ -403,7 +404,7 @@ metadata: app.kubernetes.io/managed-by: roxie `, operatorNamespace, operatorNamespace) - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: strings.NewReader(nsYAML), }) @@ -427,7 +428,7 @@ func (d *Deployer) createServiceAccount(ctx context.Context, namespace, name str return fmt.Errorf("failed to marshal ServiceAccount '%s/%s': %w", namespace, name, err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -463,7 +464,7 @@ func (d *Deployer) createClusterRoleFromCSV(ctx context.Context, deploymentSpec return fmt.Errorf("failed to marshal ClusterRole 'rhacs-operator-manager-role': %w", err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -501,7 +502,7 @@ func (d *Deployer) createClusterRoleBinding(ctx context.Context, namespace, serv return fmt.Errorf("failed to marshal ClusterRoleBinding 'rhacs-operator-manager-rolebinding' for ServiceAccount '%s/%s': %w", namespace, serviceAccountName, err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -549,7 +550,7 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string return fmt.Errorf("failed to marshal Deployment '%s/%s': %w", namespace, deploymentName, err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -562,7 +563,7 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string func (d *Deployer) applyBundleServiceResources(ctx context.Context, bundleDir, namespace string) error { serviceFile := filepath.Join(bundleDir, "rhacs-operator-controller-manager-metrics-service_v1_service.yaml") if _, err := os.Stat(serviceFile); err == nil { - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-n", namespace, "-f", serviceFile}, IgnoreErrors: true, }) @@ -570,7 +571,7 @@ func (d *Deployer) applyBundleServiceResources(ctx context.Context, bundleDir, n clusterRoleFile := filepath.Join(bundleDir, "rhacs-operator-metrics-reader_rbac.authorization.k8s.io_v1_clusterrole.yaml") if _, err := os.Stat(clusterRoleFile); err == nil { - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", clusterRoleFile}, IgnoreErrors: true, }) @@ -585,7 +586,7 @@ func (d *Deployer) waitForOperatorReady(ctx context.Context, namespace, deployme start := time.Now() for time.Since(start) < time.Duration(timeout)*time.Second { - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployment", deploymentName, "-n", namespace, "-o", "jsonpath={.status.readyReplicas}"}, }) if err == nil && result.Stdout != "" { @@ -612,7 +613,7 @@ func (d *Deployer) teardownOperatorNonOLM(ctx context.Context) error { d.logger.Info("🧹 Tearing down operator deployed without OLM...") // Delete operator namespace. - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", "namespace", operatorNamespace, "--wait=false"}, IgnoreErrors: true, }) @@ -626,7 +627,7 @@ func (d *Deployer) teardownOperatorNonOLM(ctx context.Context) error { {name: "rhacs-operator-manager-role", kind: "clusterrole"}, } for _, resource := range clusterResources { - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", resource.kind, resource.name, "--ignore-not-found=true"}, IgnoreErrors: true, }) diff --git a/internal/deployer/operator_olm.go b/internal/deployer/operator_olm.go index 94b33f04..52a6a681 100644 --- a/internal/deployer/operator_olm.go +++ b/internal/deployer/operator_olm.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/stackrox/roxie/internal/k8s" "gopkg.in/yaml.v3" ) @@ -85,7 +86,7 @@ func (d *Deployer) checkOLMInstalled(ctx context.Context) error { // TODO(#91): actually this is not the right way to check whether it's safe to create a resource of a given kind. // A CRD can be present, but still being loaded or end up not accepted by the API server. // Instead we should use the `kubectl api-resources` subcommand which exposes the status we're looking for. - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "crd", crd}, }) if err != nil { @@ -140,7 +141,7 @@ func (d *Deployer) createCatalogSource(ctx context.Context, indexImage string) e return fmt.Errorf("failed to marshal CatalogSource: %w", err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -154,7 +155,7 @@ func (d *Deployer) createCatalogSource(ctx context.Context, indexImage string) e // catalogSourceSupportsSecurityContextConfig checks if the CatalogSource CRD supports securityContextConfig. func (d *Deployer) catalogSourceSupportsSecurityContextConfig(ctx context.Context) (bool, error) { - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "crd", "catalogsources.operators.coreos.com", "-o", "yaml"}, }) if err != nil { @@ -184,7 +185,7 @@ func (d *Deployer) createOperatorGroup(ctx context.Context) error { return fmt.Errorf("failed to marshal OperatorGroup: %w", err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -224,7 +225,7 @@ func (d *Deployer) createSubscription(ctx context.Context) error { return fmt.Errorf("failed to marshal Subscription: %w", err) } - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"apply", "-f", "-"}, Stdin: bytes.NewReader(yamlData), }) @@ -245,7 +246,7 @@ func (d *Deployer) waitForAndApproveInstallPlan(ctx context.Context) error { timeout := 5 * time.Minute for time.Since(start) < timeout { - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "subscription", subscriptionName, "-n", operatorNamespace, "-o", "jsonpath={.status.conditions[?(@.type=='InstallPlanPending')].status}"}, }) if err == nil && strings.TrimSpace(result.Stdout) == "True" { @@ -263,7 +264,7 @@ func (d *Deployer) waitForAndApproveInstallPlan(ctx context.Context) error { // Sanity check:Verify currentCSV matches expected version. expectedCSV := fmt.Sprintf("rhacs-operator.v%s", d.operatorTag) - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "subscription", subscriptionName, "-n", operatorNamespace, "-o", "jsonpath={.status.currentCSV}"}, }) if err != nil { @@ -276,7 +277,7 @@ func (d *Deployer) waitForAndApproveInstallPlan(ctx context.Context) error { } // Get InstallPlan name. - result, err = d.runKubectl(ctx, KubectlOptions{ + result, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "subscription", subscriptionName, "-n", operatorNamespace, "-o", "jsonpath={.status.installPlanRef.name}"}, }) if err != nil { @@ -291,7 +292,7 @@ func (d *Deployer) waitForAndApproveInstallPlan(ctx context.Context) error { d.logger.Infof("Approving InstallPlan: %s", installPlanName) // Approve the InstallPlan. - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"patch", "installplan", installPlanName, "-n", operatorNamespace, "--type", "merge", "-p", `{"spec":{"approved":true}}`}, }) if err != nil { @@ -311,7 +312,7 @@ func (d *Deployer) waitForCSVSuccess(ctx context.Context) error { timeout := 10 * time.Minute for time.Since(start) < timeout { - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "csv", csvName, "-n", operatorNamespace, "-o", "jsonpath={.status.phase}"}, }) if err == nil { @@ -336,7 +337,7 @@ func (d *Deployer) waitForCSVSuccess(ctx context.Context) error { // Returns (operatorExists bool, isOLM OperatorDeploymentMode) func (d *Deployer) detectOperatorDeploymentMode(ctx context.Context) (bool, OperatorDeploymentMode) { // First, check if a Subscription exists (OLM-specific resource) - _, err := d.runKubectl(ctx, KubectlOptions{ + _, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "subscription", subscriptionName, "-n", operatorNamespace}, }) if err == nil { @@ -344,12 +345,12 @@ func (d *Deployer) detectOperatorDeploymentMode(ctx context.Context) (bool, Oper } // If no subscription, check if operator deployment exists. - _, err = d.runKubectl(ctx, KubectlOptions{ + _, err = d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployment", operatorDeploymentName, "-n", operatorNamespace}, }) if err == nil { // Deployment exists - check if it has OLM owner labels. - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "deployment", operatorDeploymentName, "-n", operatorNamespace, "-o", "jsonpath={.metadata.labels}"}, }) // TODO(#91): This is not very robust. Better retrieve a specific label in the `get` @@ -370,20 +371,20 @@ func (d *Deployer) teardownOperatorOLM(ctx context.Context) error { d.logger.Info("🧹 Tearing down operator deployed via OLM...") // Delete Subscription (this typically cascades CSV and operands depending on OLM behavior). - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", "subscription", subscriptionName, "-n", operatorNamespace, "--ignore-not-found=true"}, IgnoreErrors: true, }) // Find the CSV name (may match operatorTag, but query to be safe). - result, err := d.runKubectl(ctx, KubectlOptions{ + result, err := d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"get", "csv", "-n", operatorNamespace, "-o", "jsonpath={.items[*].metadata.name}"}, }) if err == nil { // Best-effort delete all matching CSVs for rhacs-operator. for _, name := range strings.Fields(strings.TrimSpace(result.Stdout)) { if strings.HasPrefix(name, "rhacs-operator.v") { - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", "csv", name, "-n", operatorNamespace, "--ignore-not-found=true"}, IgnoreErrors: true, }) @@ -392,17 +393,17 @@ func (d *Deployer) teardownOperatorOLM(ctx context.Context) error { } // Delete CatalogSource and OperatorGroup. - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", "catalogsource", catalogSourceName, "-n", operatorNamespace, "--ignore-not-found=true"}, IgnoreErrors: true, }) - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", "operatorgroup", operatorGroupName, "-n", operatorNamespace, "--ignore-not-found=true"}, IgnoreErrors: true, }) // Delete operator deployment namespace (contains deployment, SA, etc.). - d.runKubectl(ctx, KubectlOptions{ + d.runKubectl(ctx, k8s.KubectlOptions{ Args: []string{"delete", "namespace", operatorNamespace, "--ignore-not-found=true", "--wait=false"}, IgnoreErrors: true, }) diff --git a/internal/k8s/context.go b/internal/k8s/context.go new file mode 100644 index 00000000..832b7247 --- /dev/null +++ b/internal/k8s/context.go @@ -0,0 +1,18 @@ +package k8s + +import ( + "os" +) + +// detectKubectl returns the kubectl command to use. +func detectKubectl() string { + if kubectl := os.Getenv("ORCH_CMD"); kubectl != "" { + return kubectl + } + return "kubectl" +} + +// GetKubectl returns the kubectl command to use. +func GetKubectl() string { + return kubectl +} diff --git a/internal/k8s/init.go b/internal/k8s/init.go new file mode 100644 index 00000000..1ac2803d --- /dev/null +++ b/internal/k8s/init.go @@ -0,0 +1,9 @@ +package k8s + +var ( + kubectl string +) + +func init() { + kubectl = detectKubectl() +} diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go new file mode 100644 index 00000000..67e8f9c4 --- /dev/null +++ b/internal/k8s/kubectl.go @@ -0,0 +1,129 @@ +package k8s + +import ( + "bytes" + "context" + "fmt" + "io" + "os/exec" + "strings" + "time" + + "github.com/stackrox/roxie/internal/logger" +) + +// KubectlOptions contains options for running kubectl commands +type KubectlOptions struct { + Args []string // Command arguments (e.g., ["get", "pod", "my-pod"]) + Stdin io.Reader // Optional stdin for commands like "apply -f -" + MaxAttempts int // Maximum number of retry attempts (default: 3) + RetryDelay int // Base retry delay in seconds (default: 2) + IgnoreErrors bool // If true, return nil on errors (useful for cleanup operations) +} + +// KubectlResult contains the result of a kubectl command execution +type KubectlResult struct { + Stdout string + Stderr string +} + +// RunKubectl executes a kubectl command with automatic retries on transient errors +func RunKubectl(ctx context.Context, log *logger.Logger, opts KubectlOptions) (*KubectlResult, error) { + if opts.MaxAttempts <= 0 { + opts.MaxAttempts = 3 + } + if opts.RetryDelay <= 0 { + opts.RetryDelay = 2 + } + + // List of transient/retryable errors for kubectl + retryableErrors := []string{ + "connection refused", + "connection reset", + "timed out", + "timeout", + "temporary failure", + "network is unreachable", + "no route to host", + "tls handshake timeout", + "eof", + "broken pipe", + "context deadline exceeded", + "unable to connect to the server", + "server is currently unable to handle the request", + "the server is currently unable to handle the request", + "etcdserver: request timed out", + "etcdserver: leader changed", + "transport is closing", + } + + var lastStderr string + var lastErr error + + for attempt := 1; attempt <= opts.MaxAttempts; attempt++ { + if attempt > 1 { + waitTime := time.Duration(attempt*opts.RetryDelay) * time.Second + if log != nil { + log.Infof("Retrying kubectl command (attempt %d/%d) after %v...", attempt, opts.MaxAttempts, waitTime) + } + time.Sleep(waitTime) + } + + cmd := exec.CommandContext(ctx, kubectl, opts.Args...) + + if opts.Stdin != nil { + // For retry attempts, we need to reset the reader if it's a bytes.Reader + if reader, ok := opts.Stdin.(*bytes.Reader); ok { + reader.Seek(0, io.SeekStart) + } + cmd.Stdin = opts.Stdin + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + lastStderr = stderr.String() + lastErr = err + + if err == nil { + return &KubectlResult{ + Stdout: stdout.String(), + Stderr: lastStderr, + }, nil + } + + if opts.IgnoreErrors { + return &KubectlResult{ + Stdout: stdout.String(), + Stderr: lastStderr, + }, nil + } + + isRetryable := false + stderrLower := strings.ToLower(lastStderr) + for _, retryErr := range retryableErrors { + if strings.Contains(stderrLower, strings.ToLower(retryErr)) { + isRetryable = true + break + } + } + + if !isRetryable || attempt == opts.MaxAttempts { + return &KubectlResult{ + Stdout: stdout.String(), + Stderr: lastStderr, + }, fmt.Errorf("kubectl command failed: %w", err) + } + + if log != nil { + log.Warningf("Transient error in kubectl command: %s", lastStderr) + } + } + + return &KubectlResult{ + Stdout: "", + Stderr: lastStderr, + }, fmt.Errorf("kubectl command failed after %d attempts: %w", opts.MaxAttempts, lastErr) +} diff --git a/internal/k8s/resource.go b/internal/k8s/resource.go new file mode 100644 index 00000000..eb54b521 --- /dev/null +++ b/internal/k8s/resource.go @@ -0,0 +1,82 @@ +package k8s + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/stackrox/roxie/internal/logger" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var ( + // ErrResourceNotFound is returned when a resource is not found in the cluster. + ErrResourceNotFound = errors.New("resource not found") +) + +// RetrieveResourceFromCluster retrieves a Kubernetes resource from the cluster and returns it as an unstructured.Unstructured. +// +// Parameters: +// - ctx: The context for the kubectl command +// - log: Logger for diagnostic output (can be nil for silent operation) +// - namespace: The namespace where the resource is located (use "" for cluster-scoped resources) +// - resourceType: The resource type (e.g., "pod", "secret", "pvc") +// - resourceName: The name of the resource +// +// Returns: +// - *unstructured.Unstructured: The resource as an unstructured object containing all metadata +// - error: nil if successful, error otherwise (including ErrResourceNotFound for not found resources) +func RetrieveResourceFromCluster(ctx context.Context, log *logger.Logger, namespace, resourceType, resourceName string) (*unstructured.Unstructured, error) { + // We use --ignore-not-found=true for more reliable distinction between "not found" and other errors. + args := []string{"get", resourceType, resourceName, "-o", "json", "--ignore-not-found=true"} + if namespace != "" { + args = append([]string{"-n", namespace}, args...) + } + + result, err := RunKubectl(ctx, log, KubectlOptions{ + Args: args, + }) + + // When --ignore-not-found=true is used, kubectl returns success (err == nil) with empty output if not found, + // which seems more reliable than parsing error messages. + if err == nil && len(strings.TrimSpace(result.Stdout)) == 0 { + return nil, ErrResourceNotFound + } + if err != nil { + if log != nil { + log.Warningf("Failed to retrieve %s/%s from namespace %s: %v", resourceType, resourceName, namespace, err) + } + return nil, fmt.Errorf("kubectl get failed: %w", err) + } + + obj := &unstructured.Unstructured{} + if err := json.Unmarshal([]byte(result.Stdout), obj); err != nil { + if log != nil { + log.Warningf("Failed to unmarshal %s/%s: %v", resourceType, resourceName, err) + } + return nil, fmt.Errorf("failed to unmarshal resource JSON: %w", err) + } + + return obj, nil +} + +// IsResourceNotFound checks if an error is a resource not found error. +func IsResourceNotFound(err error) bool { + return err == ErrResourceNotFound +} + +// 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. +func ResourceNotOwnedByName(obj *unstructured.Unstructured, ownerName string) bool { + ownerRefs := obj.GetOwnerReferences() + + for _, ownerRef := range ownerRefs { + if ownerRef.Name == ownerName { + return false + } + } + return true +} diff --git a/internal/k8s/resource_integration_test.go b/internal/k8s/resource_integration_test.go new file mode 100644 index 00000000..13cc7259 --- /dev/null +++ b/internal/k8s/resource_integration_test.go @@ -0,0 +1,30 @@ +//go:build integration + +package k8s + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRetrieveResourceFromCluster_NotFound(t *testing.T) { + ctx := context.Background() + namespace := "default" + resourceType := "pod" + resourceName := "i-do-not-exist-0987654321" + + obj, err := RetrieveResourceFromCluster(ctx, nil, namespace, resourceType, resourceName) + assert.Nil(t, obj, "Expected no object to be returned when resource is not found") + assert.ErrorIs(t, err, ErrResourceNotFound, "Expected ErrResourceNotFound when resource is not found") +} + +func TestRetrieveResourceFromCluster_Found(t *testing.T) { + ctx := context.Background() + + obj, err := RetrieveResourceFromCluster(ctx, nil, "", "namespace", "default") + assert.NotNil(t, obj, "Expected an object to be returned when resource is found") + assert.Nil(t, err, "Expected no error to be returned when resource is found") + assert.Equal(t, "default", obj.GetName(), "Expected resource name to match") +} diff --git a/internal/k8s/resource_test.go b/internal/k8s/resource_test.go new file mode 100644 index 00000000..a44cfb75 --- /dev/null +++ b/internal/k8s/resource_test.go @@ -0,0 +1,98 @@ +package k8s + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestIsResourceNotFound(t *testing.T) { + // Test that IsResourceNotFound correctly identifies the error + if !IsResourceNotFound(ErrResourceNotFound) { + t.Errorf("Expected IsResourceNotFound to return true for ErrResourceNotFound") + } + + // Test with a different error + otherErr := context.Canceled + if IsResourceNotFound(otherErr) { + t.Errorf("Expected IsResourceNotFound to return false for a different error") + } +} + +func TestResourceNotOwnedByName(t *testing.T) { + tests := []struct { + name string + ownerReferences []metav1.OwnerReference + ownerName string + expectedResult bool + }{ + { + name: "no owner references - not owned", + ownerReferences: []metav1.OwnerReference{}, + ownerName: "expected-owner", + expectedResult: true, + }, + { + name: "nil owner references - not owned", + ownerReferences: nil, + ownerName: "expected-owner", + expectedResult: true, + }, + { + name: "single owner reference - match", + ownerReferences: []metav1.OwnerReference{ + {Name: "expected-owner", Kind: "Central"}, + }, + ownerName: "expected-owner", + expectedResult: false, + }, + { + name: "single owner reference - no match", + ownerReferences: []metav1.OwnerReference{ + {Name: "different-owner", Kind: "Central"}, + }, + ownerName: "expected-owner", + expectedResult: true, + }, + { + name: "multiple owner references", + ownerReferences: []metav1.OwnerReference{ + {Name: "expected-owner", Kind: "Central"}, + {Name: "other-owner", Kind: "SecuredCluster"}, + }, + ownerName: "expected-owner", + expectedResult: false, + }, + { + name: "multiple owner references - none match", + ownerReferences: []metav1.OwnerReference{ + {Name: "owner-1", Kind: "Central"}, + {Name: "owner-2", Kind: "SecuredCluster"}, + }, + ownerName: "expected-owner", + expectedResult: true, + }, + { + name: "exact match with similar names", + ownerReferences: []metav1.OwnerReference{ + {Name: "expected-owner-suffix", Kind: "Central"}, + {Name: "prefix-expected-owner", Kind: "Central"}, + }, + ownerName: "expected-owner", + expectedResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := &unstructured.Unstructured{} + obj.SetOwnerReferences(tt.ownerReferences) + + result := ResourceNotOwnedByName(obj, tt.ownerName) + assert.Equal(t, tt.expectedResult, result, "ResourceNotOwnedByName() = %v, want %v", result, tt.expectedResult) + }) + } +}