diff --git a/.woodpecker.yml b/.woodpecker.yml index 64efaf8..dbc161d 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -28,12 +28,18 @@ steps: printf '{"auths":{"registry.be.kaien.ru":{"auth":"%s"}}}' "$(printf '%s:%s' "$ZOT_USERNAME" "$ZOT_PASSWORD" | base64 | tr -d '\n')" > /kaniko/.docker/config.json - - >- - /kaniko/executor - --context "$CI_WORKSPACE" - --dockerfile "$CI_WORKSPACE/Dockerfile" - --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" + # Stamp buildinfo via Dockerfile ARG/ldflags. + # Woodpecker expands ${VAR} before the shell — escape shell locals as $${VAR}. + - | + BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + /kaniko/executor \ + --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: - test when: diff --git a/Dockerfile b/Dockerfile index 7ea73bc..cacb2fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,17 @@ RUN apk add --no-cache git RUN go mod download 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 RUN apk add --no-cache ca-certificates diff --git a/cmd/kaya/main.go b/cmd/kaya/main.go index bb6c2b3..e11a1c4 100644 --- a/cmd/kaya/main.go +++ b/cmd/kaya/main.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/prometheus/client_golang/prometheus/promhttp" + "l-ai-agent-kaya/internal/buildinfo" "l-ai-agent-kaya/internal/config" "l-ai-agent-kaya/internal/inference" "l-ai-agent-kaya/internal/profile" @@ -42,8 +43,11 @@ func run() error { } defer core.Close() - go serveHTTP(cfg.MetricsListenAddr, promhttp.Handler()) - go serveHTTP(cfg.HealthListenAddr, healthHandler()) + ops := http.NewServeMux() + ops.Handle("/metrics", promhttp.Handler()) + ops.Handle("/healthz", buildinfo.Handler()) + go serveHTTP(cfg.MetricsListenAddr, ops) + go serveHTTP(cfg.HealthListenAddr, buildinfo.Handler()) loop := &tasksboard.Loop{ WSURL: cfg.TasksWSURL, @@ -75,12 +79,3 @@ func serveHTTP(addr string, h http.Handler) { 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 -} diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..c01246d --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -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) +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..570bfa8 --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -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) + } +}