Files
llmgw/internal/cli/backup_test.go
2026-10-04 10:38:47 +02:00

241 lines
7.0 KiB
Go

package cli
import (
"archive/tar"
"compress/gzip"
"crypto/ed25519"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
agecrypt "filippo.io/age"
"golang.org/x/crypto/ssh"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func TestABackupIsEncryptedToItsRecipients(t *testing.T) {
tmp := t.TempDir()
etc := filepath.Join(tmp, "etc")
_ = os.MkdirAll(filepath.Join(etc, "pki"), 0o700)
_ = os.WriteFile(filepath.Join(etc, "pki", "ca-key.pem"), []byte("THE-CA-KEY"), 0o600)
id, err := agecrypt.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
to, err := config.ParseBackupRecipients([]string{id.Recipient().String()})
if err != nil {
t.Fatal(err)
}
out := filepath.Join(tmp, "backups")
path, err := writeBackup(t.Context(), "", etc, out, to)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(path, ".tar.gz.age") {
t.Errorf("named %s", filepath.Base(path))
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "THE-CA-KEY") {
t.Fatal("the CA key is in the archive in the clear")
}
f, _ := os.Open(path)
defer f.Close()
plain, err := agecrypt.Decrypt(f, id)
if err != nil {
t.Fatalf("the recipient cannot open it: %v", err)
}
gz, err := gzip.NewReader(plain)
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(gz)
found := false
for {
h, err := tr.Next()
if err != nil {
break
}
found = found || h.Name == "etc/llmgw/pki/ca-key.pem"
}
if !found {
t.Error("the opened archive lacks the CA key")
}
// Seen and pruned like any other.
if NewestBackup(out).IsZero() {
t.Error("the control plane would not see an encrypted archive")
}
}
func TestBackupRecipientsMustBeKeys(t *testing.T) {
if _, err := config.ParseBackupRecipients([]string{"not a key"}); err == nil {
t.Error("nonsense was taken as a recipient")
}
pub, _, _ := ed25519.GenerateKey(nil)
sp, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
if _, err := config.ParseBackupRecipients([]string{
strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " ops@example",
}); err != nil {
t.Errorf("an SSH key was refused: %v", err)
}
}
func TestABackupHoldsTheStoreAndTheConfiguration(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "store.db")
db, err := store.Open(t.Context(), dbPath)
if err != nil {
t.Fatal(err)
}
secret, err := db.ClusterSecret(t.Context(), "engine-key")
if err != nil {
t.Fatal(err)
}
_ = db.Close()
etc := filepath.Join(tmp, "etc")
_ = os.MkdirAll(filepath.Join(etc, "pki"), 0o700)
_ = os.WriteFile(filepath.Join(etc, "server.yaml"), []byte("listen: :8080\n"), 0o644)
_ = os.WriteFile(filepath.Join(etc, "pki", "ca-key.pem"), []byte("KEY"), 0o600)
out := filepath.Join(tmp, "backups")
path, err := writeBackup(t.Context(), dbPath, etc, out, nil)
if err != nil {
t.Fatal(err)
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0o600 {
t.Errorf("the archive holds the CA's key and is %v", info.Mode().Perm())
}
files := map[string][]byte{}
f, _ := os.Open(path)
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(tr)
files[h.Name] = b
}
for _, want := range []string{"MANIFEST", "store.sqlite", "etc/llmgw/server.yaml", "etc/llmgw/pki/ca-key.pem"} {
if _, ok := files[want]; !ok {
t.Errorf("%s is not in the archive", want)
}
}
// And the store in it is a working store, with what was in it.
restored := filepath.Join(tmp, "restored.db")
_ = os.WriteFile(restored, files["store.sqlite"], 0o600)
rdb, err := store.Open(t.Context(), restored)
if err != nil {
t.Fatalf("the backed-up store does not open: %v", err)
}
defer rdb.Close()
if got, _ := rdb.ClusterSecret(t.Context(), "engine-key"); got != secret {
t.Error("the restored store lost the engine key secret; every engine would be locked out")
}
}
func TestOldBackupsArePrunedAndTheNewestIsFound(t *testing.T) {
dir := t.TempDir()
for i, stamp := range []string{"20260101T000000Z", "20260102T000000Z", "20260103T000000Z"} {
p := filepath.Join(dir, backupPrefix+"h-"+stamp+".tar.gz")
_ = os.WriteFile(p, []byte("x"), 0o600)
mt := time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)
_ = os.Chtimes(p, mt, mt)
}
_ = os.WriteFile(filepath.Join(dir, "someone-elses-file"), []byte("x"), 0o600)
_ = os.WriteFile(filepath.Join(dir, backupPrefix+"h-20260104T000000Z.tar.gz.part"), []byte("x"), 0o600)
removed, err := pruneBackups(dir, 2)
if err != nil || removed != 1 {
t.Fatalf("removed %d, %v", removed, err)
}
if _, err := os.Stat(filepath.Join(dir, "someone-elses-file")); err != nil {
t.Error("a file the backup did not write was removed")
}
newest := NewestBackup(dir)
if !newest.Equal(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)) {
t.Errorf("newest %v; an interrupted .part must not count", newest)
}
if !NewestBackup(filepath.Join(dir, "none")).IsZero() {
t.Error("a missing directory reported a backup")
}
}
func TestABackupDirectoryTheControlPlaneCannotListIsSaid(t *testing.T) {
dir := t.TempDir()
_ = os.Chmod(dir, 0o700)
if unlistable(dir) == "" {
t.Error("a directory only its owner can list was taken as visible")
}
_ = os.Chmod(dir, 0o755)
if why := unlistable(dir); why != "" {
t.Errorf("a listable directory: %s", why)
}
}
func TestNoScratchCopyOfTheStoreIsLeftBehind(t *testing.T) {
// The snapshot is made before it is archived. Made straight into the
// listable archive directory, it was readable by anyone for that time.
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "store.db")
db, err := store.Open(t.Context(), dbPath)
if err != nil {
t.Fatal(err)
}
_ = db.Close()
out := filepath.Join(tmp, "backups")
if _, err := writeBackup(t.Context(), dbPath, t.TempDir(), out, nil); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(out)
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".store") {
t.Errorf("a scratch copy of the store was left behind: %s", e.Name())
}
}
}
func TestAStoreOfSeveralHostsIsGivenToPgDumpAsLibpqLists(t *testing.T) {
// The address of a Patroni cluster: url.Parse read its host as one name,
// and pg_dump reached the database only by falling through to the second.
env, err := pgEnv("postgres://llmgw:s3cret@10.0.0.149:5432,10.0.0.150/llmgw?sslmode=disable&target_session_attrs=read-write")
if err != nil {
t.Fatal(err)
}
got := strings.Join(env, " ")
for _, want := range []string{"PGHOST=10.0.0.149,10.0.0.150", "PGPORT=5432,5432", "PGUSER=llmgw",
"PGPASSWORD=s3cret", "PGDATABASE=llmgw", "PGSSLMODE=disable", "PGTARGETSESSIONATTRS=prefer-standby"} {
if !strings.Contains(got, want) {
t.Errorf("%s is not in %s", want, got)
}
}
one, err := pgEnv("postgres://u:p@db.internal:6432/llmgw")
if err != nil || !strings.Contains(strings.Join(one, " "), "PGHOST=db.internal PGPORT=6432") {
t.Errorf("one host: %v, %v", one, err)
}
if _, err := pgEnv("postgres://u:p@a:5432,:5432/x"); err == nil {
t.Error("an empty host in the list was accepted")
}
}