Expose stamped /healthz JSON next to Prometheus metrics.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
alex 2026-10-01 16:28:20 +03:00
parent 3a2a0c1c34
commit 7758c097af
5 changed files with 167 additions and 18 deletions

View File

@ -28,12 +28,18 @@ steps:
printf '{"auths":{"registry.be.kaien.ru":{"auth":"%s"}}}' printf '{"auths":{"registry.be.kaien.ru":{"auth":"%s"}}}'
"$(printf '%s:%s' "$ZOT_USERNAME" "$ZOT_PASSWORD" | base64 | tr -d '\n')" "$(printf '%s:%s' "$ZOT_USERNAME" "$ZOT_PASSWORD" | base64 | tr -d '\n')"
> /kaniko/.docker/config.json > /kaniko/.docker/config.json
- >- # Stamp buildinfo via Dockerfile ARG/ldflags.
/kaniko/executor # Woodpecker expands ${VAR} before the shell — escape shell locals as $${VAR}.
--context "$CI_WORKSPACE" - |
--dockerfile "$CI_WORKSPACE/Dockerfile" BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
--destination "registry.be.kaien.ru/l_ai_agent_kaya/agent_kaya:$CI_COMMIT_SHA" /kaniko/executor \
--destination "registry.be.kaien.ru/l_ai_agent_kaya/agent_kaya:latest" --context "$CI_WORKSPACE" \
--dockerfile "$CI_WORKSPACE/Dockerfile" \
--build-arg "BUILD_DATE=$${BUILD_DATE}" \
--build-arg "BUILD_COMMIT=${CI_COMMIT_SHA}" \
--build-arg "BUILD_BRANCH=${CI_COMMIT_BRANCH}" \
--destination "registry.be.kaien.ru/l_ai_agent_kaya/agent_kaya:$CI_COMMIT_SHA" \
--destination "registry.be.kaien.ru/l_ai_agent_kaya/agent_kaya:latest"
depends_on: depends_on:
- test - test
when: when:

View File

@ -6,7 +6,17 @@ RUN apk add --no-cache git
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -o /kaya ./cmd/kaya
ARG BUILD_DATE=unknown
ARG BUILD_COMMIT=dev
ARG BUILD_BRANCH=unknown
RUN CGO_ENABLED=0 go build \
-ldflags="-s -w \
-X l-ai-agent-kaya/internal/buildinfo.Date=${BUILD_DATE} \
-X l-ai-agent-kaya/internal/buildinfo.Commit=${BUILD_COMMIT} \
-X l-ai-agent-kaya/internal/buildinfo.Branch=${BUILD_BRANCH}" \
-o /kaya ./cmd/kaya
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates

View File

@ -10,6 +10,7 @@ import (
"syscall" "syscall"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"l-ai-agent-kaya/internal/buildinfo"
"l-ai-agent-kaya/internal/config" "l-ai-agent-kaya/internal/config"
"l-ai-agent-kaya/internal/inference" "l-ai-agent-kaya/internal/inference"
"l-ai-agent-kaya/internal/profile" "l-ai-agent-kaya/internal/profile"
@ -42,8 +43,11 @@ func run() error {
} }
defer core.Close() defer core.Close()
go serveHTTP(cfg.MetricsListenAddr, promhttp.Handler()) ops := http.NewServeMux()
go serveHTTP(cfg.HealthListenAddr, healthHandler()) ops.Handle("/metrics", promhttp.Handler())
ops.Handle("/healthz", buildinfo.Handler())
go serveHTTP(cfg.MetricsListenAddr, ops)
go serveHTTP(cfg.HealthListenAddr, buildinfo.Handler())
loop := &tasksboard.Loop{ loop := &tasksboard.Loop{
WSURL: cfg.TasksWSURL, WSURL: cfg.TasksWSURL,
@ -75,12 +79,3 @@ func serveHTTP(addr string, h http.Handler) {
slog.Error("http server stopped", "addr", addr, "error", err) slog.Error("http server stopped", "addr", addr, "error", err)
} }
} }
func healthHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
})
return mux
}

View File

@ -0,0 +1,96 @@
// Package buildinfo exposes stamped build metadata and the shared ops /healthz JSON.
package buildinfo
import (
"encoding/json"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Populated at link time via -ldflags -X.
var (
Date = "unknown"
Commit = "dev"
Branch = "unknown"
)
// Hardcoded service identity for autodiscovery / status dashboard.
const (
Name = "l-ai-agent-kaya"
Description = "Tasks board agent Kaya: @kaya mention replies via core RunAgent"
)
// Tags is the controlled vocabulary for this service (see l-ai-docs services/healthz.md).
var Tags = []string{"agent", "tasks", "llm"}
var startedAt = time.Now()
func init() {
promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "build_info",
Help: "Build metadata for this process (always 1).",
},
[]string{"name", "commit", "branch", "date"},
).With(prometheus.Labels{
"name": Name,
"commit": Commit,
"branch": Branch,
"date": Date,
}).Set(1)
}
// Build is the nested build object in /healthz.
type Build struct {
Date string `json:"date"`
Commit string `json:"commit"`
Branch string `json:"branch"`
}
// Response is the shared /healthz JSON contract.
type Response struct {
Status string `json:"status"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
UptimeSeconds int64 `json:"uptime_seconds"`
Build Build `json:"build"`
}
// Snapshot returns the current health payload with status "ok".
func Snapshot() Response {
tags := make([]string, len(Tags))
copy(tags, Tags)
return Response{
Status: "ok",
Name: Name,
Description: Description,
Tags: tags,
UptimeSeconds: int64(time.Since(startedAt).Seconds()),
Build: Build{
Date: Date,
Commit: Commit,
Branch: Branch,
},
}
}
// Handler serves GET/HEAD /healthz as application/json (always 200 while process is up).
func Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, Snapshot())
})
}
func writeJSON(w http.ResponseWriter, code int, body Response) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}

View File

@ -0,0 +1,42 @@
package buildinfo
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandlerOK(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code=%d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("content-type=%q", ct)
}
var body Response
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status != "ok" || body.Name != Name || body.Description == "" {
t.Fatalf("unexpected body: %+v", body)
}
if len(body.Tags) == 0 {
t.Fatal("expected tags")
}
if body.Build.Commit == "" {
t.Fatal("expected commit")
}
}
func TestHandlerMethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/healthz", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("code=%d", rec.Code)
}
}