Vellum/cmd/vellum/main.go
mithral eb1ad6d920 Phase 0: skeleton, config, migrations, health check, container
Bootstraps Vellum as a single Go binary with one SQLite database.

- core: env-driven config, slog logging, SQLite (WAL) with pooled pragmas,
  embedded migration runner keyed on PRAGMA user_version
- migrations/0001_core.sql: identity, media, user state, infra. Media items
  are global; all per-user state (progress, favorites, grants) is a join.
  One polymorphic progress table covers every media kind.
- httpapi: request-ID, structured request logging, panic recovery, JSON
  endpoints for /healthz and /api/v1/version
- multi-arch Dockerfile (pure-Go SQLite, CGO_ENABLED=0) with an entrypoint
  that honours PUID/PGID like the rest of the self-hosted stack
- tests for migration idempotency, FK enforcement, WAL, config, middleware

Media does not exist yet. Nothing is exposed beyond health and version.
2026-10-09 16:26:08 -04:00

93 lines
2.2 KiB
Go

// Command vellum is the unified media server: one process, one database,
// every media type.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/migrations"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "vellum: fatal:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := core.LoadConfig()
if err != nil {
return err
}
log := core.NewLogger(cfg.LogLevel, cfg.LogFormat)
log.Info("starting vellum", "version", version, "listen", cfg.Listen)
if err := cfg.Validate(); err != nil {
return err
}
db, err := core.OpenDB(cfg.DBPath)
if err != nil {
return err
}
defer db.Close()
migCtx, cancelMig := context.WithTimeout(context.Background(), 60*time.Second)
err = core.Migrate(migCtx, db, migrations.FS, log)
cancelMig()
if err != nil {
return err
}
api := httpapi.New(db, log, version)
srv := &http.Server{
Addr: cfg.Listen,
Handler: api.Handler(),
// ReadHeaderTimeout protects against slowloris. WriteTimeout is
// deliberately zero: streaming large media files must not be cut off.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
ErrorLog: nil,
}
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Listen, "data_dir", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
log.Info("stopped")
return nil
}