check / check (push) Waiting to run
snapshot list says why a remote-only snapshot is listed by its remote key. snapshot verify says what access to the destination it needs. The note on the age-keygen file is two sentences in snapshot verify and snapshot restore. remote nuke says "backup destination store". config.example.yml names the commands that run without a snapshot. Only help strings and comments change. Model: fable-5-1
372 lines
13 KiB
Go
372 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/spf13/cobra"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
var (
|
|
errSnapshotIDRequired = errors.New("snapshot ID required")
|
|
errWrongArgCount = errors.New("wrong argument count")
|
|
errPurgeCriteriaNeeded = errors.New(
|
|
"must specify either --keep-latest or --older-than")
|
|
errPurgeCriteriaBoth = errors.New(
|
|
"cannot specify both --keep-latest and --older-than")
|
|
)
|
|
|
|
// requireSnapshotIDArg validates that exactly one positional argument
|
|
// (the snapshot ID) was supplied, printing help otherwise.
|
|
func requireSnapshotIDArg(cmd *cobra.Command, args []string) error {
|
|
if len(args) != 1 {
|
|
_ = cmd.Help()
|
|
|
|
if len(args) == 0 {
|
|
return errSnapshotIDRequired
|
|
}
|
|
|
|
return fmt.Errorf("%w: expected 1 argument, got %d",
|
|
errWrongArgCount, len(args))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewSnapshotCommand creates the snapshot command and subcommands
|
|
func NewSnapshotCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "snapshot",
|
|
Short: "Snapshot management commands",
|
|
Long: "Commands for creating, listing, and managing snapshots",
|
|
// cobra checks Args only on a command that has a Run: without
|
|
// one, a mistyped subcommand printed help and exited 0.
|
|
Args: cobra.NoArgs,
|
|
Run: func(cmd *cobra.Command, _ []string) {
|
|
_ = cmd.Help()
|
|
},
|
|
}
|
|
|
|
// Add subcommands
|
|
cmd.AddCommand(newSnapshotCreateCommand())
|
|
cmd.AddCommand(newSnapshotListCommand())
|
|
cmd.AddCommand(newSnapshotPurgeCommand())
|
|
cmd.AddCommand(newSnapshotVerifyCommand())
|
|
cmd.AddCommand(newSnapshotRemoveCommand())
|
|
cmd.AddCommand(newSnapshotRestoreCommand())
|
|
|
|
return cmd
|
|
}
|
|
|
|
// snapshotCreateLong is the long help text of 'snapshot create'.
|
|
const snapshotCreateLong = `Creates new snapshots of the configured directories.
|
|
|
|
If snapshot names are provided, only those snapshots are created.
|
|
If no names are provided, all configured snapshots are created.
|
|
|
|
Needs at least one public key in age_recipients, and permission to write
|
|
objects at the backup destination store. It does not need the age private
|
|
key. With --prune it also needs permission to list, read and delete objects
|
|
there.
|
|
|
|
The config is read from the path given by --config or VAULTIK_CONFIG;
|
|
otherwise from the platform config directory (~/.config/vaultik/config.yml
|
|
on Linux), then /etc/vaultik/config.yml.`
|
|
|
|
// newSnapshotCreateCommand creates the 'snapshot create' subcommand
|
|
func newSnapshotCreateCommand() *cobra.Command {
|
|
opts := &vaultik.SnapshotCreateOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "create [snapshot-names...]",
|
|
Short: "Create new snapshots",
|
|
Long: snapshotCreateLong,
|
|
Args: cobra.ArbitraryArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
err := opts.Validate()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Pass snapshot names from args
|
|
opts.Snapshots = args
|
|
// --skip-errors is a global flag on the root command.
|
|
opts.SkipErrors = rootFlags.SkipErrors
|
|
// Use unified config resolution
|
|
configPath, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Use the backup functionality from cli package
|
|
rootFlags := GetRootFlags()
|
|
|
|
// --cron suppression is wired through v.UI by setupGlobals.
|
|
return RunOperation(cmd.Context(), AppOptions{
|
|
ConfigPath: configPath,
|
|
LogOptions: log.Options{
|
|
Verbose: rootFlags.Verbose,
|
|
Debug: rootFlags.Debug,
|
|
Cron: opts.Cron,
|
|
Quiet: rootFlags.Quiet,
|
|
},
|
|
Mode: mutating,
|
|
}, func(v *vaultik.Vaultik) error {
|
|
return v.CreateSnapshot(opts)
|
|
}, func(err error) {
|
|
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
|
|
log.Error("Post-backup prune failed", "error", err)
|
|
ReportErrorf("Backup completed, but %v", err)
|
|
|
|
return
|
|
}
|
|
|
|
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
|
|
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
|
|
errors.Is(err, vaultik.ErrVolumeNotMounted) {
|
|
log.Error("Backup completed with an error", "error", err)
|
|
ReportErrorf("Backup completed, but %v", err)
|
|
|
|
return
|
|
}
|
|
|
|
log.Error("Snapshot creation failed", "error", err)
|
|
ReportErrorf("Snapshot creation failed: %v", err)
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
|
"Run in cron mode (silent unless warning or error)")
|
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
|
"After backup, drop older snapshots of the same name, remove "+
|
|
"orphaned blobs older than prune_grace_period, and delete each "+
|
|
"directory under metadata/ with no manifest once all its "+
|
|
"objects are older than prune_grace_period and its snapshot is "+
|
|
"not in the local index (not after a backup that skipped "+
|
|
"unreadable files, found a configured path empty, or did not "+
|
|
"find a mount point an earlier snapshot found)")
|
|
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
|
"With --prune: keep snapshots newer than this duration "+
|
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
|
"instead of only the latest")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotListCommand creates the 'snapshot list' subcommand
|
|
func newSnapshotListCommand() *cobra.Command {
|
|
var jsonOutput bool
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Aliases: []string{"ls"},
|
|
Short: "List all snapshots",
|
|
Long: `Lists the snapshots in the local index and on the backup destination
|
|
store, with their ID, timestamp, and sizes.
|
|
|
|
Needs permission to list and read the destination store. It does not need
|
|
the age private key. A snapshot found only on the destination store is
|
|
listed by its remote key, because its ID is stored only in the local index
|
|
and in the snapshot's encrypted database. If the destination store cannot
|
|
be listed, the command warns and lists the local index alone.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return runVaultikApp(cmd, readOnly, false,
|
|
"Failed to list snapshots",
|
|
func(v *vaultik.Vaultik) error {
|
|
return v.ListSnapshots(jsonOutput)
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
|
func newSnapshotPurgeCommand() *cobra.Command {
|
|
opts := &vaultik.SnapshotPurgeOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "purge",
|
|
Short: "Purge old snapshots",
|
|
Long: `Removes snapshots based on age or count criteria. Exactly one of
|
|
--keep-latest and --older-than is required.
|
|
|
|
Retention is per-snapshot-name: --keep-latest keeps the latest snapshot of
|
|
each name, not the latest globally. Use --snapshot to restrict the
|
|
operation to specific snapshot names.
|
|
|
|
Only snapshots in the local index are removed. Each one is removed from
|
|
the local index and its metadata is deleted from the backup destination
|
|
store. Blobs are not touched: run 'vaultik prune' afterwards.
|
|
|
|
Needs permission to list and delete objects on the destination store. If
|
|
the destination store cannot be listed, nothing is removed.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
// Validate flags
|
|
if !opts.KeepLatest && opts.OlderThan == "" {
|
|
return errPurgeCriteriaNeeded
|
|
}
|
|
|
|
if opts.KeepLatest && opts.OlderThan != "" {
|
|
return errPurgeCriteriaBoth
|
|
}
|
|
|
|
return runVaultikApp(cmd, mutating, false,
|
|
"Failed to purge snapshots",
|
|
func(v *vaultik.Vaultik) error {
|
|
return v.PurgeSnapshotsWithOptions(opts)
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false,
|
|
"Keep only the latest snapshot of each name")
|
|
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "",
|
|
"Remove snapshots older than duration "+
|
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months)")
|
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
|
cmd.Flags().StringArrayVar(&opts.Names, "snapshot", nil,
|
|
"Restrict to snapshots with these names (repeat for multiple)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotVerifyCommand creates the 'snapshot verify' subcommand
|
|
func newSnapshotVerifyCommand() *cobra.Command {
|
|
opts := &vaultik.VerifyOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "verify <snapshot-id>",
|
|
Short: "Check a snapshot's blobs are present with the listed size",
|
|
Long: `Checks that every blob the snapshot's manifest lists is present
|
|
in storage with the size the manifest records, and that the
|
|
snapshot's encrypted database is present. It does not read blob
|
|
contents and does not need the age private key.
|
|
|
|
Needs permission to read the backup destination store, and to list it
|
|
when a remote key is given.
|
|
|
|
Use --deep to download, decrypt, and re-hash every blob to detect
|
|
corruption -- integrity, not who wrote it.
|
|
|
|
--deep requires the age private key, in the VAULTIK_AGE_SECRET_KEY
|
|
environment variable or as age_secret_key in the config file; the
|
|
environment variable wins. The variable may hold the whole age-keygen
|
|
file, with its comments and all of its keys. Read the key from the
|
|
file rather than typing it, so that it does not land in your shell
|
|
history:
|
|
|
|
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
|
|
|
The snapshot may be named by its ID or, on a host with no local
|
|
index, by the remote key that 'snapshot list' prints for a
|
|
remote-only snapshot (an unambiguous leading part is enough).`,
|
|
Args: requireSnapshotIDArg,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
snapshotID := args[0]
|
|
|
|
// Use unified config resolution
|
|
configPath, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rootFlags := GetRootFlags()
|
|
|
|
return RunOperation(cmd.Context(), AppOptions{
|
|
ConfigPath: configPath,
|
|
LogOptions: log.Options{
|
|
Verbose: rootFlags.Verbose,
|
|
Debug: rootFlags.Debug,
|
|
Quiet: rootFlags.Quiet,
|
|
JSON: opts.JSON,
|
|
},
|
|
Mode: readOnly,
|
|
}, func(v *vaultik.Vaultik) error {
|
|
return v.VerifySnapshotWithOptions(snapshotID, opts)
|
|
}, func(err error) {
|
|
if opts.JSON {
|
|
return
|
|
}
|
|
|
|
log.Error("Verification failed", "error", err)
|
|
ReportErrorf("Verification failed: %v", err)
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&opts.Deep, "deep", false,
|
|
"Download, decrypt and re-hash every blob; requires the age private "+
|
|
"key in VAULTIK_AGE_SECRET_KEY, or as age_secret_key in the config "+
|
|
"file")
|
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newSnapshotRemoveCommand creates the 'snapshot remove' subcommand
|
|
func newSnapshotRemoveCommand() *cobra.Command {
|
|
opts := &vaultik.RemoveOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "remove <snapshot-id>",
|
|
Aliases: []string{"rm"},
|
|
Short: "Remove a snapshot from local index and remote metadata",
|
|
Long: `Removes a snapshot.
|
|
|
|
By default, this removes the snapshot from the local index database and
|
|
strips the snapshot's metadata from the backup destination store. Blobs
|
|
are NOT touched: deleting them requires reading every remaining remote
|
|
manifest (the destination store may hold snapshots this host doesn't
|
|
know about), which is what 'vaultik prune' does. The command prints the
|
|
'vaultik prune' invocation to run as a follow-up only when it removed the
|
|
snapshot's metadata from the destination store, and never under --json.
|
|
|
|
The argument must be a snapshot ID: a remote key is not accepted. An ID
|
|
that names no snapshot in the local index or on the destination store is
|
|
an error. Needs permission to list and delete objects on the destination
|
|
store, unless --local-only is given.
|
|
|
|
Use --local-only to skip the remote half (e.g. when you want to forget a
|
|
snapshot locally without touching the destination store); the snapshot
|
|
must then be in the local index.
|
|
|
|
If removing the snapshot's metadata from the destination store fails (for
|
|
example because the remote is unreachable), the local-database removal
|
|
still completes and a warning is emitted; run 'vaultik snapshot remove
|
|
<snapshot-id>' again once the destination store is reachable to remove
|
|
the snapshot's metadata from it ('vaultik prune' does not). For a
|
|
snapshot that is only on the destination store, that failure is an error.
|
|
|
|
To wipe the entire destination store and start over, use 'vaultik remote
|
|
nuke --force' — it is the single supported entry point for that.`,
|
|
Args: requireSnapshotIDArg,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return runVaultikApp(cmd, mutating, opts.JSON,
|
|
"Failed to remove snapshot",
|
|
func(v *vaultik.Vaultik) error {
|
|
_, err := v.RemoveSnapshot(args[0], opts)
|
|
|
|
return err
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
|
"Show what would be removed without removing")
|
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
|
"Output result as JSON; skips the confirmation prompt, as --force does")
|
|
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
|
"Skip remote cleanup; only touch the local index")
|
|
|
|
return cmd
|
|
}
|