Move a control plane to PostgreSQL with server init --postgres

Installs PostgreSQL with the machine's own package manager when it has
none (initdb with password auth on the RHEL family, whose default is
ident), creates the llmgw role and database with a generated password
kept in /etc/llmgw/secrets/store-dsn, and checks the address connects.
On a machine already on SQLite it stops the gateway, copies the store
so accounts and keys survive, points auth.store at the new address and
starts it again. A move that fails starts the gateway on SQLite again.

server.yaml is edited as text so its comments survive, and kept beside
it. A PostgreSQL init did not install is not resized: it may serve
something else. It refuses to move one PostgreSQL store into another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-09-28 14:33:03 +02:00
1 parent 6dba6bd93a
commit cba65f5be7
6 files changed
+612 -2

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
package cli
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func TestSetAuthStoreChangesOnlyAuthStore(t *testing.T) {
p := filepath.Join(t.TempDir(), "server.yaml")
body := `# my notes
threads:
store: /var/lib/llmgw/threads.db # not this one
retain_hours: 24
auth:
enabled: true
# the store
store: /var/lib/llmgw/llmgw.db
signup_open: true
landing:
contact: "x"
`
if err := os.WriteFile(p, []byte(body), 0o640); err != nil {
t.Fatal(err)
}
if err := setAuthStore(p, "@/etc/llmgw/secrets/store-dsn"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(p)
want := strings.Replace(body, " store: /var/lib/llmgw/llmgw.db",
` store: "@/etc/llmgw/secrets/store-dsn"`, 1)
if string(got) != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
cfg, err := config.Load(p)
if err != nil {
t.Fatal(err)
}
if cfg.Auth.Store != "@/etc/llmgw/secrets/store-dsn" || cfg.Threads.Store != "/var/lib/llmgw/threads.db" {
t.Errorf("auth.store %q, threads.store %q", cfg.Auth.Store, cfg.Threads.Store)
}
backups, _ := filepath.Glob(p + ".bak-*")
if len(backups) != 1 {
t.Errorf("the old file should be kept beside it, found %v", backups)
}
}
func TestSetAuthStoreAddsTheLineWhenAuthHasNone(t *testing.T) {
p := filepath.Join(t.TempDir(), "server.yaml")
if err := os.WriteFile(p, []byte("auth:\n enabled: true\nlanding:\n contact: x\n"), 0o640); err != nil {
t.Fatal(err)
}
if err := setAuthStore(p, "@/x"); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(p)
if err != nil {
t.Fatal(err)
}
if cfg.Auth.Store != "@/x" || !cfg.Auth.Enabled {
t.Errorf("auth = %+v", cfg.Auth)
}
}
// fakePostgres stands in for setupPostgres the way it really answers: an
// address file under /etc/llmgw/secrets, returned as "@file". What the file
// holds is a SQLite path, so everything after the setup runs for real.
func fakePostgres(t *testing.T, in *serverInit) (address, db string) {
t.Helper()
db = filepath.Join(t.TempDir(), "pg-standin.db")
in.pgSetup = func(context.Context) (string, error) {
if err := os.MkdirAll(filepath.Dir(in.dsnPath()), 0o750); err != nil {
return "", err
}
return "@" + in.dsnPath(), os.WriteFile(in.dsnPath(), []byte(db+"\n"), 0o600)
}
return "@" + in.dsnPath(), db
}
// Moving an install that began on SQLite: the administrator it created must
// be in the new store, the configuration must name it, and the SQLite file
// must still be there. The new store is SQLite too here — what PostgreSQL
// adds is the setup, which needs a machine to run on.
func TestInitMovesAnExistingStore(t *testing.T) {
in, out := newTestInit(t)
if err := in.run(context.Background()); err != nil {
t.Fatalf("%v\n%s", err, out)
}
old := filepath.Join(in.stateDir, "llmgw.db")
in.postgres = true
target, db := fakePostgres(t, in)
out.Reset()
if err := in.run(context.Background()); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if !strings.Contains(out.String(), "copied") {
t.Errorf("nothing was copied:\n%s", out)
}
cfg, err := config.Load(in.configPath())
if err != nil {
t.Fatal(err)
}
if cfg.Auth.Store != target {
t.Fatalf("server.yaml still names %q", cfg.Auth.Store)
}
s, err := store.Open(context.Background(), db)
if err != nil {
t.Fatal(err)
}
u, err := s.UserByEmail(context.Background(), "admin@example.com")
s.Close()
if err != nil || !u.IsAdmin {
t.Fatalf("the administrator did not move: %v %+v", err, u)
}
if _, err := os.Stat(old); err != nil {
t.Error("the SQLite store should be kept")
}
// And once moved, again is nothing.
in.changed = false
out.Reset()
if err := in.run(context.Background()); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if in.changed {
t.Errorf("a run after the move changed something:\n%s", out)
}
}
func TestInitWritesAPostgresConfigurationFromTheStart(t *testing.T) {
in, out := newTestInit(t)
in.postgres = true
target, _ := fakePostgres(t, in)
if err := in.run(context.Background()); err != nil {
t.Fatalf("%v\n%s", err, out)
}
cfg, _ := config.Load(in.configPath())
if cfg.Auth.Store != target {
t.Errorf("auth.store = %q", cfg.Auth.Store)
}
if _, err := os.Stat(filepath.Join(in.stateDir, "llmgw.db")); err == nil {
t.Error("a PostgreSQL install should not leave a SQLite store behind")
}
}
func TestInitWillNotMoveOnePostgresIntoAnother(t *testing.T) {
in, out := newTestInit(t)
if err := os.MkdirAll(in.etcDir, 0o755); err != nil {
t.Fatal(err)
}
cfg := "control:\n enabled: true\nauth:\n enabled: true\n store: postgres://elsewhere/llmgw\n"
if err := os.WriteFile(in.configPath(), []byte(cfg), 0o640); err != nil {
t.Fatal(err)
}
in.postgres = true
in.pgSetup = func(context.Context) (string, error) { return "@/somewhere/else", nil }
err := in.run(context.Background())
if err == nil || !strings.Contains(err.Error(), "already names a PostgreSQL store") {
t.Fatalf("expected a refusal, got %v\n%s", err, out)
}
}