From db4b0de2c7420876296af993717df41116e72ede Mon Sep 17 00:00:00 2001 From: davdhacs <105243888+davdhacs@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:54:52 -0700 Subject: [PATCH 1/5] support route exposure for Central endpoint --- internal/deployer/deploy_via_operator.go | 34 ++++++++++++++++++++++++ internal/types/exposure.go | 8 ++++++ 2 files changed, 42 insertions(+) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 59e3ae5c..0eddc3b1 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -660,6 +660,34 @@ func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceNa return "", errors.New("timeout waiting for LoadBalancer to get external address") } +// waitForRoute waits for an OpenShift Route to get its hostname. +// Returns the endpoint as "host:port", with no https:// prefix. +func (d *Deployer) waitForRoute(ctx context.Context, namespace, routeName string, waitTime time.Duration) (string, error) { + log.Infof("⏳ Waiting %s for Route %s to get its hostname...", waitTime, routeName) + + start := time.Now() + for time.Since(start) < waitTime { + result, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"get", "route", routeName, "-n", namespace, "-o", "jsonpath={.spec.host}"}, + }) + if err == nil { + hostname := strings.TrimSpace(result.Stdout) + if hostname != "" { + if env.RunningInteractively { + log.Successf("✓ Route hostname: %s", hostname) + } else { + log.Success("✓ Route hostname") + } + return fmt.Sprintf("%s:443", hostname), nil + } + } + + time.Sleep(5 * time.Second) + } + + return "", errors.New("timeout waiting for Route to get its hostname") +} + // fetchCentralCACerts fetches CA certificates needed to verify Central's TLS cert. // It always fetches the internal StackRox CA from the central-tls secret, and // additionally fetches CA certs from the defaultTLSSecret if one is configured @@ -800,6 +828,12 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context) error { return fmt.Errorf("failed to get LoadBalancer endpoint: %w", err) } d.centralEndpoint = endpoint + } else if exposure == types.ExposureRoute { + endpoint, err := d.waitForRoute(ctx, d.config.Central.Namespace, "central", loadBalancerWaitTime) + if err != nil { + return fmt.Errorf("failed to get Route endpoint: %w", err) + } + d.centralEndpoint = endpoint } else { d.centralEndpoint = "central." + d.config.Central.Namespace + ".svc:443" } diff --git a/internal/types/exposure.go b/internal/types/exposure.go index d35bbd53..749b1c27 100644 --- a/internal/types/exposure.go +++ b/internal/types/exposure.go @@ -9,12 +9,14 @@ type Exposure int const ( ExposureNone Exposure = iota ExposureLoadBalancer + ExposureRoute ) var ( exposureNames = map[Exposure]string{ ExposureNone: "none", ExposureLoadBalancer: "loadbalancer", + ExposureRoute: "route", } exposureValues = func() map[string]Exposure { @@ -66,6 +68,12 @@ func (e *Exposure) ToUnstructuredConfig() map[string]interface{} { "port": 443, }, } + case ExposureRoute: + return map[string]interface{}{ + "route": map[string]interface{}{ + "enabled": true, + }, + } case ExposureNone: return map[string]interface{}{ "nodePort": map[string]interface{}{ From ebdddafb937baba1ed622a4eb876f302d4af3a59 Mon Sep 17 00:00:00 2001 From: davdhacs <105243888+davdhacs@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:52:07 -0700 Subject: [PATCH 2/5] wait for route admission and honor cancellation --- internal/deployer/deploy_via_operator.go | 35 +++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 0eddc3b1..623a8876 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -654,16 +654,20 @@ func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceNa } } - time.Sleep(5 * time.Second) + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return "", fmt.Errorf("cancelled waiting for LoadBalancer to get external address: %w", ctx.Err()) + } } return "", errors.New("timeout waiting for LoadBalancer to get external address") } -// waitForRoute waits for an OpenShift Route to get its hostname. +// waitForRoute waits for an OpenShift Route to be admitted by the router. // Returns the endpoint as "host:port", with no https:// prefix. func (d *Deployer) waitForRoute(ctx context.Context, namespace, routeName string, waitTime time.Duration) (string, error) { - log.Infof("⏳ Waiting %s for Route %s to get its hostname...", waitTime, routeName) + log.Infof("⏳ Waiting %s for Route %s to be admitted...", waitTime, routeName) start := time.Now() for time.Since(start) < waitTime { @@ -672,20 +676,31 @@ func (d *Deployer) waitForRoute(ctx context.Context, namespace, routeName string }) if err == nil { hostname := strings.TrimSpace(result.Stdout) + // The host is assigned on admission, but a host conflict can leave + // the Route unadmitted with a host set; require Admitted=True. if hostname != "" { - if env.RunningInteractively { - log.Successf("✓ Route hostname: %s", hostname) - } else { - log.Success("✓ Route hostname") + admitted, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"get", "route", routeName, "-n", namespace, "-o", `jsonpath={.status.conditions[?(@.type=="Admitted")].status}`}, + }) + if err == nil && strings.TrimSpace(admitted.Stdout) == "True" { + if env.RunningInteractively { + log.Successf("✓ Route admitted with hostname: %s", hostname) + } else { + log.Success("✓ Route admitted") + } + return fmt.Sprintf("%s:443", hostname), nil } - return fmt.Sprintf("%s:443", hostname), nil } } - time.Sleep(5 * time.Second) + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return "", fmt.Errorf("cancelled waiting for Route to be admitted: %w", ctx.Err()) + } } - return "", errors.New("timeout waiting for Route to get its hostname") + return "", errors.New("timeout waiting for Route to be admitted") } // fetchCentralCACerts fetches CA certificates needed to verify Central's TLS cert. From fdf906400fe54f3a8d8de478538071df95eb70bd Mon Sep 17 00:00:00 2001 From: davdhacs <105243888+davdhacs@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:00:19 -0700 Subject: [PATCH 3/5] read admission from route ingress status --- internal/deployer/deploy_via_operator.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 623a8876..22ac4e6c 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "os" + "slices" "strings" "time" @@ -679,10 +680,14 @@ func (d *Deployer) waitForRoute(ctx context.Context, namespace, routeName string // The host is assigned on admission, but a host conflict can leave // the Route unadmitted with a host set; require Admitted=True. if hostname != "" { + // Admission conditions live on the per-router status.ingress + // entries; a host conflict can leave the Route unadmitted with + // a host set, so require an admitted ingress for the host. admitted, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "route", routeName, "-n", namespace, "-o", `jsonpath={.status.conditions[?(@.type=="Admitted")].status}`}, + Args: []string{"get", "route", routeName, "-n", namespace, "-o", fmt.Sprintf(`jsonpath={.status.ingress[?(@.host=="%s")].conditions[?(@.type=="Admitted")].status}`, hostname)}, }) - if err == nil && strings.TrimSpace(admitted.Stdout) == "True" { + admittedStatuses := strings.Fields(admitted.Stdout) + if err == nil && slices.Contains(admittedStatuses, "True") { if env.RunningInteractively { log.Successf("✓ Route admitted with hostname: %s", hostname) } else { From b65ff3eda64c3599fd234632ce020994363f48ae Mon Sep 17 00:00:00 2001 From: davdhacs <105243888+davdhacs@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:18:51 -0700 Subject: [PATCH 4/5] list route in the exposure flag help --- cmd/deploy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index 4089b128..32826f25 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -105,7 +105,7 @@ this flag can be used to tell roxie how to pre-load images for the current clust }), ) - registerFlag(cmd, settings, "exposure", "Central exposure backend (loadbalancer, none)", + registerFlag(cmd, settings, "exposure", "Central exposure backend (loadbalancer, route, none)", withApplyFn("exposure", func(config *deployer.Config, val string) error { var exposure types.Exposure if err := yaml.Unmarshal([]byte(val), &exposure); err != nil { From c4d2fb9c33e0b8a1aed1c2c553cee73ed8f10a66 Mon Sep 17 00:00:00 2001 From: davdhacs <105243888+davdhacs@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:34:49 -0700 Subject: [PATCH 5/5] fetch route once and flatten the admission wait --- internal/deployer/deploy_via_operator.go | 84 +++++++++++++++++------- internal/deployer/route_status_test.go | 34 ++++++++++ 2 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 internal/deployer/route_status_test.go diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 22ac4e6c..99e27650 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -5,11 +5,11 @@ import ( "context" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "errors" "fmt" "os" - "slices" "strings" "time" @@ -665,6 +665,58 @@ func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceNa return "", errors.New("timeout waiting for LoadBalancer to get external address") } +// routeStatus is the subset of the OpenShift Route object waitForRoute needs. +type routeStatus struct { + Spec struct { + Host string `json:"host"` + } `json:"spec"` + Status struct { + Ingress []struct { + Host string `json:"host"` + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } `json:"conditions"` + } `json:"ingress"` + } `json:"status"` +} + +// fetchRouteStatus reads the Route's host and admission state from a single +// object, so both reflect the same state of the Route. Admission conditions +// live on the per-router status.ingress entries; a host conflict can leave the +// Route unadmitted with a host set, so admission is only reported when an +// ingress entry for the host carries Admitted=True. +func fetchRouteStatus(ctx context.Context, namespace, routeName string) (host string, admitted bool, err error) { + result, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ + Args: []string{"get", "route", routeName, "-n", namespace, "-o", "json"}, + }) + if err != nil { + return "", false, err + } + var route routeStatus + if err := json.Unmarshal([]byte(result.Stdout), &route); err != nil { + return "", false, err + } + host, admitted = routeAdmittedFor(route) + return host, admitted, nil +} + +// routeAdmittedFor reports the Route's host and whether an ingress entry for +// that host carries an Admitted=True condition. +func routeAdmittedFor(route routeStatus) (string, bool) { + for _, ingress := range route.Status.Ingress { + if ingress.Host != route.Spec.Host { + continue + } + for _, condition := range ingress.Conditions { + if condition.Type == "Admitted" && condition.Status == "True" { + return route.Spec.Host, true + } + } + } + return route.Spec.Host, false +} + // waitForRoute waits for an OpenShift Route to be admitted by the router. // Returns the endpoint as "host:port", with no https:// prefix. func (d *Deployer) waitForRoute(ctx context.Context, namespace, routeName string, waitTime time.Duration) (string, error) { @@ -672,30 +724,14 @@ func (d *Deployer) waitForRoute(ctx context.Context, namespace, routeName string start := time.Now() for time.Since(start) < waitTime { - result, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "route", routeName, "-n", namespace, "-o", "jsonpath={.spec.host}"}, - }) - if err == nil { - hostname := strings.TrimSpace(result.Stdout) - // The host is assigned on admission, but a host conflict can leave - // the Route unadmitted with a host set; require Admitted=True. - if hostname != "" { - // Admission conditions live on the per-router status.ingress - // entries; a host conflict can leave the Route unadmitted with - // a host set, so require an admitted ingress for the host. - admitted, err := k8s.RunKubectl(ctx, k8s.KubectlOptions{ - Args: []string{"get", "route", routeName, "-n", namespace, "-o", fmt.Sprintf(`jsonpath={.status.ingress[?(@.host=="%s")].conditions[?(@.type=="Admitted")].status}`, hostname)}, - }) - admittedStatuses := strings.Fields(admitted.Stdout) - if err == nil && slices.Contains(admittedStatuses, "True") { - if env.RunningInteractively { - log.Successf("✓ Route admitted with hostname: %s", hostname) - } else { - log.Success("✓ Route admitted") - } - return fmt.Sprintf("%s:443", hostname), nil - } + hostname, admitted, err := fetchRouteStatus(ctx, namespace, routeName) + if err == nil && hostname != "" && admitted { + if env.RunningInteractively { + log.Successf("✓ Route admitted with hostname: %s", hostname) + } else { + log.Success("✓ Route admitted") } + return fmt.Sprintf("%s:443", hostname), nil } select { diff --git a/internal/deployer/route_status_test.go b/internal/deployer/route_status_test.go new file mode 100644 index 00000000..57d17fbf --- /dev/null +++ b/internal/deployer/route_status_test.go @@ -0,0 +1,34 @@ +package deployer + +import ( + "encoding/json" + "testing" +) + +func TestRouteStatusParse(t *testing.T) { + raw := `{"spec":{"host":"central-acs-central.apps-crc.testing"}, + "status":{"ingress":[{"host":"central-acs-central.apps-crc.testing", + "conditions":[{"type":"Admitted","status":"True","lastTransitionTime":"x"}]}]}}` + var r routeStatus + if err := json.Unmarshal([]byte(raw), &r); err != nil { + t.Fatal(err) + } + if r.Spec.Host != "central-acs-central.apps-crc.testing" { + t.Fatalf("host: %q", r.Spec.Host) + } + host, admitted := routeAdmittedFor(r) + if !admitted || host != r.Spec.Host { + t.Fatalf("admitted=%v host=%q", admitted, host) + } + // unadmitted / host-mismatch variants + raw2 := `{"spec":{"host":"h1"},"status":{"ingress":[{"host":"h2","conditions":[{"type":"Admitted","status":"True"}]}]}}` + json.Unmarshal([]byte(raw2), &r) + if _, admitted := routeAdmittedFor(r); admitted { + t.Fatal("host mismatch must not be admitted") + } + raw3 := `{"spec":{"host":"h1"},"status":{"ingress":[{"host":"h1","conditions":[{"type":"Admitted","status":"False"}]}]}}` + json.Unmarshal([]byte(raw3), &r) + if _, admitted := routeAdmittedFor(r); admitted { + t.Fatal("Admitted=False must not be admitted") + } +}