diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dbc0e5cb..74b97ff0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -176,6 +176,7 @@ jobs: - name: Build image run: | + echo "${GITHUB_SHA::7}" > .git-commit docker build -t meshcore-analyzer:latest . echo "Built $(git rev-parse --short HEAD)" @@ -249,6 +250,9 @@ jobs: - name: Deploy Go staging run: | echo "Building Go staging image..." + echo "${GITHUB_SHA::7}" > .git-commit + APP_VERSION=$(node -p "require('./package.json').version") \ + GIT_COMMIT="${GITHUB_SHA::7}" \ docker compose --profile staging-go build staging-go echo "Replacing Go staging container..." diff --git a/Dockerfile b/Dockerfile index a0d8fe0d..f6244072 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,10 @@ RUN npm ci --production COPY *.js config.example.json channel-rainbow.json ./ COPY public/ ./public/ +# Bake git commit SHA (CI writes .git-commit before build; fallback to "unknown") +COPY .git-commi[t] ./ +RUN if [ ! -f .git-commit ]; then echo "unknown" > .git-commit; fi + # Supervisor + Mosquitto + Caddy config COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf diff --git a/Dockerfile.go b/Dockerfile.go index 58693aaa..554ba3c5 100644 --- a/Dockerfile.go +++ b/Dockerfile.go @@ -2,12 +2,15 @@ FROM golang:1.22-alpine AS builder RUN apk add --no-cache build-base +ARG APP_VERSION=unknown +ARG GIT_COMMIT=unknown + # Build server WORKDIR /build/server COPY cmd/server/go.mod cmd/server/go.sum ./ RUN go mod download COPY cmd/server/ ./ -RUN go build -o /meshcore-server . +RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT}" -o /meshcore-server . # Build ingestor WORKDIR /build/ingestor @@ -30,6 +33,10 @@ COPY --from=builder /meshcore-server /meshcore-ingestor /app/ COPY public/ ./public/ COPY config.example.json channel-rainbow.json ./ +# Bake git commit SHA (CI writes .git-commit before build; fallback for non-ldflags usage) +COPY .git-commi[t] ./ +RUN if [ ! -f .git-commit ]; then echo "unknown" > .git-commit; fi + # Supervisor + Mosquitto + Caddy config COPY docker/supervisord-go.conf /etc/supervisor/conf.d/supervisord.conf COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf diff --git a/cmd/server/main.go b/cmd/server/main.go index a5aeedd4..8d880733 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,14 +7,44 @@ import ( "log" "net/http" "os" + "os/exec" "os/signal" "path/filepath" + "strings" "syscall" "time" "github.com/gorilla/mux" ) +// Set via -ldflags at build time +var Version string +var Commit string + +func resolveCommit() string { + if Commit != "" { + return Commit + } + // Try .git-commit file (baked by Docker / CI) + if data, err := os.ReadFile(".git-commit"); err == nil { + if c := strings.TrimSpace(string(data)); c != "" && c != "unknown" { + return c + } + } + // Try git rev-parse at runtime + if out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output(); err == nil { + return strings.TrimSpace(string(out)) + } + return "unknown" +} + +func resolveVersion() string { + if Version != "" { + return Version + } + return "unknown" +} + func main() { var ( configDir string diff --git a/cmd/server/routes.go b/cmd/server/routes.go index d537f9b2..febd7035 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -23,6 +23,8 @@ type Server struct { store *PacketStore // in-memory packet store (nil = fallback to DB) startedAt time.Time perfStats *PerfStats + version string + commit string } // PerfStats tracks request performance. @@ -56,6 +58,8 @@ func NewServer(db *DB, cfg *Config, hub *Hub) *Server { hub: hub, startedAt: time.Now(), perfStats: NewPerfStats(), + version: resolveVersion(), + commit: resolveCommit(), } } @@ -265,6 +269,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{ "status": "ok", "engine": "go", + "version": s.version, + "commit": s.commit, "uptime": int(uptime), "uptimeHuman": fmt.Sprintf("%dh %dm", int(uptime)/3600, (int(uptime)%3600)/60), "memory": map[string]interface{}{ @@ -316,6 +322,8 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { "totalObservers": stats.TotalObservers, "packetsLastHour": stats.PacketsLastHour, "engine": "go", + "version": s.version, + "commit": s.commit, "counts": counts, } writeJSON(w, result) diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index e4f8b05e..30cc2060 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -38,6 +38,12 @@ func TestHealthEndpoint(t *testing.T) { if body["engine"] != "go" { t.Errorf("expected engine go, got %v", body["engine"]) } + if _, ok := body["version"]; !ok { + t.Error("expected version field in health response") + } + if _, ok := body["commit"]; !ok { + t.Error("expected commit field in health response") + } } func TestStatsEndpoint(t *testing.T) { @@ -60,6 +66,12 @@ func TestStatsEndpoint(t *testing.T) { if body["engine"] != "go" { t.Errorf("expected engine go, got %v", body["engine"]) } + if _, ok := body["version"]; !ok { + t.Error("expected version field in stats response") + } + if _, ok := body["commit"]; !ok { + t.Error("expected commit field in stats response") + } } func TestPacketsEndpoint(t *testing.T) { diff --git a/docker-compose.yml b/docker-compose.yml index b731be4c..d7d593f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,9 @@ services: build: context: . dockerfile: Dockerfile.go + args: + APP_VERSION: ${APP_VERSION:-unknown} + GIT_COMMIT: ${GIT_COMMIT:-unknown} image: meshcore-go:latest container_name: meshcore-staging-go restart: unless-stopped diff --git a/server.js b/server.js index 04a7f4ea..a13ae3be 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,22 @@ const config = loadConfigFile(); const decoder = require('./decoder'); const PAYLOAD_TYPES = decoder.PAYLOAD_TYPES; const { nodeNearRegion, IATA_COORDS } = require('./iata-coords'); +const { execSync } = require('child_process'); + +// Version + git commit for /api/stats and /api/health +const APP_VERSION = (() => { + try { return require('./package.json').version; } catch { return 'unknown'; } +})(); +const GIT_COMMIT = (() => { + // 1. .git-commit file (baked by Docker / CI) + try { + const c = fs.readFileSync(path.join(__dirname, '.git-commit'), 'utf8').trim(); + if (c && c !== 'unknown') return c; + } catch { /* ignore */ } + // 2. git rev-parse at runtime + try { return execSync('git rev-parse --short HEAD', { encoding: 'utf8', timeout: 3000 }).trim(); } catch { /* ignore */ } + return 'unknown'; +})(); // Health thresholds — configurable with sensible defaults const HEALTH = buildHealthConfig(config); @@ -472,6 +488,8 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', engine: 'node', + version: APP_VERSION, + commit: GIT_COMMIT, uptime: Math.round(uptime), uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`, memory: { @@ -886,7 +904,7 @@ app.get('/api/stats', (req, res) => { const r = db.db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE role = ? AND last_seen > ?`).get(role, sevenDaysAgo); counts[role + 's'] = r.count; } - res.json({ ...stats, engine: 'node', counts }); + res.json({ ...stats, engine: 'node', version: APP_VERSION, commit: GIT_COMMIT, counts }); }); app.get('/api/packets', (req, res) => { diff --git a/test-server-routes.js b/test-server-routes.js index c2ae9a66..9326c67d 100644 --- a/test-server-routes.js +++ b/test-server-routes.js @@ -172,6 +172,8 @@ seedTestData(); const r = await request(app).get('/api/health').expect(200); assert(r.body.status, 'should have status'); assert(r.body.engine === 'node', 'health should include engine=node'); + assert(typeof r.body.version === 'string', 'health should include version'); + assert(typeof r.body.commit === 'string', 'health should include commit'); }); // --- Stats --- @@ -179,6 +181,8 @@ seedTestData(); const r = await request(app).get('/api/stats').expect(200); assert(typeof r.body === 'object', 'should return stats'); assert(r.body.engine === 'node', 'stats should include engine=node'); + assert(typeof r.body.version === 'string', 'stats should include version'); + assert(typeof r.body.commit === 'string', 'stats should include commit'); }); // --- Perf ---