From c68d0740436c72174c07016b4983eb4ec504b095 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 17:35:52 +0200 Subject: [PATCH 1/8] Add detached port-forward support Add StartDetached() to portforward.Manager that spawns kubectl port-forward in a new session (setsid) so it survives the parent process exiting. Returns the endpoint and PID for later cleanup. Co-Authored-By: Claude Opus 4.6 --- internal/portforward/portforward.go | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/portforward/portforward.go b/internal/portforward/portforward.go index 4dcbd0cf..b62f69cb 100644 --- a/internal/portforward/portforward.go +++ b/internal/portforward/portforward.go @@ -120,6 +120,51 @@ func (m *Manager) Start(namespace, serviceName string, remotePort, preferredLoca return endpoint, nil } +// StartDetached starts port-forward as a detached process that survives the +// parent process exiting. Returns the endpoint and the PID of the subprocess. +// The caller is responsible for killing the process when done. +func (m *Manager) StartDetached(namespace, serviceName string, remotePort, preferredLocalPort int) (string, int, error) { + localPort, err := m.findFreeLocalPort(preferredLocalPort) + if err != nil { + return "", 0, fmt.Errorf("failed to find free port: %w", err) + } + + cmd := exec.Command( + m.kubectl, + "-n", namespace, + "port-forward", + fmt.Sprintf("svc/%s", serviceName), + fmt.Sprintf("%d:%d", localPort, remotePort), + "--address", "127.0.0.1", + ) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + return "", 0, fmt.Errorf("failed to start port-forward: %w", err) + } + + pid := cmd.Process.Pid + + // Release the process so it won't be waited on by this process. + cmd.Process.Release() + + if !m.waitTCPReady("127.0.0.1", localPort, 20.0) { + syscall.Kill(pid, syscall.SIGTERM) + return "", 0, fmt.Errorf("port-forward did not become ready") + } + + endpoint := fmt.Sprintf("127.0.0.1:%d", localPort) + m.logger.Successf("✓ Detached port-forward active at https://%s (pid %d)", endpoint, pid) + + return endpoint, pid, nil +} + // Stop stops the active port-forward if running func (m *Manager) Stop() { if m.proc == nil || m.proc.Process == nil { From e01753726ea209f169a2ff617e3da9b94890a343 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 17:36:47 +0200 Subject: [PATCH 2/8] Allow --envrc with port-forwarding and exposure=none Use detached port-forward in envrc mode so the kubectl port-forward process survives roxie exiting. The PID is written to the envrc file as ROXIE_PORT_FORWARD_PID for later cleanup. This removes the restrictions that prevented --envrc from being combined with --port-forwarding or --exposure=none, enabling roxie to work on kind clusters in non-interactive (CI) mode. Co-Authored-By: Claude Opus 4.6 --- cmd/deploy.go | 8 -------- internal/deployer/deploy_via_operator.go | 17 +++++++++++++---- internal/deployer/deployer.go | 5 ++++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index a5a6131a..a324a5cc 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -76,14 +76,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { return errors.New("running without a controlling terminal requires --envrc to be set") } - if envrc != "" && portForwarding { - return errors.New("cannot use --envrc with --port-forwarding. The --envrc flag is for non-interactive mode with remote cluster access") - } - - if envrc != "" && exposure == "none" { - return errors.New("cannot use --envrc with --exposure=none. The --envrc flag requires a remotely accessible endpoint (e.g., --exposure=loadbalancer)") - } - portForwardEnabledFinal := portForwarding || exposure == "none" if env.RunningInRoxieContainer { diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 2bc1ca73..6365a870 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -607,11 +607,20 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context, exposure string } } - endpoint, err := d.portForward.Start(d.centralNamespace, serviceName, 443, 8443) - if err != nil { - return fmt.Errorf("failed to start port-forward: %w", err) + if d.envrcFile != "" { + endpoint, pid, err := d.portForward.StartDetached(d.centralNamespace, serviceName, 443, 8443) + if err != nil { + return fmt.Errorf("failed to start detached port-forward: %w", err) + } + d.centralEndpoint = endpoint + d.portForwardPID = pid + } else { + endpoint, err := d.portForward.Start(d.centralNamespace, serviceName, 443, 8443) + if err != nil { + return fmt.Errorf("failed to start port-forward: %w", err) + } + d.centralEndpoint = endpoint } - d.centralEndpoint = endpoint } else if exposure == "loadbalancer" { endpoint, err := d.waitForLoadBalancer(ctx, d.centralNamespace, "central-loadbalancer", 300) if err != nil { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 69b77c19..2df703e8 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -64,6 +64,7 @@ type Deployer struct { securedClusterOverrides map[string]interface{} featureFlagOverrides map[string]interface{} envrcFile string + portForwardPID int useOLM bool useKonflux bool shouldDeployOperator bool @@ -559,7 +560,6 @@ func (d *Deployer) deployCentral(ctx context.Context, resources, exposure string return err } - // envrc may be used from different processes, so use actual endpoint not port-forward if d.envrcFile != "" { d.logger.Dimf("Writing environment variables to %s", d.envrcFile) if err := d.writeEnvrcFile(ctx, exposure, portForwardWanted); err != nil { @@ -998,6 +998,9 @@ func (d *Deployer) writeEnvrcFile(ctx context.Context, exposure string, portForw fmt.Fprintf(&content, "export ROX_USERNAME=%q\n", AdminUsername) fmt.Fprintf(&content, "export ROX_ADMIN_PASSWORD=%q\n", d.centralPassword) fmt.Fprintf(&content, "export ROX_CA_CERT_FILE=%q\n", d.roxCACertFile) + 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) From 8aa0030a2ee68f7316ff0940c934ecd110df6b54 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 17:37:06 +0200 Subject: [PATCH 3/8] Remove hardcoded exposure from e2e tests Stop passing --exposure=loadbalancer explicitly in e2e tests and rely on roxie's built-in cluster type detection to set the appropriate defaults (e.g. exposure=none + port-forwarding on kind, loadbalancer on GKE). Co-Authored-By: Claude Opus 4.6 --- tests/e2e/basic_test.go | 2 +- tests/e2e/e2e_test.go | 2 +- tests/e2e/helpers.go | 3 +-- tests/e2e/olm_switch_test.go | 18 +++++++++--------- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/e2e/basic_test.go b/tests/e2e/basic_test.go index ac91591f..e4f58ef6 100644 --- a/tests/e2e/basic_test.go +++ b/tests/e2e/basic_test.go @@ -22,7 +22,7 @@ func TestDeployBothSimple(t *testing.T) { envrcFile.Close() t.Log("=== Deploying both components together ===") - args := append([]string{roxieBinary, "deploy", "--early-readiness", "both", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args := append([]string{roxieBinary, "deploy", "--early-readiness", "both", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout*2, nil, args...) // Verify namespaces exist and have managed-by labels diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 425ec7e9..cb42af42 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -50,7 +50,7 @@ func TestDeployBothComponentsTogetherInSingleNamespace(t *testing.T) { envrcFile.Close() t.Log("=== Deploying both components in single namespace ===") - args := append([]string{roxieBinary, "deploy", "both", "--single-namespace", "--early-readiness", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args := append([]string{roxieBinary, "deploy", "both", "--single-namespace", "--early-readiness", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout*2, nil, args...) verifyCentralInstalled(t, "stackrox") diff --git a/tests/e2e/helpers.go b/tests/e2e/helpers.go index 14c20fce..27afb22a 100644 --- a/tests/e2e/helpers.go +++ b/tests/e2e/helpers.go @@ -26,8 +26,7 @@ const ( ) var ( - commonDeployArgs = []string{"--port-forwarding", "--exposure=none", "--resources=small"} - commonDeployArgsNoPortForward = []string{"--exposure=loadbalancer", "--resources=small"} + commonDeployArgs = []string{"--resources=small"} roxieBinary = "roxie" ) diff --git a/tests/e2e/olm_switch_test.go b/tests/e2e/olm_switch_test.go index fe9512b0..0c6fa480 100644 --- a/tests/e2e/olm_switch_test.go +++ b/tests/e2e/olm_switch_test.go @@ -81,7 +81,7 @@ func TestOLMToNonOLMSwitch(t *testing.T) { // Step 1: Deploy central with OLM operator t.Log("=== Step 1: Deploy central with OLM operator ===") - args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator is in OLM mode @@ -94,7 +94,7 @@ func TestOLMToNonOLMSwitch(t *testing.T) { // Step 2: Deploy central again without OLM (should switch modes) t.Log("=== Step 2: Redeploy central without OLM (triggering mode switch) ===") - args = append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args = append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator switched to non-OLM mode @@ -131,7 +131,7 @@ func TestNonOLMToOLMSwitch(t *testing.T) { // Step 1: Deploy central without OLM (non-OLM operator) t.Log("=== Step 1: Deploy central with non-OLM operator ===") - args := append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args := append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator is in non-OLM mode @@ -144,7 +144,7 @@ func TestNonOLMToOLMSwitch(t *testing.T) { // Step 2: Deploy central again with OLM (should switch modes) t.Log("=== Step 2: Redeploy central with OLM (triggering mode switch) ===") - args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator switched to OLM mode @@ -185,7 +185,7 @@ func TestOLMOperatorVersionUpgrade(t *testing.T) { // Step 1: Deploy central with OLM operator t.Log("=== Step 1: Deploy central with OLM operator ===") - args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator is in OLM mode @@ -207,7 +207,7 @@ func TestOLMOperatorVersionUpgrade(t *testing.T) { // Step 2: Redeploy with same version (should skip if version matches) t.Log("=== Step 2: Redeploy with same version (should detect correct version) ===") - args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) // Verify operator is still in OLM mode and deployment exists @@ -245,7 +245,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) { // Step 1: Deploy central with OLM t.Log("=== Step 1: Deploy central with OLM ===") - args := append([]string{roxieBinary, "deploy", "--early-readiness", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...) + args := append([]string{roxieBinary, "deploy", "--early-readiness", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...) runCommand(t, deployTimeout, nil, args...) verifyOperatorMode(t, true) @@ -259,7 +259,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) { // Step 2: Deploy secured-cluster (should reuse OLM operator) t.Log("=== Step 2: Deploy secured-cluster (should reuse OLM operator) ===") - args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster", "--olm"}, commonDeployArgsNoPortForward...) + args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster", "--olm"}, commonDeployArgs...) runCommand(t, deployTimeout, envrcEnv, args...) // Verify operator is still in OLM mode @@ -268,7 +268,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) { // Step 3: Switch to non-OLM by redeploying secured-cluster without --olm t.Log("=== Step 3: Redeploy secured-cluster without OLM (triggering mode switch) ===") - args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster"}, commonDeployArgsNoPortForward...) + args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster"}, commonDeployArgs...) runCommand(t, deployTimeout, envrcEnv, args...) // Verify operator switched to non-OLM mode From ff07a803272163e8ee888628b6dde55a29d9fa8e Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Thu, 7 May 2026 18:19:41 +0200 Subject: [PATCH 4/8] Clean up detached port-forward on teardown Read ROXIE_PORT_FORWARD_PID from the environment on deployer init and kill the process during teardownCentral, so the detached port-forward started in envrc mode doesn't leak. Co-Authored-By: Claude Opus 4.6 --- internal/deployer/deployer.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 2df703e8..574ae029 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -7,7 +7,9 @@ import ( "fmt" "os" "os/exec" + "strconv" "strings" + "syscall" "sync" "time" @@ -435,6 +437,12 @@ func New(log *logger.Logger) (*Deployer, error) { d.roxCACertFile = caCert } + if pidStr := os.Getenv("ROXIE_PORT_FORWARD_PID"); pidStr != "" { + if pid, err := strconv.Atoi(pidStr); err == nil { + d.portForwardPID = pid + } + } + d.kubeContext = env.GetCurrentContext() clusterResourceKinds, err := d.getClusterResourceKinds() @@ -481,6 +489,22 @@ func (d *Deployer) Cleanup() { } } +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 { + d.logger.Dimf("Detached port-forward (pid %d) already gone", d.portForwardPID) + return + } + d.logger.Dimf("Stopped detached port-forward (pid %d)", d.portForwardPID) + d.portForwardPID = 0 +} + // Deploy deploys the specified components to the cluster. func (d *Deployer) Deploy(ctx context.Context, components component.Component, resources, exposure string) error { adjustedResources, adjustedExposure, adjustedPortForward := d.clusterDefaults.ApplyConvenienceDefaults( @@ -636,6 +660,7 @@ func (d *Deployer) teardownCentral(ctx context.Context) error { } d.portForward.Stop() + d.stopDetachedPortForward() // Add pause-reconcile annotation to not have the operator interfere during resource deletion. if d.doesResourceExist(ctx, "central", "stackrox-central-services", d.centralNamespace) { From c360cc338e636361730d29b9f4f017afe6f8a24e Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Fri, 8 May 2026 11:34:49 +0200 Subject: [PATCH 5/8] make fmt --- internal/deployer/deployer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 574ae029..a30372a0 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -9,8 +9,8 @@ import ( "os/exec" "strconv" "strings" - "syscall" "sync" + "syscall" "time" "github.com/fatih/color" From c6afbf2ce4d102b26ad87e0f7fbe25a402aed9f8 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 11 May 2026 20:20:24 +0200 Subject: [PATCH 6/8] Test --- tests/e2e/basic_test.go | 49 +++++++++++++++++++++++++++++++++++++++++ tests/e2e/helpers.go | 27 +++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/tests/e2e/basic_test.go b/tests/e2e/basic_test.go index e4f58ef6..83e24960 100644 --- a/tests/e2e/basic_test.go +++ b/tests/e2e/basic_test.go @@ -5,8 +5,14 @@ package e2e import ( "os" + "strconv" + "strings" + "syscall" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestDeployBothSimple tests deploying both components together (simplest scenario) @@ -46,3 +52,46 @@ func TestDeployBothSimple(t *testing.T) { verifyCentralNotInstalled(t, "acs-central") verifySecuredClusterNotInstalled(t, "acs-sensor") } + +// TestDetachedPortForwarding tests the detached port-forwarding mode for central. +func TestDetachedPortForwarding(t *testing.T) { + dumpClusterStateOnFailure(t) + + envrcFile, err := os.CreateTemp(t.TempDir(), ".envrc.roxie-test-*") + if err != nil { + t.Fatalf("Failed to create temp envrc: %v", err) + } + envrcPath := envrcFile.Name() + envrcFile.Close() + + t.Log("=== Deploying central without exposure and with port-forwarding and envrc ===") + args := append([]string{roxieBinary, "deploy", "--early-readiness", "central", "--exposure=none", "--port-forwarding", "--envrc", envrcPath}, commonDeployArgs...) + runCommand(t, deployTimeout, nil, args...) + + env, err := loadEnvrcFile(envrcPath) + require.NoError(t, err, "Failed to load envrc file") + pidStr, ok := env["ROXIE_PORT_FORWARD_PID"] + require.True(t, ok, "ROXIE_PORT_FORWARD_PID not set in envrc") + pid, err := strconv.Atoi(pidStr) + require.NoError(t, err, "ROXIE_PORT_FORWARD_PID is not a valid integer: %s", pidStr) + t.Logf("Port-forward PID: %d", pid) + + require.NoError(t, syscall.Kill(pid, 0), "Port-forward process (PID %d) does not exist", pid) + + endpoint, ok := env["API_ENDPOINT"] + require.True(t, ok, "API_ENDPOINT not set in envrc") + require.True(t, strings.HasPrefix(endpoint, "127.0.0.1:"), + "Expected localhost endpoint, got: %s", endpoint) + + caCertFile, ok := env["ROX_CA_CERT_FILE"] + require.True(t, ok, "ROX_CA_CERT_FILE not set in envrc") + + testCentralAPI(t, endpoint, caCertFile) + + t.Log("=== Cleaning up ===") + teardownArgs := []string{roxieBinary, "teardown", "central"} + runCommand(t, teardownTimeout, env, teardownArgs...) + + err = syscall.Kill(pid, 0) + assert.Error(t, err, "Port-forward process (PID %d) should not exist after teardown", pid) +} diff --git a/tests/e2e/helpers.go b/tests/e2e/helpers.go index 27afb22a..73b7faf2 100644 --- a/tests/e2e/helpers.go +++ b/tests/e2e/helpers.go @@ -5,8 +5,11 @@ package e2e import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" + "net/http" "os" "os/exec" "strings" @@ -347,6 +350,30 @@ func dumpLogsForFailingPods(namespace string) { } } +func testCentralAPI(t *testing.T, endpoint, caCertFile string) { + t.Helper() + + caCert, err := os.ReadFile(caCertFile) + require.NoError(t, err, "Failed to read CA cert file: %s", caCertFile) + certPool := x509.NewCertPool() + require.True(t, certPool.AppendCertsFromPEM(caCert), "Failed to parse CA certificate") + + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + ServerName: "central.stackrox.svc", + }, + }, + } + resp, err := client.Get(fmt.Sprintf("https://%s/v1/ping", endpoint)) + require.NoError(t, err, "Failed to reach Central via %s", endpoint) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "Central API returned unexpected status") + t.Logf("Central at %s responded with status: %d", endpoint, resp.StatusCode) +} + func verifyAnnotation(t *testing.T, resourceType, resourceName, namespace, annotationKey, expectedValue string) { t.Helper() From 2d9ff6beee7958eadc79e2fc3f677d3eadff382f Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 11 May 2026 21:13:57 +0200 Subject: [PATCH 7/8] Eventually --- tests/e2e/basic_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/basic_test.go b/tests/e2e/basic_test.go index 83e24960..50d8c96a 100644 --- a/tests/e2e/basic_test.go +++ b/tests/e2e/basic_test.go @@ -92,6 +92,7 @@ func TestDetachedPortForwarding(t *testing.T) { teardownArgs := []string{roxieBinary, "teardown", "central"} runCommand(t, teardownTimeout, env, teardownArgs...) - err = syscall.Kill(pid, 0) - assert.Error(t, err, "Port-forward process (PID %d) should not exist after teardown", pid) + assert.Eventually(t, func() bool { + return syscall.Kill(pid, 0) != nil + }, 10*time.Second, 200*time.Millisecond, "Port-forward process (PID %d) should not exist after teardown", pid) } From 8a1a15ffc25dd7f02f8b7176d861957b59b073d8 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 11 May 2026 22:15:54 +0200 Subject: [PATCH 8/8] Another attempt --- tests/e2e/basic_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/e2e/basic_test.go b/tests/e2e/basic_test.go index 50d8c96a..9141c7d5 100644 --- a/tests/e2e/basic_test.go +++ b/tests/e2e/basic_test.go @@ -4,6 +4,7 @@ package e2e import ( + "net" "os" "strconv" "strings" @@ -92,7 +93,16 @@ func TestDetachedPortForwarding(t *testing.T) { teardownArgs := []string{roxieBinary, "teardown", "central"} runCommand(t, teardownTimeout, env, teardownArgs...) + // Verify port-forward cleanup by checking the port is free. We can't use + // kill(pid, 0) because CI containers often lack a proper init to reap + // zombies, causing the check to pass for dead processes. Binding to the + // port works because even zombies release their file descriptors. assert.Eventually(t, func() bool { - return syscall.Kill(pid, 0) != nil - }, 10*time.Second, 200*time.Millisecond, "Port-forward process (PID %d) should not exist after teardown", pid) + ln, err := net.Listen("tcp", endpoint) + if err != nil { + return false + } + ln.Close() + return true + }, 10*time.Second, 200*time.Millisecond, "Port-forward port %s should be free after teardown", endpoint) }