// 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" "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-.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) }