Skip to content

Commit 94d603e

Browse files
mclasmeierMoritz Clasmeierclaude
authored
Support detached port forwarding (#169)
Co-authored-by: Moritz Clasmeier <mclasmeier@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d619f4e commit 94d603e

8 files changed

Lines changed: 186 additions & 26 deletions

File tree

cmd/deploy.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,6 @@ func runDeploy(cmd *cobra.Command, args []string) error {
7676
return errors.New("running without a controlling terminal requires --envrc to be set")
7777
}
7878

79-
if envrc != "" && portForwarding {
80-
return errors.New("cannot use --envrc with --port-forwarding. The --envrc flag is for non-interactive mode with remote cluster access")
81-
}
82-
83-
if envrc != "" && exposure == "none" {
84-
return errors.New("cannot use --envrc with --exposure=none. The --envrc flag requires a remotely accessible endpoint (e.g., --exposure=loadbalancer)")
85-
}
86-
8779
portForwardEnabledFinal := portForwarding || exposure == "none"
8880

8981
if env.RunningInRoxieContainer {

internal/deployer/deploy_via_operator.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -607,11 +607,20 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context, exposure string
607607
}
608608
}
609609

610-
endpoint, err := d.portForward.Start(d.centralNamespace, serviceName, 443, 8443)
611-
if err != nil {
612-
return fmt.Errorf("failed to start port-forward: %w", err)
610+
if d.envrcFile != "" {
611+
endpoint, pid, err := d.portForward.StartDetached(d.centralNamespace, serviceName, 443, 8443)
612+
if err != nil {
613+
return fmt.Errorf("failed to start detached port-forward: %w", err)
614+
}
615+
d.centralEndpoint = endpoint
616+
d.portForwardPID = pid
617+
} else {
618+
endpoint, err := d.portForward.Start(d.centralNamespace, serviceName, 443, 8443)
619+
if err != nil {
620+
return fmt.Errorf("failed to start port-forward: %w", err)
621+
}
622+
d.centralEndpoint = endpoint
613623
}
614-
d.centralEndpoint = endpoint
615624
} else if exposure == "loadbalancer" {
616625
endpoint, err := d.waitForLoadBalancer(ctx, d.centralNamespace, "central-loadbalancer", 300)
617626
if err != nil {

internal/deployer/deployer.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"fmt"
88
"os"
99
"os/exec"
10+
"strconv"
1011
"strings"
1112
"sync"
13+
"syscall"
1214
"time"
1315

1416
"github.com/fatih/color"
@@ -64,6 +66,7 @@ type Deployer struct {
6466
securedClusterOverrides map[string]interface{}
6567
featureFlagOverrides map[string]interface{}
6668
envrcFile string
69+
portForwardPID int
6770
useOLM bool
6871
useKonflux bool
6972
shouldDeployOperator bool
@@ -434,6 +437,12 @@ func New(log *logger.Logger) (*Deployer, error) {
434437
d.roxCACertFile = caCert
435438
}
436439

440+
if pidStr := os.Getenv("ROXIE_PORT_FORWARD_PID"); pidStr != "" {
441+
if pid, err := strconv.Atoi(pidStr); err == nil {
442+
d.portForwardPID = pid
443+
}
444+
}
445+
437446
d.kubeContext = env.GetCurrentContext()
438447

439448
clusterResourceKinds, err := d.getClusterResourceKinds()
@@ -480,6 +489,22 @@ func (d *Deployer) Cleanup() {
480489
}
481490
}
482491

492+
func (d *Deployer) stopDetachedPortForward() {
493+
if d.portForwardPID == 0 {
494+
return
495+
}
496+
proc, err := os.FindProcess(d.portForwardPID)
497+
if err != nil {
498+
return
499+
}
500+
if err := proc.Signal(syscall.SIGKILL); err != nil {
501+
d.logger.Dimf("Detached port-forward (pid %d) already gone", d.portForwardPID)
502+
return
503+
}
504+
d.logger.Dimf("Stopped detached port-forward (pid %d)", d.portForwardPID)
505+
d.portForwardPID = 0
506+
}
507+
483508
// Deploy deploys the specified components to the cluster.
484509
func (d *Deployer) Deploy(ctx context.Context, components component.Component, resources, exposure string) error {
485510
adjustedResources, adjustedExposure, adjustedPortForward := d.clusterDefaults.ApplyConvenienceDefaults(
@@ -559,7 +584,6 @@ func (d *Deployer) deployCentral(ctx context.Context, resources, exposure string
559584
return err
560585
}
561586

562-
// envrc may be used from different processes, so use actual endpoint not port-forward
563587
if d.envrcFile != "" {
564588
d.logger.Dimf("Writing environment variables to %s", d.envrcFile)
565589
if err := d.writeEnvrcFile(ctx, exposure, portForwardWanted); err != nil {
@@ -636,6 +660,7 @@ func (d *Deployer) teardownCentral(ctx context.Context) error {
636660
}
637661

638662
d.portForward.Stop()
663+
d.stopDetachedPortForward()
639664

640665
// Add pause-reconcile annotation to not have the operator interfere during resource deletion.
641666
if d.doesResourceExist(ctx, "central", "stackrox-central-services", d.centralNamespace) {
@@ -998,6 +1023,9 @@ func (d *Deployer) writeEnvrcFile(ctx context.Context, exposure string, portForw
9981023
fmt.Fprintf(&content, "export ROX_USERNAME=%q\n", AdminUsername)
9991024
fmt.Fprintf(&content, "export ROX_ADMIN_PASSWORD=%q\n", d.centralPassword)
10001025
fmt.Fprintf(&content, "export ROX_CA_CERT_FILE=%q\n", d.roxCACertFile)
1026+
if d.portForwardPID != 0 {
1027+
fmt.Fprintf(&content, "export ROXIE_PORT_FORWARD_PID=%d\n", d.portForwardPID)
1028+
}
10011029

10021030
if err := os.WriteFile(d.envrcFile, []byte(content.String()), 0600); err != nil {
10031031
return fmt.Errorf("failed to write envrc file: %w", err)

internal/portforward/portforward.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,51 @@ func (m *Manager) Start(namespace, serviceName string, remotePort, preferredLoca
120120
return endpoint, nil
121121
}
122122

123+
// StartDetached starts port-forward as a detached process that survives the
124+
// parent process exiting. Returns the endpoint and the PID of the subprocess.
125+
// The caller is responsible for killing the process when done.
126+
func (m *Manager) StartDetached(namespace, serviceName string, remotePort, preferredLocalPort int) (string, int, error) {
127+
localPort, err := m.findFreeLocalPort(preferredLocalPort)
128+
if err != nil {
129+
return "", 0, fmt.Errorf("failed to find free port: %w", err)
130+
}
131+
132+
cmd := exec.Command(
133+
m.kubectl,
134+
"-n", namespace,
135+
"port-forward",
136+
fmt.Sprintf("svc/%s", serviceName),
137+
fmt.Sprintf("%d:%d", localPort, remotePort),
138+
"--address", "127.0.0.1",
139+
)
140+
141+
cmd.SysProcAttr = &syscall.SysProcAttr{
142+
Setsid: true,
143+
}
144+
145+
cmd.Stdout = nil
146+
cmd.Stderr = nil
147+
148+
if err := cmd.Start(); err != nil {
149+
return "", 0, fmt.Errorf("failed to start port-forward: %w", err)
150+
}
151+
152+
pid := cmd.Process.Pid
153+
154+
// Release the process so it won't be waited on by this process.
155+
cmd.Process.Release()
156+
157+
if !m.waitTCPReady("127.0.0.1", localPort, 20.0) {
158+
syscall.Kill(pid, syscall.SIGTERM)
159+
return "", 0, fmt.Errorf("port-forward did not become ready")
160+
}
161+
162+
endpoint := fmt.Sprintf("127.0.0.1:%d", localPort)
163+
m.logger.Successf("✓ Detached port-forward active at https://%s (pid %d)", endpoint, pid)
164+
165+
return endpoint, pid, nil
166+
}
167+
123168
// Stop stops the active port-forward if running
124169
func (m *Manager) Stop() {
125170
if m.proc == nil || m.proc.Process == nil {

tests/e2e/basic_test.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@
44
package e2e
55

66
import (
7+
"net"
78
"os"
9+
"strconv"
10+
"strings"
11+
"syscall"
812
"testing"
913
"time"
14+
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
1017
)
1118

1219
// TestDeployBothSimple tests deploying both components together (simplest scenario)
@@ -22,7 +29,7 @@ func TestDeployBothSimple(t *testing.T) {
2229
envrcFile.Close()
2330

2431
t.Log("=== Deploying both components together ===")
25-
args := append([]string{roxieBinary, "deploy", "--early-readiness", "both", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
32+
args := append([]string{roxieBinary, "deploy", "--early-readiness", "both", "--envrc", envrcPath}, commonDeployArgs...)
2633
runCommand(t, deployTimeout*2, nil, args...)
2734

2835
// Verify namespaces exist and have managed-by labels
@@ -46,3 +53,56 @@ func TestDeployBothSimple(t *testing.T) {
4653
verifyCentralNotInstalled(t, "acs-central")
4754
verifySecuredClusterNotInstalled(t, "acs-sensor")
4855
}
56+
57+
// TestDetachedPortForwarding tests the detached port-forwarding mode for central.
58+
func TestDetachedPortForwarding(t *testing.T) {
59+
dumpClusterStateOnFailure(t)
60+
61+
envrcFile, err := os.CreateTemp(t.TempDir(), ".envrc.roxie-test-*")
62+
if err != nil {
63+
t.Fatalf("Failed to create temp envrc: %v", err)
64+
}
65+
envrcPath := envrcFile.Name()
66+
envrcFile.Close()
67+
68+
t.Log("=== Deploying central without exposure and with port-forwarding and envrc ===")
69+
args := append([]string{roxieBinary, "deploy", "--early-readiness", "central", "--exposure=none", "--port-forwarding", "--envrc", envrcPath}, commonDeployArgs...)
70+
runCommand(t, deployTimeout, nil, args...)
71+
72+
env, err := loadEnvrcFile(envrcPath)
73+
require.NoError(t, err, "Failed to load envrc file")
74+
pidStr, ok := env["ROXIE_PORT_FORWARD_PID"]
75+
require.True(t, ok, "ROXIE_PORT_FORWARD_PID not set in envrc")
76+
pid, err := strconv.Atoi(pidStr)
77+
require.NoError(t, err, "ROXIE_PORT_FORWARD_PID is not a valid integer: %s", pidStr)
78+
t.Logf("Port-forward PID: %d", pid)
79+
80+
require.NoError(t, syscall.Kill(pid, 0), "Port-forward process (PID %d) does not exist", pid)
81+
82+
endpoint, ok := env["API_ENDPOINT"]
83+
require.True(t, ok, "API_ENDPOINT not set in envrc")
84+
require.True(t, strings.HasPrefix(endpoint, "127.0.0.1:"),
85+
"Expected localhost endpoint, got: %s", endpoint)
86+
87+
caCertFile, ok := env["ROX_CA_CERT_FILE"]
88+
require.True(t, ok, "ROX_CA_CERT_FILE not set in envrc")
89+
90+
testCentralAPI(t, endpoint, caCertFile)
91+
92+
t.Log("=== Cleaning up ===")
93+
teardownArgs := []string{roxieBinary, "teardown", "central"}
94+
runCommand(t, teardownTimeout, env, teardownArgs...)
95+
96+
// Verify port-forward cleanup by checking the port is free. We can't use
97+
// kill(pid, 0) because CI containers often lack a proper init to reap
98+
// zombies, causing the check to pass for dead processes. Binding to the
99+
// port works because even zombies release their file descriptors.
100+
assert.Eventually(t, func() bool {
101+
ln, err := net.Listen("tcp", endpoint)
102+
if err != nil {
103+
return false
104+
}
105+
ln.Close()
106+
return true
107+
}, 10*time.Second, 200*time.Millisecond, "Port-forward port %s should be free after teardown", endpoint)
108+
}

tests/e2e/e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func TestDeployBothComponentsTogetherInSingleNamespace(t *testing.T) {
5050
envrcFile.Close()
5151

5252
t.Log("=== Deploying both components in single namespace ===")
53-
args := append([]string{roxieBinary, "deploy", "both", "--single-namespace", "--early-readiness", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
53+
args := append([]string{roxieBinary, "deploy", "both", "--single-namespace", "--early-readiness", "--envrc", envrcPath}, commonDeployArgs...)
5454
runCommand(t, deployTimeout*2, nil, args...)
5555

5656
verifyCentralInstalled(t, "stackrox")

tests/e2e/helpers.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ package e2e
55
import (
66
"bytes"
77
"context"
8+
"crypto/tls"
9+
"crypto/x509"
810
"encoding/json"
911
"fmt"
12+
"net/http"
1013
"os"
1114
"os/exec"
1215
"strings"
@@ -26,8 +29,7 @@ const (
2629
)
2730

2831
var (
29-
commonDeployArgs = []string{"--port-forwarding", "--exposure=none", "--resources=small"}
30-
commonDeployArgsNoPortForward = []string{"--exposure=loadbalancer", "--resources=small"}
32+
commonDeployArgs = []string{"--resources=small"}
3133

3234
roxieBinary = "roxie"
3335
)
@@ -348,6 +350,30 @@ func dumpLogsForFailingPods(namespace string) {
348350
}
349351
}
350352

353+
func testCentralAPI(t *testing.T, endpoint, caCertFile string) {
354+
t.Helper()
355+
356+
caCert, err := os.ReadFile(caCertFile)
357+
require.NoError(t, err, "Failed to read CA cert file: %s", caCertFile)
358+
certPool := x509.NewCertPool()
359+
require.True(t, certPool.AppendCertsFromPEM(caCert), "Failed to parse CA certificate")
360+
361+
client := &http.Client{
362+
Timeout: 10 * time.Second,
363+
Transport: &http.Transport{
364+
TLSClientConfig: &tls.Config{
365+
RootCAs: certPool,
366+
ServerName: "central.stackrox.svc",
367+
},
368+
},
369+
}
370+
resp, err := client.Get(fmt.Sprintf("https://%s/v1/ping", endpoint))
371+
require.NoError(t, err, "Failed to reach Central via %s", endpoint)
372+
defer resp.Body.Close()
373+
assert.Equal(t, http.StatusOK, resp.StatusCode, "Central API returned unexpected status")
374+
t.Logf("Central at %s responded with status: %d", endpoint, resp.StatusCode)
375+
}
376+
351377
func verifyAnnotation(t *testing.T, resourceType, resourceName, namespace, annotationKey, expectedValue string) {
352378
t.Helper()
353379

tests/e2e/olm_switch_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func TestOLMToNonOLMSwitch(t *testing.T) {
8181

8282
// Step 1: Deploy central with OLM operator
8383
t.Log("=== Step 1: Deploy central with OLM operator ===")
84-
args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
84+
args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...)
8585
runCommand(t, deployTimeout, nil, args...)
8686

8787
// Verify operator is in OLM mode
@@ -94,7 +94,7 @@ func TestOLMToNonOLMSwitch(t *testing.T) {
9494

9595
// Step 2: Deploy central again without OLM (should switch modes)
9696
t.Log("=== Step 2: Redeploy central without OLM (triggering mode switch) ===")
97-
args = append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
97+
args = append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgs...)
9898
runCommand(t, deployTimeout, nil, args...)
9999

100100
// Verify operator switched to non-OLM mode
@@ -131,7 +131,7 @@ func TestNonOLMToOLMSwitch(t *testing.T) {
131131

132132
// Step 1: Deploy central without OLM (non-OLM operator)
133133
t.Log("=== Step 1: Deploy central with non-OLM operator ===")
134-
args := append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
134+
args := append([]string{roxieBinary, "deploy", "central", "--envrc", envrcPath}, commonDeployArgs...)
135135
runCommand(t, deployTimeout, nil, args...)
136136

137137
// Verify operator is in non-OLM mode
@@ -144,7 +144,7 @@ func TestNonOLMToOLMSwitch(t *testing.T) {
144144

145145
// Step 2: Deploy central again with OLM (should switch modes)
146146
t.Log("=== Step 2: Redeploy central with OLM (triggering mode switch) ===")
147-
args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
147+
args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...)
148148
runCommand(t, deployTimeout, nil, args...)
149149

150150
// Verify operator switched to OLM mode
@@ -185,7 +185,7 @@ func TestOLMOperatorVersionUpgrade(t *testing.T) {
185185

186186
// Step 1: Deploy central with OLM operator
187187
t.Log("=== Step 1: Deploy central with OLM operator ===")
188-
args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
188+
args := append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...)
189189
runCommand(t, deployTimeout, nil, args...)
190190

191191
// Verify operator is in OLM mode
@@ -207,7 +207,7 @@ func TestOLMOperatorVersionUpgrade(t *testing.T) {
207207

208208
// Step 2: Redeploy with same version (should skip if version matches)
209209
t.Log("=== Step 2: Redeploy with same version (should detect correct version) ===")
210-
args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
210+
args = append([]string{roxieBinary, "deploy", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...)
211211
runCommand(t, deployTimeout, nil, args...)
212212

213213
// Verify operator is still in OLM mode and deployment exists
@@ -245,7 +245,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) {
245245

246246
// Step 1: Deploy central with OLM
247247
t.Log("=== Step 1: Deploy central with OLM ===")
248-
args := append([]string{roxieBinary, "deploy", "--early-readiness", "central", "--olm", "--envrc", envrcPath}, commonDeployArgsNoPortForward...)
248+
args := append([]string{roxieBinary, "deploy", "--early-readiness", "central", "--olm", "--envrc", envrcPath}, commonDeployArgs...)
249249
runCommand(t, deployTimeout, nil, args...)
250250

251251
verifyOperatorMode(t, true)
@@ -259,7 +259,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) {
259259

260260
// Step 2: Deploy secured-cluster (should reuse OLM operator)
261261
t.Log("=== Step 2: Deploy secured-cluster (should reuse OLM operator) ===")
262-
args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster", "--olm"}, commonDeployArgsNoPortForward...)
262+
args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster", "--olm"}, commonDeployArgs...)
263263
runCommand(t, deployTimeout, envrcEnv, args...)
264264

265265
// Verify operator is still in OLM mode
@@ -268,7 +268,7 @@ func TestSecuredClusterWithOLMSwitch(t *testing.T) {
268268

269269
// Step 3: Switch to non-OLM by redeploying secured-cluster without --olm
270270
t.Log("=== Step 3: Redeploy secured-cluster without OLM (triggering mode switch) ===")
271-
args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster"}, commonDeployArgsNoPortForward...)
271+
args = append([]string{roxieBinary, "deploy", "--early-readiness", "secured-cluster"}, commonDeployArgs...)
272272
runCommand(t, deployTimeout, envrcEnv, args...)
273273

274274
// Verify operator switched to non-OLM mode

0 commit comments

Comments
 (0)