diff --git a/cmd/deploy.go b/cmd/deploy.go index a0a9a5e..8a30676 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 { diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 59e3ae5..99e2765 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -5,6 +5,7 @@ import ( "context" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -654,12 +655,95 @@ 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") } +// 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) { + log.Infof("⏳ Waiting %s for Route %s to be admitted...", waitTime, routeName) + + start := time.Now() + for time.Since(start) < waitTime { + 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 { + 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 be admitted") +} + // 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 +884,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/deployer/route_status_test.go b/internal/deployer/route_status_test.go new file mode 100644 index 0000000..57d17fb --- /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") + } +} diff --git a/internal/types/exposure.go b/internal/types/exposure.go index d35bbd5..749b1c2 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{}{