Skip to content

Commit b37d9fe

Browse files
Michael McNeesclaude
authored andcommitted
fix: exempt /metrics, /healthz, /readyz from auth; numeric UID in Dockerfile
- server: register /metrics, /healthz, and /readyz on a top-level mux so Kubernetes probes and Prometheus scrapers bypass API key auth entirely. All other routes fall through to the auth/rate-limit-wrapped apiMux. - Dockerfile: create the mantle user with explicit -u 10001 -g 10001 and switch to USER 10001:10001 so kubelet can verify runAsNonRoot: true without a numeric runAsUser override in the Helm chart. Closes #139 Closes #137 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dd60b80 commit b37d9fe

2 files changed

Lines changed: 40 additions & 34 deletions

File tree

packages/engine/Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
2626
FROM alpine:3.21
2727

2828
RUN apk add --no-cache ca-certificates tzdata \
29-
&& addgroup -S mantle \
30-
&& adduser -S -G mantle -h /home/mantle -s /bin/sh mantle
29+
&& addgroup -S -g 10001 mantle \
30+
&& adduser -S -u 10001 -G mantle -h /home/mantle -s /bin/sh mantle
3131

3232
COPY --from=builder /mantle /usr/local/bin/mantle
3333

@@ -39,7 +39,7 @@ EXPOSE 8080
3939
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
4040
CMD wget -qO- http://localhost:8080/healthz || exit 1
4141

42-
USER mantle
42+
USER 10001:10001
4343
WORKDIR /home/mantle
4444

4545
ENTRYPOINT ["mantle"]

