Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion controller/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down
6 changes: 3 additions & 3 deletions controller/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions controller/cmd/router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"os"
"os/signal"
"syscall"
"time"

ctrl "sigs.k8s.io/controller-runtime"
kclient "sigs.k8s.io/controller-runtime/pkg/client"
Expand All @@ -45,6 +46,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"))
Expand All @@ -58,6 +63,15 @@ func main() {
"buildDate", buildDate,
)

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)
}

cfg := ctrl.GetConfigOrDie()
client, err := kclient.New(cfg, kclient.Options{})
if err != nil {
Expand Down Expand Up @@ -88,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")
}
}
}
63 changes: 63 additions & 0 deletions controller/cmd/router/metrics.go
Comment thread
RoddieKieley marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
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 (
"context"
"net"
"net/http"
"time"

"github.com/prometheus/client_golang/prometheus/promhttp"
Comment thread
RoddieKieley marked this conversation as resolved.
ctrl "sigs.k8s.io/controller-runtime"
)

// startMetricsServer starts an HTTP server exposing GET /metrics.
// 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.
// 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, nil
}

ln, err := net.Listen("tcp", addr)
if err != nil {
return "", nil, err
}

mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())

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() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
ctrl.Log.WithName("metrics").Error(err, "metrics server stopped unexpectedly")
}
}()
Comment thread
RoddieKieley marked this conversation as resolved.

return ln.Addr().String(), srv.Shutdown, nil
}
161 changes: 161 additions & 0 deletions controller/cmd/router/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
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 (
"context"
"io"
"net/http"
"strings"
"testing"
"time"

dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
)

func TestMetricsEndpointServesPrometheusText(t *testing.T) {
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
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 func() { _ = 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)
}

// 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; 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
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) {
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")
}
}
Comment thread
RoddieKieley marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
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 (
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"
)

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,
},
},
}
})

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]
Expect(c.Args).To(Or(
ContainElement("-metrics-bind-address=:8080"),
ContainElement("--metrics-bind-address=:8080"),
))

var metricsPort *corev1.ContainerPort
for i := range c.Ports {
if c.Ports[i].Name == "metrics" {
metricsPort = &c.Ports[i]
break
}
}
Expect(metricsPort).NotTo(BeNil(), "expected container port named metrics")
Expect(metricsPort.ContainerPort).To(Equal(int32(8080)))
})
})