Fix CLI semantics: exit codes, --prune, dedup, deep-verify
This commit is contained in:
1
go.mod
1
go.mod
@@ -17,7 +17,6 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668
|
github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668
|
||||||
github.com/klauspost/compress v1.18.1
|
github.com/klauspost/compress v1.18.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.29
|
|
||||||
github.com/rclone/rclone v1.72.1
|
github.com/rclone/rclone v1.72.1
|
||||||
github.com/schollz/progressbar/v3 v3.19.0
|
github.com/schollz/progressbar/v3 v3.19.0
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -593,8 +593,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
|||||||
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
||||||
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
|
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestCLIEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all subcommands are registered
|
// Verify all subcommands are registered
|
||||||
expectedCommands := []string{"snapshot", "store", "restore", "prune", "verify", "info", "version"}
|
expectedCommands := []string{"snapshot", "store", "restore", "prune", "info", "version", "remote", "database"}
|
||||||
for _, expected := range expectedCommands {
|
for _, expected := range expectedCommands {
|
||||||
found := false
|
found := false
|
||||||
for _, cmd := range cmd.Commands() {
|
for _, cmd := range cmd.Commands() {
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PurgeOptions contains options for the purge command
|
|
||||||
type PurgeOptions struct {
|
|
||||||
KeepLatest bool
|
|
||||||
OlderThan string
|
|
||||||
Force bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPurgeCommand creates the purge command
|
|
||||||
func NewPurgeCommand() *cobra.Command {
|
|
||||||
opts := &PurgeOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "purge",
|
|
||||||
Short: "Purge old snapshots",
|
|
||||||
Long: `Removes snapshots based on age or count criteria.
|
|
||||||
|
|
||||||
This command allows you to:
|
|
||||||
- Keep only the latest snapshot (--keep-latest)
|
|
||||||
- Remove snapshots older than a specific duration (--older-than)
|
|
||||||
|
|
||||||
Config is located at /etc/vaultik/config.yml by default, but can be overridden by
|
|
||||||
specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// Validate flags
|
|
||||||
if !opts.KeepLatest && opts.OlderThan == "" {
|
|
||||||
return fmt.Errorf("must specify either --keep-latest or --older-than")
|
|
||||||
}
|
|
||||||
if opts.KeepLatest && opts.OlderThan != "" {
|
|
||||||
return fmt.Errorf("cannot specify both --keep-latest and --older-than")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use unified config resolution
|
|
||||||
configPath, err := ResolveConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the app framework like other commands
|
|
||||||
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 {
|
|
||||||
// Start the purge operation in a goroutine
|
|
||||||
go func() {
|
|
||||||
// Run the purge operation
|
|
||||||
if err := v.PurgeSnapshots(opts.KeepLatest, opts.OlderThan, opts.Force); err != nil {
|
|
||||||
if err != context.Canceled {
|
|
||||||
log.Error("Purge operation failed", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown the app when purge completes
|
|
||||||
if err := v.Shutdowner.Shutdown(); err != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(ctx context.Context) error {
|
|
||||||
log.Debug("Stopping purge operation")
|
|
||||||
v.Cancel()
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot")
|
|
||||||
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g. 30d, 6m, 1y)")
|
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompts")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
"git.eeqj.de/sneak/vaultik/internal/config"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
"git.eeqj.de/sneak/vaultik/internal/globals"
|
||||||
@@ -108,6 +109,7 @@ Examples:
|
|||||||
if err := app.Vaultik.Restore(restoreOpts); err != nil {
|
if err := app.Vaultik.Restore(restoreOpts); err != nil {
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
log.Error("Restore operation failed", "error", err)
|
log.Error("Restore operation failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ on the source system.`,
|
|||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewRestoreCommand(),
|
NewRestoreCommand(),
|
||||||
NewPruneCommand(),
|
NewPruneCommand(),
|
||||||
NewVerifyCommand(),
|
|
||||||
NewStoreCommand(),
|
NewStoreCommand(),
|
||||||
NewSnapshotCommand(),
|
NewSnapshotCommand(),
|
||||||
NewInfoCommand(),
|
NewInfoCommand(),
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
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)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Cron, "cron", false, "Run in cron mode (silent unless error)")
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false, "Run in cron mode (silent unless error)")
|
||||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false, "Delete all previous snapshots and unreferenced blobs after backup")
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false, "After backup, drop older snapshots of the same name and remove orphaned blobs")
|
||||||
cmd.Flags().BoolVar(&opts.SkipErrors, "skip-errors", false, "Skip file read errors (log them loudly but continue)")
|
cmd.Flags().BoolVar(&opts.SkipErrors, "skip-errors", false, "Skip file read errors (log them loudly but continue)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -169,11 +170,16 @@ func newSnapshotPurgeCommand() *cobra.Command {
|
|||||||
var keepLatest bool
|
var keepLatest bool
|
||||||
var olderThan string
|
var olderThan string
|
||||||
var force bool
|
var force bool
|
||||||
|
var names []string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "purge",
|
Use: "purge",
|
||||||
Short: "Purge old snapshots",
|
Short: "Purge old snapshots",
|
||||||
Long: "Removes snapshots based on age or count criteria",
|
Long: `Removes snapshots based on age or count criteria.
|
||||||
|
|
||||||
|
Retention is per-snapshot-name: --keep-latest keeps the latest of each
|
||||||
|
configured snapshot name, not the latest globally. Use --snapshot to
|
||||||
|
restrict the operation to specific snapshot names.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// Validate flags
|
// Validate flags
|
||||||
@@ -204,7 +210,13 @@ func newSnapshotPurgeCommand() *cobra.Command {
|
|||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
go func() {
|
go func() {
|
||||||
if err := v.PurgeSnapshots(keepLatest, olderThan, force); err != nil {
|
purgeOpts := &vaultik.PurgeOptions{
|
||||||
|
KeepLatest: keepLatest,
|
||||||
|
OlderThan: olderThan,
|
||||||
|
Force: force,
|
||||||
|
Names: names,
|
||||||
|
}
|
||||||
|
if err := v.PurgeSnapshots(purgeOpts); err != nil {
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
log.Error("Failed to purge snapshots", "error", err)
|
log.Error("Failed to purge snapshots", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -227,9 +239,10 @@ func newSnapshotPurgeCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&keepLatest, "keep-latest", false, "Keep only the latest snapshot")
|
cmd.Flags().BoolVar(&keepLatest, "keep-latest", false, "Keep only the latest snapshot of each name")
|
||||||
cmd.Flags().StringVar(&olderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
|
cmd.Flags().StringVar(&olderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
|
||||||
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
|
||||||
|
cmd.Flags().StringArrayVar(&names, "snapshot", nil, "Restrict to snapshots with these names (repeat for multiple)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -275,13 +288,7 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
go func() {
|
go func() {
|
||||||
var err error
|
if err := v.VerifySnapshotWithOptions(snapshotID, opts); err != nil {
|
||||||
if opts.Deep {
|
|
||||||
err = v.RunDeepVerify(snapshotID, opts)
|
|
||||||
} else {
|
|
||||||
err = v.VerifySnapshotWithOptions(snapshotID, opts)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
log.Error("Verification failed", "error", err)
|
log.Error("Verification failed", "error", err)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewVerifyCommand creates the verify command
|
|
||||||
func NewVerifyCommand() *cobra.Command {
|
|
||||||
opts := &vaultik.VerifyOptions{}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "verify <snapshot-id>",
|
|
||||||
Short: "Verify snapshot integrity",
|
|
||||||
Long: `Verifies that all blobs referenced in a snapshot exist and optionally verifies their contents.
|
|
||||||
|
|
||||||
Shallow verification (default):
|
|
||||||
- Downloads and decompresses manifest
|
|
||||||
- Checks existence of all blobs in S3
|
|
||||||
- Reports missing blobs
|
|
||||||
|
|
||||||
Deep verification (--deep):
|
|
||||||
- Downloads and decrypts database
|
|
||||||
- Verifies blob lists match between manifest and database
|
|
||||||
- Downloads, decrypts, and decompresses each blob
|
|
||||||
- Verifies SHA256 hash of each chunk matches database
|
|
||||||
- Ensures chunks are ordered correctly
|
|
||||||
|
|
||||||
The command will fail immediately on any verification error and exit with non-zero status.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snapshotID := args[0]
|
|
||||||
|
|
||||||
// Use unified config resolution
|
|
||||||
configPath, err := ResolveConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the app framework for all verification
|
|
||||||
rootFlags := GetRootFlags()
|
|
||||||
return RunWithApp(cmd.Context(), AppOptions{
|
|
||||||
ConfigPath: configPath,
|
|
||||||
LogOptions: log.LogOptions{
|
|
||||||
Verbose: rootFlags.Verbose,
|
|
||||||
Debug: rootFlags.Debug,
|
|
||||||
Quiet: rootFlags.Quiet || opts.JSON, // Suppress log output in JSON mode
|
|
||||||
},
|
|
||||||
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 {
|
|
||||||
// Run the verify operation directly
|
|
||||||
go func() {
|
|
||||||
var err error
|
|
||||||
if opts.Deep {
|
|
||||||
err = v.RunDeepVerify(snapshotID, opts)
|
|
||||||
} else {
|
|
||||||
err = v.VerifySnapshotWithOptions(snapshotID, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err != context.Canceled {
|
|
||||||
if !opts.JSON {
|
|
||||||
log.Error("Verification 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 {
|
|
||||||
log.Debug("Stopping verify operation")
|
|
||||||
v.Cancel()
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Perform deep verification by downloading and verifying all blob contents")
|
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
@@ -79,6 +79,33 @@ func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
|
|||||||
return timestamp.UTC(), nil
|
return timestamp.UTC(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshotNameFromID extracts the snapshot name from a snapshot ID.
|
||||||
|
// Snapshot IDs are formatted as `<hostname>_<name>_<RFC3339>` (or
|
||||||
|
// `<hostname>_<RFC3339>` if no name was given). The hostname argument
|
||||||
|
// is used to disambiguate cases where the hostname itself contains
|
||||||
|
// underscores. Returns "" if the ID has no name component.
|
||||||
|
func snapshotNameFromID(snapshotID, hostname string) string {
|
||||||
|
// Strip the trailing `_<RFC3339>` suffix.
|
||||||
|
idx := strings.LastIndex(snapshotID, "_")
|
||||||
|
if idx <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
prefix := snapshotID[:idx]
|
||||||
|
|
||||||
|
// Strip the leading hostname prefix.
|
||||||
|
if !strings.HasPrefix(prefix, hostname) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := prefix[len(hostname):]
|
||||||
|
if rest == "" {
|
||||||
|
return "" // No name component
|
||||||
|
}
|
||||||
|
if rest[0] == '_' {
|
||||||
|
return rest[1:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// parseDuration parses a duration string with support for days
|
// parseDuration parses a duration string with support for days
|
||||||
func parseDuration(s string) (time.Duration, error) {
|
func parseDuration(s string) (time.Duration, error) {
|
||||||
// Check for days suffix
|
// Check for days suffix
|
||||||
|
|||||||
@@ -541,3 +541,142 @@ func TestBackupAndRestore(t *testing.T) {
|
|||||||
|
|
||||||
t.Log("Backup and restore test completed successfully")
|
t.Log("Backup and restore test completed successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEndToEndFileStorage exercises the full backup → restore loop against the
|
||||||
|
// real `file://` storage backend (FileStorer) on a real OS filesystem. This is
|
||||||
|
// the closest local approximation of a production backup: encrypted blobs get
|
||||||
|
// written to disk, the metadata SQLite database is exported through the same
|
||||||
|
// blobgen pipeline as a real backup, and restoration reads them back through
|
||||||
|
// the public Vaultik.Restore entrypoint. It is the canonical end-to-end smoke
|
||||||
|
// test for 1.0.
|
||||||
|
func TestEndToEndFileStorage(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
// Real OS filesystem (SQLite + FileStorer both need it).
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir, err := os.MkdirTemp("", "vaultik-e2e-")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||||
|
|
||||||
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
|
storeDir := filepath.Join(tempDir, "remote")
|
||||||
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
// Write a representative mix of file sizes:
|
||||||
|
// - empty file
|
||||||
|
// - tiny text file
|
||||||
|
// - file just under chunk boundary
|
||||||
|
// - file forcing multiple chunks
|
||||||
|
// - nested subdirectories
|
||||||
|
chunkSize := int64(64 * 1024)
|
||||||
|
maxBlobSize := int64(512 * 1024)
|
||||||
|
|
||||||
|
testFiles := map[string][]byte{
|
||||||
|
filepath.Join(dataDir, "empty.txt"): {},
|
||||||
|
filepath.Join(dataDir, "small.txt"): []byte("hello vaultik"),
|
||||||
|
filepath.Join(dataDir, "subdir", "medium.bin"): bytesPattern("medium-", int(chunkSize/2)),
|
||||||
|
filepath.Join(dataDir, "subdir", "large.bin"): bytesPattern("large-", int(chunkSize*4)),
|
||||||
|
filepath.Join(dataDir, "deep", "nest", "leaf.txt"): []byte("leaf"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, content := range testFiles {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStorer is the real-world local-disk backend.
|
||||||
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
agePublicKey := "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
||||||
|
ageSecretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
AgeRecipients: []string{agePublicKey},
|
||||||
|
AgeSecretKey: ageSecretKey,
|
||||||
|
CompressionLevel: 3,
|
||||||
|
Hostname: "test-host",
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
||||||
|
Repos: repos,
|
||||||
|
Storage: storer,
|
||||||
|
Config: cfg,
|
||||||
|
})
|
||||||
|
sm.SetFilesystem(fs)
|
||||||
|
|
||||||
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
|
FS: fs,
|
||||||
|
Storage: storer,
|
||||||
|
ChunkSize: chunkSize,
|
||||||
|
MaxBlobSize: maxBlobSize,
|
||||||
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
|
Repositories: repos,
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshotID, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "e2e", "test-version", "test-git")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
scanResult, err := scanner.Scan(ctx, dataDir, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, scanResult.FilesScanned, 0)
|
||||||
|
require.Greater(t, scanResult.BlobsCreated, 0)
|
||||||
|
|
||||||
|
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
||||||
|
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||||
|
|
||||||
|
// Verify the backup actually landed on disk under blobs/ and metadata/.
|
||||||
|
blobInfo, err := os.Stat(filepath.Join(storeDir, "blobs"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, blobInfo.IsDir())
|
||||||
|
metaInfo, err := os.Stat(filepath.Join(storeDir, "metadata", snapshotID))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, metaInfo.IsDir())
|
||||||
|
|
||||||
|
// Tear down the source DB before restore — restore must work using only
|
||||||
|
// the remote bytes plus the secret key, with no help from the local index.
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
restoreVaultik := &vaultik.Vaultik{
|
||||||
|
Config: cfg,
|
||||||
|
Storage: storer,
|
||||||
|
Fs: fs,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
restoreVaultik.SetContext(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, restoreVaultik.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Verify: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Byte-equality compare every original against its restored copy.
|
||||||
|
for origPath, expected := range testFiles {
|
||||||
|
restoredPath := filepath.Join(restoreDir, origPath)
|
||||||
|
got, err := afero.ReadFile(fs, restoredPath)
|
||||||
|
require.NoError(t, err, "restored file missing: %s", restoredPath)
|
||||||
|
require.Equalf(t, expected, got, "byte-equality failed for %s", origPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesPattern returns a deterministic byte slice of length n with a tag prefix,
|
||||||
|
// useful for forcing chunker behavior with reproducible content.
|
||||||
|
func bytesPattern(tag string, n int) []byte {
|
||||||
|
out := make([]byte, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = byte(tag[i%len(tag)] ^ byte(i&0xff))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,37 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
_, _ = fmt.Fprintf(v.Stdout, "\nAll %d snapshots completed in %s\n", len(snapshotNames), time.Since(overallStartTime).Round(time.Second))
|
_, _ = fmt.Fprintf(v.Stdout, "\nAll %d snapshots completed in %s\n", len(snapshotNames), time.Since(overallStartTime).Round(time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.Prune {
|
||||||
|
if err := v.runPostBackupPrune(snapshotNames); err != nil {
|
||||||
|
return fmt.Errorf("post-backup prune: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPostBackupPrune drops older snapshots of the given names (keeping only
|
||||||
|
// the latest of each) and removes orphan blobs from remote storage. Invoked
|
||||||
|
// when `snapshot create --prune` is used.
|
||||||
|
func (v *Vaultik) runPostBackupPrune(snapshotNames []string) error {
|
||||||
|
log.Info("Running post-backup prune", "snapshots", snapshotNames)
|
||||||
|
_, _ = fmt.Fprintln(v.Stdout, "\n=== Post-backup prune ===")
|
||||||
|
|
||||||
|
purgeOpts := &PurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
Names: snapshotNames,
|
||||||
|
Quiet: true,
|
||||||
|
}
|
||||||
|
if err := v.PurgeSnapshots(purgeOpts); err != nil {
|
||||||
|
return fmt.Errorf("purging old snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneOpts := &PruneOptions{Force: true}
|
||||||
|
if err := v.PruneBlobs(pruneOpts); err != nil {
|
||||||
|
return fmt.Errorf("pruning orphaned blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +329,6 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
|
|||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(v.Stdout, "Duration: %s\n", formatDuration(snapshotDuration))
|
_, _ = fmt.Fprintf(v.Stdout, "Duration: %s\n", formatDuration(snapshotDuration))
|
||||||
|
|
||||||
if opts.Prune {
|
|
||||||
log.Info("Pruning enabled - will delete old snapshots after snapshot")
|
|
||||||
// TODO: Implement pruning
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,8 +493,20 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeSnapshots removes old snapshots based on criteria
|
// PurgeOptions configures snapshot purge behavior.
|
||||||
func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) error {
|
type PurgeOptions struct {
|
||||||
|
KeepLatest bool // Keep only the most recent snapshot per name
|
||||||
|
OlderThan string // Drop snapshots older than this duration (e.g. "30d", "6m", "1y")
|
||||||
|
Force bool // Skip confirmation prompt and noisy output
|
||||||
|
Names []string // If non-empty, only operate on snapshots with one of these names
|
||||||
|
Quiet bool // Suppress informational output (used by --prune flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeSnapshots removes old snapshots based on criteria.
|
||||||
|
// Retention is per-snapshot-name: KeepLatest keeps the latest of EACH configured
|
||||||
|
// snapshot name, not the latest globally. This prevents `home` and `system`
|
||||||
|
// snapshots from cannibalizing each other.
|
||||||
|
func (v *Vaultik) PurgeSnapshots(opts *PurgeOptions) error {
|
||||||
// Sync with remote first
|
// Sync with remote first
|
||||||
if err := v.syncWithRemote(); err != nil {
|
if err := v.syncWithRemote(); err != nil {
|
||||||
return fmt.Errorf("syncing with remote: %w", err)
|
return fmt.Errorf("syncing with remote: %w", err)
|
||||||
@@ -480,17 +518,31 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
|
|||||||
return fmt.Errorf("listing snapshots: %w", err)
|
return fmt.Errorf("listing snapshots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to SnapshotInfo format, only including completed snapshots
|
// Convert to SnapshotInfo format, only including completed snapshots,
|
||||||
|
// optionally filtered by name.
|
||||||
|
hostname := v.shortHostname()
|
||||||
|
nameFilter := make(map[string]struct{}, len(opts.Names))
|
||||||
|
for _, n := range opts.Names {
|
||||||
|
nameFilter[n] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
snapshots := make([]SnapshotInfo, 0, len(dbSnapshots))
|
snapshots := make([]SnapshotInfo, 0, len(dbSnapshots))
|
||||||
for _, s := range dbSnapshots {
|
for _, s := range dbSnapshots {
|
||||||
if s.CompletedAt != nil {
|
if s.CompletedAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(nameFilter) > 0 {
|
||||||
|
name := snapshotNameFromID(s.ID.String(), hostname)
|
||||||
|
if _, ok := nameFilter[name]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
snapshots = append(snapshots, SnapshotInfo{
|
snapshots = append(snapshots, SnapshotInfo{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Timestamp: s.StartedAt,
|
Timestamp: s.StartedAt,
|
||||||
CompressedSize: s.BlobSize,
|
CompressedSize: s.BlobSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by timestamp (newest first)
|
// Sort by timestamp (newest first)
|
||||||
sort.Slice(snapshots, func(i, j int) bool {
|
sort.Slice(snapshots, func(i, j int) bool {
|
||||||
@@ -499,14 +551,21 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
|
|||||||
|
|
||||||
var toDelete []SnapshotInfo
|
var toDelete []SnapshotInfo
|
||||||
|
|
||||||
if keepLatest {
|
if opts.KeepLatest {
|
||||||
// Keep only the most recent snapshot
|
// Keep only the most recent snapshot of each name. Group by snapshot name
|
||||||
if len(snapshots) > 1 {
|
// (derived from snapshot ID) and keep the newest in each group.
|
||||||
toDelete = snapshots[1:]
|
seen := make(map[string]bool)
|
||||||
|
for _, snap := range snapshots {
|
||||||
|
name := snapshotNameFromID(snap.ID.String(), hostname)
|
||||||
|
if seen[name] {
|
||||||
|
toDelete = append(toDelete, snap)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
} else if olderThan != "" {
|
seen[name] = true
|
||||||
|
}
|
||||||
|
} else if opts.OlderThan != "" {
|
||||||
// Parse duration
|
// Parse duration
|
||||||
duration, err := parseDuration(olderThan)
|
duration, err := parseDuration(opts.OlderThan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid duration: %w", err)
|
return fmt.Errorf("invalid duration: %w", err)
|
||||||
}
|
}
|
||||||
@@ -520,34 +579,38 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(toDelete) == 0 {
|
if len(toDelete) == 0 {
|
||||||
fmt.Println("No snapshots to delete")
|
if !opts.Quiet {
|
||||||
|
_, _ = fmt.Fprintln(v.Stdout, "No snapshots to delete")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show what will be deleted
|
// Show what will be deleted
|
||||||
fmt.Printf("The following snapshots will be deleted:\n\n")
|
if !opts.Quiet {
|
||||||
|
_, _ = fmt.Fprintf(v.Stdout, "The following snapshots will be deleted:\n\n")
|
||||||
for _, snap := range toDelete {
|
for _, snap := range toDelete {
|
||||||
fmt.Printf(" %s (%s, %s)\n",
|
_, _ = fmt.Fprintf(v.Stdout, " %s (%s, %s)\n",
|
||||||
snap.ID,
|
snap.ID,
|
||||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
formatBytes(snap.CompressedSize))
|
formatBytes(snap.CompressedSize))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Confirm unless --force is used
|
// Confirm unless --force is used
|
||||||
if !force {
|
if !opts.Force {
|
||||||
fmt.Printf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
_, _ = fmt.Fprintf(v.Stdout, "\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
||||||
var confirm string
|
var confirm string
|
||||||
if _, err := fmt.Scanln(&confirm); err != nil {
|
if _, err := fmt.Fscanln(v.Stdin, &confirm); err != nil {
|
||||||
// Treat EOF or error as "no"
|
// Treat EOF or error as "no"
|
||||||
fmt.Println("Cancelled")
|
_, _ = fmt.Fprintln(v.Stdout, "Cancelled")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.ToLower(confirm) != "y" {
|
if strings.ToLower(confirm) != "y" {
|
||||||
fmt.Println("Cancelled")
|
_, _ = fmt.Fprintln(v.Stdout, "Cancelled")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else if !opts.Quiet {
|
||||||
fmt.Printf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
|
_, _ = fmt.Fprintf(v.Stdout, "\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete snapshots (both local and remote)
|
// Delete snapshots (both local and remote)
|
||||||
@@ -562,43 +625,49 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Deleted %d snapshot(s)\n", len(toDelete))
|
if !opts.Quiet {
|
||||||
|
_, _ = fmt.Fprintf(v.Stdout, "Deleted %d snapshot(s)\n", len(toDelete))
|
||||||
// Note: Run 'vaultik prune' separately to clean up unreferenced blobs
|
_, _ = fmt.Fprintln(v.Stdout, "\nNote: Run 'vaultik prune' to clean up unreferenced blobs.")
|
||||||
fmt.Println("\nNote: Run 'vaultik prune' to clean up unreferenced blobs.")
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shortHostname returns the configured hostname stripped of its domain suffix.
|
||||||
|
// This matches the hostname-prefix used when building snapshot IDs.
|
||||||
|
func (v *Vaultik) shortHostname() string {
|
||||||
|
hostname := v.Config.Hostname
|
||||||
|
if hostname == "" {
|
||||||
|
hostname, _ = os.Hostname()
|
||||||
|
}
|
||||||
|
if idx := strings.Index(hostname, "."); idx != -1 {
|
||||||
|
hostname = hostname[:idx]
|
||||||
|
}
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
// VerifySnapshot checks snapshot integrity
|
// VerifySnapshot checks snapshot integrity
|
||||||
func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error {
|
func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error {
|
||||||
return v.VerifySnapshotWithOptions(snapshotID, &VerifyOptions{Deep: deep})
|
return v.VerifySnapshotWithOptions(snapshotID, &VerifyOptions{Deep: deep})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifySnapshotWithOptions checks snapshot integrity with full options
|
// VerifySnapshotWithOptions checks snapshot integrity with full options.
|
||||||
|
// Deep verification is delegated to RunDeepVerify so this function only
|
||||||
|
// implements the shallow (existence-only) path.
|
||||||
func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptions) error {
|
func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptions) error {
|
||||||
|
if opts.Deep {
|
||||||
|
return v.RunDeepVerify(snapshotID, opts)
|
||||||
|
}
|
||||||
result := &VerifyResult{
|
result := &VerifyResult{
|
||||||
SnapshotID: snapshotID,
|
SnapshotID: snapshotID,
|
||||||
Mode: "shallow",
|
Mode: "shallow",
|
||||||
}
|
}
|
||||||
if opts.Deep {
|
|
||||||
result.Mode = "deep"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse snapshot ID to extract timestamp
|
// Parse snapshot ID to extract timestamp.
|
||||||
parts := strings.Split(snapshotID, "-")
|
// Snapshot ID format: hostname[_name]_<RFC3339>
|
||||||
var snapshotTime time.Time
|
var snapshotTime time.Time
|
||||||
if len(parts) >= 3 {
|
if t, err := parseSnapshotTimestamp(snapshotID); err == nil {
|
||||||
// Format: hostname-YYYYMMDD-HHMMSSZ
|
snapshotTime = t
|
||||||
dateStr := parts[len(parts)-2]
|
|
||||||
timeStr := parts[len(parts)-1]
|
|
||||||
if len(dateStr) == 8 && len(timeStr) == 7 && strings.HasSuffix(timeStr, "Z") {
|
|
||||||
timeStr = timeStr[:6] // Remove Z
|
|
||||||
timestamp, err := time.Parse("20060102150405", dateStr+timeStr)
|
|
||||||
if err == nil {
|
|
||||||
snapshotTime = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
@@ -645,15 +714,7 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
|
|||||||
for _, blob := range manifest.Blobs {
|
for _, blob := range manifest.Blobs {
|
||||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||||
|
|
||||||
if opts.Deep {
|
// Shallow: just check existence
|
||||||
// Download and verify hash
|
|
||||||
// TODO: Implement deep verification
|
|
||||||
if !opts.JSON {
|
|
||||||
fmt.Printf("Deep verification not yet implemented\n")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
// Just check existence
|
|
||||||
_, err := v.Storage.Stat(v.ctx, blobPath)
|
_, err := v.Storage.Stat(v.ctx, blobPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
@@ -665,7 +726,6 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
|
|||||||
verified++
|
verified++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
result.Verified = verified
|
result.Verified = verified
|
||||||
result.Missing = missing
|
result.Missing = missing
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VerifyOptions contains options for the verify command
|
// VerifyOptions contains options for the verify command
|
||||||
@@ -272,7 +272,7 @@ func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser, secretKey string)
|
|||||||
log.Info("Database decompressed", "size", humanize.Bytes(uint64(written)))
|
log.Info("Database decompressed", "size", humanize.Bytes(uint64(written)))
|
||||||
|
|
||||||
// Open the database
|
// Open the database
|
||||||
db, err := sql.Open("sqlite3", tempPath)
|
db, err := sql.Open("sqlite", tempPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tempPath)
|
_ = os.Remove(tempPath)
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user