diff --git a/test/e2e/README.md b/test/e2e/README.md index f5ee56682d..4bb2677a0a 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -19,6 +19,9 @@ non-technical stakeholders. ## Project Structure +Component step packages are introduced incrementally. The layout below shows +their intended ownership after all component extractions land. + ``` test/ ├── e2e/ @@ -33,8 +36,12 @@ test/ └── internal/ ├── e2e/ │ ├── runner.go # Godog options, scenario selection, summary wiring - │ ├── steps/ # All existing step definitions and hooks + │ ├── steps/ # Shared world, hooks, catalog, resource, RBAC, and condition helpers │ │ └── testdata/ # RBAC and resource templates + │ ├── operator-controller/steps/ # ClusterExtension lifecycle and integration assertions + │ ├── catalogd/steps/ # HA/proxy/TLS steps and restoration hooks + │ ├── object-controller/steps/ # ClusterObjectSet/Deployment assertions + │ ├── platform/steps/ # Demo steps and recording │ └── support-bundle.yaml # Shared diagnostics └── summary/ # Summary generation and embedded templates ``` @@ -43,7 +50,7 @@ test/ ### 1. Test Runner (`operator-controller/suite_test.go`) -The component entry point registers the full shared step set and hooks, then calls +The component entry point registers shared steps, hooks, and its component steps, then calls `e2e.Run` from `test/internal/e2e` with its feature directory. The shared runner configures Godog options, scenario selection, and summary generation. @@ -53,10 +60,22 @@ controller unit tests live under `internal/testutil`, which is accessible throug the repository. Features follow the ownership in the [per-component suite design](../../docs/designs/e2e-per-component.md). -Each component has a thin entry point that still registers the full shared step set; -step ownership is separated in the next stage. The newer `cos_*`, `cod_*`, and -`group` features belong to object-controller. Feature contents and step behavior -are unchanged, including ClusterExtension integration assertions in object features. +The newer `cos_*`, `cod_*`, and `group` features belong to object-controller. +Suites register additional component steps when existing features contain integration +assertions: operator-controller uses object assertions, object-controller retains +ClusterExtension integration coverage, and catalogd proxy tests use ClusterExtension +steps. Platform registers ClusterExtension steps for demos. Feature contents, +scenario names, tags, and assertions are unchanged. + +`test/internal/e2e/steps` owns `World` and the `Scenario(ctx)` accessor, generic resource and +condition helpers, catalog/registry prerequisites, RBAC templates, and cleanup. +`ListClusterObjectSetsInGroup` and `ResolveObjectRef` remain shared because +both ClusterExtension and ClusterObjectSet assertions consume them. +Catalogd owns deployment restoration and proxy state. Platform owns recording; +shared helpers observe commands through an optional `CommandRecorder` interface. +Pass extra lifecycle registration to `sharedsteps.RegisterHooks` to keep suite hooks +between shared scenario setup and cleanup. The upgrade helpers remain shared because +catalogd HA scenarios also consume component readiness and catalog reconciliation steps. ### 2. Feature Files (`operator-controller/features/*.feature`) @@ -112,7 +131,7 @@ Feature: Install ClusterExtension And ClusterExtension is available ``` -### 3. Step Definitions (`test/internal/e2e/steps/steps.go`) +### 3. Step Definitions (`test/internal/e2e//steps/steps.go`) Go functions that implement the steps defined in feature files. Each step is registered with a regex pattern that matches the Gherkin text. @@ -121,9 +140,8 @@ matches the Gherkin text. ```go func RegisterSteps(sc *godog.ScenarioContext) { -sc.Step(`^OLM is available$`, OLMisAvailable) sc.Step(`^bundle "([^"]+)" is installed in version "([^"]+)"$`, BundleInstalled) -sc.Step(`^ClusterExtension is applied$`, ResourceIsApplied) +sc.Step(`^ClusterExtension is applied$`, shared.ResourceIsApplied) // ... more steps } ``` @@ -132,7 +150,7 @@ sc.Step(`^ClusterExtension is applied$`, ResourceIsApplied) Status assertions must read metadata and status from the same API response and require the asserted condition's `observedGeneration` to equal `metadata.generation`. -Use `waitForCondition` for condition checks. Bundle metadata uses the `Installed` +Use `shared.WaitForCondition` for condition checks. Bundle metadata uses the `Installed` condition, and COS phase digests and object counts use the `Ready` condition to verify that the status reflects the current spec. Deployments instead expose `status.observedGeneration` at the resource level. @@ -146,9 +164,9 @@ That step accepts any current-generation `Progressing` status or reason, whereas ```go func BundleInstalled(ctx context.Context, name, version string) error { - sc := scenarioCtx(ctx) - waitFor(ctx, func() bool { - v, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "json") + sc := shared.Scenario(ctx) + shared.WaitFor(ctx, func() bool { + v, err := shared.K8sClient(ctx, "get", "clusterextension", sc.ClusterExtensionName, "-o", "json") if err != nil { return false } @@ -156,8 +174,8 @@ func BundleInstalled(ctx context.Context, name, version string) error { if err := json.Unmarshal([]byte(v), &ext); err != nil { return false } - installed := currentCondition(ext.Generation, ext.Status.Conditions, ocv1.TypeInstalled) - return conditionMatches(installed, metav1.ConditionTrue, nil, nil) && + installed := shared.CurrentCondition(ext.Generation, ext.Status.Conditions, ocv1.TypeInstalled) + return shared.ConditionMatches(installed, metav1.ConditionTrue, nil, nil) && ext.Status.Install.Bundle.Name == name && ext.Status.Install.Bundle.Version == version }) return nil @@ -199,7 +217,7 @@ Feature: Your Feature Name ### 2. Implement Step Definitions -Add step implementations in `test/internal/e2e/steps/steps.go`: +Add component assertions in `test/internal/e2e//steps/`; add reusable prerequisites and helpers in `test/internal/e2e/steps/`: ```go func RegisterSteps(sc *godog.ScenarioContext) { @@ -208,7 +226,7 @@ func RegisterSteps(sc *godog.ScenarioContext) { } func YourStepFunction(ctx context.Context, param string) error { - sc := scenarioCtx(ctx) + sc := shared.Scenario(ctx) // Implementation return nil } @@ -230,7 +248,7 @@ Use these variables in YAML templates: - `${NAME}`: Scenario-specific ClusterExtension name (e.g., `ce-123`) - `${COS_NAME}`: Scenario-specific ClusterObjectSet name (e.g., `cos-123`; for applying ClusterObjectSets directly) - `${TEST_NAMESPACE}`: Scenario-specific namespace (e.g., `ns-123`) -- `${SCENARIO_ID}`: Unique scenario identifier used for resource name isolation +- `${SCENARIO_ID}`: Feature-scoped scenario identifier used for resource isolation across concurrent Go suites - `${PACKAGE:}`: Parameterized package name (e.g., `${PACKAGE:test}` expands to `test-`) - `${CATALOG:}`: Catalog resource name (e.g., `${CATALOG:test}` expands to `test-catalog-`) @@ -427,8 +445,12 @@ go test -count=1 ./test/e2e/operator-controller/... -args --log.debug --k8s.cli= Each scenario runs in its own namespace with unique resource names, ensuring complete isolation: -- Namespace: `ns-{scenario-id}` -- ClusterExtension: `ce-{scenario-id}` +- Namespace: `ns-{feature-hash}-{godog-id}` +- ClusterExtension: `ce-{feature-hash}-{godog-id}` + +Godog restarts numeric IDs in each process. The feature hash prevents collisions +between concurrent component suites and stays stable when a feature moves. Feature +and scenario annotations retain their existing values. ### 2. Test-Identifying Annotations @@ -476,7 +498,7 @@ When ClusterExtension is applied All asynchronous operations use `waitFor` with consistent timeout (300s) and tick (1s): ```go -waitFor(ctx, func() bool { +shared.WaitFor(ctx, func() bool { // Check condition return conditionMet }) diff --git a/test/e2e/features_test.go b/test/e2e/features_test.go index 4be0828b24..539cd36df0 100644 --- a/test/e2e/features_test.go +++ b/test/e2e/features_test.go @@ -42,6 +42,6 @@ func TestMain(m *testing.M) { }, func(sc *godog.ScenarioContext) { steps.RegisterSteps(sc) steps.RegisterDemoSteps(sc) - steps.RegisterHooks(sc) + steps.RegisterHooks(sc, steps.RegisterAsciiCastHooks) })) } diff --git a/test/e2e/platform/suite_test.go b/test/e2e/platform/suite_test.go index 721ee038df..2f38fde827 100644 --- a/test/e2e/platform/suite_test.go +++ b/test/e2e/platform/suite_test.go @@ -16,6 +16,6 @@ func TestMain(m *testing.M) { }, func(sc *godog.ScenarioContext) { steps.RegisterSteps(sc) steps.RegisterDemoSteps(sc) - steps.RegisterHooks(sc) + steps.RegisterHooks(sc, steps.RegisterAsciiCastHooks) })) } diff --git a/test/internal/e2e/steps/demo_steps.go b/test/internal/e2e/steps/demo_steps.go index eebe4f7850..088e43fb86 100644 --- a/test/internal/e2e/steps/demo_steps.go +++ b/test/internal/e2e/steps/demo_steps.go @@ -104,7 +104,7 @@ func ensureCatalogPortForward(ctx context.Context) (string, error) { port = 8443 } - addr, cleanup, err := portForward(ctx, ns, target, port) + addr, cleanup, err := PortForward(ctx, ns, target, port) if err != nil { return "", fmt.Errorf("failed to start catalog port-forward to %s: %w", target, err) } diff --git a/test/internal/e2e/steps/hooks.go b/test/internal/e2e/steps/hooks.go index af56f0cee4..14e88b8c67 100644 --- a/test/internal/e2e/steps/hooks.go +++ b/test/internal/e2e/steps/hooks.go @@ -112,10 +112,13 @@ func init() { flagSet.BoolVar(&devMode, "log.debug", false, "print debug log level") } -func RegisterHooks(sc *godog.ScenarioContext) { +// RegisterHooks installs shared lifecycle hooks, with optional suite hooks between setup and cleanup. +func RegisterHooks(sc *godog.ScenarioContext, hooks ...func(*godog.ScenarioContext)) { sc.Before(CheckFeatureTags) sc.Before(CreateScenarioContext) - RegisterAsciiCastHooks(sc) + for _, register := range hooks { + register(sc) + } sc.After(ScenarioCleanup) } diff --git a/test/internal/e2e/steps/portforward.go b/test/internal/e2e/steps/portforward.go new file mode 100644 index 0000000000..2db6ad4bd4 --- /dev/null +++ b/test/internal/e2e/steps/portforward.go @@ -0,0 +1,91 @@ +package steps + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +func randomAvailablePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +// PortForward starts a kubectl port-forward to target (e.g. "service/foo" or "pod/bar") +// in the given namespace, mapping a random local port to remotePort. It returns the +// local address and a cleanup function. The caller is responsible for calling cleanup. +func PortForward(ctx context.Context, ns, target string, remotePort int32) (string, func(), error) { + localPort, err := randomAvailablePort() + if err != nil { + return "", nil, fmt.Errorf("failed to find a free local port: %w", err) + } + + pfCmd := exec.Command(K8sCLI, "port-forward", "-n", ns, //nolint:gosec + target, fmt.Sprintf("%d:%d", localPort, remotePort)) + pfCmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", KubeconfigPath)) + if err := pfCmd.Start(); err != nil { + return "", nil, fmt.Errorf("failed to start port-forward to %s: %w", target, err) + } + + cleanup := func() { + if p := pfCmd.Process; p != nil { + _ = p.Kill() + _ = pfCmd.Wait() + } + } + + if rec := CommandRecorderFromContext(ctx); rec != nil { + rec.RecordCustom( + fmt.Sprintf("%s port-forward -n %s %s %d:%d &", K8sCLI, ns, target, localPort, remotePort), + "", "", + ) + } + + addr := fmt.Sprintf("127.0.0.1:%d", localPort) + return addr, cleanup, nil +} + +// GetMetricsService returns the full Service object for the named component's metrics service. +// The namespace is available via svc.Namespace. +func GetMetricsService(component string) (*corev1.Service, error) { + serviceName := fmt.Sprintf("%s-service", component) + serviceNs, err := K8sClient(context.Background(), "get", "service", "-A", "-o", + fmt.Sprintf(`jsonpath={.items[?(@.metadata.name=="%s")].metadata.namespace}`, serviceName)) + if err != nil { + return nil, fmt.Errorf("failed to find namespace for service %s: %w", serviceName, err) + } + serviceNs = strings.TrimSpace(serviceNs) + if serviceNs == "" { + return nil, fmt.Errorf("service %s not found in any namespace", serviceName) + } + + raw, err := K8sClient(context.Background(), "get", "service", "-n", serviceNs, serviceName, "-o", "json") + if err != nil { + return nil, fmt.Errorf("failed to get service %s: %w", serviceName, err) + } + var svc corev1.Service + if err := json.Unmarshal([]byte(raw), &svc); err != nil { + return nil, fmt.Errorf("failed to unmarshal service %s: %w", serviceName, err) + } + return &svc, nil +} + +// MetricsPort returns the port number of the port named "metrics" on the given service. +func MetricsPort(svc *corev1.Service) (int32, error) { + for _, p := range svc.Spec.Ports { + if p.Name == "metrics" { + return p.Port, nil + } + } + return 0, fmt.Errorf("no port named 'metrics' found on service %s", svc.Name) +} diff --git a/test/internal/e2e/steps/steps.go b/test/internal/e2e/steps/steps.go index f29d21f754..d635f10d58 100644 --- a/test/internal/e2e/steps/steps.go +++ b/test/internal/e2e/steps/steps.go @@ -1067,7 +1067,7 @@ func ClusterObjectDeploymentConsistentlyReportsCondition(ctx context.Context, na if err != nil { return fmt.Errorf("invalid duration %q: %w", durationStr, err) } - return consistentlyCondition(ctx, "clusterobjectdeployment", SubstituteScenarioVars(name, Scenario(ctx)), conditionType, metav1.ConditionStatus(conditionStatus), conditionReason, d) + return ConsistentlyCondition(ctx, "clusterobjectdeployment", SubstituteScenarioVars(name, Scenario(ctx)), conditionType, metav1.ConditionStatus(conditionStatus), conditionReason, d) } // consistently invokes check every tick until the expected state has held for the full @@ -1142,11 +1142,11 @@ func conditionCheck(resourceType, resourceName, conditionType string, conditionS } } -// consistentlyCondition fails the test if, at any point during duration, the named +// ConsistentlyCondition fails the test if, at any point during duration, the named // resource's condition deviates from the expected type/status/reason or disappears. // Transient API read failures are tolerated only briefly; see consistently and // conditionCheck for the exact semantics. -func consistentlyCondition(ctx context.Context, resourceType, resourceName, conditionType string, conditionStatus metav1.ConditionStatus, conditionReason string, duration time.Duration) error { +func ConsistentlyCondition(ctx context.Context, resourceType, resourceName, conditionType string, conditionStatus metav1.ConditionStatus, conditionReason string, duration time.Duration) error { return consistently(ctx, duration, conditionCheck(resourceType, resourceName, conditionType, conditionStatus, conditionReason)) } @@ -1447,7 +1447,7 @@ func ClusterObjectSetReferredSecretsContainLabels(ctx context.Context, revisionN sc := Scenario(ctx) revisionName = SubstituteScenarioVars(strings.TrimSpace(revisionName), sc) - expected, err := parseKeyValueTable(table, sc) + expected, err := ParseKeyValueTable(table, sc) if err != nil { return fmt.Errorf("invalid labels table: %w", err) } @@ -1458,7 +1458,7 @@ func ClusterObjectSetReferredSecretsContainLabels(ctx context.Context, revisionN return false } for _, s := range secrets { - if _, _, ok := matchLabels(s.Labels, expected); !ok { + if _, _, ok := MatchLabels(s.Labels, expected); !ok { return false } } @@ -1954,11 +1954,11 @@ func httpGet(url string, token string) (*http.Response, error) { // to return a successful response. Stores the response body per pod in the scenario context. Polls with timeout. func SendMetricsRequest(ctx context.Context, serviceAccount string, endpoint string, controllerName string) error { sc := Scenario(ctx) - svc, err := getMetricsService(controllerName) + svc, err := GetMetricsService(controllerName) if err != nil { return err } - mPort, err := metricsPort(svc) + mPort, err := MetricsPort(svc) if err != nil { return err } @@ -1984,7 +1984,7 @@ func SendMetricsRequest(ctx context.Context, serviceAccount string, endpoint str sc.MetricsResponse = make(map[string]string) for _, p := range pods { if err := func() error { - addr, cleanup, err := portForward(ctx, p.Namespace, fmt.Sprintf("pod/%s", p.Name), mPort) + addr, cleanup, err := PortForward(ctx, p.Namespace, fmt.Sprintf("pod/%s", p.Name), mPort) if err != nil { return err } @@ -2679,7 +2679,7 @@ func AnnotationsAreAdded(ctx context.Context, resourceName string, table *godog. sc := Scenario(ctx) resourceName = SubstituteScenarioVars(resourceName, sc) - pairs, err := parseKeyValueTable(table, sc) + pairs, err := ParseKeyValueTable(table, sc) if err != nil { return fmt.Errorf("invalid annotations table: %w", err) } @@ -2707,7 +2707,7 @@ func LabelsAreAdded(ctx context.Context, resourceName string, table *godog.Table sc := Scenario(ctx) resourceName = SubstituteScenarioVars(resourceName, sc) - pairs, err := parseKeyValueTable(table, sc) + pairs, err := ParseKeyValueTable(table, sc) if err != nil { return fmt.Errorf("invalid labels table: %w", err) } @@ -2735,7 +2735,7 @@ func ResourceHasAnnotations(ctx context.Context, resourceName string, table *god sc := Scenario(ctx) resourceName = SubstituteScenarioVars(resourceName, sc) - expected, err := parseKeyValueTable(table, sc) + expected, err := ParseKeyValueTable(table, sc) if err != nil { return fmt.Errorf("invalid annotations table: %w", err) } @@ -2766,9 +2766,9 @@ func ResourceHasAnnotations(ctx context.Context, resourceName string, table *god return nil } -// matchLabels checks that actual labels contain all expected key-value pairs. +// MatchLabels checks that actual labels contain all expected key-value pairs. // Returns the first mismatched key, the actual value, and false if a mismatch is found. -func matchLabels(actual, expected map[string]string) (string, string, bool) { +func MatchLabels(actual, expected map[string]string) (string, string, bool) { for k, v := range expected { if a, found := actual[k]; !found || a != v { return k, a, false @@ -2784,7 +2784,7 @@ func ResourceHasLabels(ctx context.Context, resourceName string, table *godog.Ta sc := Scenario(ctx) resourceName = SubstituteScenarioVars(resourceName, sc) - expected, err := parseKeyValueTable(table, sc) + expected, err := ParseKeyValueTable(table, sc) if err != nil { return fmt.Errorf("invalid labels table: %w", err) } @@ -2803,7 +2803,7 @@ func ResourceHasLabels(ctx context.Context, resourceName string, table *godog.Ta if err := json.Unmarshal([]byte(out), &obj); err != nil { return false } - if key, got, ok := matchLabels(obj.GetLabels(), expected); !ok { + if key, got, ok := MatchLabels(obj.GetLabels(), expected); !ok { logger.V(1).Info("Label not yet present or value mismatch", "resource", resourceName, "key", key, "expected", expected[key], "actual", got) return false } @@ -2817,7 +2817,7 @@ func NamespaceHasLabels(ctx context.Context, nsName string, table *godog.Table) sc := Scenario(ctx) nsName = SubstituteScenarioVars(nsName, sc) - expected, err := parseKeyValueTable(table, sc) + expected, err := ParseKeyValueTable(table, sc) if err != nil { return fmt.Errorf("invalid labels table: %w", err) } @@ -2831,7 +2831,7 @@ func NamespaceHasLabels(ctx context.Context, nsName string, table *godog.Table) if err := json.Unmarshal([]byte(out), &obj); err != nil { return false } - if key, got, ok := matchLabels(obj.GetLabels(), expected); !ok { + if key, got, ok := MatchLabels(obj.GetLabels(), expected); !ok { logger.V(1).Info("Namespace label not yet present or value mismatch", "namespace", nsName, "key", key, "expected", expected[key], "actual", got) return false } @@ -2909,9 +2909,9 @@ func NamespaceHasNoOwnerReferences(ctx context.Context, nsName string) error { return nil } -// nestedString traverses a nested map[string]interface{} by the given keys +// NestedString traverses a nested map[string]interface{} by the given keys // and returns the leaf value as a string. -func nestedString(obj map[string]interface{}, keys ...string) (string, bool) { +func NestedString(obj map[string]interface{}, keys ...string) (string, bool) { if len(keys) == 0 { return "", false } @@ -2927,10 +2927,10 @@ func nestedString(obj map[string]interface{}, keys ...string) (string, bool) { return v, ok } -// parseKeyValueTable extracts key-value pairs from a godog data table. +// ParseKeyValueTable extracts key-value pairs from a godog data table. // The table must have a header row with "key" and "value" columns. // If sc is non-nil, variable substitution is applied to both keys and values. -func parseKeyValueTable(table *godog.Table, sc *World) (map[string]string, error) { +func ParseKeyValueTable(table *godog.Table, sc *World) (map[string]string, error) { if len(table.Rows) < 2 { return nil, fmt.Errorf("table must have a header row and at least one data row") } @@ -3046,7 +3046,7 @@ func TriggerClusterExtensionReconciliation(ctx context.Context) error { return fmt.Errorf("failed to unmarshal ClusterExtension %s JSON: %w", sc.ClusterExtensionName, err) } - currentEnforcement, _ := nestedString(obj, "spec", "install", "preflight", "crdUpgradeSafety", "enforcement") + currentEnforcement, _ := NestedString(obj, "spec", "install", "preflight", "crdUpgradeSafety", "enforcement") newEnforcement := "None" if currentEnforcement == "None" { diff --git a/test/internal/e2e/steps/tls_steps.go b/test/internal/e2e/steps/tls_steps.go index dc0e924604..d5231507a8 100644 --- a/test/internal/e2e/steps/tls_steps.go +++ b/test/internal/e2e/steps/tls_steps.go @@ -6,13 +6,10 @@ import ( "encoding/json" "fmt" "net" - "os" - "os/exec" "strings" "time" appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" ) // tlsCipherSuiteByName is a name→ID map that covers TLS 1.3 cipher suites @@ -56,98 +53,20 @@ var curveIDByName = map[string]tls.CurveID{ "secp521r1": tls.CurveP521, } -// getMetricsService returns the full Service object for the named component's metrics service. -// The namespace is available via svc.Namespace. -func getMetricsService(component string) (*corev1.Service, error) { - serviceName := fmt.Sprintf("%s-service", component) - serviceNs, err := K8sClient(context.Background(), "get", "service", "-A", "-o", - fmt.Sprintf(`jsonpath={.items[?(@.metadata.name=="%s")].metadata.namespace}`, serviceName)) - if err != nil { - return nil, fmt.Errorf("failed to find namespace for service %s: %w", serviceName, err) - } - serviceNs = strings.TrimSpace(serviceNs) - if serviceNs == "" { - return nil, fmt.Errorf("service %s not found in any namespace", serviceName) - } - - raw, err := K8sClient(context.Background(), "get", "service", "-n", serviceNs, serviceName, "-o", "json") - if err != nil { - return nil, fmt.Errorf("failed to get service %s: %w", serviceName, err) - } - var svc corev1.Service - if err := json.Unmarshal([]byte(raw), &svc); err != nil { - return nil, fmt.Errorf("failed to unmarshal service %s: %w", serviceName, err) - } - return &svc, nil -} - -// metricsPort returns the port number of the port named "metrics" on the given service. -func metricsPort(svc *corev1.Service) (int32, error) { - for _, p := range svc.Spec.Ports { - if p.Name == "metrics" { - return p.Port, nil - } - } - return 0, fmt.Errorf("no port named 'metrics' found on service %s", svc.Name) -} - -func randomAvailablePort() (int, error) { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, err - } - defer l.Close() - return l.Addr().(*net.TCPAddr).Port, nil -} - -// portForward starts a kubectl port-forward to target (e.g. "service/foo" or "pod/bar") -// in the given namespace, mapping a random local port to remotePort. It returns the -// local address and a cleanup function. The caller is responsible for calling cleanup. -func portForward(ctx context.Context, ns, target string, remotePort int32) (string, func(), error) { - localPort, err := randomAvailablePort() - if err != nil { - return "", nil, fmt.Errorf("failed to find a free local port: %w", err) - } - - pfCmd := exec.Command(K8sCLI, "port-forward", "-n", ns, //nolint:gosec - target, fmt.Sprintf("%d:%d", localPort, remotePort)) - pfCmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", KubeconfigPath)) - if err := pfCmd.Start(); err != nil { - return "", nil, fmt.Errorf("failed to start port-forward to %s: %w", target, err) - } - - cleanup := func() { - if p := pfCmd.Process; p != nil { - _ = p.Kill() - _ = pfCmd.Wait() - } - } - - if rec := CommandRecorderFromContext(ctx); rec != nil { - rec.RecordCustom( - fmt.Sprintf("%s port-forward -n %s %s %d:%d &", K8sCLI, ns, target, localPort, remotePort), - "", "", - ) - } - - addr := fmt.Sprintf("127.0.0.1:%d", localPort) - return addr, cleanup, nil -} - // withMetricsPortForward starts a kubectl port-forward to the component's metrics service, // waits until a basic TLS connection succeeds (confirming the port-forward is ready), // then calls fn with the local address. The port-forward is torn down when fn returns. func withMetricsPortForward(ctx context.Context, component string, fn func(addr string) error) error { - svc, err := getMetricsService(component) + svc, err := GetMetricsService(component) if err != nil { return err } - port, err := metricsPort(svc) + port, err := MetricsPort(svc) if err != nil { return err } - addr, cleanup, err := portForward(ctx, svc.Namespace, fmt.Sprintf("service/%s", svc.Name), port) + addr, cleanup, err := PortForward(ctx, svc.Namespace, fmt.Sprintf("service/%s", svc.Name), port) if err != nil { return err }