packages/engine/internal/server/server.go

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -108,43 +108,43 @@ func metricsMiddleware(next http.Handler) http.Handler {
108108
// Start starts the HTTP server, cron scheduler, and webhook handler.
109109
// It blocks until the context is cancelled.
110110
func (s *Server) Start(ctx context.Context) error {
111-
mux := http.NewServeMux()
112-
113-
// Prometheus metrics endpoint.
114-
mux.Handle("/metrics", promhttp.Handler())
111+
// apiMux holds all routes that require authentication (and rate limiting).
112+
// Health and metrics endpoints are registered on the top-level mux below
113+
// so that Kubernetes probes and Prometheus scrapes bypass auth entirely.
114+
apiMux := http.NewServeMux()
115115

116116
// Git push webhook endpoint — registered before the generic /hooks/ prefix
117117
// so the more-specific path is explicit (Go's ServeMux uses longest-prefix
118118
// match regardless of registration order, but explicit ordering is clearer).
119-
mux.Handle("/hooks/git/", &GitWebhookHandler{
119+
apiMux.Handle("/hooks/git/", &GitWebhookHandler{
120120
DB: s.DB,
121121
Store: s.RepoStore,
122122
Driver: s.GitDriver,
123123
})
124124

125125
// Webhook endpoints.
126-
mux.HandleFunc("/hooks/", s.webhooks.ServeHTTP)
126+
apiMux.HandleFunc("/hooks/", s.webhooks.ServeHTTP)
127127

128128
// API endpoints.
129-
mux.HandleFunc("POST /api/v1/run/{workflow}", s.handleRun)
130-
mux.HandleFunc("POST /api/v1/cancel/{execution}", s.handleCancel)
131-
mux.HandleFunc("GET /api/v1/executions", s.handleListExecutions)
132-
mux.HandleFunc("GET /api/v1/executions/{id}", s.handleGetExecution)
129+
apiMux.HandleFunc("POST /api/v1/run/{workflow}", s.handleRun)
130+
apiMux.HandleFunc("POST /api/v1/cancel/{execution}", s.handleCancel)
131+
apiMux.HandleFunc("GET /api/v1/executions", s.handleListExecutions)
132+
apiMux.HandleFunc("GET /api/v1/executions/{id}", s.handleGetExecution)
133133

134134
// Workflow definition endpoints.
135-
mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflows)
136-
mux.HandleFunc("GET /api/v1/workflows/{name}", s.handleGetWorkflow)
137-
mux.HandleFunc("GET /api/v1/workflows/{name}/versions", s.handleListWorkflowVersions)
138-
mux.HandleFunc("GET /api/v1/workflows/{name}/versions/{version}", s.handleGetWorkflowVersion)
135+
apiMux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflows)
136+
apiMux.HandleFunc("GET /api/v1/workflows/{name}", s.handleGetWorkflow)
137+
apiMux.HandleFunc("GET /api/v1/workflows/{name}/versions", s.handleListWorkflowVersions)
138+
apiMux.HandleFunc("GET /api/v1/workflows/{name}/versions/{version}", s.handleGetWorkflowVersion)
139139

140140
// Budget endpoints.
141-
mux.HandleFunc("GET /api/v1/budgets", s.handleListBudgets)
142-
mux.HandleFunc("PUT /api/v1/budgets/{provider}", s.handleSetBudget)
143-
mux.HandleFunc("DELETE /api/v1/budgets/{provider}", s.handleDeleteBudget)
144-
mux.HandleFunc("GET /api/v1/budgets/usage", s.handleGetUsage)
141+
apiMux.HandleFunc("GET /api/v1/budgets", s.handleListBudgets)
142+
apiMux.HandleFunc("PUT /api/v1/budgets/{provider}", s.handleSetBudget)
143+
apiMux.HandleFunc("DELETE /api/v1/budgets/{provider}", s.handleDeleteBudget)
144+
apiMux.HandleFunc("GET /api/v1/budgets/usage", s.handleGetUsage)
145145

146146
// OpenAPI spec endpoint.
147-
mux.HandleFunc("GET /api/v1/openapi.json", handleOpenAPISpec)
147+
apiMux.HandleFunc("GET /api/v1/openapi.json", handleOpenAPISpec)
148148

149149
// Start distributed engine components (worker + reaper).
150150
if cfg := config.FromContext(ctx); cfg != nil {
@@ -238,24 +238,30 @@ func (s *Server) Start(ctx context.Context) error {
238238
go s.pollQueueDepth(ctx, cfg.Engine.ReaperInterval)
239239
}
240240

241-
// Health endpoints. /readyz checks DB connectivity only; worker/reaper
242-
// liveness is no longer gated here to avoid flapping between poll cycles.
243-
mux.Handle("/healthz", health.HealthzHandler())
244-
mux.Handle("/readyz", health.ReadyzHandler(s.DB))
245-
246-
// Wrap with metrics middleware (innermost, runs for every request).
247-
handler := metricsMiddleware(mux)
241+
// Wrap the API mux with metrics middleware, auth, and rate limiting.
242+
apiHandler := metricsMiddleware(apiMux)
248243
if s.AuthStore != nil {
249244
if s.Auditor != nil {
250-
handler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, handler, s.Auditor)
245+
apiHandler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, apiHandler, s.Auditor)
251246
} else {
252-
handler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, handler)
247+
apiHandler = auth.AuthMiddleware(s.AuthStore, s.OIDCValidator, apiHandler)
253248
}
254249
}
255-
256-
// Apply rate limiting (after auth so rate limit keys can use API key prefix).
257250
rl := NewRateLimiter(100, 200)
258-
handler = rl.Middleware(handler)
251+
apiHandler = rl.Middleware(apiHandler)
252+
253+
// Top-level mux: health and metrics endpoints bypass auth so that Kubernetes
254+
// probes and Prometheus scrapers work without an API key. All other requests
255+
// fall through to the auth-wrapped apiHandler.
256+
mux := http.NewServeMux()
257+
mux.Handle("/metrics", promhttp.Handler())
258+
mux.Handle("/healthz", health.HealthzHandler())
259+
// /readyz checks DB connectivity only; worker/reaper liveness is intentionally
260+
// excluded to prevent flapping between poll cycles.
261+
mux.Handle("/readyz", health.ReadyzHandler(s.DB))
262+
mux.Handle("/", apiHandler)
263+
264+
handler := mux
259265

260266
s.httpServer = &http.Server{
261267
Addr: s.Address,

0 commit comments

Comments
 (0)