feat(P04): backup/restore — signed tarball (HMAC-SHA256)

internal/backup/backup.go: Backup (tar.gz + HMAC-SHA256 signature,
excludes /run/orca + sockets + WAL/SHM), VerifySignature, Restore
(signature verify + extract + Force flag). internal/cli/backup.go:
orca backup --out + orca restore --in --force. Tests: round-trip,
signature mismatch, exclusion, force-refuse, force-overwrite.

---ci---
project: orca
phase: 04
milestone: v0.11
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-08-07 04:55:27 +00:00
parent 2e6436608f
commit f61ef2aa9e
5 changed files with 962 additions and 0 deletions
+349
View File
@@ -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
}
+316
View File
@@ -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)
}
+118
View File
@@ -0,0 +1,118 @@
// Package cli: backup.go implements the `orca backup` and `orca restore`
// subcommands (P04, v0.11 milestone).
//
// orca backup --out <path> — create a signed tar.gz of ORCA_HOME
// orca restore --in <path> — 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-<timestamp>.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)
}
+177
View File
@@ -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))
}
}
+2
View File
@@ -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()
}