Loading docs/openapi.yaml +20 −0 Changes for docs/openapi.yaml: 20 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -467,6 +467,26 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' /metrics: get: tags: [metrics] operationId: metrics summary: Prometheus metrics (felis_* collectors) on the internal face. description: >- Scrape-only infrastructure route, not a product API: the internal listener is ClusterIP-only and a Prometheus scrape carries no token, the same stance as the probes. Serves the felis_* exposition documented in troubleshooting §14; the external face never serves it. x-felis-face: [internal] x-felis-tier: public security: [] responses: '200': description: Prometheus text exposition format. content: text/plain: schema: { type: string } /session/minecraft/hasJoined: get: tags: [nano] Loading internal/api/api.go +5 −1 Changes for internal/api/api.go: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -264,11 +264,15 @@ type apiRoute struct { } // internalAPIRoutes is the internal face's served route table (spec §7, §14): // service-token auth, never Zero Trust. It carries both health probes. // service-token auth, never Zero Trust. It carries both health probes and the // metrics scrape. func (a *API) internalAPIRoutes() []apiRoute { return []apiRoute{ {Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz}, {Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz}, // Prometheus scrape (felis_* collectors); public because a scrape carries // no token, internal-only so it is never exposed off-cluster. {Method: "GET", Pattern: "/metrics", Public: true, h: a.handleMetrics}, {Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers}, // The build Pod's context-fetch initContainer streams a submission's stored Loading internal/api/metrics.go 0 → 100644 +34 −0 Changes for internal/api/metrics.go: 34 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "net/http" "felis.lolicon.best/internal/metrics" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) // apiMetricsHandler serves the felis_* collectors from a process-local registry. // The API process is the only producer of felis_image_build_failures_total (the // build reconcile loop lives in cmd/felis.reconcileBuilds), so this endpoint is // that counter's sole scrape path; the operator's :8080 carries the fleet // gauges instead. var apiMetricsHandler = newAPIMetricsHandler() func newAPIMetricsHandler() http.Handler { reg := prometheus.NewRegistry() // A fresh registry cannot already hold a collector, so Register's // AlreadyRegistered tolerance arm never triggers here. _ = metrics.Register(reg) return promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) } // handleMetrics is mounted as a Public route on the internal face (the same // stance as the health probes): the internal listener is ClusterIP-only and a // Prometheus scrape carries no token. The external face never serves metrics. // See docs/troubleshooting.md §14 for what to scrape and the alert rules that // consume these series. func (a *API) handleMetrics(w http.ResponseWriter, r *http.Request) { apiMetricsHandler.ServeHTTP(w, r) } internal/api/metrics_test.go 0 → 100644 +44 −0 Changes for internal/api/metrics_test.go: 44 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "net/http" "strings" "testing" "felis.lolicon.best/internal/metrics" ) // TestMetricsEndpoint locks the scrape surface: the internal face serves the // felis_* collectors (the API process is felis_image_build_failures_total's // only producer), and the external face never serves metrics. func TestMetricsEndpoint(t *testing.T) { // Give every collector a sample so the exposition carries all four families: // an empty vector family is omitted from the text output entirely. The bumps // stay inside this test binary — the collectors are per-process globals. metrics.SyncServerGauge([]string{"Running"}) metrics.StartDurationSeconds.Observe(3) metrics.ImageBuildFailuresTotal.Inc() metrics.ReaperWorldsDeletedTotal.Inc() a := &API{} w := do(a.InternalHandler(), "GET", "/metrics", "", nil) if w.Code != http.StatusOK { t.Fatalf("internal GET /metrics = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{ `felis_servers_total{state="Running"} 1`, "felis_start_duration_seconds_count 1", "felis_image_build_failures_total", "felis_reaper_worlds_deleted_total", } { if !strings.Contains(body, want) { t.Errorf("metrics exposition missing %q", want) } } if w := do(a.ExternalHandler(), "GET", "/metrics", "", nil); w.Code != http.StatusNotFound { t.Fatalf("external GET /metrics = %d, want 404 (metrics stay internal)", w.Code) } } Loading
docs/openapi.yaml +20 −0 Changes for docs/openapi.yaml: 20 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -467,6 +467,26 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' /metrics: get: tags: [metrics] operationId: metrics summary: Prometheus metrics (felis_* collectors) on the internal face. description: >- Scrape-only infrastructure route, not a product API: the internal listener is ClusterIP-only and a Prometheus scrape carries no token, the same stance as the probes. Serves the felis_* exposition documented in troubleshooting §14; the external face never serves it. x-felis-face: [internal] x-felis-tier: public security: [] responses: '200': description: Prometheus text exposition format. content: text/plain: schema: { type: string } /session/minecraft/hasJoined: get: tags: [nano] Loading
internal/api/api.go +5 −1 Changes for internal/api/api.go: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -264,11 +264,15 @@ type apiRoute struct { } // internalAPIRoutes is the internal face's served route table (spec §7, §14): // service-token auth, never Zero Trust. It carries both health probes. // service-token auth, never Zero Trust. It carries both health probes and the // metrics scrape. func (a *API) internalAPIRoutes() []apiRoute { return []apiRoute{ {Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz}, {Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz}, // Prometheus scrape (felis_* collectors); public because a scrape carries // no token, internal-only so it is never exposed off-cluster. {Method: "GET", Pattern: "/metrics", Public: true, h: a.handleMetrics}, {Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers}, // The build Pod's context-fetch initContainer streams a submission's stored Loading
internal/api/metrics.go 0 → 100644 +34 −0 Changes for internal/api/metrics.go: 34 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "net/http" "felis.lolicon.best/internal/metrics" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) // apiMetricsHandler serves the felis_* collectors from a process-local registry. // The API process is the only producer of felis_image_build_failures_total (the // build reconcile loop lives in cmd/felis.reconcileBuilds), so this endpoint is // that counter's sole scrape path; the operator's :8080 carries the fleet // gauges instead. var apiMetricsHandler = newAPIMetricsHandler() func newAPIMetricsHandler() http.Handler { reg := prometheus.NewRegistry() // A fresh registry cannot already hold a collector, so Register's // AlreadyRegistered tolerance arm never triggers here. _ = metrics.Register(reg) return promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) } // handleMetrics is mounted as a Public route on the internal face (the same // stance as the health probes): the internal listener is ClusterIP-only and a // Prometheus scrape carries no token. The external face never serves metrics. // See docs/troubleshooting.md §14 for what to scrape and the alert rules that // consume these series. func (a *API) handleMetrics(w http.ResponseWriter, r *http.Request) { apiMetricsHandler.ServeHTTP(w, r) }
internal/api/metrics_test.go 0 → 100644 +44 −0 Changes for internal/api/metrics_test.go: 44 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "net/http" "strings" "testing" "felis.lolicon.best/internal/metrics" ) // TestMetricsEndpoint locks the scrape surface: the internal face serves the // felis_* collectors (the API process is felis_image_build_failures_total's // only producer), and the external face never serves metrics. func TestMetricsEndpoint(t *testing.T) { // Give every collector a sample so the exposition carries all four families: // an empty vector family is omitted from the text output entirely. The bumps // stay inside this test binary — the collectors are per-process globals. metrics.SyncServerGauge([]string{"Running"}) metrics.StartDurationSeconds.Observe(3) metrics.ImageBuildFailuresTotal.Inc() metrics.ReaperWorldsDeletedTotal.Inc() a := &API{} w := do(a.InternalHandler(), "GET", "/metrics", "", nil) if w.Code != http.StatusOK { t.Fatalf("internal GET /metrics = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{ `felis_servers_total{state="Running"} 1`, "felis_start_duration_seconds_count 1", "felis_image_build_failures_total", "felis_reaper_worlds_deleted_total", } { if !strings.Contains(body, want) { t.Errorf("metrics exposition missing %q", want) } } if w := do(a.ExternalHandler(), "GET", "/metrics", "", nil); w.Code != http.StatusNotFound { t.Fatalf("external GET /metrics = %d, want 404 (metrics stay internal)", w.Code) } }