Closes the silent-deploy-rot gap caught by Phase 3n's triage. The problem: a missing Gitea webhook left 11 commits stuck on an old container while /healthz kept reporting 200 from the stale binary. With no commit-level evidence on the wire, "deploy rolled" was unverifiable. Mechanism: - Dockerfile installs git, reads `git rev-parse --short HEAD` at build time, injects via `-ldflags="-X main.gitCommit=<sha>"`. Works under Dokploy's `git clone --depth 1` flow (the .git/ folder is in the build context) and under plain `docker build .` (same). Local `go run` falls back to "unknown". - main.gitCommit assigns to web.Server.Version in main(). - /healthz now emits two lines: "ok" and "version: <sha>". Endpoint remains unauthenticated so any worker / monitor can verify "deploy rolled" without a session. CLAUDE.md gets a mandatory "Post-deploy verification" section: after every push, compare `git rev-parse --short HEAD` against `curl /healthz | tail -1`. Mismatch = webhook broken; inspect Gitea hook 172 (URL pattern `http://mlake.horse-ayu.ts.net:3000/api/deploy/ <refreshToken>` per the working webhooks on m/msbls.de + m/flexsiebels.de). TestHealthzSurfacesVersion regression-guards the new line. Existing TestHealthz updated to accept the multi-line body.
149 lines
4.1 KiB
Go
149 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/caldav"
|
|
"github.com/m/projax/db"
|
|
"github.com/m/projax/gitea"
|
|
"github.com/m/projax/mcp"
|
|
"github.com/m/projax/store"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
// gitCommit is the short SHA of HEAD at build time, injected via
|
|
// -ldflags="-X main.gitCommit=...". Defaults to "unknown" so local `go run`
|
|
// without ldflags still works. Surfaced on /admin's system panel so every
|
|
// shift can verify which commit is actually running — closes the silent
|
|
// deploy-rot gap from Phase 3n's triage.
|
|
var gitCommit = "unknown"
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
dbURL := os.Getenv("PROJAX_DB_URL")
|
|
if dbURL == "" {
|
|
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
|
}
|
|
if dbURL == "" {
|
|
logger.Error("startup: set PROJAX_DB_URL (or SUPABASE_DATABASE_URL)")
|
|
os.Exit(1)
|
|
}
|
|
listen := os.Getenv("PROJAX_LISTEN_ADDR")
|
|
if listen == "" {
|
|
listen = ":8080"
|
|
}
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
pool, err := pgxpool.New(ctx, dbURL)
|
|
if err != nil {
|
|
logger.Error("db pool", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
logger.Error("db ping", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if os.Getenv("PROJAX_AUTO_MIGRATE") != "off" {
|
|
if err := db.ApplyMigrations(ctx, pool); err != nil {
|
|
logger.Error("apply migrations", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
logger.Info("migrations applied")
|
|
}
|
|
|
|
srv, err := web.New(store.New(pool), logger)
|
|
if err != nil {
|
|
logger.Error("server init", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
srv.Version = gitCommit
|
|
logger.Info("startup", "version", gitCommit)
|
|
|
|
if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" {
|
|
anon := os.Getenv("SUPABASE_ANON_KEY")
|
|
if anon == "" {
|
|
logger.Error("SUPABASE_URL set but SUPABASE_ANON_KEY missing — refusing to start")
|
|
os.Exit(1)
|
|
}
|
|
srv.Auth = &web.AuthConfig{
|
|
SupabaseURL: supaURL,
|
|
AnonKey: anon,
|
|
}
|
|
logger.Info("auth: own-login enabled", "supabase", supaURL)
|
|
} else {
|
|
logger.Warn("auth: disabled — SUPABASE_URL not set, every request is anonymous")
|
|
}
|
|
|
|
if davURL := os.Getenv("DAV_URL"); davURL != "" {
|
|
davUser := os.Getenv("DAV_USER")
|
|
davPass := os.Getenv("DAV_PASSWORD")
|
|
if davUser == "" || davPass == "" {
|
|
logger.Error("DAV_URL set but DAV_USER / DAV_PASSWORD missing — refusing to start")
|
|
os.Exit(1)
|
|
}
|
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(davURL, davUser, davPass)}
|
|
logger.Info("caldav: enabled", "base_url", davURL)
|
|
} else {
|
|
logger.Info("caldav: disabled — DAV_URL not set")
|
|
}
|
|
|
|
if giteaURL := os.Getenv("GITEA_URL"); giteaURL != "" {
|
|
giteaToken := os.Getenv("GITEA_TOKEN")
|
|
if giteaToken == "" {
|
|
logger.Error("GITEA_URL set but GITEA_TOKEN missing — refusing to start")
|
|
os.Exit(1)
|
|
}
|
|
srv.Gitea = web.NewGiteaDeps(gitea.New(giteaURL, giteaToken))
|
|
logger.Info("gitea: enabled", "base_url", giteaURL)
|
|
} else {
|
|
logger.Info("gitea: disabled — GITEA_URL not set")
|
|
}
|
|
|
|
if mcpToken := os.Getenv("PROJAX_MCP_TOKEN"); mcpToken != "" {
|
|
mcpSrv := mcp.New("projax", "0.1.0", mcpToken, logger)
|
|
mcp.RegisterProjaxTools(mcpSrv, store.New(pool))
|
|
mcpMux := http.NewServeMux()
|
|
mcpSrv.Routes(mcpMux)
|
|
srv.MCP = mcpMux
|
|
logger.Info("mcp: enabled", "path", "/mcp")
|
|
} else {
|
|
logger.Info("mcp: disabled — PROJAX_MCP_TOKEN not set")
|
|
}
|
|
|
|
httpServer := &http.Server{
|
|
Addr: listen,
|
|
Handler: srv.Routes(),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = httpServer.Shutdown(shutdownCtx)
|
|
}()
|
|
|
|
logger.Info("listening", "addr", listen)
|
|
if err := httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
|
logger.Error("listen", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
logger.Info("shutdown clean")
|
|
}
|