0358efe95b
- 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---
152 lines
5.4 KiB
Go
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)
|
|
}
|