Files
llmgw/internal/store/open.go
T
Ilker ManapandClaude Opus 5.5 1da4a2e1ef Back up the store and the certificate authority, and alert when it stops
There were no backups: docs/install.md gave a pg_dump command, and nothing
ran it. The store holds every key, person, quota and usage record, the models
added at runtime, and now the secret each engine's key is derived from; the
certificate authority's key sits in /etc/llmgw/pki beside it. Losing the disk
lost all of it.

`llmgw server backup` writes both to one archive: pg_dump's custom format, or
a VACUUM INTO snapshot for SQLite, plus /etc/llmgw. The archive holds the CA's
key, so it is 0600; the directory is listable, so the control plane — not
root — can see when the newest was written. pg_dump is given the connection
through its environment, since a DSN carries the password and a process's
arguments are readable by every user. Written beside its name and renamed,
so an interrupted backup is never the newest; the newest 14 are kept, and
nothing the command did not write is ever removed.

llmgw-backup.timer runs it nightly and catches up after downtime. The control
plane publishes the newest archive's time, and BackupTooOld fires past 26
hours — or when there has never been one. The install guide now covers
restoring, and says to copy archives off the machine, since they share its
disk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:44:32 +02:00

68 lines
2.3 KiB
Go

package store
import (
"context"
"fmt"
"os"
"strings"
)
// Open picks a backend from what the address looks like.
//
// A path is SQLite and a postgres:// URL is PostgreSQL, so an existing
// configuration keeps working untouched and moving to PostgreSQL is one line
// changed rather than a new setting to discover. A gateway on one machine has
// no reason to run a database server, and a gateway that has outgrown that
// should not have to be reconfigured twice.
//
// An address of the form @/path/to/file reads the real address out of that
// file, which is how a DSN carrying a password stays out of a configuration
// file that gets copied, diffed and pasted into issues.
func Open(ctx context.Context, address string) (*DB, error) {
address, err := resolveAddress(address)
if err != nil {
return nil, err
}
if isPostgres(address) {
return OpenPostgres(ctx, address)
}
return OpenSQLite(ctx, address)
}
// isPostgres reports whether an address names a PostgreSQL server.
//
// By scheme, because that is the only part of a DSN that is reliably there.
// A bare host:port would be ambiguous with a relative file path, so it is not
// accepted: an address this cannot classify should fail loudly rather than
// quietly create a SQLite file named after someone's database server.
func isPostgres(address string) bool {
return strings.HasPrefix(address, "postgres://") ||
strings.HasPrefix(address, "postgresql://")
}
// resolveAddress expands an @file indirection.
//
// The file is read whole and trimmed, so a trailing newline — which every
// editor and every `echo` adds — does not become part of a password.
func resolveAddress(address string) (string, error) {
if !strings.HasPrefix(address, "@") {
return address, nil
}
path := strings.TrimPrefix(address, "@")
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading the store address from %s: %w", path, err)
}
out := strings.TrimSpace(string(b))
if out == "" {
return "", fmt.Errorf("%s is empty, so there is no store address in it", path)
}
return out, nil
}
// ResolveAddress expands an @file store address, as Open does.
func ResolveAddress(address string) (string, error) { return resolveAddress(address) }
// IsPostgres reports whether a resolved address names a PostgreSQL server.
func IsPostgres(address string) bool { return isPostgres(address) }