Files
kandev/apps/backend/internal/backendapp/startup_database_test.go
T
2026-09-12 16:29:38 +01:00

617 lines
18 KiB
Go

package backendapp
import (
"bufio"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"syscall"
"testing"
"time"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/persistence"
"github.com/kandev/kandev/internal/startup"
"github.com/kandev/kandev/internal/system/backups"
"github.com/kandev/kandev/internal/system/jobs"
)
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.1, .5
func TestRunBindsBeforeDatabaseFailure(t *testing.T) {
core, logs := observer.New(zap.InfoLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = log.Close() })
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
cfg.HomeDir = t.TempDir()
cfg.Database.Path = filepath.Join(cfg.HomeDir, "data", "kandev.db")
if err := os.MkdirAll(cfg.Database.Path, 0700); err != nil {
t.Fatal(err)
} // Directory, not a database.
var cleanups []func() error
cleanup := func() {
for i := len(cleanups) - 1; i >= 0; i-- {
if cleanups[i] != nil {
_ = cleanups[i]()
}
}
cleanups = nil
}
defer cleanup()
if run(cfg, log, &cleanups, cleanup) {
t.Fatal("database failure reported success")
}
if logs.FilterMessage("HTTP listener bound; serving liveness probe while startup continues").Len() != 1 {
t.Fatal("database initialization ran before the liveness listener bound")
}
if _, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/health"); err == nil {
t.Fatal("failed initializer left healthy listener")
}
}
func TestBootstrapReportsStartupPhase(t *testing.T) {
w := httptest.NewRecorder()
newBootstrapHandler("test").ServeHTTP(w, httptest.NewRequest("GET", "/ready", nil))
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
startupBody, ok := body["startup"].(map[string]any)
if !ok {
t.Fatalf("startup body = %#v, want an object", body["startup"])
}
if startupBody["phase"] != string(startup.OpeningDatabase) {
t.Fatalf("startup phase = %v, want %q", startupBody["phase"], startup.OpeningDatabase)
}
for _, field := range []string{"elapsed_ms", "phase_elapsed_ms"} {
value, ok := startupBody[field].(float64)
if !ok || value < 0 {
t.Fatalf("startup %s = %v, want a non-negative number", field, startupBody[field])
}
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.2, .6
func TestBootstrapCancellationClosesListenerBeforeInitializerReturns(t *testing.T) {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
entered := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
done := make(chan bool, 1)
defer releaseOnce.Do(func() { close(release) })
go func() {
done <- runWithBootstrap(ctx, cfg, testLogger(t), func(ctx context.Context) bool {
close(entered)
<-release
return true
})
}()
<-entered
for _, path := range []string{"/ready", "/api/v1/tasks"} {
status, _, err := getJSON("127.0.0.1", cfg.Server.Port, path)
if err != nil || status != http.StatusServiceUnavailable {
t.Fatalf("%s: %d %v", path, status, err)
}
}
cancel()
deadline := time.After(2 * time.Second)
for {
_, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/health")
if err != nil {
break
}
select {
case <-deadline:
t.Fatal("canceled initialization left listener healthy")
default:
}
}
// The wrapper must join initialization instead of racing resource cleanup.
select {
case <-done:
t.Fatal("returned before initializer drained")
default:
}
releaseOnce.Do(func() { close(release) })
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("initializer did not drain after release")
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.1, .2, .3, .6
func TestBootstrapDelayedSuccessKeepsLivenessUntilReadiness(t *testing.T) {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
cfg.Launcher.HealthTimeoutMs = 100
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
entered := make(chan struct{})
release := make(chan struct{})
published := make(chan struct{})
done := make(chan bool, 1)
var releaseOnce sync.Once
var publishOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
go func() {
done <- runWithBootstrap(ctx, cfg, testLogger(t), func(ctx context.Context) bool {
runtime := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime)
close(entered)
<-release
application := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
writeBootstrapJSON(w, http.StatusOK, map[string]any{
statusKey: "ok",
serviceFieldKey: kandevName,
"mode": "websocket+http",
versionFieldKey: Version,
})
return
}
if r.URL.Path == "/ready" || r.URL.Path == "/api/v1/tasks" {
writeBootstrapJSON(w, http.StatusOK, map[string]any{statusKey: "ok"})
return
}
writeBootstrapJSON(w, http.StatusNotFound, map[string]any{statusKey: "not_found"})
})
if !runtime.beginReadinessPublication(ctx) {
return false
}
publishReadiness(func() {
ready.Store(true)
runtime.ready.Store(true)
publishOnce.Do(func() { close(published) })
}, func() { runtime.handler.Store(application) })
<-processContextFromContext(ctx).Done()
return true
})
}()
<-entered
// Keep the bootstrap initializer behind the same short health budget the
// launcher uses for its keep-or-kill decision. Readiness has no matching
// deadline: it must keep polling while recovery is still running.
healthDeadline := time.Now().Add(time.Duration(cfg.Launcher.HealthTimeoutMs) * time.Millisecond)
for time.Now().Before(healthDeadline) {
status, body, err := getJSON("127.0.0.1", cfg.Server.Port, "/health")
if err != nil || status != http.StatusOK {
t.Fatalf("bootstrap /health during delayed initialization = %d, %v; want 200", status, err)
}
if body[statusKey] != "ok" || body[serviceFieldKey] != kandevName || body[versionFieldKey] != Version {
t.Fatalf("bootstrap /health identity = %#v, want status=ok service=%q version=%q", body, kandevName, Version)
}
status, _, err = getJSON("127.0.0.1", cfg.Server.Port, "/ready")
if err != nil || status != http.StatusServiceUnavailable {
t.Fatalf("bootstrap /ready during delayed initialization = %d, %v; want 503", status, err)
}
status, _, err = getJSON("127.0.0.1", cfg.Server.Port, "/api/v1/tasks")
if err != nil || status != http.StatusServiceUnavailable {
t.Fatalf("bootstrap application route during delayed initialization = %d, %v; want 503", status, err)
}
}
select {
case result := <-done:
t.Fatalf("bootstrap exited before readiness handoff: %v", result)
default:
}
select {
case <-published:
t.Fatal("readiness published before initialization gate released")
default:
}
readyDone := make(chan error, 1)
go func() {
for {
status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/ready")
if err != nil {
readyDone <- err
return
}
if status == http.StatusOK {
readyDone <- nil
return
}
time.Sleep(10 * time.Millisecond)
}
}()
select {
case err := <-readyDone:
t.Fatalf("unbounded readiness wait completed before initialization release: %v", err)
case <-time.After(50 * time.Millisecond):
}
releaseOnce.Do(func() { close(release) })
select {
case <-published:
case <-time.After(2 * time.Second):
t.Fatal("initializer did not publish readiness after release")
}
select {
case err := <-readyDone:
if err != nil {
t.Fatalf("readiness wait after release: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("readiness wait did not observe router handoff")
}
if status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/api/v1/tasks"); err != nil || status != http.StatusOK {
t.Fatalf("application route after readiness = %d, %v; want 200", status, err)
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("bootstrap did not stop after explicit process cancellation")
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.3, .6
func TestBootstrapRestoreKeepsProcessAliveUntilResultAndShutdown(t *testing.T) {
root := t.TempDir()
databasePath := filepath.Join(root, "kandev.db")
backupsDir := filepath.Join(root, "backups")
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(databasePath, []byte("before"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backupsDir, "manual-restore.db"), []byte("after"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
restoreBlocked := make(chan struct{})
restoreRelease := make(chan struct{})
jobIDReady := make(chan string, 1)
resultReady := make(chan *jobs.Job, 1)
bootstrapReady := make(chan struct{})
done := make(chan bool, 1)
var restoreBlockedOnce sync.Once
var bootstrapReadyOnce sync.Once
var restoreReleaseOnce sync.Once
defer restoreReleaseOnce.Do(func() { close(restoreRelease) })
go func() {
done <- runWithBootstrap(ctx, cfg, testLogger(t), func(ctx context.Context) bool {
runtime := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime)
tracker := jobs.NewTracker(nil, testLogger(t))
svc := backups.NewService(databasePath, nil, tracker, testLogger(t))
svc.RestoreQuiesce = func() error {
return quiesceForRestore(
workerCancelFromContext(ctx),
func() error {
restoreBlockedOnce.Do(func() { close(restoreBlocked) })
<-restoreRelease
return nil
}, nil, nil, nil,
)
}
application := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" || r.URL.Path == "/ready" {
writeBootstrapJSON(w, http.StatusOK, map[string]any{statusKey: "ok"})
return
}
writeBootstrapJSON(w, http.StatusNotFound, map[string]any{statusKey: "not_found"})
})
if !runtime.beginReadinessPublication(ctx) {
return false
}
publishReadiness(func() {
ready.Store(true)
runtime.ready.Store(true)
bootstrapReadyOnce.Do(func() { close(bootstrapReady) })
}, func() { runtime.handler.Store(application) })
jobID, err := svc.Restore(context.Background(), "manual-restore.db", backups.RestoreConfirmToken)
if err != nil {
return false
}
jobIDReady <- jobID
<-restoreBlocked
<-restoreRelease
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if job := tracker.Get(jobID); job != nil && job.State == jobs.StateSucceeded {
resultReady <- job
<-processContextFromContext(ctx).Done()
return true
}
time.Sleep(5 * time.Millisecond)
}
return false
})
}()
select {
case <-bootstrapReady:
case <-time.After(2 * time.Second):
t.Fatal("bootstrap did not publish readiness")
}
select {
case <-jobIDReady:
case <-time.After(2 * time.Second):
t.Fatal("restore job was not submitted")
}
select {
case <-restoreBlocked:
case <-time.After(2 * time.Second):
t.Fatal("restore did not reach the blocked quiesce boundary")
}
if status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/health"); err != nil || status != http.StatusOK {
t.Fatalf("/health while restore quiesce is blocked = %d, %v; want 200", status, err)
}
if status, _, err := getJSON("127.0.0.1", cfg.Server.Port, "/ready"); err != nil || status != http.StatusOK {
t.Fatalf("/ready while restore quiesce is blocked = %d, %v; want 200", status, err)
}
select {
case result := <-done:
t.Fatalf("backend exited while restore was blocked: %v", result)
default:
}
restoreReleaseOnce.Do(func() { close(restoreRelease) })
select {
case job := <-resultReady:
if got, _ := job.Result["restart_required"].(bool); !got {
t.Fatalf("restore result = %#v, want restart_required=true", job.Result)
}
case <-time.After(2 * time.Second):
t.Fatal("restore result was not delivered")
}
select {
case <-done:
t.Fatal("backend exited before explicit shutdown after restore result")
default:
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("backend did not exit after explicit shutdown")
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.5, .7
func TestRepositoryBackupFailureReportsPhaseAndPreventsMigration(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dataDir, "kandev.db")
db, err := sqlx.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if _, err = db.Exec("CREATE TABLE sentinel(value TEXT); INSERT INTO sentinel VALUES ('original')"); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(dataDir, "backups"), []byte("blocked"), 0600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{HomeDir: dir}
cfg.Database.Path = path
progress := startup.New(nil)
ctx := startup.WithReporter(context.Background(), progress)
_, _, _, err = provideRepositories(ctx, cfg, testLogger(t), "upgrade-test")
if err == nil {
t.Fatal("backup failure did not fail initialization")
}
if got := progress.Snapshot().Phase; got != startup.BackingUpDatabase {
t.Fatalf("phase=%s, want backup", got)
}
var count int
if err = db.Get(&count, "SELECT count(*) FROM sqlite_master WHERE name='tasks'"); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("migration ran after failed backup")
}
}
func TestCanceledPersistenceDoesNotStartBackup(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{HomeDir: dir}
cfg.Database.Path = filepath.Join(dir, "test.db")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, cleanup, err := persistence.ProvideContext(ctx, cfg, nil, "test")
if cleanup != nil {
defer func() { _ = cleanup() }()
}
if err == nil {
t.Fatal("canceled initialization opened persistence")
}
}
func TestRepositoryFailureClosesDatabase(t *testing.T) {
if _, err := os.Stat("/proc/self/fd"); err != nil {
t.Skip("requires Linux descriptor inspection")
}
dir := t.TempDir()
data := filepath.Join(dir, "data")
if err := os.MkdirAll(data, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(data, "kandev.db")
db, err := sqlx.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
if _, err = db.Exec("CREATE TABLE tasks(id TEXT)"); err != nil {
t.Fatal(err)
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
cfg := &config.Config{HomeDir: dir}
cfg.Database.Path = path
_, _, cleanups, err := provideRepositories(context.Background(), cfg, testLogger(t), "")
for _, cleanup := range cleanups {
if cleanup != nil {
_ = cleanup()
}
}
if err == nil {
t.Fatal("invalid schema unexpectedly initialized")
}
fds, err := os.ReadDir("/proc/self/fd")
if err != nil {
t.Fatal(err)
}
for _, fd := range fds {
target, _ := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
if target == path {
t.Fatalf("failed migration leaked database descriptor %s", fd.Name())
}
}
}
func TestBootstrapSignalDuringInitialization(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signals")
}
if os.Getenv("KANDEV_TEST_BOOTSTRAP_SIGNAL") == "1" {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ok := runWithBootstrap(context.Background(), cfg, testLogger(t), func(ctx context.Context) bool {
_, _ = os.Stdout.WriteString("BOUND\n")
<-ctx.Done()
return false
})
if ok {
t.Fatal("canceled startup succeeded")
}
return
}
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestBootstrapSignalDuringInitialization$")
cmd.Env = append(os.Environ(), "KANDEV_TEST_BOOTSTRAP_SIGNAL=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill() }()
scan := bufio.NewScanner(stdout)
bound := false
for scan.Scan() {
if scan.Text() == "BOUND" {
bound = true
break
}
}
if !bound {
t.Fatal("child did not bind")
}
if err = cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
if err = cmd.Wait(); err != nil {
t.Fatalf("initialization did not drain on SIGTERM: %v", err)
}
}
// @covers AC-PLATFORM-STARTUP-LIFECYCLE-001.3, .6
func TestBootstrapSignalAfterReadiness(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signals")
}
if os.Getenv("KANDEV_TEST_BOOTSTRAP_SIGNAL_READY") == "1" {
cfg := &config.Config{}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = freePort(t)
ok := runWithBootstrap(context.Background(), cfg, testLogger(t), func(ctx context.Context) bool {
runtime := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime)
application := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeBootstrapJSON(w, http.StatusOK, map[string]any{statusKey: "ok"})
})
if !runtime.beginReadinessPublication(ctx) {
t.Fatal("readiness publication rejected before signal test")
}
publishReadiness(func() {
ready.Store(true)
runtime.ready.Store(true)
}, func() { runtime.handler.Store(application) })
_, _ = os.Stdout.WriteString("READY\n")
awaitShutdown(ctx, runtime.server, runtime.listeners, &schedulingRuntime{}, nil, nil, func() {}, testLogger(t))
return true
})
if !ok {
t.Fatal("ready bootstrap did not complete graceful shutdown")
}
return
}
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestBootstrapSignalAfterReadiness$")
cmd.Env = append(os.Environ(), "KANDEV_TEST_BOOTSTRAP_SIGNAL_READY=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill() }()
scan := bufio.NewScanner(stdout)
readyOutput := false
for scan.Scan() {
if scan.Text() == "READY" {
readyOutput = true
break
}
}
if !readyOutput {
t.Fatal("child did not publish readiness")
}
if err = cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
if err = cmd.Wait(); err != nil {
t.Fatalf("ready bootstrap did not shut down on SIGTERM: %v", err)
}
}