From c778c0824d24f74ce4268e29e38f1819248f2c27 Mon Sep 17 00:00:00 2001 From: Roddie Kieley Date: Wed, 29 Jul 2026 11:43:14 -0230 Subject: [PATCH 1/6] feat: router Prometheus /metrics endpoint (JEP-0013 Phase 2) - Add HTTP `GET /metrics` on the Jumpstarter router (Prometheus/OpenMetrics text) via `--metrics-bind-address` (or equivalent). - Operator wires Router Deployment metrics bind address and container port. - Tests assert `/metrics` returns 200 and parseable exposition; no undocumented `jumpstarter_*` router series invented in this PR. --- controller/Containerfile | 2 +- controller/Makefile | 4 +- controller/cmd/router/main.go | 11 +++ controller/cmd/router/metrics.go | 49 ++++++++++++ controller/cmd/router/metrics_test.go | 77 +++++++++++++++++++ .../jumpstarter/jumpstarter_controller.go | 7 +- .../jumpstarter/router_metrics_bind_test.go | 77 +++++++++++++++++++ 7 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 controller/cmd/router/metrics.go create mode 100644 controller/cmd/router/metrics_test.go create mode 100644 controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go diff --git a/controller/Containerfile b/controller/Containerfile index 53f81eb6c..846e4d340 100644 --- a/controller/Containerfile +++ b/controller/Containerfile @@ -47,7 +47,7 @@ RUN --mount=type=cache,target=/opt/app-root/src/go/pkg/mod,sharing=locked,uid=1 CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ go build -a \ -ldflags "-X main.version=${GIT_VERSION} -X main.gitCommit=${GIT_COMMIT} -X main.buildDate=${BUILD_DATE}" \ - -o router cmd/router/main.go + -o router ./cmd/router FROM registry.access.redhat.com/ubi9/ubi-micro:9.8-1786321990@sha256:7e7f79ab747bf2b452e3043dd89f388e92be4c7fdcc8b815b58adf6c99c39c95 WORKDIR / diff --git a/controller/Makefile b/controller/Makefile index 206f060c7..8cb2490a0 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -121,7 +121,7 @@ build-operator-ci: .PHONY: build build: manifests generate fmt vet ## Build manager binary. go build -ldflags "$(LDFLAGS)" -o bin/manager cmd/main.go - go build -ldflags "$(LDFLAGS)" -o bin/router cmd/router/main.go + go build -ldflags "$(LDFLAGS)" -o bin/router ./cmd/router go build -ldflags "$(LDFLAGS)" -o bin/exporter-set-controller cmd/exporter-set-controller/main.go .PHONY: run @@ -130,7 +130,7 @@ run: manifests generate fmt vet ## Run a controller from your host. .PHONY: run-router run-router: manifests generate fmt vet ## Run a router from your host. - go run ./cmd/router/main.go + go run ./cmd/router # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. diff --git a/controller/cmd/router/main.go b/controller/cmd/router/main.go index f17a9a98f..202b45bbc 100644 --- a/controller/cmd/router/main.go +++ b/controller/cmd/router/main.go @@ -45,6 +45,10 @@ func main() { opts := zap.Options{} opts.BindFlags(flag.CommandLine) + var metricsAddr string + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", + "The address the metric endpoint binds to. Use :8080 to enable. Set to 0 to disable.") + flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)).WithValues("component", "router")) @@ -58,6 +62,13 @@ func main() { "buildDate", buildDate, ) + if listenAddr, err := startMetricsServer(metricsAddr); err != nil { + logger.Error(err, "failed to start metrics server", "bindAddress", metricsAddr) + os.Exit(1) + } else if listenAddr != "" { + logger.Info("Serving metrics server", "bindAddress", listenAddr) + } + cfg := ctrl.GetConfigOrDie() client, err := kclient.New(cfg, kclient.Options{}) if err != nil { diff --git a/controller/cmd/router/metrics.go b/controller/cmd/router/metrics.go new file mode 100644 index 000000000..c8358c52c --- /dev/null +++ b/controller/cmd/router/metrics.go @@ -0,0 +1,49 @@ +/* +Copyright 2026. The Jumpstarter Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "net" + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// startMetricsServer starts an HTTP server exposing GET /metrics. +// addr "0" or empty disables the server and returns ("", nil). +// addr ending with ":0" binds an ephemeral port; the returned listen address +// is host:port suitable for http.Get. +func startMetricsServer(addr string) (string, error) { + if addr == "" || addr == "0" { + return "", nil + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + return "", err + } + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + + srv := &http.Server{Handler: mux} + go func() { + _ = srv.Serve(ln) + }() + + return ln.Addr().String(), nil +} diff --git a/controller/cmd/router/metrics_test.go b/controller/cmd/router/metrics_test.go new file mode 100644 index 000000000..66da63a55 --- /dev/null +++ b/controller/cmd/router/metrics_test.go @@ -0,0 +1,77 @@ +/* +Copyright 2026. The Jumpstarter Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestMetricsEndpointServesPrometheusText(t *testing.T) { + addr, err := startMetricsServer("127.0.0.1:0") + if err != nil { + t.Fatalf("startMetricsServer: %v", err) + } + if addr == "" { + t.Fatal("expected non-empty listen address") + } + + client := &http.Client{Timeout: 2 * time.Second} + var resp *http.Response + var lastErr error + for i := 0; i < 20; i++ { + resp, lastErr = client.Get("http://" + addr + "/metrics") + if lastErr == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + if lastErr != nil { + t.Fatalf("GET /metrics: %v", lastErr) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/plain") && !strings.Contains(ct, "openmetrics") { + t.Fatalf("unexpected Content-Type %q", ct) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + text := string(body) + // Default Go process metrics should appear; do not require undocumented jumpstarter_* series. + if !strings.Contains(text, "go_") && !strings.Contains(text, "process_") && !strings.Contains(text, "promhttp_") { + t.Fatalf("expected Prometheus exposition with go_/process_ metrics, got:\n%s", text) + } +} + +func TestMetricsServerDisabledWhenAddrZero(t *testing.T) { + addr, err := startMetricsServer("0") + if err != nil { + t.Fatalf("startMetricsServer(0): %v", err) + } + if addr != "" { + t.Fatalf("expected empty addr when disabled, got %q", addr) + } +} diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go index 21c5c2147..f6054e860 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -1144,8 +1144,11 @@ func (r *JumpstarterReconciler) createRouterDeployment(jumpstarter *operatorv1al Image: jumpstarter.Spec.Routers.Image, ImagePullPolicy: jumpstarter.Spec.Routers.ImagePullPolicy, Command: []string{"/router"}, - Env: envVars, - VolumeMounts: volumeMounts, + Args: []string{ + "-metrics-bind-address=:8080", + }, + Env: envVars, + VolumeMounts: volumeMounts, Ports: []corev1.ContainerPort{ { ContainerPort: 8083, diff --git a/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go new file mode 100644 index 000000000..f0266b630 --- /dev/null +++ b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go @@ -0,0 +1,77 @@ +/* +Copyright 2026. The Jumpstarter Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jumpstarter + +import ( + "testing" + + operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/deploy/operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Stdlib unit test (no envtest): asserts Router Deployment metrics bind for JEP-0013 Phase 2. +func TestRouterDeploymentMetricsBind(t *testing.T) { + r := &JumpstarterReconciler{} + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "jumpstarter", + Namespace: "jumpstarter-lab", + }, + Spec: operatorv1alpha1.JumpstarterSpec{ + Routers: operatorv1alpha1.RoutersConfig{ + Image: "example.com/router:test", + ImagePullPolicy: corev1.PullIfNotPresent, + Replicas: 1, + }, + }, + } + + dep := r.createRouterDeployment(js, 0) + if dep == nil { + t.Fatal("expected non-nil deployment") + } + if len(dep.Spec.Template.Spec.Containers) == 0 { + t.Fatal("expected at least one container") + } + + c := dep.Spec.Template.Spec.Containers[0] + foundArg := false + for _, arg := range c.Args { + if arg == "-metrics-bind-address=:8080" || arg == "--metrics-bind-address=:8080" { + foundArg = true + break + } + } + if !foundArg { + t.Fatalf("expected metrics-bind-address=:8080 in router args, got %#v", c.Args) + } + + foundPort := false + for _, p := range c.Ports { + if p.Name == "metrics" { + foundPort = true + if p.ContainerPort != 8080 { + t.Fatalf("metrics port = %d, want 8080", p.ContainerPort) + } + break + } + } + if !foundPort { + t.Fatal("expected container port named metrics") + } +} From 216b2876220f415c85a0f9dfad2b7caf4a109b91 Mon Sep 17 00:00:00 2001 From: Roddie Kieley Date: Fri, 7 Aug 2026 10:44:11 -0230 Subject: [PATCH 2/6] fix: evaluate and address coderabbitai feedback. Bound router metrics HTTP server timeouts, parse /metrics with a Prometheus exposition parser in tests, and fold the router metrics-bind assertion into a Ginkgo Describe consistent with sibling operator unit tests. Co-authored-by: Cursor --- controller/cmd/router/metrics.go | 11 ++- controller/cmd/router/metrics_test.go | 41 ++++++++- .../jumpstarter/router_metrics_bind_test.go | 86 +++++++++---------- 3 files changed, 86 insertions(+), 52 deletions(-) diff --git a/controller/cmd/router/metrics.go b/controller/cmd/router/metrics.go index c8358c52c..7e1854f19 100644 --- a/controller/cmd/router/metrics.go +++ b/controller/cmd/router/metrics.go @@ -19,6 +19,7 @@ package main import ( "net" "net/http" + "time" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -40,7 +41,15 @@ func startMetricsServer(addr string) (string, error) { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) - srv := &http.Server{Handler: mux} + srv := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + // IdleTimeout is generous so Prometheus scrape keepalives survive + // typical scrape intervals without churning connections. + IdleTimeout: 5 * time.Minute, + } go func() { _ = srv.Serve(ln) }() diff --git a/controller/cmd/router/metrics_test.go b/controller/cmd/router/metrics_test.go index 66da63a55..1372fa688 100644 --- a/controller/cmd/router/metrics_test.go +++ b/controller/cmd/router/metrics_test.go @@ -22,6 +22,9 @@ import ( "strings" "testing" "time" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" ) func TestMetricsEndpointServesPrometheusText(t *testing.T) { @@ -46,7 +49,7 @@ func TestMetricsEndpointServesPrometheusText(t *testing.T) { if lastErr != nil { t.Fatalf("GET /metrics: %v", lastErr) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) @@ -59,11 +62,41 @@ func TestMetricsEndpointServesPrometheusText(t *testing.T) { if err != nil { t.Fatalf("read body: %v", err) } - text := string(body) + + // Validate Prometheus exposition contract (not substring heuristics). + var parser expfmt.TextParser + families, err := parser.TextToMetricFamilies(strings.NewReader(string(body))) + if err != nil { + t.Fatalf("parse Prometheus exposition: %v\nbody:\n%s", err, body) + } + if len(families) == 0 { + t.Fatal("expected at least one metric family from default promhttp handler") + } + // Default Go process metrics should appear; do not require undocumented jumpstarter_* series. - if !strings.Contains(text, "go_") && !strings.Contains(text, "process_") && !strings.Contains(text, "promhttp_") { - t.Fatalf("expected Prometheus exposition with go_/process_ metrics, got:\n%s", text) + hasGo, hasProcess := false, false + for name := range families { + switch { + case strings.HasPrefix(name, "go_"): + hasGo = true + case strings.HasPrefix(name, "process_"): + hasProcess = true + } + } + if !hasGo { + t.Fatalf("expected go_* metric family, got families: %v", familyNames(families)) + } + if !hasProcess { + t.Fatalf("expected process_* metric family, got families: %v", familyNames(families)) + } +} + +func familyNames(families map[string]*dto.MetricFamily) []string { + names := make([]string, 0, len(families)) + for name := range families { + names = append(names, name) } + return names } func TestMetricsServerDisabledWhenAddrZero(t *testing.T) { diff --git a/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go index f0266b630..cb83b84a1 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go @@ -17,61 +17,53 @@ limitations under the License. package jumpstarter import ( - "testing" - operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/deploy/operator/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// Stdlib unit test (no envtest): asserts Router Deployment metrics bind for JEP-0013 Phase 2. -func TestRouterDeploymentMetricsBind(t *testing.T) { - r := &JumpstarterReconciler{} - js := &operatorv1alpha1.Jumpstarter{ - ObjectMeta: metav1.ObjectMeta{ - Name: "jumpstarter", - Namespace: "jumpstarter-lab", - }, - Spec: operatorv1alpha1.JumpstarterSpec{ - Routers: operatorv1alpha1.RoutersConfig{ - Image: "example.com/router:test", - ImagePullPolicy: corev1.PullIfNotPresent, - Replicas: 1, +var _ = Describe("createRouterDeployment metrics bind", func() { + var r *JumpstarterReconciler + var js *operatorv1alpha1.Jumpstarter + + BeforeEach(func() { + r = &JumpstarterReconciler{} + js = &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "jumpstarter", + Namespace: "jumpstarter-lab", + }, + Spec: operatorv1alpha1.JumpstarterSpec{ + Routers: operatorv1alpha1.RoutersConfig{ + Image: "example.com/router:test", + ImagePullPolicy: corev1.PullIfNotPresent, + Replicas: 1, + }, }, - }, - } + } + }) - dep := r.createRouterDeployment(js, 0) - if dep == nil { - t.Fatal("expected non-nil deployment") - } - if len(dep.Spec.Template.Spec.Containers) == 0 { - t.Fatal("expected at least one container") - } + It("exposes metrics-bind-address=:8080 and metrics port 8080", func() { + dep := r.createRouterDeployment(js, 0) + Expect(dep).NotTo(BeNil()) + Expect(dep.Spec.Template.Spec.Containers).NotTo(BeEmpty()) - c := dep.Spec.Template.Spec.Containers[0] - foundArg := false - for _, arg := range c.Args { - if arg == "-metrics-bind-address=:8080" || arg == "--metrics-bind-address=:8080" { - foundArg = true - break - } - } - if !foundArg { - t.Fatalf("expected metrics-bind-address=:8080 in router args, got %#v", c.Args) - } + c := dep.Spec.Template.Spec.Containers[0] + Expect(c.Args).To(Or( + ContainElement("-metrics-bind-address=:8080"), + ContainElement("--metrics-bind-address=:8080"), + )) - foundPort := false - for _, p := range c.Ports { - if p.Name == "metrics" { - foundPort = true - if p.ContainerPort != 8080 { - t.Fatalf("metrics port = %d, want 8080", p.ContainerPort) + var metricsPort *corev1.ContainerPort + for i := range c.Ports { + if c.Ports[i].Name == "metrics" { + metricsPort = &c.Ports[i] + break } - break } - } - if !foundPort { - t.Fatal("expected container port named metrics") - } -} + Expect(metricsPort).NotTo(BeNil(), "expected container port named metrics") + Expect(metricsPort.ContainerPort).To(Equal(int32(8080))) + }) +}) From 3cbf07f923a5eecb47be42bb5b66c07cbc73b58e Mon Sep 17 00:00:00 2001 From: Roddie Kieley Date: Fri, 7 Aug 2026 10:58:29 -0230 Subject: [PATCH 3/6] fix: evaluate and address human feedback. Return a shutdown callback from the router metrics server and invoke it on SIGINT/SIGTERM, and log unexpected Serve errors. Timeouts and independent metric-family assertions were already covered by the CodeRabbit follow-up. Co-authored-by: Cursor --- controller/cmd/router/main.go | 13 ++++++- controller/cmd/router/metrics.go | 17 +++++---- controller/cmd/router/metrics_test.go | 52 +++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/controller/cmd/router/main.go b/controller/cmd/router/main.go index 202b45bbc..501c1bc1c 100644 --- a/controller/cmd/router/main.go +++ b/controller/cmd/router/main.go @@ -22,6 +22,7 @@ import ( "os" "os/signal" "syscall" + "time" ctrl "sigs.k8s.io/controller-runtime" kclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -62,10 +63,12 @@ func main() { "buildDate", buildDate, ) - if listenAddr, err := startMetricsServer(metricsAddr); err != nil { + var shutdownMetrics func(context.Context) error + if listenAddr, shutdown, err := startMetricsServer(metricsAddr); err != nil { logger.Error(err, "failed to start metrics server", "bindAddress", metricsAddr) os.Exit(1) } else if listenAddr != "" { + shutdownMetrics = shutdown logger.Info("Serving metrics server", "bindAddress", listenAddr) } @@ -99,4 +102,12 @@ func main() { signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) sig := <-sigs logger.Info("received signal, exiting", "signal", sig) + + if shutdownMetrics != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := shutdownMetrics(shutdownCtx); err != nil { + logger.Error(err, "failed to shut down metrics server") + } + } } diff --git a/controller/cmd/router/metrics.go b/controller/cmd/router/metrics.go index 7e1854f19..2cc8cc420 100644 --- a/controller/cmd/router/metrics.go +++ b/controller/cmd/router/metrics.go @@ -17,25 +17,28 @@ limitations under the License. package main import ( + "context" "net" "net/http" "time" "github.com/prometheus/client_golang/prometheus/promhttp" + ctrl "sigs.k8s.io/controller-runtime" ) // startMetricsServer starts an HTTP server exposing GET /metrics. -// addr "0" or empty disables the server and returns ("", nil). +// addr "0" or empty disables the server and returns ("", nil, nil). // addr ending with ":0" binds an ephemeral port; the returned listen address // is host:port suitable for http.Get. -func startMetricsServer(addr string) (string, error) { +// The returned shutdown func gracefully stops the server (nil when disabled). +func startMetricsServer(addr string) (string, func(context.Context) error, error) { if addr == "" || addr == "0" { - return "", nil + return "", nil, nil } ln, err := net.Listen("tcp", addr) if err != nil { - return "", err + return "", nil, err } mux := http.NewServeMux() @@ -51,8 +54,10 @@ func startMetricsServer(addr string) (string, error) { IdleTimeout: 5 * time.Minute, } go func() { - _ = srv.Serve(ln) + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + ctrl.Log.WithName("metrics").Error(err, "metrics server stopped unexpectedly") + } }() - return ln.Addr().String(), nil + return ln.Addr().String(), srv.Shutdown, nil } diff --git a/controller/cmd/router/metrics_test.go b/controller/cmd/router/metrics_test.go index 1372fa688..8d317eb3e 100644 --- a/controller/cmd/router/metrics_test.go +++ b/controller/cmd/router/metrics_test.go @@ -17,6 +17,7 @@ limitations under the License. package main import ( + "context" "io" "net/http" "strings" @@ -28,13 +29,21 @@ import ( ) func TestMetricsEndpointServesPrometheusText(t *testing.T) { - addr, err := startMetricsServer("127.0.0.1:0") + addr, shutdown, err := startMetricsServer("127.0.0.1:0") if err != nil { t.Fatalf("startMetricsServer: %v", err) } if addr == "" { t.Fatal("expected non-empty listen address") } + if shutdown == nil { + t.Fatal("expected non-nil shutdown func when server is enabled") + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = shutdown(ctx) + }) client := &http.Client{Timeout: 2 * time.Second} var resp *http.Response @@ -100,11 +109,50 @@ func familyNames(families map[string]*dto.MetricFamily) []string { } func TestMetricsServerDisabledWhenAddrZero(t *testing.T) { - addr, err := startMetricsServer("0") + addr, shutdown, err := startMetricsServer("0") if err != nil { t.Fatalf("startMetricsServer(0): %v", err) } if addr != "" { t.Fatalf("expected empty addr when disabled, got %q", addr) } + if shutdown != nil { + t.Fatal("expected nil shutdown func when server is disabled") + } +} + +func TestMetricsServerShutdown(t *testing.T) { + addr, shutdown, err := startMetricsServer("127.0.0.1:0") + if err != nil { + t.Fatalf("startMetricsServer: %v", err) + } + if shutdown == nil { + t.Fatal("expected non-nil shutdown func") + } + + client := &http.Client{Timeout: 2 * time.Second} + var lastErr error + for i := 0; i < 20; i++ { + var resp *http.Response + resp, lastErr = client.Get("http://" + addr + "/metrics") + if lastErr == nil { + _ = resp.Body.Close() + break + } + time.Sleep(50 * time.Millisecond) + } + if lastErr != nil { + t.Fatalf("GET /metrics before shutdown: %v", lastErr) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := shutdown(ctx); err != nil { + t.Fatalf("shutdown: %v", err) + } + + _, err = client.Get("http://" + addr + "/metrics") + if err == nil { + t.Fatal("expected GET /metrics to fail after shutdown") + } } From a44204046da2bd0ffc29a8d68a344a8b74416390 Mon Sep 17 00:00:00 2001 From: Roddie Kieley Date: Tue, 11 Aug 2026 07:51:47 -0230 Subject: [PATCH 4/6] fix: reject undocumented jumpstarter_* router metrics. Enforce the Phase 2 contract in the /metrics test by failing if any jumpstarter_* family appears alongside the required go_* and process_* series. --- controller/cmd/router/metrics_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/controller/cmd/router/metrics_test.go b/controller/cmd/router/metrics_test.go index 8d317eb3e..3cec99fe5 100644 --- a/controller/cmd/router/metrics_test.go +++ b/controller/cmd/router/metrics_test.go @@ -82,9 +82,12 @@ func TestMetricsEndpointServesPrometheusText(t *testing.T) { t.Fatal("expected at least one metric family from default promhttp handler") } - // Default Go process metrics should appear; do not require undocumented jumpstarter_* series. + // Default Go process metrics should appear; reject undocumented jumpstarter_* series. hasGo, hasProcess := false, false for name := range families { + if strings.HasPrefix(name, "jumpstarter_") { + t.Fatalf("unexpected undocumented metric family %q; got families: %v", name, familyNames(families)) + } switch { case strings.HasPrefix(name, "go_"): hasGo = true From ec9bf58fb6828ba79fd2d0aad46bc449f82c9670 Mon Sep 17 00:00:00 2001 From: Roddie Kieley Date: Wed, 19 Aug 2026 17:12:50 -0230 Subject: [PATCH 5/6] fix: pass tlsSecretHash into createRouterDeployment bind test. Co-authored-by: Cursor --- .../internal/controller/jumpstarter/router_metrics_bind_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go index cb83b84a1..fe65dc990 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go @@ -46,7 +46,7 @@ var _ = Describe("createRouterDeployment metrics bind", func() { }) It("exposes metrics-bind-address=:8080 and metrics port 8080", func() { - dep := r.createRouterDeployment(js, 0) + dep := r.createRouterDeployment(js, 0, "") Expect(dep).NotTo(BeNil()) Expect(dep.Spec.Template.Spec.Containers).NotTo(BeEmpty()) From e9f30365e90fac4ce7b04d9494c9aaeab0e93066 Mon Sep 17 00:00:00 2001 From: Roddie Kieley Date: Wed, 19 Aug 2026 18:21:39 -0230 Subject: [PATCH 6/6] fix: build the full router package in docker-build-ci. Co-authored-by: Cursor --- controller/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/Makefile b/controller/Makefile index 8cb2490a0..f9e2d7aca 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -147,7 +147,7 @@ docker-build: ## Build docker image with the manager. docker-build-ci: ## Build docker images from pre-compiled host binaries (fast CI path). rm -rf bin/ci-stage && mkdir -p bin/ci-stage/controller bin/ci-stage/esc CGO_ENABLED=0 GOOS=linux GOARCH=$(GOARCH) go build -ldflags "$(LDFLAGS)" -o bin/ci-stage/controller/manager cmd/main.go - CGO_ENABLED=0 GOOS=linux GOARCH=$(GOARCH) go build -ldflags "$(LDFLAGS)" -o bin/ci-stage/controller/router cmd/router/main.go + CGO_ENABLED=0 GOOS=linux GOARCH=$(GOARCH) go build -ldflags "$(LDFLAGS)" -o bin/ci-stage/controller/router ./cmd/router CGO_ENABLED=0 GOOS=linux GOARCH=$(GOARCH) go build -ldflags "$(LDFLAGS)" -o bin/ci-stage/esc/exporter-set-controller cmd/exporter-set-controller/main.go $(CONTAINER_TOOL) build --build-arg BIN=manager -t $(IMG) -f Containerfile.prebuilt bin/ci-stage/controller $(CONTAINER_TOOL) build --build-arg BIN=exporter-set-controller -t $(EXPORTER_SET_CONTROLLER_IMG) -f Containerfile.prebuilt bin/ci-stage/esc