From d1f6965ff064648334c97f6c575ba924251ee15f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 1 Jun 2026 15:40:07 +0200 Subject: [PATCH] feat(api): public /version endpoint exposes git SHA + build time Adds GitSHA ldflag + a public GET /version returning version, git_sha, and build_time so a running environment's deployed commit can be verified against the branch HEAD (diagnoses deploy-lag). Wires the short SHA through the Docker/Make build. --- Dockerfile | 11 ++++- Makefile | 5 +- cmd/server/main.go | 10 ++-- internal/api/handler_version.go | 42 +++++++++++++++++ internal/api/handler_version_test.go | 69 ++++++++++++++++++++++++++++ internal/api/middleware.go | 1 + internal/api/router.go | 10 ++++ internal/server/http.go | 26 +++++++++++ 8 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 internal/api/handler_version.go create mode 100644 internal/api/handler_version_test.go diff --git a/Dockerfile b/Dockerfile index 1f915f60b..7ad8edbba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,14 @@ FROM golang:1.25.4-alpine3.21@sha256:3289aac2aac769e031d644313d094dbda745f28af81 ARG TARGETARCH ARG TARGETOS +# Build metadata stamped into the binary via ldflags and surfaced by the +# public GET /version endpoint. GIT_COMMIT and BUILD_DATE are supplied by the +# terraform build module (modules/build); they default to "unknown" so a bare +# `docker build .` still succeeds without git context. +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_DATE + # Install build dependencies RUN apk add --no-cache \ git \ @@ -66,9 +74,10 @@ COPY . . # Supports both ARM64 and AMD64 via build args # Default: ARM64 for cost optimization (20% savings on AWS Fargate) RUN echo "Building for ${TARGETOS}/${TARGETARCH}" && \ + BUILD_TIME="${BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \ CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ -p 6 \ - -ldflags="-s -w -X main.Version=${VERSION:-dev} -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -ldflags="-s -w -X main.Version=${VERSION:-dev} -X main.BuildTime=${BUILD_TIME} -X main.GitSHA=${GIT_COMMIT:-unknown}" \ -o /app/cudly \ ./cmd/server diff --git a/Makefile b/Makefile index dddff1557..ddb735e6c 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,9 @@ # Variables VERSION?=dev -BUILD_TIME=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') -LDFLAGS=-ldflags "-s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)" +BUILD_TIME?=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +GIT_SHA?=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +LDFLAGS=-ldflags "-s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitSHA=$(GIT_SHA)" # Default target all: build diff --git a/cmd/server/main.go b/cmd/server/main.go index 43f9a6d1d..bbca19b99 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,10 +13,11 @@ import ( "github.com/LeanerCloud/CUDly/internal/server" ) -// Version and BuildTime are set at build time via ldflags +// Version, BuildTime, and GitSHA are set at build time via ldflags. var ( Version = "dev" BuildTime = "unknown" + GitSHA = "unknown" ) func main() { @@ -27,10 +28,13 @@ func main() { flag.Parse() // Print version info - log.Printf("CUDly Server v%s (built: %s)", Version, BuildTime) + log.Printf("CUDly Server v%s (git: %s, built: %s)", Version, GitSHA, BuildTime) - // Set version in environment for the application + // Export build metadata to the environment so the api package can read it + // without importing main (which would create an import cycle). os.Setenv("VERSION", Version) + os.Setenv("BUILD_TIME", BuildTime) + os.Setenv("GIT_SHA", GitSHA) ctx := context.Background() diff --git a/internal/api/handler_version.go b/internal/api/handler_version.go new file mode 100644 index 000000000..fdc256d34 --- /dev/null +++ b/internal/api/handler_version.go @@ -0,0 +1,42 @@ +package api + +import ( + "context" + "os" + + "github.com/aws/aws-lambda-go/events" +) + +// VersionResponse holds build-identity metadata for the public /version +// endpoint. It carries no sensitive data (no account IDs, ARNs, or secrets) so +// it is safe to expose unauthenticated. The fields let an operator curl a +// running environment and compare git_sha against the branch HEAD to diagnose +// deploy-lag (an environment still serving a stale build). +type VersionResponse struct { + Version string `json:"version"` + GitSHA string `json:"git_sha"` + BuildTime string `json:"build_time"` +} + +// getVersion returns the deployed build's version string, git commit SHA, and +// build timestamp. The values are stamped into the binary at build time via +// ldflags and exported to the environment by cmd/server/main.go (reading them +// from env avoids an import cycle between this package and main). When the +// binary was built without ldflags (e.g. a bare `go run`), the fields fall +// back to the same "dev"/"unknown" sentinels main.go declares. +func (h *Handler) getVersion(_ context.Context, _ *events.LambdaFunctionURLRequest) (*VersionResponse, error) { + return &VersionResponse{ + Version: envOrDefault("VERSION", "dev"), + GitSHA: envOrDefault("GIT_SHA", "unknown"), + BuildTime: envOrDefault("BUILD_TIME", "unknown"), + }, nil +} + +// envOrDefault returns the value of the named env var, or def when it is unset +// or empty. +func envOrDefault(name, def string) string { + if v := os.Getenv(name); v != "" { + return v + } + return def +} diff --git a/internal/api/handler_version_test.go b/internal/api/handler_version_test.go new file mode 100644 index 000000000..16f74a23e --- /dev/null +++ b/internal/api/handler_version_test.go @@ -0,0 +1,69 @@ +package api + +import ( + "context" + "testing" + + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetVersion_Defaults verifies the handler returns the three build-metadata +// fields and falls back to the "dev"/"unknown" sentinels when the build-time +// env vars are unset (the case for a bare `go run` / `go test` binary that was +// never stamped with ldflags). t.Setenv on a separate test ensures we are not +// leaking real CI-injected values into this assertion. +func TestGetVersion_Defaults(t *testing.T) { + t.Setenv("VERSION", "") + t.Setenv("GIT_SHA", "") + t.Setenv("BUILD_TIME", "") + + h := &Handler{} + resp, err := h.getVersion(context.Background(), &events.LambdaFunctionURLRequest{}) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, "dev", resp.Version) + assert.Equal(t, "unknown", resp.GitSHA) + assert.Equal(t, "unknown", resp.BuildTime) +} + +// TestGetVersion_FromEnv verifies the handler surfaces the values main.go +// exports to the environment after reading them from ldflags. +func TestGetVersion_FromEnv(t *testing.T) { + t.Setenv("VERSION", "abc1234") + t.Setenv("GIT_SHA", "abc1234") + t.Setenv("BUILD_TIME", "2026-06-01T12:00:00Z") + + h := &Handler{} + resp, err := h.getVersion(context.Background(), &events.LambdaFunctionURLRequest{}) + require.NoError(t, err) + + assert.Equal(t, "abc1234", resp.Version) + assert.Equal(t, "abc1234", resp.GitSHA) + assert.Equal(t, "2026-06-01T12:00:00Z", resp.BuildTime) +} + +// TestRouterDispatch_Version verifies GET /version dispatches to the version +// handler as a public (no-auth) route: no session is configured on the Handler, +// so a non-public route would fail auth, but AuthPublic must succeed and return +// a *VersionResponse with the default sentinels. +func TestRouterDispatch_Version(t *testing.T) { + t.Setenv("VERSION", "") + t.Setenv("GIT_SHA", "") + t.Setenv("BUILD_TIME", "") + + ctx := context.Background() + // No auth/config wired — a public route must not consult either. + r := NewRouter(&Handler{}) + + result, err := r.Route(ctx, "GET", "/version", &events.LambdaFunctionURLRequest{}) + require.NoError(t, err) + + resp, ok := result.(*VersionResponse) + require.True(t, ok, "expected *VersionResponse, got %T", result) + assert.Equal(t, "dev", resp.Version) + assert.Equal(t, "unknown", resp.GitSHA) + assert.Equal(t, "unknown", resp.BuildTime) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 21468c31e..0c43fe964 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -16,6 +16,7 @@ func (h *Handler) isPublicEndpoint(path string) bool { "/health", // Root health endpoint (no /api prefix) "/api/health", // API health endpoint "/api/info", + "/version", // Public build-version endpoint (version / git SHA / build time) "/api/purchases/approve/", "/api/purchases/cancel/", "/api/ri-exchange/approve/", diff --git a/internal/api/router.go b/internal/api/router.go index 1438f78fe..75e38059d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -314,6 +314,12 @@ func (r *Router) registerRoutes() { // Public info endpoint {ExactPath: "/api/info", Method: "GET", Handler: r.getPublicInfoHandler, Auth: AuthPublic}, + // Build-version endpoint — public, returns only version / git SHA / + // build time (no account IDs, ARNs, or secrets) so an operator can + // curl a running environment and compare the SHA to the branch HEAD + // to detect deploy-lag. + {ExactPath: "/version", Method: "GET", Handler: r.getVersionHandler, Auth: AuthPublic}, + // API documentation (Swagger UI + raw spec) {PathPrefix: "/api/docs", Method: "GET", Handler: r.docsHandler, Auth: AuthPublic}, {PathPrefix: "/api/docs", Method: "HEAD", Handler: r.docsHandler, Auth: AuthPublic}, @@ -679,6 +685,10 @@ func (r *Router) getPublicInfoHandler(ctx context.Context, req *events.LambdaFun return r.h.getPublicInfo(ctx, req) } +func (r *Router) getVersionHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + return r.h.getVersion(ctx, req) +} + func (r *Router) docsHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { return r.h.docsHandler(ctx, req, params) } diff --git a/internal/server/http.go b/internal/server/http.go index 9bf6bc232..d2128d27c 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -25,6 +25,13 @@ func CreateHTTPServer(app *Application, port int) *http.Server { // SCHEDULED_TASK_AUTH_MODE: oidc on GCP, bearer on Azure, disabled // for local dev. mux.HandleFunc("/health", app.handleHealthCheck) + // /version is a root-path (no /api prefix) public endpoint. Like /health + // it must be registered explicitly: when STATIC_DIR is set the catch-all + // "/" route serves the SPA, so an unregistered /version would return the + // frontend index instead of the build metadata. It dispatches through the + // API router (single source of truth) but skips ensureDB so the deployed + // commit can still be read when the database is unreachable. + mux.HandleFunc("/version", app.handleVersion) mux.Handle("/api/scheduled/", app.scheduledAuthMiddleware(http.HandlerFunc(app.handleScheduledHTTP))) // When STATIC_DIR is set, serve static files for non-API paths @@ -108,6 +115,25 @@ func (app *Application) handleHTTPRequest(w http.ResponseWriter, r *http.Request lambdaResponseToHTTP(w, lambdaResp) } +// handleVersion serves the public /version endpoint. It dispatches through the +// API router (the api package owns the response shape and the AuthPublic route) +// but, unlike handleHTTPRequest, deliberately skips ensureDB: the build-version +// metadata is read from process environment variables stamped at build time, so +// it must remain answerable even when the database is unreachable. That is +// precisely the scenario where confirming the deployed commit matters most. +func (app *Application) handleVersion(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + lambdaResp, err := app.API.HandleRequest(ctx, httpToLambdaRequest(r)) + if err != nil { + log.Printf("Version handler error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + lambdaResponseToHTTP(w, lambdaResp) +} + // scheduledAuthMiddleware returns the configured scheduledauth middleware, // or a passthrough no-op if app.scheduledAuth is nil. Tests that build // an Application directly (without NewApplicationFromDeps) leave it