Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()

Expand Down
42 changes: 42 additions & 0 deletions internal/api/handler_version.go
Original file line number Diff line number Diff line change
@@ -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
}
69 changes: 69 additions & 0 deletions internal/api/handler_version_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
10 changes: 10 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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)
}
Expand Down
26 changes: 26 additions & 0 deletions internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading