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
+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()
}