Merge fix/cron-silence-list-sideffect-gitignore
All checks were successful
check / check (push) Successful in 1m18s
All checks were successful
check / check (push) Successful in 1m18s
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Binary
|
# Binary
|
||||||
vaultik
|
/vaultik
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
*.out
|
*.out
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
|||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
||||||
vaultik [--config <path>] snapshot remove <snapshot-id|--all> [--dry-run] [--force] [--remote] [--json]
|
vaultik [--config <path>] snapshot remove <snapshot-id|--all> [--dry-run] [--force] [--remote] [--json]
|
||||||
vaultik [--config <path>] snapshot prune
|
vaultik [--config <path>] snapshot prune
|
||||||
|
vaultik [--config <path>] snapshot cleanup
|
||||||
vaultik [--config <path>] restore <snapshot-id> <target-dir> [paths...] [--verify]
|
vaultik [--config <path>] restore <snapshot-id> <target-dir> [paths...] [--verify]
|
||||||
vaultik [--config <path>] prune [--force] [--json]
|
vaultik [--config <path>] prune [--force] [--json]
|
||||||
vaultik [--config <path>] info
|
vaultik [--config <path>] info
|
||||||
@@ -181,6 +182,10 @@ latest globally).
|
|||||||
**snapshot prune**: Clean orphaned data from the local database (files,
|
**snapshot prune**: Clean orphaned data from the local database (files,
|
||||||
chunks, blobs not referenced by any snapshot).
|
chunks, blobs not referenced by any snapshot).
|
||||||
|
|
||||||
|
**snapshot cleanup**: Remove stale local snapshot records that have no
|
||||||
|
corresponding metadata in remote storage. These are typically left behind
|
||||||
|
by incomplete or interrupted backups. Does not touch remote storage.
|
||||||
|
|
||||||
**restore**: Restore files from a backup snapshot.
|
**restore**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
* Optional path arguments to restore specific files/directories (default: all)
|
* Optional path arguments to restore specific files/directories (default: all)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestCLIEntry(t *testing.T) {
|
|||||||
t.Errorf("Failed to find snapshot command: %v", err)
|
t.Errorf("Failed to find snapshot command: %v", err)
|
||||||
} else {
|
} else {
|
||||||
// Check snapshot subcommands
|
// Check snapshot subcommands
|
||||||
expectedSubCommands := []string{"create", "list", "purge", "verify"}
|
expectedSubCommands := []string{"create", "list", "purge", "verify", "cleanup"}
|
||||||
for _, expected := range expectedSubCommands {
|
for _, expected := range expectedSubCommands {
|
||||||
found := false
|
found := false
|
||||||
for _, subcmd := range snapshotCmd.Commands() {
|
for _, subcmd := range snapshotCmd.Commands() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
"git.eeqj.de/sneak/vaultik/internal/log"
|
||||||
@@ -26,6 +27,7 @@ func NewSnapshotCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(newSnapshotVerifyCommand())
|
cmd.AddCommand(newSnapshotVerifyCommand())
|
||||||
cmd.AddCommand(newSnapshotRemoveCommand())
|
cmd.AddCommand(newSnapshotRemoveCommand())
|
||||||
cmd.AddCommand(newSnapshotPruneCommand())
|
cmd.AddCommand(newSnapshotPruneCommand())
|
||||||
|
cmd.AddCommand(newSnapshotCleanupCommand())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -71,7 +73,9 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
// Start the snapshot creation in a goroutine
|
// Start the snapshot creation in a goroutine
|
||||||
go func() {
|
go func() {
|
||||||
// Run the snapshot creation
|
if opts.Cron {
|
||||||
|
v.Stdout = io.Discard
|
||||||
|
}
|
||||||
if err := v.CreateSnapshot(opts); err != nil {
|
if err := v.CreateSnapshot(opts); err != nil {
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
@@ -463,3 +467,60 @@ accumulate from incomplete backups or deleted snapshots.`,
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newSnapshotCleanupCommand creates the 'snapshot cleanup' subcommand
|
||||||
|
func newSnapshotCleanupCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "cleanup",
|
||||||
|
Short: "Remove stale local snapshot records not found in remote storage",
|
||||||
|
Long: `Removes local database records for snapshots whose metadata no longer
|
||||||
|
exists in remote storage. These are typically left behind by incomplete
|
||||||
|
or interrupted backups.
|
||||||
|
|
||||||
|
This command does not delete anything from remote storage.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
configPath, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rootFlags := GetRootFlags()
|
||||||
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogOptions: log.LogOptions{
|
||||||
|
Verbose: rootFlags.Verbose,
|
||||||
|
Debug: rootFlags.Debug,
|
||||||
|
Quiet: rootFlags.Quiet,
|
||||||
|
},
|
||||||
|
Modules: []fx.Option{},
|
||||||
|
Invokes: []fx.Option{
|
||||||
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
go func() {
|
||||||
|
if err := v.CleanupLocalSnapshots(); err != nil {
|
||||||
|
if err != context.Canceled {
|
||||||
|
log.Error("Cleanup failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := v.Shutdowner.Shutdown(); err != nil {
|
||||||
|
log.Error("Failed to shutdown", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(ctx context.Context) error {
|
||||||
|
v.Cancel()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|||||||
@@ -409,7 +409,26 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
return encoder.Encode(snapshots)
|
return encoder.Encode(snapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.printSnapshotTable(snapshots)
|
if err := v.printSnapshotTable(snapshots); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about local snapshots that don't exist in remote storage.
|
||||||
|
var stale []string
|
||||||
|
for id := range localSnapshotMap {
|
||||||
|
if !remoteSnapshots[id] {
|
||||||
|
stale = append(stale, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stale) > 0 {
|
||||||
|
v.printfStdout("\nWarning: %d local snapshot(s) not found in remote storage:\n", len(stale))
|
||||||
|
for _, id := range stale {
|
||||||
|
v.printfStdout(" %s\n", id)
|
||||||
|
}
|
||||||
|
v.printlnStdout("Run 'vaultik snapshot cleanup' to remove stale local records.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// listRemoteSnapshotIDs returns a set of snapshot IDs found in remote storage
|
// listRemoteSnapshotIDs returns a set of snapshot IDs found in remote storage
|
||||||
@@ -873,6 +892,41 @@ func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanupLocalSnapshots removes local snapshot records that have no
|
||||||
|
// corresponding metadata in remote storage. These are typically left
|
||||||
|
// behind by incomplete or interrupted backups.
|
||||||
|
func (v *Vaultik) CleanupLocalSnapshots() error {
|
||||||
|
remoteSnapshots, err := v.listRemoteSnapshotIDs()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing local snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed int
|
||||||
|
for _, snap := range localSnapshots {
|
||||||
|
id := snap.ID.String()
|
||||||
|
if !remoteSnapshots[id] {
|
||||||
|
v.printfStdout("Removing stale local record: %s\n", id)
|
||||||
|
if err := v.deleteSnapshotFromLocalDB(id); err != nil {
|
||||||
|
log.Error("Failed to delete local snapshot", "snapshot_id", id, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removed == 0 {
|
||||||
|
v.printlnStdout("No stale local snapshots found.")
|
||||||
|
} else {
|
||||||
|
v.printfStdout("Removed %d stale local snapshot record(s).\n", removed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Helper methods that were previously on SnapshotApp
|
// Helper methods that were previously on SnapshotApp
|
||||||
|
|
||||||
func (v *Vaultik) downloadManifest(snapshotID string) (*snapshot.Manifest, error) {
|
func (v *Vaultik) downloadManifest(snapshotID string) (*snapshot.Manifest, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user