diff --git a/controller/Containerfile b/controller/Containerfile index 331a8530a..a6da91548 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-1782840931@sha256:35de56a9413112f1474e392ebc35e0cf6f0fb484c8e8877bbae59b513694b41f WORKDIR / diff --git a/controller/Makefile b/controller/Makefile index 1fbd0ef9a..e9198321b 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -113,7 +113,7 @@ build-operator: .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 @@ -122,7 +122,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 2b4c967eb..0860572ff 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -1030,8 +1030,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") + } +}