Files
orca/internal/cli/backup.go
T
Jon Chery 0358efe95b fix(P07): concurrency safety — SQLite, flock, cache, atomic writes (REQ-156)
- SQLite busy_timeout(5000) + SetMaxOpenConns(1) on all 4 DSNs
- secrets file flock (concurrent set on same ns no longer loses data)
- upgrade lock file (refuse concurrent orca upgrade)
- backup lock file (refuse concurrent backup)
- cache invalidation by writes (read-after-write consistency)
- Executor.Run mutex scope fix (hold only for DB inserts)
- ns create/inherit/set-constraint atomic writeNSMdAtomic
- writeCurrentLead + rotateSSHKeys atomic
- consolidate 3 writeAtomic impls onto security.WriteAtomic
- WebAuthn session stores guarded with sync.Mutex

Tests: concurrent secrets set, upgrade lock rejection, cache
read-after-write, WebAuthn session thread-safety (pass under -race).

---ci---
project: orca
phase: 7
milestone: v0.13
status: complete
requirements:
  covered: [156]
---/ci---
2026-08-10 12:27:05 +00:00

152 lines
5.4 KiB
Go

// 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"
"os"
"path/filepath"
"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
restoreDryRun bool
)
// acquireBackupLock atomically creates an exclusive lock file at
// paths.ClusterDir()/backup.lock (REQ-156, P07 T4). Returns a release
// function that MUST be deferred (it removes the lock file). If the
// lock file already exists, returns an error "backup already in
// progress" — preventing two concurrent `orca backup` invocations
// from racing on the same ORCA_HOME (two tarballs being written from
// the same source tree could produce inconsistent archives). O_CREATE
// |O_EXCL is atomic under POSIX.
func acquireBackupLock() (func(), error) {
lockPath := filepath.Join(paths.ClusterDir(), "backup.lock")
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
return nil, fmt.Errorf("create cluster dir for backup lock: %w", err)
}
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
if os.IsExist(err) {
return nil, fmt.Errorf("backup already in progress (lock file %s exists; remove it if stale)", lockPath)
}
return nil, fmt.Errorf("acquire backup lock: %w", err)
}
_, _ = f.WriteString(fmt.Sprintf("pid=%d started=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339)))
_ = f.Close()
return func() { _ = os.Remove(lockPath) }, nil
}
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)
}
// REQ-156 / P07 T4: acquire an exclusive backup lock so two
// concurrent `orca backup` invocations don't race on the same
// ORCA_HOME (producing interleaved / inconsistent archives).
backupRelease, err := acquireBackupLock()
if err != nil {
return err
}
defer backupRelease()
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/P07).
Verifies the HMAC-SHA256 signature on --in (using the cluster master
key) before extracting. Reconciles with live state: refuses to clobber
running allocations unless --force is given (with --force, stops the
running allocs, extracts, then restarts them from the restored state).
With --dry-run, extracts to a temp dir and reports what WOULD be
restored without touching the real ORCA_HOME. Performs post-restore
verification (master key, namespace dirs, SQLite DBs) and records the
restore in the audit log.`,
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 := RestoreOptions{
InputPath: restoreInPath,
TargetDir: target,
MasterKey: mk,
Force: restoreForce,
DryRun: restoreDryRun,
}
return runRestore(cmd, opts)
},
}
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 and stop+restart running allocs")
restoreCmd.Flags().BoolVar(&restoreDryRun, "dry-run", false, "extract to a temp dir and report what would be restored without touching ORCA_HOME")
rootCmd.AddCommand(backupCmd)
rootCmd.AddCommand(restoreCmd)
}