Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
547 lines
17 KiB
Go
547 lines
17 KiB
Go
package cli
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
agecrypt "filippo.io/age"
|
|
"filippo.io/age/agessh"
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
|
)
|
|
|
|
// restore is `llmgw server restore`: an archive `server backup` wrote, put
|
|
// back on this machine.
|
|
type restore struct {
|
|
archive string
|
|
identities []string
|
|
etcDir string
|
|
storeAddr string // overrides the restored server.yaml's
|
|
dryRun bool
|
|
force bool
|
|
// service is the unit stopped before and started after; "" touches no
|
|
// service, for tests and machines without systemd.
|
|
service string
|
|
out io.Writer
|
|
now func() time.Time
|
|
}
|
|
|
|
func newRestoreCmd() *cobra.Command {
|
|
rs := restore{etcDir: "/etc/llmgw", service: "llmgw-server", now: time.Now}
|
|
c := &cobra.Command{
|
|
Use: "restore ARCHIVE",
|
|
Short: "Put a backup back: /etc/llmgw and the store, on this machine",
|
|
Long: "Reads an archive `llmgw server backup` wrote — opening an encrypted one\n" +
|
|
"with --identity, an age identity file or an SSH private key — and checks\n" +
|
|
"it before anything is touched: that it is whole, that this llmgw is not\n" +
|
|
"older than the one that wrote it, and where the store goes. --dry-run\n" +
|
|
"stops there and says what it would do.\n\n" +
|
|
"Then: the server is stopped, /etc/llmgw is moved aside to\n" +
|
|
"/etc/llmgw.before-restore-<time> and the archive's put in its place, the\n" +
|
|
"store is restored — a SQLite file replaced, the old one kept beside it;\n" +
|
|
"PostgreSQL by pg_restore --clean into the database the address names,\n" +
|
|
"which must exist — and the server is started and asked whether it\n" +
|
|
"answers.\n\n" +
|
|
"Running engines keep serving throughout: their agents reconnect, and the\n" +
|
|
"engine-key secret came back with the store. For a whole drill, see\n" +
|
|
"\"Restoring\" in docs/install.md.",
|
|
Example: " sudo llmgw server restore /var/backups/llmgw-server/llmgw-backup-ctl-20261004T020000Z.tar.gz --dry-run\n" +
|
|
" sudo llmgw server restore backup.tar.gz.age --identity ~/llmgw-backup-key.txt",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
rs.archive, rs.out = args[0], cmd.OutOrStdout()
|
|
return rs.run(cmd.Context())
|
|
},
|
|
}
|
|
f := c.Flags()
|
|
f.StringArrayVar(&rs.identities, "identity", nil, "a key that opens an encrypted archive: an age identity file or an SSH private key; repeatable")
|
|
f.StringVar(&rs.etcDir, "etc", rs.etcDir, "the configuration directory to restore into")
|
|
f.StringVar(&rs.storeAddr, "store", "", "restore the store here rather than where the archive's server.yaml says: a SQLite path or a postgres:// address")
|
|
f.BoolVar(&rs.dryRun, "dry-run", false, "check the archive and say what would be done; change nothing")
|
|
f.BoolVar(&rs.force, "force", false, "restore an archive written by a newer llmgw")
|
|
f.StringVar(&rs.service, "service", rs.service, "the unit stopped before and started after; empty touches none")
|
|
return c
|
|
}
|
|
|
|
func (rs *restore) say(format string, a ...any) { fmt.Fprintf(rs.out, format+"\n", a...) }
|
|
|
|
func (rs *restore) run(ctx context.Context) error {
|
|
tmp, err := os.MkdirTemp("", "llmgw-restore-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
// 1. The archive, opened and unpacked where only this user can read it.
|
|
man, err := rs.unpack(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rs.say("archive written by llmgw %s on %s at %s", man.version, man.host, man.time)
|
|
if newer(man.version, version.Version) && !rs.force {
|
|
return fmt.Errorf("the archive was written by llmgw %s, newer than this one (%s): its store may have "+
|
|
"changes this one does not know. Install %s or newer first, or pass --force", man.version, version.Version, man.version)
|
|
}
|
|
restoredEtc := filepath.Join(tmp, "etc", "llmgw")
|
|
if _, err := os.Stat(filepath.Join(restoredEtc, "server.yaml")); err != nil {
|
|
return errors.New("the archive has no etc/llmgw/server.yaml: not a control plane's backup")
|
|
}
|
|
n := 0
|
|
_ = filepath.WalkDir(restoredEtc, func(_ string, d os.DirEntry, err error) error {
|
|
if err == nil && !d.IsDir() {
|
|
n++
|
|
}
|
|
return nil
|
|
})
|
|
rs.say("config %d files for %s", n, rs.etcDir)
|
|
|
|
// 2. Where the store goes, and what it is.
|
|
dump, kind := filepath.Join(tmp, "store.sqlite"), "sqlite"
|
|
if _, err := os.Stat(dump); err != nil {
|
|
dump, kind = filepath.Join(tmp, "store.pgdump"), "postgres"
|
|
if _, err := os.Stat(dump); err != nil {
|
|
dump, kind = "", ""
|
|
}
|
|
}
|
|
target, err := rs.storeTarget(restoredEtc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case kind == "":
|
|
rs.say("store none in the archive; nothing to restore")
|
|
case kind == "postgres" && !store.IsPostgres(target):
|
|
return fmt.Errorf("the archive holds a PostgreSQL dump and the store goes to %s: "+
|
|
"give --store a postgres:// address", target)
|
|
case kind == "sqlite" && store.IsPostgres(target):
|
|
return fmt.Errorf("the archive holds a SQLite store and the store goes to PostgreSQL: give --store a file, " +
|
|
"then `llmgw keys move-store` copies it into PostgreSQL")
|
|
case kind == "postgres":
|
|
if _, err := exec.LookPath("pg_restore"); err != nil {
|
|
return errors.New("the store is a PostgreSQL dump and pg_restore is not installed here (apt-get install postgresql-client)")
|
|
}
|
|
rs.say("store PostgreSQL dump into %s, with pg_restore --clean", redactDSN(target))
|
|
default:
|
|
rs.say("store SQLite file to %s%s", target, aside(target, rs.now()))
|
|
}
|
|
if rs.service != "" {
|
|
if exec.Command("systemctl", "is-active", "--quiet", rs.service).Run() == nil { //nolint:gosec // our unit
|
|
rs.say("service %s is running: stopped first, started after", rs.service)
|
|
} else {
|
|
rs.say("service %s is not running: started after", rs.service)
|
|
}
|
|
}
|
|
if _, err := os.Stat(rs.etcDir); err == nil {
|
|
rs.say("current %s moved aside to %s", rs.etcDir, rs.etcDir+".before-restore-"+stamp(rs.now()))
|
|
}
|
|
if rs.dryRun {
|
|
rs.say("\ndry run: nothing was changed")
|
|
return nil
|
|
}
|
|
|
|
// 3. Done, in an order that can be undone by hand at every step.
|
|
if rs.service != "" {
|
|
_ = exec.CommandContext(ctx, "systemctl", "stop", rs.service).Run() //nolint:gosec // our unit
|
|
}
|
|
if _, err := os.Stat(rs.etcDir); err == nil {
|
|
if err := os.Rename(rs.etcDir, rs.etcDir+".before-restore-"+stamp(rs.now())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := copyTree(restoredEtc, rs.etcDir); err != nil {
|
|
return fmt.Errorf("putting back %s: %w", rs.etcDir, err)
|
|
}
|
|
rs.say("ok %s restored", rs.etcDir)
|
|
switch kind {
|
|
case "sqlite":
|
|
if err := restoreSQLite(dump, target, rs.now()); err != nil {
|
|
return fmt.Errorf("the store: %w", err)
|
|
}
|
|
rs.say("ok store restored to %s", target)
|
|
case "postgres":
|
|
if err := pgRestore(ctx, target, dump); err != nil {
|
|
return fmt.Errorf("the store: %w", err)
|
|
}
|
|
rs.say("ok store restored into %s", redactDSN(target))
|
|
}
|
|
if rs.service != "" {
|
|
if err := exec.CommandContext(ctx, "systemctl", "start", rs.service).Run(); err != nil { //nolint:gosec // our unit
|
|
return fmt.Errorf("starting %s: %w; `journalctl -u %s` says why", rs.service, err, rs.service)
|
|
}
|
|
if err := rs.waitHealthy(ctx); err != nil {
|
|
return err
|
|
}
|
|
rs.say("ok %s answers", rs.service)
|
|
}
|
|
rs.say("\nRestored. `llmgw ctl status` shows the cluster as it comes back; agents reconnect by themselves.")
|
|
return nil
|
|
}
|
|
|
|
type manifest struct{ version, host, time string }
|
|
|
|
// unpack opens the archive — decrypting it when it is sealed — and writes
|
|
// its files under dir, keeping their modes and, by name, their owners.
|
|
func (rs *restore) unpack(dir string) (manifest, error) {
|
|
f, err := os.Open(rs.archive)
|
|
if err != nil {
|
|
return manifest{}, err
|
|
}
|
|
defer f.Close()
|
|
var r io.Reader = bufio.NewReader(f)
|
|
if strings.HasSuffix(rs.archive, ".age") {
|
|
if len(rs.identities) == 0 {
|
|
return manifest{}, errors.New("the archive is encrypted: give the key that opens it with --identity")
|
|
}
|
|
ids, err := readIdentities(rs.identities)
|
|
if err != nil {
|
|
return manifest{}, err
|
|
}
|
|
if r, err = agecrypt.Decrypt(r, ids...); err != nil {
|
|
return manifest{}, fmt.Errorf("opening the archive: %w", err)
|
|
}
|
|
}
|
|
gz, err := gzip.NewReader(r)
|
|
if err != nil {
|
|
return manifest{}, fmt.Errorf("not an archive server backup wrote: %w", err)
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
var man manifest
|
|
for {
|
|
h, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return manifest{}, fmt.Errorf("the archive is damaged: %w", err)
|
|
}
|
|
name := filepath.Clean(h.Name)
|
|
if filepath.IsAbs(name) || strings.HasPrefix(name, "..") {
|
|
return manifest{}, fmt.Errorf("the archive names %q, outside itself", h.Name)
|
|
}
|
|
p := filepath.Join(dir, name)
|
|
switch h.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(p, 0o700); err != nil {
|
|
return manifest{}, err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
return manifest{}, err
|
|
}
|
|
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return manifest{}, err
|
|
}
|
|
_, err = io.Copy(out, tr)
|
|
if cerr := out.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
return manifest{}, err
|
|
}
|
|
if name == "MANIFEST" {
|
|
b, _ := os.ReadFile(p)
|
|
man = parseManifest(b)
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
// The mode and owner, applied when the tree is copied into place.
|
|
if err := writeMeta(p, h); err != nil {
|
|
return manifest{}, err
|
|
}
|
|
}
|
|
if man.version == "" {
|
|
return manifest{}, errors.New("the archive has no MANIFEST: not one server backup wrote, or cut short")
|
|
}
|
|
return man, nil
|
|
}
|
|
|
|
func parseManifest(b []byte) manifest {
|
|
var m manifest
|
|
for _, l := range strings.Split(string(b), "\n") {
|
|
k, v, _ := strings.Cut(l, " ")
|
|
switch k {
|
|
case "llmgw":
|
|
m.version = v
|
|
case "host":
|
|
m.host = v
|
|
case "time":
|
|
m.time = v
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// meta is a restored file's mode and owner, kept beside it until it is
|
|
// copied into place.
|
|
const metaSuffix = ".llmgw-restore-meta"
|
|
|
|
func writeMeta(p string, h *tar.Header) error {
|
|
return os.WriteFile(p+metaSuffix, fmt.Appendf(nil, "%o %s %s %d %d", h.Mode&0o7777, h.Uname, h.Gname, h.Uid, h.Gid), 0o600)
|
|
}
|
|
|
|
// copyTree puts the unpacked tree in place with each file's mode and owner.
|
|
func copyTree(from, to string) error {
|
|
return filepath.WalkDir(from, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || strings.HasSuffix(p, metaSuffix) {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(from, p)
|
|
dst := filepath.Join(to, rel)
|
|
mode, uid, gid := readMeta(p, d.IsDir())
|
|
if d.IsDir() {
|
|
if err := os.MkdirAll(dst, mode); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
b, err := os.ReadFile(p) //nolint:gosec // our own temporary copy
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(dst, b, mode); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := os.Chmod(dst, mode); err != nil {
|
|
return err
|
|
}
|
|
if os.Geteuid() == 0 && uid >= 0 {
|
|
return os.Lchown(dst, uid, gid)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// readMeta is a file's mode, and its owner resolved by name on this machine,
|
|
// falling back to the number; -1 leaves it as it is.
|
|
func readMeta(p string, dir bool) (os.FileMode, int, int) {
|
|
mode := os.FileMode(0o640)
|
|
if dir {
|
|
mode = 0o750
|
|
}
|
|
b, err := os.ReadFile(p + metaSuffix)
|
|
if err != nil {
|
|
return mode, -1, -1
|
|
}
|
|
var m uint32
|
|
var un, gn string
|
|
var uid, gid int
|
|
if _, err := fmt.Sscanf(string(b), "%o %s %s %d %d", &m, &un, &gn, &uid, &gid); err != nil {
|
|
// Names may be empty: read what is there.
|
|
f := strings.Fields(string(b))
|
|
if len(f) == 0 {
|
|
return mode, -1, -1
|
|
}
|
|
v, _ := strconv.ParseUint(f[0], 8, 32)
|
|
return os.FileMode(v), -1, -1
|
|
}
|
|
if u, err := user.Lookup(un); err == nil {
|
|
uid, _ = strconv.Atoi(u.Uid)
|
|
}
|
|
if g, err := user.LookupGroup(gn); err == nil {
|
|
gid, _ = strconv.Atoi(g.Gid)
|
|
}
|
|
return os.FileMode(m), uid, gid
|
|
}
|
|
|
|
func readIdentities(paths []string) ([]agecrypt.Identity, error) {
|
|
var out []agecrypt.Identity
|
|
for _, p := range paths {
|
|
b, err := os.ReadFile(p) //nolint:gosec // the operator's key file
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if bytes.Contains(b, []byte("PRIVATE KEY")) {
|
|
id, err := agessh.ParseIdentity(b)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", p, err)
|
|
}
|
|
out = append(out, id)
|
|
continue
|
|
}
|
|
ids, err := agecrypt.ParseIdentities(bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s is neither an age identity file nor an SSH private key: %w", p, err)
|
|
}
|
|
out = append(out, ids...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// storeTarget is where the store is restored: --store, or the restored
|
|
// server.yaml's auth.store, read from where it was unpacked.
|
|
func (rs *restore) storeTarget(restoredEtc string) (string, error) {
|
|
if rs.storeAddr != "" {
|
|
return store.ResolveAddress(rs.storeAddr)
|
|
}
|
|
cfg, err := config.Load(filepath.Join(restoredEtc, "server.yaml"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("the archive's server.yaml: %w", err)
|
|
}
|
|
addr := cfg.Auth.Store
|
|
if addr == "" {
|
|
return "/var/lib/llmgw/llmgw.db", nil
|
|
}
|
|
// "@/etc/llmgw/secrets/…" names a file the archive brought back.
|
|
if f, ok := strings.CutPrefix(addr, "@"); ok && strings.HasPrefix(f, rs.etcDir+"/") {
|
|
addr = "@" + filepath.Join(restoredEtc, strings.TrimPrefix(f, rs.etcDir+"/"))
|
|
}
|
|
return store.ResolveAddress(addr)
|
|
}
|
|
|
|
func restoreSQLite(dump, target string, now time.Time) error {
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(target); err == nil {
|
|
if err := os.Rename(target, target+".before-restore-"+stamp(now)); err != nil {
|
|
return err
|
|
}
|
|
for _, side := range []string{"-wal", "-shm"} {
|
|
_ = os.Remove(target + side)
|
|
}
|
|
}
|
|
b, err := os.ReadFile(dump) //nolint:gosec // our own temporary copy
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(target, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
if u, err := user.Lookup("llmgw"); err == nil {
|
|
uid, _ := strconv.Atoi(u.Uid)
|
|
gid, _ := strconv.Atoi(u.Gid)
|
|
return os.Chown(target, uid, gid)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pgRestore loads a dump into the database the address names, as its
|
|
// owner, on the primary.
|
|
func pgRestore(ctx context.Context, dsn, dump string) error {
|
|
env, err := pgEnv(dsn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, e := range env {
|
|
if strings.HasPrefix(e, "PGTARGETSESSIONATTRS=") {
|
|
env[i] = "PGTARGETSESSIONATTRS=read-write"
|
|
}
|
|
}
|
|
env = append(env, "PGTARGETSESSIONATTRS=read-write")
|
|
db := ""
|
|
for _, e := range env {
|
|
if v, ok := strings.CutPrefix(e, "PGDATABASE="); ok {
|
|
db = v
|
|
}
|
|
}
|
|
if db == "" {
|
|
return errors.New("the store's address names no database")
|
|
}
|
|
// Without --dbname pg_restore writes SQL out rather than restoring; the
|
|
// rest of the connection comes from the environment, the password with it.
|
|
cmd := exec.CommandContext(ctx, "pg_restore", "--clean", "--if-exists", "--no-owner", "--single-transaction", //nolint:gosec // fixed binary, our own file
|
|
"--exit-on-error", "--dbname="+db, dump)
|
|
cmd.Env = append(os.Environ(), env...)
|
|
if b, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("pg_restore: %v: %s", err, strings.TrimSpace(string(b)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitHealthy waits for the restored server to answer, for a minute.
|
|
func (rs *restore) waitHealthy(ctx context.Context) error {
|
|
cfg, err := config.Load(filepath.Join(rs.etcDir, "server.yaml"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c := newCtlClient(cfg.AdminListen, adminTokenPath(filepath.Join(rs.etcDir, "server.yaml"), cfg))
|
|
deadline := time.Now().Add(time.Minute)
|
|
for {
|
|
var v map[string]any
|
|
if err := c.get(ctx, "/api/v1/summary", &v); err == nil {
|
|
return nil
|
|
} else if time.Now().After(deadline) {
|
|
return fmt.Errorf("%s started but does not answer: %w", rs.service, err)
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}
|
|
|
|
func stamp(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
|
|
|
func aside(target string, now time.Time) string {
|
|
if _, err := os.Stat(target); err == nil {
|
|
return "; the current one kept as " + filepath.Base(target) + ".before-restore-" + stamp(now)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// redactDSN is an address without its password, for saying where.
|
|
func redactDSN(dsn string) string {
|
|
if i := strings.Index(dsn, "://"); i >= 0 {
|
|
if j := strings.Index(dsn[i+3:], "@"); j >= 0 {
|
|
cred := dsn[i+3 : i+3+j]
|
|
if k := strings.IndexByte(cred, ':'); k >= 0 {
|
|
return dsn[:i+3] + cred[:k] + ":…" + dsn[i+3+j:]
|
|
}
|
|
}
|
|
}
|
|
return dsn
|
|
}
|
|
|
|
// newer says whether version a is newer than b, by their leading
|
|
// major.minor.patch; anything unreadable is not newer.
|
|
func newer(a, b string) bool {
|
|
pa, oka := semverOf(a)
|
|
pb, okb := semverOf(b)
|
|
if !oka || !okb {
|
|
return false
|
|
}
|
|
for i := range pa {
|
|
if pa[i] != pb[i] {
|
|
return pa[i] > pb[i]
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func semverOf(v string) ([3]int, bool) {
|
|
v = strings.TrimPrefix(v, "v")
|
|
if i := strings.IndexAny(v, "-+"); i >= 0 {
|
|
v = v[:i]
|
|
}
|
|
parts := strings.Split(v, ".")
|
|
var out [3]int
|
|
if len(parts) != 3 {
|
|
return out, false
|
|
}
|
|
for i, p := range parts {
|
|
n, err := strconv.Atoi(p)
|
|
if err != nil {
|
|
return out, false
|
|
}
|
|
out[i] = n
|
|
}
|
|
return out, true
|
|
}
|