Compare commits
1 Commits
mai/pasteu
...
mai/mendel
| Author | SHA1 | Date | |
|---|---|---|---|
| a657154b35 |
73
Makefile
Normal file
73
Makefile
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Paliad — developer entrypoints.
|
||||||
|
#
|
||||||
|
# Targets here are the gate tier from the test-strategy design
|
||||||
|
# (docs/design-paliad-test-strategy-2026-05-19.md). Slice 1 lands:
|
||||||
|
#
|
||||||
|
# make verify-migrations — dry-run every pending migration (BEGIN..ROLLBACK)
|
||||||
|
# plus the full boot smoke (apply + tracker
|
||||||
|
# advances + /healthz returns 200).
|
||||||
|
# make verify-mig — alias for verify-migrations.
|
||||||
|
# make test — short test pass: go test ./internal/... -short
|
||||||
|
# plus the cmd/server package. Includes the
|
||||||
|
# live-DB tests when TEST_DATABASE_URL is set,
|
||||||
|
# skips them otherwise.
|
||||||
|
# make test-go — go test ./... -race (full Go suite).
|
||||||
|
#
|
||||||
|
# Future slices will extend this with:
|
||||||
|
# make test-frontend — bun test (Slice 3 / Slice 6)
|
||||||
|
# make e2e — Playwright golden-path suite (Slice 4)
|
||||||
|
#
|
||||||
|
# All targets are idempotent. None of them write to the filesystem outside
|
||||||
|
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||||
|
# files.
|
||||||
|
|
||||||
|
.PHONY: help verify-migrations verify-mig test test-go
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Paliad — developer targets"
|
||||||
|
@echo ""
|
||||||
|
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
|
||||||
|
@echo " verify-mig Alias for verify-migrations"
|
||||||
|
@echo " test Short test pass — covers gate tier"
|
||||||
|
@echo " test-go Full Go suite with race detector"
|
||||||
|
@echo ""
|
||||||
|
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||||
|
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||||
|
|
||||||
|
# Gate target — the test that would have caught mig 098 / mig 099 before
|
||||||
|
# deploy. Combines:
|
||||||
|
# - TestMigrations_DryRun (internal/db): per-migration BEGIN..ROLLBACK
|
||||||
|
# - TestBootSmoke (cmd/server): apply-end-to-end + tracker advances
|
||||||
|
# + /healthz 200
|
||||||
|
#
|
||||||
|
# Requires TEST_DATABASE_URL. Without it, both tests skip and the target
|
||||||
|
# is effectively a no-op — guard against that explicitly so CI doesn't
|
||||||
|
# silently green a missing env var.
|
||||||
|
verify-migrations:
|
||||||
|
@if [ -z "$$TEST_DATABASE_URL" ]; then \
|
||||||
|
echo "ERROR: TEST_DATABASE_URL is not set."; \
|
||||||
|
echo " The migration gate cannot run without a scratch DB."; \
|
||||||
|
echo " Set TEST_DATABASE_URL to a Postgres URL the test can"; \
|
||||||
|
echo " open transactions against, e.g."; \
|
||||||
|
echo " export TEST_DATABASE_URL=postgres://paliad:PW@localhost:11833/paliad_test"; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
|
@echo "==> migration dry-run (per-mig BEGIN..ROLLBACK)"
|
||||||
|
go test -count=1 -run TestMigrations_DryRun ./internal/db/
|
||||||
|
@echo "==> boot smoke (apply + tracker + /healthz)"
|
||||||
|
go test -count=1 -run TestBootSmoke ./cmd/server/
|
||||||
|
|
||||||
|
verify-mig: verify-migrations
|
||||||
|
|
||||||
|
# Gate-tier test pass. -short skips the slow live-DB tests when the
|
||||||
|
# author opts out via `if testing.Short() { t.Skip(...) }`; today most of
|
||||||
|
# paliad's live-DB tests gate on TEST_DATABASE_URL instead, so -short is
|
||||||
|
# forward-compatible rather than load-bearing.
|
||||||
|
test:
|
||||||
|
go test -short ./internal/... ./cmd/...
|
||||||
|
|
||||||
|
# Full Go suite with race detection. Slower but catches concurrent-map
|
||||||
|
# regressions that -short would skip; intended for the merge-to-main gate
|
||||||
|
# (full suite, not per-PR).
|
||||||
|
test-go:
|
||||||
|
go test -race ./...
|
||||||
170
cmd/server/main_smoke_test.go
Normal file
170
cmd/server/main_smoke_test.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// Boot smoke test — assert paliad reaches a serving state.
|
||||||
|
//
|
||||||
|
// Three checks against TEST_DATABASE_URL:
|
||||||
|
//
|
||||||
|
// 1. db.ApplyMigrations does not panic and returns nil.
|
||||||
|
// 2. The migration tracker (public.paliad_schema_migrations) advances to
|
||||||
|
// the highest *.up.sql version on disk — no migrations were silently
|
||||||
|
// skipped, no "dirty=true" stragglers left behind.
|
||||||
|
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
|
||||||
|
//
|
||||||
|
// This is the lightweight cousin of the migration dry-run gate
|
||||||
|
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
|
||||||
|
// errors before merge; this smoke confirms the apply+bind path the
|
||||||
|
// container actually runs at boot. Together they cover the mig-098 /
|
||||||
|
// mig-099 class of crash-loops end-to-end.
|
||||||
|
//
|
||||||
|
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
|
||||||
|
//
|
||||||
|
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/internal/auth"
|
||||||
|
"mgit.msbls.de/m/paliad/internal/db"
|
||||||
|
"mgit.msbls.de/m/paliad/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBootSmoke(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set — skipping boot smoke")
|
||||||
|
}
|
||||||
|
|
||||||
|
// (1) Apply migrations end-to-end. The same code path the prod
|
||||||
|
// container runs at boot before `http.ListenAndServe`. A regression
|
||||||
|
// like mig-098's digit-regex would surface here as a non-nil error.
|
||||||
|
if err := db.ApplyMigrations(url); err != nil {
|
||||||
|
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) Assert the tracker advanced to the highest *.up.sql version we
|
||||||
|
// embed. If a migration was silently skipped or the tracker is dirty,
|
||||||
|
// the prod container would crash-loop — this turns that into a test
|
||||||
|
// failure with a precise reason.
|
||||||
|
expected := highestEmbeddedMigrationVersion(t)
|
||||||
|
got, dirty := readTrackerVersion(t, url)
|
||||||
|
if dirty {
|
||||||
|
t.Errorf("tracker reports dirty=true at version %d — investigate before deploying", got)
|
||||||
|
}
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("tracker at version %d; expected %d (highest *.up.sql on disk). "+
|
||||||
|
"A migration was skipped or applied out of order.",
|
||||||
|
got, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) Mount the public handlers (the same Register call main() makes,
|
||||||
|
// minus the DB-backed Services bundle which the /healthz route doesn't
|
||||||
|
// need) and assert /healthz returns 200. This is the bind-and-serve
|
||||||
|
// half of the smoke: catches a regression that would make /healthz
|
||||||
|
// 404 or break the mux registration order.
|
||||||
|
//
|
||||||
|
// We deliberately do not boot the full main() — that would require
|
||||||
|
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, an open
|
||||||
|
// listening socket and a real auth client. The /healthz handler is
|
||||||
|
// auth-independent by design, and Register registers it on the outer
|
||||||
|
// mux before any DB-backed route, so this minimal setup exercises the
|
||||||
|
// exact code path main() takes.
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
|
||||||
|
handlers.Register(mux, authClient, "", nil)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /healthz: status=%d, body=%q; want 200 OK", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
|
||||||
|
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// highestEmbeddedMigrationVersion finds max(N) over every NNN_*.up.sql
|
||||||
|
// file in internal/db/migrations/ on disk. Used as the expected tracker
|
||||||
|
// version after a clean apply. We read from disk (not the embed.FS in
|
||||||
|
// the db package — it's unexported) since the test runs from the repo.
|
||||||
|
func highestEmbeddedMigrationVersion(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
root, err := repoRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("locate repo root: %v", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(root, "internal", "db", "migrations")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migrations dir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
var versions []int
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(name, ".up.sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(name, ".up.sql")
|
||||||
|
underscore := strings.IndexByte(base, '_')
|
||||||
|
if underscore <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(base[:underscore])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
if len(versions) == 0 {
|
||||||
|
t.Fatalf("no *.up.sql files found in %s", dir)
|
||||||
|
}
|
||||||
|
sort.Ints(versions)
|
||||||
|
return versions[len(versions)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTrackerVersion fetches the lone row from the tracker. golang-migrate
|
||||||
|
// keeps exactly one row; if we ever see zero or more, that's the dirty-state
|
||||||
|
// the test is designed to flag.
|
||||||
|
func readTrackerVersion(t *testing.T, url string) (version int, dirty bool) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := sql.Open("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
row := conn.QueryRow(`SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`)
|
||||||
|
if err := row.Scan(&version, &dirty); err != nil {
|
||||||
|
t.Fatalf("read tracker: %v", err)
|
||||||
|
}
|
||||||
|
return version, dirty
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoRoot walks upward from the test binary's working directory until it
|
||||||
|
// finds a go.mod. `go test` runs in the package dir, so we typically have
|
||||||
|
// to climb a couple of levels.
|
||||||
|
func repoRoot() (string, error) {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
198
internal/db/migrate_test.go
Normal file
198
internal/db/migrate_test.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Package db tests — migration dry-run gate.
|
||||||
|
//
|
||||||
|
// This is the test that catches mig-N crash-loops before they reach prod.
|
||||||
|
// The convention since t-paliad-098/099 is that paliad migrations land in
|
||||||
|
// numeric order on a single trunk; the next deploy runs whichever ones are
|
||||||
|
// pending against the live `public.paliad_schema_migrations` tracker. A
|
||||||
|
// migration that compiles cleanly but fails on apply (typo, missing column,
|
||||||
|
// wrong CHECK shape) crashes the Dokploy container loop before paliad.de
|
||||||
|
// finishes binding :8080, and the only way to learn about it today is to
|
||||||
|
// watch the deploy log.
|
||||||
|
//
|
||||||
|
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
|
||||||
|
// directory whose version is greater than the scratch DB's current tracker
|
||||||
|
// version, it opens a transaction, runs the SQL, and ROLLBACKs. Any error
|
||||||
|
// fails the test with the file name + Postgres error. Always non-destructive
|
||||||
|
// — the ROLLBACK runs even on success, so the scratch DB stays at its
|
||||||
|
// starting version.
|
||||||
|
//
|
||||||
|
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
|
||||||
|
// tests). Skipped without it.
|
||||||
|
//
|
||||||
|
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migration is one *.up.sql file from the embedded migrations FS.
|
||||||
|
type migration struct {
|
||||||
|
version int
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
|
||||||
|
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
|
||||||
|
// fails the test on the first SQL error. Reports per-file as a sub-test so
|
||||||
|
// `go test -v` shows which migration failed.
|
||||||
|
//
|
||||||
|
// What "pending" means: greater than the scratch DB's current tracker
|
||||||
|
// version (or 0 if the tracker doesn't exist yet). In CI against a fresh
|
||||||
|
// scratch DB, every migration is pending and gets verified. On a developer
|
||||||
|
// laptop whose scratch DB is already at HEAD, no migrations are pending and
|
||||||
|
// the test logs the start version and passes — the protection only kicks in
|
||||||
|
// the moment a new *.up.sql lands in the tree before the developer runs
|
||||||
|
// `db.ApplyMigrations` against the same scratch DB.
|
||||||
|
func TestMigrations_DryRun(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set — skipping migration dry-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := sql.Open("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
t.Fatalf("ping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The paliad schema must exist before migration 001 runs against it,
|
||||||
|
// mirroring the bootstrap step in ApplyMigrations. Without this, a
|
||||||
|
// fresh scratch DB would fail migration 001's CREATE TABLE paliad.*
|
||||||
|
// statements inside the BEGIN/ROLLBACK probe with "schema paliad does
|
||||||
|
// not exist" — a false negative that distracts from real errors.
|
||||||
|
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
|
||||||
|
t.Fatalf("ensure paliad schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
startVersion, dirty, err := currentTrackerVersion(conn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tracker: %v", err)
|
||||||
|
}
|
||||||
|
if dirty {
|
||||||
|
t.Fatalf("tracker is dirty at version %d — fix that first (DROP the tracker row "+
|
||||||
|
"or restore from backup); the dry-run cannot trust a dirty starting state",
|
||||||
|
startVersion)
|
||||||
|
}
|
||||||
|
t.Logf("scratch DB tracker at version %d; walking pending migrations from %d upward",
|
||||||
|
startVersion, startVersion+1)
|
||||||
|
|
||||||
|
migs, err := loadPendingMigrations(startVersion)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load migrations: %v", err)
|
||||||
|
}
|
||||||
|
if len(migs) == 0 {
|
||||||
|
t.Logf("no pending migrations — scratch DB is at HEAD (%d)", startVersion)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migs {
|
||||||
|
t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
|
||||||
|
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", m.filename, err)
|
||||||
|
}
|
||||||
|
tx, err := conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin: %v", err)
|
||||||
|
}
|
||||||
|
// Always rollback; the dry-run must not leave the scratch DB
|
||||||
|
// at a different version than where it started. Rollback is
|
||||||
|
// safe to call even after a failed Exec — Postgres aborts the
|
||||||
|
// transaction internally on the first error.
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(string(body)); err != nil {
|
||||||
|
t.Fatalf("migration %s failed dry-run: %v", m.filename, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentTrackerVersion reads the latest version + dirty flag from the
|
||||||
|
// `public.paliad_schema_migrations` tracker. Returns (0, false, nil) when the
|
||||||
|
// tracker doesn't exist yet — that's the "fresh scratch DB" path.
|
||||||
|
//
|
||||||
|
// We don't use golang-migrate's API to read this because golang-migrate's
|
||||||
|
// driver locks the tracker row on read; a test runner that calls this while
|
||||||
|
// the developer has paliad running locally would race. A plain SELECT is
|
||||||
|
// race-safe and matches what `psql` would show.
|
||||||
|
func currentTrackerVersion(conn *sql.DB) (version int, dirty bool, err error) {
|
||||||
|
const q = `SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`
|
||||||
|
row := conn.QueryRow(q)
|
||||||
|
if scanErr := row.Scan(&version, &dirty); scanErr != nil {
|
||||||
|
// Missing table → fresh DB → start at 0. lib/pq surfaces this
|
||||||
|
// as `pq.Error.Code = "42P01"` (undefined_table); the simpler
|
||||||
|
// sql.ErrNoRows fires if the table exists but is empty (also
|
||||||
|
// fresh-DB-shaped).
|
||||||
|
if errors.Is(scanErr, sql.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(scanErr.Error(), "does not exist") {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
return 0, false, scanErr
|
||||||
|
}
|
||||||
|
return version, dirty, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPendingMigrations returns every *.up.sql in the embedded FS whose
|
||||||
|
// version is greater than startVersion, sorted by version ascending. A
|
||||||
|
// filename like "098_submission_codes_prefix_and_rename.up.sql" yields
|
||||||
|
// version=98, name="submission_codes_prefix_and_rename".
|
||||||
|
func loadPendingMigrations(startVersion int) ([]migration, error) {
|
||||||
|
entries, err := migrationFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
var out []migration
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(name, ".up.sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, n, ok := parseMigrationName(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unparseable migration filename: %s "+
|
||||||
|
"(expected NNN_description.up.sql)", name)
|
||||||
|
}
|
||||||
|
if v <= startVersion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, migration{version: v, name: n, filename: name})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMigrationName splits "NNN_description.up.sql" into (NNN, description).
|
||||||
|
// Returns ok=false on any deviation from that shape.
|
||||||
|
func parseMigrationName(filename string) (version int, name string, ok bool) {
|
||||||
|
base := strings.TrimSuffix(filename, ".up.sql")
|
||||||
|
if base == filename { // suffix wasn't present
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
underscore := strings.IndexByte(base, '_')
|
||||||
|
if underscore <= 0 {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(base[:underscore])
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
return v, base[underscore+1:], true
|
||||||
|
}
|
||||||
@@ -128,6 +128,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Liveness probe. Public, no auth, no DB touch — just confirms the
|
||||||
|
// process bound the listener and the goroutine is alive. Used by the
|
||||||
|
// boot-smoke test (cmd/server/main_smoke_test.go) to assert the server
|
||||||
|
// reaches a serving state after migrations apply; also safe for any
|
||||||
|
// future container orchestrator or uptime check.
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
|
})
|
||||||
|
|
||||||
// API endpoints (JSON, public)
|
// API endpoints (JSON, public)
|
||||||
mux.HandleFunc("POST /api/login", handleAPILogin)
|
mux.HandleFunc("POST /api/login", handleAPILogin)
|
||||||
mux.HandleFunc("POST /api/register", handleAPIRegister)
|
mux.HandleFunc("POST /api/register", handleAPIRegister)
|
||||||
|
|||||||
Reference in New Issue
Block a user