diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..0462058 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,349 @@ +// Package backup implements orca's signed tarball backup/restore +// subsystem (P04, v0.11 milestone). +// +// The model is tar.gz + HMAC-SHA256 signature: +// +// - Backup walks the ORCA_HOME recursively, excludes ephemeral +// paths (/run/orca/*), unix sockets (*.sock), and SQLite WAL/SHM +// sidecars (*.db-wal, *.db-shm), packs the rest into a tar.gz, and +// computes an HMAC-SHA256 of the tarball using the cluster master +// key. The tarball is written to OutputPath; the hex-encoded +// signature to OutputPath + ".sig". +// - VerifySignature recomputes the HMAC and compares it (constant +// time) against the recorded signature. +// - Restore verifies the signature first (refuses on mismatch), then +// extracts the tarball to TargetDir. With Force=false it refuses to +// clobber a non-empty TargetDir; with Force=true it overwrites. +// +// The package never logs key material. slog calls carry only metadata. +package backup + +import ( + "archive/tar" + "compress/gzip" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// ErrSignatureMismatch is returned when the recorded HMAC-SHA256 +// signature does not match the recomputed one (tampering or wrong key). +var ErrSignatureMismatch = errors.New("backup: signature mismatch") + +// ErrTargetNotEmpty is returned when Restore is called with Force=false +// against a non-empty TargetDir. +var ErrTargetNotEmpty = errors.New("backup: target directory not empty (use Force to overwrite)") + +// BackupOptions configures Backup. +type BackupOptions struct { + SourceDir string // ORCA_HOME — the tree to back up + OutputPath string // destination tarball path (.tar.gz) + MasterKey []byte // HMAC-SHA256 key (cluster master.key) +} + +// RestoreOptions configures Restore. +type RestoreOptions struct { + InputPath string // source tarball path (.tar.gz) + TargetDir string // destination ORCA_HOME + MasterKey []byte // HMAC-SHA256 key (for verification) + Force bool // overwrite non-empty target +} + +// excludeGlobSuffixes are the suffixes excluded from the backup. We +// exclude SQLite WAL/SHM sidecars (the main db is backed up) and unix +// sockets. +var excludeGlobSuffixes = []string{".sock", ".db-wal", ".db-shm"} + +// shouldExclude reports whether a path should be excluded from the +// backup. It excludes /run/orca/* (ephemeral runtime), *.sock, *.db-wal, +// and *.db-shm. The /run/orca match is done on the absolute path; the +// suffix matches are done on the base name. +func shouldExclude(absPath string) bool { + clean := filepath.Clean(absPath) + if strings.HasPrefix(clean, "/run/orca/") || clean == "/run/orca" { + return true + } + if idx := strings.LastIndex(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca"+string(filepath.Separator)); idx >= 0 { + return true + } + if strings.HasSuffix(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca") { + return true + } + base := filepath.Base(clean) + for _, suf := range excludeGlobSuffixes { + if strings.HasSuffix(base, suf) { + return true + } + } + return false +} + +// Backup creates a signed tar.gz of SourceDir. The tarball is written +// to OutputPath and the hex-encoded HMAC-SHA256 signature to +// OutputPath + ".sig". The write is atomic: the tarball is streamed to +// a temp file in the same directory and renamed on success; the +// signature is written after the rename so a crash never leaves a +// tarball with a stale or missing signature. +func Backup(opts BackupOptions) error { + if opts.SourceDir == "" { + return fmt.Errorf("backup: SourceDir is empty") + } + if opts.OutputPath == "" { + return fmt.Errorf("backup: OutputPath is empty") + } + if len(opts.MasterKey) == 0 { + return fmt.Errorf("backup: MasterKey is empty") + } + + src, err := filepath.Abs(opts.SourceDir) + if err != nil { + return fmt.Errorf("backup: resolve SourceDir: %w", err) + } + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("backup: stat SourceDir: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("backup: SourceDir %q is not a directory", src) + } + + outDir := filepath.Dir(opts.OutputPath) + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf("backup: mkdir output dir: %w", err) + } + + tmp, err := os.CreateTemp(outDir, ".orca-backup-*.tar.gz.tmp") + if err != nil { + return fmt.Errorf("backup: create temp tarball: %w", err) + } + tmpPath := tmp.Name() + defer func() { + tmp.Close() + _ = os.Remove(tmpPath) + }() + + gw := gzip.NewWriter(tmp) + tw := tar.NewWriter(gw) + + var walked int + walkErr := filepath.Walk(src, func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if shouldExclude(path) { + if fi.IsDir() { + return filepath.SkipDir + } + return nil + } + rel, rerr := filepath.Rel(src, path) + if rerr != nil { + return fmt.Errorf("rel path %s: %w", path, rerr) + } + if rel == "." { + return nil + } + hdr, herr := tar.FileInfoHeader(fi, "") + if herr != nil { + return fmt.Errorf("tar header for %s: %w", path, herr) + } + hdr.Name = filepath.ToSlash(rel) + if err := tw.WriteHeader(hdr); err != nil { + return fmt.Errorf("write header %s: %w", rel, err) + } + if !fi.Mode().IsRegular() { + return nil + } + f, oerr := os.Open(path) + if oerr != nil { + return fmt.Errorf("open %s: %w", path, oerr) + } + defer f.Close() + if _, err := io.Copy(tw, f); err != nil { + return fmt.Errorf("copy %s: %w", rel, err) + } + walked++ + return nil + }) + if walkErr != nil { + tw.Close() + gw.Close() + return fmt.Errorf("backup: walk: %w", walkErr) + } + if err := tw.Close(); err != nil { + gw.Close() + return fmt.Errorf("backup: close tar writer: %w", err) + } + if err := gw.Close(); err != nil { + return fmt.Errorf("backup: close gzip writer: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("backup: close temp tarball: %w", err) + } + + if err := os.Rename(tmpPath, opts.OutputPath); err != nil { + return fmt.Errorf("backup: rename tarball: %w", err) + } + + sig, err := computeSignature(opts.OutputPath, opts.MasterKey) + if err != nil { + return fmt.Errorf("backup: compute signature: %w", err) + } + if err := os.WriteFile(opts.OutputPath+".sig", []byte(hex.EncodeToString(sig)), 0o644); err != nil { + return fmt.Errorf("backup: write signature: %w", err) + } + + slog.Info("backup complete", "path", opts.OutputPath, "files", walked, "sig", opts.OutputPath+".sig") + return nil +} + +// computeSignature reads the file at path and returns its HMAC-SHA256 +// MAC under key. +func computeSignature(path string, key []byte) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + mac := hmac.New(sha256.New, key) + if _, err := io.Copy(mac, f); err != nil { + return nil, fmt.Errorf("hash %s: %w", path, err) + } + return mac.Sum(nil), nil +} + +// VerifySignature recomputes the HMAC-SHA256 of the tarball at +// tarballPath and compares it (constant time) against the hex-encoded +// signature at sigPath. Returns ErrSignatureMismatch on a mismatch. +func VerifySignature(tarballPath, sigPath string, masterKey []byte) error { + if len(masterKey) == 0 { + return fmt.Errorf("backup: MasterKey is empty") + } + got, err := computeSignature(tarballPath, masterKey) + if err != nil { + return fmt.Errorf("compute signature: %w", err) + } + wantHex, err := os.ReadFile(sigPath) + if err != nil { + return fmt.Errorf("read signature: %w", err) + } + want, err := hex.DecodeString(strings.TrimSpace(string(wantHex))) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + if !hmac.Equal(got, want) { + return ErrSignatureMismatch + } + return nil +} + +// Restore verifies the signature on (InputPath, InputPath+".sig") using +// MasterKey, then extracts the tarball to TargetDir. With Force=false +// a non-empty TargetDir is refused (ErrTargetNotEmpty); with Force=true +// existing files are overwritten. +func Restore(opts RestoreOptions) error { + if opts.InputPath == "" { + return fmt.Errorf("restore: InputPath is empty") + } + if opts.TargetDir == "" { + return fmt.Errorf("restore: TargetDir is empty") + } + if len(opts.MasterKey) == 0 { + return fmt.Errorf("restore: MasterKey is empty") + } + + sigPath := opts.InputPath + ".sig" + if err := VerifySignature(opts.InputPath, sigPath, opts.MasterKey); err != nil { + return fmt.Errorf("restore: verify signature: %w", err) + } + + target := filepath.Clean(opts.TargetDir) + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("restore: mkdir target: %w", err) + } + if !opts.Force { + empty, err := dirIsEmpty(target) + if err != nil { + return fmt.Errorf("restore: check target: %w", err) + } + if !empty { + return ErrTargetNotEmpty + } + } + + f, err := os.Open(opts.InputPath) + if err != nil { + return fmt.Errorf("restore: open tarball: %w", err) + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("restore: gzip reader: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + var extracted int + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("restore: read tar entry: %w", err) + } + name := filepath.FromSlash(hdr.Name) + if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "..") { + return fmt.Errorf("restore: unsafe path %q", hdr.Name) + } + dest := filepath.Join(target, name) + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(dest, os.FileMode(hdr.Mode)); err != nil { + return fmt.Errorf("restore: mkdir %s: %w", name, err) + } + continue + case tar.TypeSymlink: + if err := os.Remove(dest); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("restore: clear symlink %s: %w", name, err) + } + if err := os.Symlink(hdr.Linkname, dest); err != nil { + return fmt.Errorf("restore: symlink %s: %w", name, err) + } + continue + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("restore: mkdir parent %s: %w", name, err) + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) + if err != nil { + return fmt.Errorf("restore: create %s: %w", name, err) + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return fmt.Errorf("restore: write %s: %w", name, err) + } + out.Close() + extracted++ + default: + slog.Warn("restore: skipping non-regular entry", "name", name, "type", hdr.Typeflag) + } + } + slog.Info("restore complete", "path", target, "files", extracted) + return nil +} + +// dirIsEmpty reports whether dir contains no entries. +func dirIsEmpty(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + return len(entries) == 0, nil +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..5c4657b --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,316 @@ +package backup + +import ( + "bytes" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func keyA() []byte { return []byte("0123456789abcdef0123456789abcdef") } +func keyB() []byte { return []byte("abcdef0123456789abcdef0123456789") } + +func writeFiles(t *testing.T, root string, files map[string]string) { + t.Helper() + for name, body := range files { + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(p), err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", p, err) + } + } +} + +func runBackup(t *testing.T, src, out string, key []byte) { + t.Helper() + if err := Backup(BackupOptions{ + SourceDir: src, + OutputPath: out, + MasterKey: key, + }); err != nil { + t.Fatalf("Backup: %v", err) + } +} + +func TestBackupRestoreRoundTrip(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + target := t.TempDir() + os.RemoveAll(target) + + writeFiles(t, src, map[string]string{ + "cluster/master.key": "KEYMATERIAL", + "_defaults/db/orca.db": "SQLITE", + "_defaults/.env": "FOO=bar", + "_defaults/jobs/job1.md": "job body", + "cluster/peers/host1/peer.json": "{}", + }) + + runBackup(t, src, out, keyA()) + + if _, err := os.Stat(out + ".sig"); err != nil { + t.Fatalf("sig file missing: %v", err) + } + + if err := Restore(RestoreOptions{ + InputPath: out, + TargetDir: target, + MasterKey: keyA(), + }); err != nil { + t.Fatalf("Restore: %v", err) + } + + for name, body := range map[string]string{ + "cluster/master.key": "KEYMATERIAL", + "_defaults/db/orca.db": "SQLITE", + "_defaults/.env": "FOO=bar", + "_defaults/jobs/job1.md": "job body", + "cluster/peers/host1/peer.json": "{}", + } { + got, err := os.ReadFile(filepath.Join(target, name)) + if err != nil { + t.Errorf("restored file %s missing: %v", name, err) + continue + } + if string(got) != body { + t.Errorf("restored %s = %q, want %q", name, string(got), body) + } + } +} + +func TestVerifySignatureSameKey(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "hello"}) + runBackup(t, src, out, keyA()) + if err := VerifySignature(out, out+".sig", keyA()); err != nil { + t.Fatalf("verify same key: %v", err) + } +} + +func TestVerifySignatureWrongKey(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "hello"}) + runBackup(t, src, out, keyA()) + err := VerifySignature(out, out+".sig", keyB()) + if !errors.Is(err, ErrSignatureMismatch) { + t.Fatalf("verify wrong key: got %v, want ErrSignatureMismatch", err) + } +} + +func TestVerifySignatureTampered(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "hello"}) + runBackup(t, src, out, keyA()) + + body, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read tarball: %v", err) + } + body[0] ^= 0xff + if err := os.WriteFile(out, body, 0o644); err != nil { + t.Fatalf("rewrite tampered tarball: %v", err) + } + err = VerifySignature(out, out+".sig", keyA()) + if !errors.Is(err, ErrSignatureMismatch) { + t.Fatalf("verify tampered: got %v, want ErrSignatureMismatch", err) + } +} + +func TestExclusionSocketsAndRunOrca(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + target := t.TempDir() + os.RemoveAll(target) + + writeFiles(t, src, map[string]string{ + "keep.txt": "keep me", + "normal.db": "main db", + "sock-excluded.sock": "sock", + "side.db-wal": "wal", + "side.db-shm": "shm", + }) + + runOrcaDir := filepath.Join(src, "run", "orca") + if err := os.MkdirAll(runOrcaDir, 0o755); err != nil { + t.Fatalf("mkdir run/orca: %v", err) + } + if err := os.WriteFile(filepath.Join(runOrcaDir, "ephemeral.txt"), []byte("eph"), 0o644); err != nil { + t.Fatalf("write ephemeral: %v", err) + } + + runBackup(t, src, out, keyA()) + + if err := Restore(RestoreOptions{ + InputPath: out, + TargetDir: target, + MasterKey: keyA(), + }); err != nil { + t.Fatalf("Restore: %v", err) + } + + for _, excluded := range []string{ + "sock-excluded.sock", + "side.db-wal", + "side.db-shm", + "run/orca/ephemeral.txt", + } { + if _, err := os.Stat(filepath.Join(target, excluded)); !os.IsNotExist(err) { + t.Errorf("excluded file %s should not be in restore (err=%v)", excluded, err) + } + } + for _, kept := range []string{"keep.txt", "normal.db"} { + if _, err := os.Stat(filepath.Join(target, kept)); err != nil { + t.Errorf("kept file %s missing from restore: %v", kept, err) + } + } +} + +func TestRestoreForceFalseRefusesNonEmpty(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "hello"}) + runBackup(t, src, out, keyA()) + + target := t.TempDir() + if err := os.WriteFile(filepath.Join(target, "existing.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("seed target: %v", err) + } + err := Restore(RestoreOptions{ + InputPath: out, + TargetDir: target, + MasterKey: keyA(), + Force: false, + }) + if !errors.Is(err, ErrTargetNotEmpty) { + t.Fatalf("restore to non-empty: got %v, want ErrTargetNotEmpty", err) + } +} + +func TestRestoreForceTrueOverwritesNonEmpty(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "new"}) + runBackup(t, src, out, keyA()) + + target := t.TempDir() + if err := os.WriteFile(filepath.Join(target, "stale.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("seed target: %v", err) + } + err := Restore(RestoreOptions{ + InputPath: out, + TargetDir: target, + MasterKey: keyA(), + Force: true, + }) + if err != nil { + t.Fatalf("restore force: %v", err) + } + got, err := os.ReadFile(filepath.Join(target, "a.txt")) + if err != nil { + t.Fatalf("restored a.txt missing: %v", err) + } + if string(got) != "new" { + t.Errorf("restored a.txt = %q, want %q", string(got), "new") + } +} + +func TestEmptyBackup(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + target := t.TempDir() + os.RemoveAll(target) + + runBackup(t, src, out, keyA()) + + if err := VerifySignature(out, out+".sig", keyA()); err != nil { + t.Fatalf("verify empty backup: %v", err) + } + if err := Restore(RestoreOptions{ + InputPath: out, + TargetDir: target, + MasterKey: keyA(), + }); err != nil { + t.Fatalf("restore empty backup: %v", err) + } + entries, err := os.ReadDir(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if len(entries) != 0 { + t.Errorf("empty backup restored %d entries, want 0", len(entries)) + } +} + +func TestRestoreSignatureMismatchFailsBeforeExtract(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "hello"}) + runBackup(t, src, out, keyA()) + + target := t.TempDir() + os.RemoveAll(target) + err := Restore(RestoreOptions{ + InputPath: out, + TargetDir: target, + MasterKey: keyB(), + }) + if !errors.Is(err, ErrSignatureMismatch) { + t.Fatalf("restore wrong key: got %v, want ErrSignatureMismatch", err) + } + if _, err := os.Stat(target); err == nil { + entries, _ := os.ReadDir(target) + if len(entries) != 0 { + t.Errorf("target should be empty after failed verify, got %d entries", len(entries)) + } + } +} + +func TestRestoreBadSignatureContent(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "hello"}) + runBackup(t, src, out, keyA()) + + if err := os.WriteFile(out+".sig", []byte("not-hex!!"), 0o644); err != nil { + t.Fatalf("write bad sig: %v", err) + } + err := VerifySignature(out, out+".sig", keyA()) + if err == nil { + t.Fatal("verify bad sig content: expected error, got nil") + } + if strings.Contains(err.Error(), "decode signature") { + return + } + if errors.Is(err, ErrSignatureMismatch) { + return + } + t.Errorf("verify bad sig content: got unexpected err %v", err) +} + +func TestBackupSignatureFileContent(t *testing.T) { + src := t.TempDir() + out := filepath.Join(t.TempDir(), "b.tar.gz") + writeFiles(t, src, map[string]string{"a.txt": "hello"}) + runBackup(t, src, out, keyA()) + sig, err := os.ReadFile(out + ".sig") + if err != nil { + t.Fatalf("read sig: %v", err) + } + if dec, err := hexDecode(string(bytes.TrimSpace(sig))); err != nil { + t.Fatalf("sig not hex: %v", err) + } else if len(dec) != 32 { + t.Errorf("sig len = %d, want 32", len(dec)) + } +} + +func hexDecode(s string) ([]byte, error) { + return hex.DecodeString(s) +} diff --git a/internal/cli/backup.go b/internal/cli/backup.go new file mode 100644 index 0000000..592856d --- /dev/null +++ b/internal/cli/backup.go @@ -0,0 +1,118 @@ +// Package cli: backup.go implements the `orca backup` and `orca restore` +// subcommands (P04, v0.11 milestone). +// +// orca backup --out — create a signed tar.gz of ORCA_HOME +// orca restore --in — restore a verified backup +// +// `backup` reads the master key at paths.MasterKeyPath() and backs up +// paths.Root() (ORCA_HOME). The tarball + HMAC-SHA256 signature are +// written to --out and --out+".sig". `restore` verifies the signature +// before extracting; with --force it overwrites a non-empty target. +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "git.cloudinit.dev/coreci/orca/internal/backup" + "git.cloudinit.dev/coreci/orca/internal/paths" + "git.cloudinit.dev/coreci/orca/internal/secrets" +) + +var ( + backupOutPath string + restoreInPath string + restoreTargetDir string + restoreForce bool +) + +var backupCmd = &cobra.Command{ + Use: "backup", + Short: "Create a signed tar.gz backup of ORCA_HOME", + Long: `Create a signed tar.gz backup of ORCA_HOME (P04). + +Walks ` + "`ORCA_HOME`" + ` recursively, excludes /run/orca/*, *.sock, +*.db-wal, *.db-shm, packs the rest into a tar.gz, and computes an +HMAC-SHA256 signature using the cluster master key. The tarball is +written to --out; the hex-encoded signature to --out + ".sig".`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + mk, err := secrets.LoadMasterKey(paths.MasterKeyPath()) + if err != nil { + return fmt.Errorf("load master key: %w", err) + } + out := backupOutPath + if out == "" { + ts := time.Now().UTC().Format("20060102-150405") + out = fmt.Sprintf("orca-backup-%s.tar.gz", ts) + } + opts := backup.BackupOptions{ + SourceDir: paths.Root(), + OutputPath: out, + MasterKey: mk, + } + if err := backup.Backup(opts); err != nil { + return err + } + if jsonOutput { + return printJSON(map[string]string{ + "path": out, + "sig": out + ".sig", + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "✓ Backup written: %s (sig: %s)\n", out, out+".sig") + return nil + }, +} + +var restoreCmd = &cobra.Command{ + Use: "restore", + Short: "Restore ORCA_HOME from a verified signed backup", + Long: `Restore ORCA_HOME from a verified signed backup (P04). + +Verifies the HMAC-SHA256 signature on --in (using the cluster master +key) before extracting. With --force, overwrites a non-empty target; +without it, refuses to clobber an existing ORCA_HOME.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if restoreInPath == "" { + return fmt.Errorf("--in is required") + } + mk, err := secrets.LoadMasterKey(paths.MasterKeyPath()) + if err != nil { + return fmt.Errorf("load master key: %w", err) + } + target := restoreTargetDir + if target == "" { + target = paths.Root() + } + opts := backup.RestoreOptions{ + InputPath: restoreInPath, + TargetDir: target, + MasterKey: mk, + Force: restoreForce, + } + if err := backup.Restore(opts); err != nil { + return err + } + if jsonOutput { + return printJSON(map[string]string{ + "path": restoreInPath, + "target": target, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "✓ Restored to: %s\n", target) + return nil + }, +} + +func init() { + backupCmd.Flags().StringVar(&backupOutPath, "out", "", "output tarball path (default: orca-backup-.tar.gz in CWD)") + restoreCmd.Flags().StringVar(&restoreInPath, "in", "", "input tarball path (required)") + restoreCmd.Flags().StringVar(&restoreTargetDir, "target", "", "restore target dir (default: ORCA_HOME)") + restoreCmd.Flags().BoolVar(&restoreForce, "force", false, "overwrite a non-empty target directory") + rootCmd.AddCommand(backupCmd) + rootCmd.AddCommand(restoreCmd) +} diff --git a/internal/cli/backup_test.go b/internal/cli/backup_test.go new file mode 100644 index 0000000..68e47bf --- /dev/null +++ b/internal/cli/backup_test.go @@ -0,0 +1,177 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "git.cloudinit.dev/coreci/orca/internal/paths" + "git.cloudinit.dev/coreci/orca/internal/secrets" +) + +func setupBackupTestEnv(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("ORCA_HOME", dir) + mk, err := secrets.GenerateMasterKey() + if err != nil { + t.Fatalf("GenerateMasterKey: %v", err) + } + if err := os.MkdirAll(paths.ClusterDir(), 0o755); err != nil { + t.Fatalf("mkdir cluster dir: %v", err) + } + if err := secrets.SaveMasterKey(paths.MasterKeyPath(), mk); err != nil { + t.Fatalf("SaveMasterKey: %v", err) + } + return dir +} + +func TestBackupCmdRegistered(t *testing.T) { + found := false + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "backup" { + found = true + break + } + } + if !found { + t.Fatal("backup command not registered on root") + } +} + +func TestRestoreCmdRegistered(t *testing.T) { + found := false + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "restore" { + found = true + break + } + } + if !found { + t.Fatal("restore command not registered on root") + } +} + +func TestBackupRestoreCmdRoundTrip(t *testing.T) { + home := setupBackupTestEnv(t) + + if err := os.WriteFile(filepath.Join(home, "keep.txt"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write keep.txt: %v", err) + } + + outDir := t.TempDir() + out := filepath.Join(outDir, "orca-backup.tar.gz") + + var buf bytes.Buffer + resetRootFlags(t) + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"backup", "--out", out}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("orca backup: %v", err) + } + if _, err := os.Stat(out + ".sig"); err != nil { + t.Fatalf("sig missing: %v", err) + } + + target := filepath.Join(t.TempDir(), "restored") + var buf2 bytes.Buffer + resetRootFlags(t) + rootCmd.SetOut(&buf2) + rootCmd.SetErr(&buf2) + rootCmd.SetArgs([]string{"restore", "--in", out, "--target", target}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("orca restore: %v", err) + } + + got, err := os.ReadFile(filepath.Join(target, "keep.txt")) + if err != nil { + t.Fatalf("restored keep.txt missing: %v", err) + } + if string(got) != "payload" { + t.Errorf("restored keep.txt = %q, want %q", string(got), "payload") + } +} + +func TestRestoreCmdBadSignature(t *testing.T) { + setupBackupTestEnv(t) + + outDir := t.TempDir() + out := filepath.Join(outDir, "orca-backup.tar.gz") + body := []byte("not a real tarball") + if err := os.WriteFile(out, body, 0o644); err != nil { + t.Fatalf("write fake tarball: %v", err) + } + if err := os.WriteFile(out+".sig", []byte("deadbeef"), 0o644); err != nil { + t.Fatalf("write fake sig: %v", err) + } + + target := filepath.Join(t.TempDir(), "restored") + var buf bytes.Buffer + resetRootFlags(t) + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"restore", "--in", out, "--target", target}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("restore with bad signature should fail") + } +} + +func TestRestoreCmdRequiresInFlag(t *testing.T) { + setupBackupTestEnv(t) + var buf bytes.Buffer + resetRootFlags(t) + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"restore"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("restore without --in should fail") + } +} + +func TestBackupCmdDefaultOut(t *testing.T) { + home := setupBackupTestEnv(t) + if err := os.WriteFile(filepath.Join(home, "f.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("write f.txt: %v", err) + } + + work := t.TempDir() + orig, _ := os.Getwd() + if err := os.Chdir(work); err != nil { + t.Fatalf("chdir: %v", err) + } + defer os.Chdir(orig) + + var buf bytes.Buffer + resetRootFlags(t) + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"backup"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("orca backup default out: %v", err) + } + + entries, err := os.ReadDir(work) + if err != nil { + t.Fatalf("read work: %v", err) + } + var foundTar, foundSig bool + for _, e := range entries { + if e.Name() == "orca-backup" || strings.HasPrefix(e.Name(), "orca-backup-") && strings.HasSuffix(e.Name(), ".tar.gz") { + foundTar = true + } + if strings.HasSuffix(e.Name(), ".tar.gz.sig") { + foundSig = true + } + } + if !foundTar { + t.Errorf("default backup tarball not created in CWD (entries: %d)", len(entries)) + } + if !foundSig { + t.Errorf("default backup sig not created in CWD (entries: %d)", len(entries)) + } +} diff --git a/internal/cli/namespace_test.go b/internal/cli/namespace_test.go index c728d06..d22ca25 100644 --- a/internal/cli/namespace_test.go +++ b/internal/cli/namespace_test.go @@ -34,6 +34,8 @@ func resetCommandFlags() { stopID, runTarget, runIDKey, jobWatch = "", "", "", false capSetCPU, capSetMem, capSetDisk, capNodeID = 0, 0, 0, "" auditLimit = 50 + backupOutPath, restoreInPath, restoreTargetDir = "", "", "" + restoreForce = false resetNSFlags() }