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>
68 lines
2.3 KiB
Go
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) }
|