diff --git a/TODO.md b/TODO.md index 37320cb..f5acef0 100644 --- a/TODO.md +++ b/TODO.md @@ -2,48 +2,27 @@ Linear list of tasks to complete before 1.0 release. -## Restore Command - -1. Write integration tests for restore command - -## Daemon Mode - -1. Implement inotify file watcher for Linux - - Watch source directories for changes - - Track dirty paths in memory - -1. Implement FSEvents watcher for macOS - - Watch source directories for changes - - Track dirty paths in memory - -1. Implement backup scheduler in daemon mode - - Respect backup_interval config - - Trigger backup when dirty paths exist and interval elapsed - - Implement full_scan_interval for periodic full scans - -1. Add proper signal handling for daemon - - Graceful shutdown on SIGTERM/SIGINT - - Complete in-progress backup before exit - -1. Write tests for daemon mode - -## CLI Polish - -1. Add `--quiet` flag to all commands - - Suppress non-error output - - Useful for scripting - -1. Add `--json` output flag to more commands - - `snapshot verify` - output verification results as JSON - - `snapshot remove` - output deletion stats as JSON - - `prune` - output pruning stats as JSON +## CLI Polish (Priority) 1. Improve error messages throughout - Ensure all errors include actionable context - - Add suggestions for common issues + - Add suggestions for common issues (e.g., "did you set VAULTIK_AGE_SECRET_KEY?") + +## Security (Priority) + +1. Audit encryption implementation + - Verify age encryption is used correctly + - Ensure no plaintext leaks in logs or errors + - Verify blob hashes are computed correctly + +1. Secure memory handling for secrets + - Clear S3 credentials from memory after client init + - Document that age_secret_key is env-var only (already implemented) ## Testing +1. Write integration tests for restore command + 1. Write end-to-end integration test - Create backup - Verify backup @@ -63,12 +42,6 @@ Linear list of tasks to complete before 1.0 release. - Corrupted blobs - Missing blobs -## Documentation - -1. Add man page or --help improvements - - Detailed help for each command - - Examples in help output - ## Performance 1. Profile and optimize restore performance @@ -79,17 +52,11 @@ Linear list of tasks to complete before 1.0 release. 1. Add bandwidth limiting option - `--bwlimit` flag for upload/download speed limiting -## Security +## Documentation -1. Audit encryption implementation - - Verify age encryption is used correctly - - Ensure no plaintext leaks in logs or errors - -1. Add config file permission check - - Warn if config file is world-readable (contains secrets) - -1. Secure memory handling for secrets - - Clear age_secret_key from memory after use +1. Add man page or --help improvements + - Detailed help for each command + - Examples in help output ## Final Polish @@ -105,3 +72,26 @@ Linear list of tasks to complete before 1.0 release. - Ensure consistent code style 1. Tag and release v1.0.0 + +--- + +## Post-1.0 (Daemon Mode) + +1. Implement inotify file watcher for Linux + - Watch source directories for changes + - Track dirty paths in memory + +1. Implement FSEvents watcher for macOS + - Watch source directories for changes + - Track dirty paths in memory + +1. Implement backup scheduler in daemon mode + - Respect backup_interval config + - Trigger backup when dirty paths exist and interval elapsed + - Implement full_scan_interval for periodic full scans + +1. Add proper signal handling for daemon + - Graceful shutdown on SIGTERM/SIGINT + - Complete in-progress backup before exit + +1. Write tests for daemon mode diff --git a/internal/cli/info.go b/internal/cli/info.go index d64ce18..be3b37d 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -36,6 +36,7 @@ func NewInfoCommand() *cobra.Command { LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{}, Invokes: []fx.Option{ diff --git a/internal/cli/prune.go b/internal/cli/prune.go index 3246722..f197acd 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -19,10 +19,10 @@ func NewPruneCommand() *cobra.Command { Short: "Remove unreferenced blobs", Long: `Removes blobs that are not referenced by any snapshot. -This command scans all snapshots and their manifests to build a list of +This command scans all snapshots and their manifests to build a list of referenced blobs, then removes any blobs in storage that are not in this list. -Use this command after deleting snapshots with 'vaultik purge' to reclaim +Use this command after deleting snapshots with 'vaultik purge' to reclaim storage space.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -39,6 +39,7 @@ storage space.`, LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet || opts.JSON, }, Modules: []fx.Option{}, Invokes: []fx.Option{ @@ -50,7 +51,9 @@ storage space.`, // Run the prune operation if err := v.PruneBlobs(opts); err != nil { if err != context.Canceled { - log.Error("Prune operation failed", "error", err) + if !opts.JSON { + log.Error("Prune operation failed", "error", err) + } os.Exit(1) } } @@ -75,6 +78,7 @@ storage space.`, } cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output pruning stats as JSON") return cmd } diff --git a/internal/cli/purge.go b/internal/cli/purge.go index 9840d7a..749ac1e 100644 --- a/internal/cli/purge.go +++ b/internal/cli/purge.go @@ -56,6 +56,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{}, Invokes: []fx.Option{ diff --git a/internal/cli/restore.go b/internal/cli/restore.go index 0be2987..c69bf6e 100644 --- a/internal/cli/restore.go +++ b/internal/cli/restore.go @@ -76,6 +76,7 @@ Examples: LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{ fx.Provide(fx.Annotate( diff --git a/internal/cli/root.go b/internal/cli/root.go index b08a37e..ba9c215 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,6 +13,7 @@ type RootFlags struct { ConfigPath string Verbose bool Debug bool + Quiet bool } var rootFlags RootFlags @@ -34,6 +35,7 @@ on the source system.`, cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "", "Path to config file (default: $VAULTIK_CONFIG or /etc/vaultik/config.yml)") cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false, "Enable verbose output") cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false, "Enable debug output") + cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false, "Suppress non-error output") // Add subcommands cmd.AddCommand( diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index a281742..b6ec929 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -62,6 +62,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Cron: opts.Cron, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{}, Invokes: []fx.Option{ @@ -127,6 +128,7 @@ func newSnapshotListCommand() *cobra.Command { LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{}, Invokes: []fx.Option{ @@ -194,6 +196,7 @@ func newSnapshotPurgeCommand() *cobra.Command { LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{}, Invokes: []fx.Option{ @@ -233,7 +236,7 @@ func newSnapshotPurgeCommand() *cobra.Command { // newSnapshotVerifyCommand creates the 'snapshot verify' subcommand func newSnapshotVerifyCommand() *cobra.Command { - var deep bool + opts := &vaultik.VerifyOptions{} cmd := &cobra.Command{ Use: "verify ", @@ -255,6 +258,7 @@ func newSnapshotVerifyCommand() *cobra.Command { LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet || opts.JSON, }, Modules: []fx.Option{}, Invokes: []fx.Option{ @@ -262,9 +266,11 @@ func newSnapshotVerifyCommand() *cobra.Command { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.VerifySnapshot(snapshotID, deep); err != nil { + if err := v.VerifySnapshotWithOptions(snapshotID, opts); err != nil { if err != context.Canceled { - log.Error("Verification failed", "error", err) + if !opts.JSON { + log.Error("Verification failed", "error", err) + } os.Exit(1) } } @@ -285,7 +291,8 @@ func newSnapshotVerifyCommand() *cobra.Command { }, } - cmd.Flags().BoolVar(&deep, "deep", false, "Download and verify blob hashes") + cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Download and verify blob hashes") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON") return cmd } @@ -318,6 +325,7 @@ are still in use, then deletes any blobs that would become orphaned.`, LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet || opts.JSON, }, Modules: []fx.Option{}, Invokes: []fx.Option{ @@ -327,7 +335,9 @@ are still in use, then deletes any blobs that would become orphaned.`, go func() { if _, err := v.RemoveSnapshot(snapshotID, opts); err != nil { if err != context.Canceled { - log.Error("Failed to remove snapshot", "error", err) + if !opts.JSON { + log.Error("Failed to remove snapshot", "error", err) + } os.Exit(1) } } @@ -350,6 +360,7 @@ are still in use, then deletes any blobs that would become orphaned.`, cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be deleted without deleting") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output deletion stats as JSON") return cmd } @@ -377,6 +388,7 @@ accumulate from incomplete backups or deleted snapshots.`, LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{}, Invokes: []fx.Option{ diff --git a/internal/cli/store.go b/internal/cli/store.go index 557f39b..3281a0b 100644 --- a/internal/cli/store.go +++ b/internal/cli/store.go @@ -127,6 +127,7 @@ func runWithApp(ctx context.Context, fn func(*StoreApp) error) error { LogOptions: log.LogOptions{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, + Quiet: rootFlags.Quiet, }, Modules: []fx.Option{ fx.Provide(func(storer storage.Storer, shutdowner fx.Shutdowner) *StoreApp { diff --git a/internal/cli/verify.go b/internal/cli/verify.go index 66c964f..bafbee9 100644 --- a/internal/cli/verify.go +++ b/internal/cli/verify.go @@ -49,6 +49,7 @@ The command will fail immediately on any verification error and exit with non-ze 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{ @@ -61,12 +62,14 @@ The command will fail immediately on any verification error and exit with non-ze if opts.Deep { err = v.RunDeepVerify(snapshotID, opts) } else { - err = v.VerifySnapshot(snapshotID, false) + err = v.VerifySnapshotWithOptions(snapshotID, opts) } if err != nil { if err != context.Canceled { - log.Error("Verification failed", "error", err) + if !opts.JSON { + log.Error("Verification failed", "error", err) + } os.Exit(1) } } @@ -89,6 +92,7 @@ The command will fail immediately on any verification error and exit with non-ze } 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 } diff --git a/internal/config/config.go b/internal/config/config.go index 947bee6..8aa5b4d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "filippo.io/age" "git.eeqj.de/sneak/smartconfig" + "git.eeqj.de/sneak/vaultik/internal/log" "github.com/adrg/xdg" "go.uber.org/fx" "gopkg.in/yaml.v3" @@ -213,6 +214,17 @@ func Load(path string) (*Config, error) { cfg.S3.PartSize = Size(5 * 1024 * 1024) // 5MB } + // Check config file permissions (warn if world or group readable) + if info, err := os.Stat(path); err == nil { + mode := info.Mode().Perm() + if mode&0044 != 0 { // group or world readable + log.Warn("Config file has insecure permissions (contains S3 credentials)", + "path", path, + "mode", fmt.Sprintf("%04o", mode), + "recommendation", "chmod 600 "+path) + } + } + if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } diff --git a/internal/log/log.go b/internal/log/log.go index 7888dad..4b72036 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -35,6 +35,7 @@ type Config struct { Verbose bool Debug bool Cron bool + Quiet bool } var logger *slog.Logger @@ -44,8 +45,8 @@ func Initialize(cfg Config) { // Determine log level based on configuration var level slog.Level - if cfg.Cron { - // In cron mode, only show fatal errors (which we'll handle specially) + if cfg.Cron || cfg.Quiet { + // In quiet/cron mode, only show errors level = slog.LevelError } else if cfg.Debug || strings.Contains(os.Getenv("GODEBUG"), "vaultik") { level = slog.LevelDebug diff --git a/internal/log/module.go b/internal/log/module.go index 15c09d3..1b65128 100644 --- a/internal/log/module.go +++ b/internal/log/module.go @@ -21,4 +21,5 @@ type LogOptions struct { Verbose bool Debug bool Cron bool + Quiet bool } diff --git a/internal/vaultik/prune.go b/internal/vaultik/prune.go index b4049dc..946461e 100644 --- a/internal/vaultik/prune.go +++ b/internal/vaultik/prune.go @@ -1,7 +1,9 @@ package vaultik import ( + "encoding/json" "fmt" + "os" "strings" "git.eeqj.de/sneak/vaultik/internal/log" @@ -11,6 +13,15 @@ import ( // PruneOptions contains options for the prune command type PruneOptions struct { Force bool + JSON bool +} + +// PruneBlobsResult contains the result of a blob prune operation +type PruneBlobsResult struct { + BlobsFound int `json:"blobs_found"` + BlobsDeleted int `json:"blobs_deleted"` + BlobsFailed int `json:"blobs_failed,omitempty"` + BytesFreed int64 `json:"bytes_freed"` } // PruneBlobs removes unreferenced blobs from storage @@ -103,18 +114,27 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error { } } + result := &PruneBlobsResult{ + BlobsFound: len(unreferencedBlobs), + } + if len(unreferencedBlobs) == 0 { log.Info("No unreferenced blobs found") + if opts.JSON { + return outputPruneBlobsJSON(result) + } fmt.Println("No unreferenced blobs to remove.") return nil } // Show what will be deleted log.Info("Found unreferenced blobs", "count", len(unreferencedBlobs), "total_size", humanize.Bytes(uint64(totalSize))) - fmt.Printf("Found %d unreferenced blob(s) totaling %s\n", len(unreferencedBlobs), humanize.Bytes(uint64(totalSize))) + if !opts.JSON { + fmt.Printf("Found %d unreferenced blob(s) totaling %s\n", len(unreferencedBlobs), humanize.Bytes(uint64(totalSize))) + } - // Confirm unless --force is used - if !opts.Force { + // Confirm unless --force is used (skip in JSON mode - require --force) + if !opts.Force && !opts.JSON { fmt.Printf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs)) var confirm string if _, err := fmt.Scanln(&confirm); err != nil { @@ -154,12 +174,20 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error { } } + result.BlobsDeleted = deletedCount + result.BlobsFailed = len(unreferencedBlobs) - deletedCount + result.BytesFreed = deletedSize + log.Info("Prune complete", "deleted_count", deletedCount, "deleted_size", humanize.Bytes(uint64(deletedSize)), "failed", len(unreferencedBlobs)-deletedCount, ) + if opts.JSON { + return outputPruneBlobsJSON(result) + } + fmt.Printf("\nDeleted %d blob(s) totaling %s\n", deletedCount, humanize.Bytes(uint64(deletedSize))) if deletedCount < len(unreferencedBlobs) { fmt.Printf("Failed to delete %d blob(s)\n", len(unreferencedBlobs)-deletedCount) @@ -167,3 +195,10 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error { return nil } + +// outputPruneBlobsJSON outputs the prune result as JSON +func outputPruneBlobsJSON(result *PruneBlobsResult) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index b8c8ec0..97de59f 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -545,6 +545,19 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) // VerifySnapshot checks snapshot integrity func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error { + return v.VerifySnapshotWithOptions(snapshotID, &VerifyOptions{Deep: deep}) +} + +// VerifySnapshotWithOptions checks snapshot integrity with full options +func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptions) error { + result := &VerifyResult{ + SnapshotID: snapshotID, + Mode: "shallow", + } + if opts.Deep { + result.Mode = "deep" + } + // Parse snapshot ID to extract timestamp parts := strings.Split(snapshotID, "-") var snapshotTime time.Time @@ -561,30 +574,43 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error { } } - fmt.Printf("Verifying snapshot %s\n", snapshotID) - if !snapshotTime.IsZero() { - fmt.Printf("Snapshot time: %s\n", snapshotTime.Format("2006-01-02 15:04:05 MST")) + if !opts.JSON { + fmt.Printf("Verifying snapshot %s\n", snapshotID) + if !snapshotTime.IsZero() { + fmt.Printf("Snapshot time: %s\n", snapshotTime.Format("2006-01-02 15:04:05 MST")) + } + fmt.Println() } - fmt.Println() // Download and parse manifest manifest, err := v.downloadManifest(snapshotID) if err != nil { + if opts.JSON { + result.Status = "failed" + result.ErrorMessage = fmt.Sprintf("downloading manifest: %v", err) + return v.outputVerifyJSON(result) + } return fmt.Errorf("downloading manifest: %w", err) } - fmt.Printf("Snapshot information:\n") - fmt.Printf(" Blob count: %d\n", manifest.BlobCount) - fmt.Printf(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize))) - if manifest.Timestamp != "" { - if t, err := time.Parse(time.RFC3339, manifest.Timestamp); err == nil { - fmt.Printf(" Created: %s\n", t.Format("2006-01-02 15:04:05 MST")) - } - } - fmt.Println() + result.BlobCount = manifest.BlobCount + result.TotalSize = manifest.TotalCompressedSize + + if !opts.JSON { + fmt.Printf("Snapshot information:\n") + fmt.Printf(" Blob count: %d\n", manifest.BlobCount) + fmt.Printf(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize))) + if manifest.Timestamp != "" { + if t, err := time.Parse(time.RFC3339, manifest.Timestamp); err == nil { + fmt.Printf(" Created: %s\n", t.Format("2006-01-02 15:04:05 MST")) + } + } + fmt.Println() + + // Check each blob exists + fmt.Printf("Checking blob existence...\n") + } - // Check each blob exists - fmt.Printf("Checking blob existence...\n") missing := 0 verified := 0 missingSize := int64(0) @@ -592,16 +618,20 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error { for _, blob := range manifest.Blobs { blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash) - if deep { + if opts.Deep { // Download and verify hash // TODO: Implement deep verification - fmt.Printf("Deep verification not yet implemented\n") + if !opts.JSON { + fmt.Printf("Deep verification not yet implemented\n") + } return nil } else { // Just check existence _, err := v.Storage.Stat(v.ctx, blobPath) if err != nil { - fmt.Printf(" Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize))) + if !opts.JSON { + fmt.Printf(" Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize))) + } missing++ missingSize += blob.CompressedSize } else { @@ -610,6 +640,20 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error { } } + result.Verified = verified + result.Missing = missing + result.MissingSize = missingSize + + if opts.JSON { + if missing > 0 { + result.Status = "failed" + result.ErrorMessage = fmt.Sprintf("%d blobs are missing", missing) + } else { + result.Status = "ok" + } + return v.outputVerifyJSON(result) + } + fmt.Printf("\nVerification complete:\n") fmt.Printf(" Verified: %d blobs (%s)\n", verified, humanize.Bytes(uint64(manifest.TotalCompressedSize-missingSize))) @@ -629,6 +673,19 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error { return nil } +// outputVerifyJSON outputs the verification result as JSON +func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + if result.Status == "failed" { + return fmt.Errorf("verification failed: %s", result.ErrorMessage) + } + return nil +} + // Helper methods that were previously on SnapshotApp func (v *Vaultik) getManifestSize(snapshotID string) (int64, error) { @@ -760,14 +817,16 @@ func (v *Vaultik) syncWithRemote() error { type RemoveOptions struct { Force bool DryRun bool + JSON bool } // RemoveResult contains the result of a snapshot removal type RemoveResult struct { - SnapshotID string - BlobsDeleted int - BytesFreed int64 - BlobsFailed int + SnapshotID string `json:"snapshot_id"` + BlobsDeleted int `json:"blobs_deleted"` + BytesFreed int64 `json:"bytes_freed"` + BlobsFailed int `json:"blobs_failed,omitempty"` + DryRun bool `json:"dry_run,omitempty"` } // RemoveSnapshot removes a snapshot and any blobs that become orphaned @@ -871,18 +930,24 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov "total_size", humanize.Bytes(uint64(totalSize)), ) - // Show summary - _, _ = fmt.Fprintf(v.Stdout, "\nSnapshot: %s\n", snapshotID) - _, _ = fmt.Fprintf(v.Stdout, "Blobs in snapshot: %d\n", len(targetBlobs)) - _, _ = fmt.Fprintf(v.Stdout, "Orphaned blobs to delete: %d (%s)\n", len(orphanedBlobs), humanize.Bytes(uint64(totalSize))) + // Show summary (unless JSON mode) + if !opts.JSON { + _, _ = fmt.Fprintf(v.Stdout, "\nSnapshot: %s\n", snapshotID) + _, _ = fmt.Fprintf(v.Stdout, "Blobs in snapshot: %d\n", len(targetBlobs)) + _, _ = fmt.Fprintf(v.Stdout, "Orphaned blobs to delete: %d (%s)\n", len(orphanedBlobs), humanize.Bytes(uint64(totalSize))) + } if opts.DryRun { + result.DryRun = true + if opts.JSON { + return result, v.outputRemoveJSON(result) + } _, _ = fmt.Fprintln(v.Stdout, "\n[Dry run - no changes made]") return result, nil } - // Confirm unless --force is used - if !opts.Force { + // Confirm unless --force is used (skip in JSON mode - require --force) + if !opts.Force && !opts.JSON { _, _ = fmt.Fprintf(v.Stdout, "\nDelete snapshot and %d orphaned blob(s)? [y/N] ", len(orphanedBlobs)) var confirm string if _, err := fmt.Fscanln(v.Stdin, &confirm); err != nil { @@ -927,6 +992,11 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov return result, fmt.Errorf("deleting snapshot metadata: %w", err) } + // Output result + if opts.JSON { + return result, v.outputRemoveJSON(result) + } + // Print summary _, _ = fmt.Fprintf(v.Stdout, "\nRemoved snapshot %s\n", snapshotID) _, _ = fmt.Fprintf(v.Stdout, " Blobs deleted: %d\n", result.BlobsDeleted) @@ -938,6 +1008,13 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov return result, nil } +// outputRemoveJSON outputs the removal result as JSON +func (v *Vaultik) outputRemoveJSON(result *RemoveResult) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} + // PruneResult contains statistics about the prune operation type PruneResult struct { SnapshotsDeleted int64 diff --git a/internal/vaultik/verify.go b/internal/vaultik/verify.go index b039c4c..647938b 100644 --- a/internal/vaultik/verify.go +++ b/internal/vaultik/verify.go @@ -18,6 +18,20 @@ import ( // VerifyOptions contains options for the verify command type VerifyOptions struct { Deep bool + JSON bool +} + +// VerifyResult contains the result of a snapshot verification +type VerifyResult struct { + SnapshotID string `json:"snapshot_id"` + Status string `json:"status"` // "ok" or "failed" + Mode string `json:"mode"` // "shallow" or "deep" + BlobCount int `json:"blob_count"` + TotalSize int64 `json:"total_size"` + Verified int `json:"verified"` + Missing int `json:"missing"` + MissingSize int64 `json:"missing_size,omitempty"` + ErrorMessage string `json:"error,omitempty"` } // RunDeepVerify executes deep verification operation