Phase 0: bootstrap the Warp homeserver
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

Scaffolds the server module and everything Phase 0 of PLAN.md calls for:
a binary that boots, serves its discovery endpoints and nothing else yet.

- config: one YAML file plus per-field WARP_* environment overrides,
  unknown fields rejected, validation that reports every problem at once
- logging and metrics: slog, a Prometheus registry, per-route HTTP
  instrumentation labelled by pattern rather than path
- httputil: the Matrix error envelope, error-returning handlers, CORS,
  request logging, panic recovery, and a router over http.ServeMux
- endpoints: /_matrix/client/versions, /.well-known/matrix/client,
  /.well-known/matrix/server (gated), /healthz, /metrics, and
  M_UNRECOGNIZED for everything else
- storage: driver registry, shared database/sql base, checksummed
  migration runner, sqlite and postgres drivers, pocketbase stub
- settings: server_settings table behind a cached service, so the
  federation and registration switches change without a restart
- federation: the gate that makes federation a runtime mode
- cmd/warp: serve, migrate, generate-keys, version
- Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests,
  race on Linux, storage tests against PostgreSQL, lint, boot smoke test),
  Dockerfile, example config and docs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-15 17:01:13 +02:00
commit b4fb60a8ab
50 files changed
+5672

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
package setup
import (
"net/http"
"time"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// SpecVersions is what GET /_matrix/client/versions reports.
//
// Keep it honest: a version belongs here once the endpoints a client
// actually uses from it work. Phase 0 serves only /versions and
// /.well-known, so the list stays minimal and grows as the C-S API lands in
// Phases 1 to 4.
var SpecVersions = []string{"v1.13", "v1.14"}
// UnstableFeatures advertises MSC support. Simplified Sliding Sync
// (MSC4186) is Phase 10.
var UnstableFeatures = map[string]bool{}
type versionsResponse struct {
Versions []string `json:"versions"`
UnstableFeatures map[string]bool `json:"unstable_features,omitempty"`
}
// wellKnownClient is the /.well-known/matrix/client document.
type wellKnownClient struct {
Homeserver wellKnownBaseURL `json:"m.homeserver"`
IdentityServer *wellKnownBaseURL `json:"m.identity_server,omitempty"`
}
type wellKnownBaseURL struct {
BaseURL string `json:"base_url"`
}
type wellKnownServer struct {
Server string `json:"m.server"`
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
Database string `json:"database"`
Error string `json:"error,omitempty"`
}
// routes registers every endpoint Phase 0 serves.
func (s *Server) routes() *httputil.Router {
r := httputil.NewRouter(s.metrics.Instrument)
r.HandleFunc("GET /_matrix/client/versions", s.handleVersions)
r.HandleFunc("GET /.well-known/matrix/client", s.handleWellKnownClient)
// The server-side delegation document is part of the federation
// surface, so it goes through the gate and disappears with it.
r.Handle("GET /.well-known/matrix/server", s.federation.ProtectFunc(s.handleWellKnownServer))
r.HandleFunc("GET /healthz", s.handleHealth)
if s.cfg.Metrics.Enabled {
r.Handle("GET "+s.cfg.Metrics.Path, s.metrics.Handler())
}
return r
}
// handleVersions implements GET /_matrix/client/versions.
func (s *Server) handleVersions(w http.ResponseWriter, _ *http.Request) error {
httputil.CacheFor(w, time.Hour)
httputil.WriteJSON(w, http.StatusOK, versionsResponse{
Versions: SpecVersions,
UnstableFeatures: UnstableFeatures,
})
return nil
}
// handleWellKnownClient implements GET /.well-known/matrix/client, the
// document a client fetches to turn "@me:warp.example" into an API URL.
func (s *Server) handleWellKnownClient(w http.ResponseWriter, _ *http.Request) error {
doc := wellKnownClient{
Homeserver: wellKnownBaseURL{BaseURL: s.cfg.ClientBaseURL()},
}
if url := s.cfg.Global.WellKnown.IdentityServerBaseURL; url != "" {
doc.IdentityServer = &wellKnownBaseURL{BaseURL: url}
}
httputil.CacheFor(w, time.Hour)
httputil.WriteJSON(w, http.StatusOK, doc)
return nil
}
// handleWellKnownServer implements GET /.well-known/matrix/server. With no
// address configured there is nothing to delegate and the right answer is
// 404: other servers then fall back to SRV and A record resolution.
func (s *Server) handleWellKnownServer(w http.ResponseWriter, _ *http.Request) error {
address := s.cfg.Global.WellKnown.ServerAddress
if address == "" {
return httputil.NotFound("No server delegation is configured")
}
httputil.CacheFor(w, time.Hour)
httputil.WriteJSON(w, http.StatusOK, wellKnownServer{Server: address})
return nil
}
// handleHealth implements GET /healthz: a liveness and readiness check in
// one. It reports 503 when the database is unreachable, which is what a
// load balancer needs to stop sending traffic here.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) error {
resp := healthResponse{
Status: "ok",
Version: buildinfo.Version,
Database: s.db.Driver(),
}
status := http.StatusOK
ctx, cancel := contextWithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.Ping(ctx); err != nil {
resp.Status = "unavailable"
resp.Error = "database unreachable"
status = http.StatusServiceUnavailable
httputil.LoggerFrom(r.Context()).Error("health check failed", "error", err)
}
httputil.NoCache(w)
httputil.WriteJSON(w, status, resp)
return nil
}
+258
View File
@@ -0,0 +1,258 @@
// Package setup wires the components together into a running server: it
// opens storage, loads the signing key, builds the runtime-settings service
// and the HTTP stack, and owns the graceful shutdown.
package setup
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sync/atomic"
"time"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/metrics"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers
)
// Server is a configured, not yet listening homeserver.
type Server struct {
cfg *config.Config
log *slog.Logger
db storage.Database
settings *settings.Service
metrics *metrics.Registry
federation *federation.Gate
signingKey *signing.KeyPair
handler http.Handler
// addr is the address Run is listening on; empty until then.
addr atomic.Pointer[string]
// ownsDB is false when the caller supplied the database and will close
// it itself.
ownsDB bool
}
// Options are the knobs tests and commands need beyond the config file.
type Options struct {
// Logger receives everything the server logs.
Logger *slog.Logger
// Database, when set, is used instead of opening one from the config.
// Tests pass an in-memory SQLite database this way.
Database storage.Database
// SkipSigningKey starts the server without loading the signing key.
// Only tests and `warp migrate` use it: a real server must be able to
// sign.
SkipSigningKey bool
}
// New builds the server: storage, migrations, settings, routes. It does not
// listen; call Run for that.
func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error) {
log := opts.Logger
if log == nil {
log = slog.Default()
}
s := &Server{cfg: cfg, log: log, metrics: metrics.NewRegistry()}
if opts.Database != nil {
s.db = opts.Database
} else {
db, err := storage.Open(ctx, storage.Config{
Driver: cfg.Database.Driver,
ConnectionString: cfg.Database.ConnectionString,
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
}, log)
if err != nil {
return nil, err
}
s.db, s.ownsDB = db, true
}
if err := s.prepareSchema(ctx); err != nil {
_ = s.closeDB()
return nil, err
}
if !opts.SkipSigningKey {
key, err := signing.Load(cfg.Global.PrivateKeyPath)
if err != nil {
_ = s.closeDB()
return nil, fmt.Errorf("%w\nrun `warp generate-keys --config <file>` to create one", err)
}
s.signingKey = key
log.Info("loaded signing key", "key_id", key.KeyID(), "path", cfg.Global.PrivateKeyPath)
}
settingsService, err := settings.New(ctx, s.db.Settings(), settings.Defaults{
FederationEnabled: cfg.Federation.Enabled,
FederationAllowRuntimeToggle: cfg.Federation.AllowRuntimeToggle,
RegistrationEnabled: cfg.Registration.Enabled,
RegistrationAllowRuntimeToggle: cfg.Registration.AllowRuntimeToggle,
}, log)
if err != nil {
_ = s.closeDB()
return nil, err
}
s.settings = settingsService
s.federation = federation.NewGate(
settingsService.FederationEnabled,
federation.NewPolicy(cfg.Federation.Allowlist, cfg.Federation.Denylist),
)
// Phase 5 replaces this with starting and stopping the federation
// module; for now it is the audit trail for a runtime toggle.
settingsService.Subscribe(func(snap settings.Snapshot) {
log.Info("federation switch applied", "enabled", snap.FederationEnabled)
})
s.handler = httputil.Chain(s.routes(),
httputil.Recoverer,
httputil.RequestLogger(log),
s.metrics.InFlight(),
httputil.CORS(httputil.CORSOptions{AllowedOrigins: cfg.ClientAPI.CORSAllowedOrigins}),
)
return s, nil
}
// prepareSchema migrates the database, or refuses to start when migrations
// are pending and auto-migration is off.
func (s *Server) prepareSchema(ctx context.Context) error {
if s.cfg.Database.AutoMigrate {
if err := s.db.Migrate(ctx); err != nil {
return fmt.Errorf("migrate database: %w", err)
}
return nil
}
pending, err := s.db.PendingMigrations(ctx)
if err != nil {
return fmt.Errorf("check migrations: %w", err)
}
if len(pending) > 0 {
return fmt.Errorf("database has %d pending migration(s): %v; run `warp migrate`", len(pending), pending)
}
return nil
}
// Addr reports the address Run is listening on, or "" before it starts.
// Tests and `warp serve` on port 0 use it to find the chosen port.
func (s *Server) Addr() string {
if p := s.addr.Load(); p != nil {
return *p
}
return ""
}
// Handler exposes the fully wrapped HTTP handler, for tests.
func (s *Server) Handler() http.Handler { return s.handler }
// Settings exposes the runtime-settings service; the admin API in Phase 1
// is its first user.
func (s *Server) Settings() *settings.Service { return s.settings }
// Database exposes the storage handle.
func (s *Server) Database() storage.Database { return s.db }
// SigningKey returns the server signing key, or nil when it was skipped.
func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey }
// Run listens and serves until ctx is cancelled, then shuts down
// gracefully: it stops accepting connections and gives in-flight requests
// up to listen.shutdown_timeout to finish.
func (s *Server) Run(ctx context.Context) error {
listener, err := net.Listen("tcp", s.cfg.Listen.Address)
if err != nil {
return fmt.Errorf("listen on %s: %w", s.cfg.Listen.Address, err)
}
addr := listener.Addr().String()
s.addr.Store(&addr)
defer s.addr.Store(nil)
httpServer := &http.Server{
Handler: s.handler,
ReadHeaderTimeout: s.cfg.Listen.ReadHeaderTimeout,
ReadTimeout: s.cfg.Listen.ReadTimeout,
WriteTimeout: s.cfg.Listen.WriteTimeout,
IdleTimeout: s.cfg.Listen.IdleTimeout,
MaxHeaderBytes: s.cfg.Listen.MaxHeaderBytes,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug),
BaseContext: func(net.Listener) context.Context { return ctx },
}
s.log.Info("warp is listening",
"address", addr,
"server_name", s.cfg.Global.ServerName,
"version", buildinfo.String(),
"database", s.db.Driver(),
"federation", s.settings.FederationEnabled(),
"registration", s.settings.RegistrationEnabled(),
)
errCh := make(chan error, 1)
go func() {
err := httpServer.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errCh <- err
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
s.log.Info("shutting down", "timeout", s.cfg.Listen.ShutdownTimeout)
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.cfg.Listen.ShutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
s.log.Warn("graceful shutdown timed out; closing connections", "error", err)
_ = httpServer.Close()
}
// Serve has returned by now; surface anything it reported.
if err := <-errCh; err != nil {
return err
}
s.log.Info("stopped")
return nil
}
// Close releases the resources New acquired.
func (s *Server) Close() error {
return s.closeDB()
}
func (s *Server) closeDB() error {
if s.db == nil || !s.ownsDB {
return nil
}
err := s.db.Close()
s.db = nil
return err
}
// contextWithTimeout is a thin wrapper so handlers do not import context
// just for a deadline.
func contextWithTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, d)
}
+350
View File
@@ -0,0 +1,350 @@
package setup_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/setup"
)
// newTestServer builds a server on a throwaway SQLite database.
func newTestServer(t *testing.T, tweak func(*config.Config)) *setup.Server {
t.Helper()
cfg := config.Default()
cfg.Global.ServerName = "warp.example"
cfg.Database.ConnectionString = "file:" + t.Name() + "?mode=memory&cache=shared"
cfg.Metrics.Enabled = true
if tweak != nil {
tweak(cfg)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("test config is invalid: %v", err)
}
srv, err := setup.New(t.Context(), cfg, setup.Options{
Logger: slog.New(slog.DiscardHandler),
SkipSigningKey: true,
})
if err != nil {
t.Fatalf("setup.New: %v", err)
}
t.Cleanup(func() { _ = srv.Close() })
return srv
}
func get(t *testing.T, srv *setup.Server, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
body, err := io.ReadAll(rec.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("response is not JSON (%s): %v", body, err)
}
return out
}
func TestVersions(t *testing.T) {
srv := newTestServer(t, nil)
rec := get(t, srv, http.MethodGet, "/_matrix/client/versions")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
body := decode[struct {
Versions []string `json:"versions"`
}](t, rec)
if len(body.Versions) == 0 {
t.Error("versions must not be empty")
}
for _, v := range body.Versions {
if !strings.HasPrefix(v, "v") && !strings.HasPrefix(v, "r") {
t.Errorf("version %q does not look like a spec version", v)
}
}
}
func TestWellKnownClient(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.Global.WellKnown.IdentityServerBaseURL = "https://identity.example"
})
rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := decode[map[string]map[string]string](t, rec)
if got, want := body["m.homeserver"]["base_url"], "https://warp.example"; got != want {
t.Errorf("base_url = %q, want %q", got, want)
}
if got, want := body["m.identity_server"]["base_url"], "https://identity.example"; got != want {
t.Errorf("identity server = %q, want %q", got, want)
}
}
func TestWellKnownClientOmitsIdentityServerByDefault(t *testing.T) {
srv := newTestServer(t, nil)
rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client")
body := decode[map[string]any](t, rec)
if _, present := body["m.identity_server"]; present {
t.Error("m.identity_server must be omitted when it is not configured")
}
}
// TestFederationGate is the Phase 0 proof of PLAN.md section 3: the
// federation surface is absent while the switch is off, appears when it is
// flipped at runtime, and disappears again — with no restart.
func TestFederationGate(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.Global.WellKnown.ServerAddress = "warp.example:8448"
})
rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server")
if rec.Code != http.StatusNotFound {
t.Fatalf("with federation off, status = %d, want 404", rec.Code)
}
if code := decode[map[string]string](t, rec)["errcode"]; code != "M_UNRECOGNIZED" {
t.Errorf("errcode = %q, want M_UNRECOGNIZED", code)
}
if err := srv.Settings().SetFederationEnabled(t.Context(), true); err != nil {
t.Fatalf("SetFederationEnabled: %v", err)
}
rec = get(t, srv, http.MethodGet, "/.well-known/matrix/server")
if rec.Code != http.StatusOK {
t.Fatalf("with federation on, status = %d, want 200", rec.Code)
}
if got, want := decode[map[string]string](t, rec)["m.server"], "warp.example:8448"; got != want {
t.Errorf("m.server = %q, want %q", got, want)
}
if err := srv.Settings().SetFederationEnabled(t.Context(), false); err != nil {
t.Fatalf("SetFederationEnabled: %v", err)
}
if rec = get(t, srv, http.MethodGet, "/.well-known/matrix/server"); rec.Code != http.StatusNotFound {
t.Errorf("after switching off again, status = %d, want 404", rec.Code)
}
}
func TestWellKnownServerWithoutDelegation(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.Federation.Enabled = true })
rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 when no delegation is configured", rec.Code)
}
if code := decode[map[string]string](t, rec)["errcode"]; code != "M_NOT_FOUND" {
t.Errorf("errcode = %q, want M_NOT_FOUND", code)
}
}
func TestUnknownMatrixEndpoint(t *testing.T) {
srv := newTestServer(t, nil)
rec := get(t, srv, http.MethodGet, "/_matrix/client/v3/rooms/!a:b/messages")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
body := decode[map[string]string](t, rec)
if body["errcode"] != "M_UNRECOGNIZED" {
t.Errorf("errcode = %q, want M_UNRECOGNIZED", body["errcode"])
}
if body["error"] == "" {
t.Error("the error envelope must carry a human-readable message")
}
}
func TestCORSPreflight(t *testing.T) {
srv := newTestServer(t, nil)
req := httptest.NewRequest(http.MethodOptions, "/_matrix/client/versions", nil)
req.Header.Set("Origin", "https://app.example")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("preflight status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q, want *", got)
}
for _, header := range []string{"Authorization", "Content-Type", "X-Requested-With"} {
if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), header) {
t.Errorf("Access-Control-Allow-Headers is missing %s", header)
}
}
}
func TestCORSRestrictedOrigins(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.ClientAPI.CORSAllowedOrigins = []string{"https://app.example"}
})
req := httptest.NewRequest(http.MethodGet, "/_matrix/client/versions", nil)
req.Header.Set("Origin", "https://app.example")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if got, want := rec.Header().Get("Access-Control-Allow-Origin"), "https://app.example"; got != want {
t.Errorf("allowed origin = %q, want %q", got, want)
}
req = httptest.NewRequest(http.MethodGet, "/_matrix/client/versions", nil)
req.Header.Set("Origin", "https://evil.example")
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("unlisted origin got %q, want no allow-origin header", got)
}
}
func TestHealthz(t *testing.T) {
srv := newTestServer(t, nil)
rec := get(t, srv, http.MethodGet, "/healthz")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := decode[map[string]string](t, rec)
if body["status"] != "ok" {
t.Errorf("status = %q, want ok", body["status"])
}
if body["database"] != "sqlite" {
t.Errorf("database = %q, want sqlite", body["database"])
}
}
func TestMetrics(t *testing.T) {
srv := newTestServer(t, nil)
// Generate one request so the counters exist.
get(t, srv, http.MethodGet, "/_matrix/client/versions")
rec := get(t, srv, http.MethodGet, "/metrics")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"warp_build_info", "warp_http_requests_total"} {
if !strings.Contains(body, want) {
t.Errorf("metrics output is missing %s", want)
}
}
// The route label must be the pattern, not the path, or the metric
// explodes once rooms and users appear in URLs.
if !strings.Contains(body, `route="GET /_matrix/client/versions"`) {
t.Error("requests are not labelled with their route pattern")
}
}
func TestMetricsCanBeDisabled(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.Metrics.Enabled = false })
if rec := get(t, srv, http.MethodGet, "/metrics"); rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 when metrics are off", rec.Code)
}
}
func TestPendingMigrationsBlockStartup(t *testing.T) {
cfg := config.Default()
cfg.Global.ServerName = "warp.example"
cfg.Database.ConnectionString = "file:" + t.Name() + "?mode=memory&cache=shared"
cfg.Database.AutoMigrate = false
_, err := setup.New(t.Context(), cfg, setup.Options{
Logger: slog.New(slog.DiscardHandler),
SkipSigningKey: true,
})
if err == nil {
t.Fatal("expected startup to refuse a database with pending migrations")
}
if !strings.Contains(err.Error(), "warp migrate") {
t.Errorf("the error should say how to fix it, got: %v", err)
}
}
// TestRunServesAndShutsDownGracefully covers the lifecycle the signal
// handler drives: Run listens, serves, and returns cleanly once its
// context is cancelled.
func TestRunServesAndShutsDownGracefully(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.Listen.Address = "127.0.0.1:0"
c.Listen.ShutdownTimeout = 5 * time.Second
})
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- srv.Run(ctx) }()
var addr string
for range 100 {
if addr = srv.Addr(); addr != "" {
break
}
time.Sleep(10 * time.Millisecond)
}
if addr == "" {
cancel()
t.Fatal("the server never started listening")
}
resp, err := http.Get("http://" + addr + "/healthz")
if err != nil {
cancel()
t.Fatalf("GET /healthz: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run returned %v, want nil after a cancelled context", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Run did not return after its context was cancelled")
}
if resp, err := http.Get("http://" + addr + "/healthz"); err == nil {
_ = resp.Body.Close()
t.Error("the listener is still accepting connections after shutdown")
}
}
func TestRunReportsListenFailure(t *testing.T) {
// Not a valid "host:port", so net.Listen fails before blocking.
srv := newTestServer(t, func(c *config.Config) { c.Listen.Address = "nonsense" })
err := srv.Run(t.Context())
if err == nil {
t.Fatal("expected Run to fail on an unusable address")
}
if !strings.Contains(err.Error(), "listen on") {
t.Errorf("error should name the address, got: %v", err)
}
}