Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
241 lines
7.0 KiB
Go
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")
|
|
}
|
|
}
|