Add --quiet flag, --json output, and config permission check

- Add global --quiet/-q flag to suppress non-error output
- Add --json flag to verify, snapshot rm, and prune commands
- Add config file permission check (warns if world/group readable)
- Update TODO.md to remove completed items
This commit is contained in:
Jeffrey Paul 2026-01-16 09:20:29 -08:00
parent 417b25a5f5
commit bdaaadf990
15 changed files with 251 additions and 95 deletions

94
TODO.md
View File

@ -2,48 +2,27 @@
Linear list of tasks to complete before 1.0 release. Linear list of tasks to complete before 1.0 release.
## Restore Command ## CLI Polish (Priority)
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
1. Improve error messages throughout 1. Improve error messages throughout
- Ensure all errors include actionable context - 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 ## Testing
1. Write integration tests for restore command
1. Write end-to-end integration test 1. Write end-to-end integration test
- Create backup - Create backup
- Verify backup - Verify backup
@ -63,12 +42,6 @@ Linear list of tasks to complete before 1.0 release.
- Corrupted blobs - Corrupted blobs
- Missing blobs - Missing blobs
## Documentation
1. Add man page or --help improvements
- Detailed help for each command
- Examples in help output
## Performance ## Performance
1. Profile and optimize restore 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 1. Add bandwidth limiting option
- `--bwlimit` flag for upload/download speed limiting - `--bwlimit` flag for upload/download speed limiting
## Security ## Documentation
1. Audit encryption implementation 1. Add man page or --help improvements
- Verify age encryption is used correctly - Detailed help for each command
- Ensure no plaintext leaks in logs or errors - Examples in help output
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
## Final Polish ## Final Polish
@ -105,3 +72,26 @@ Linear list of tasks to complete before 1.0 release.
- Ensure consistent code style - Ensure consistent code style
1. Tag and release v1.0.0 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

View File

@ -36,6 +36,7 @@ func NewInfoCommand() *cobra.Command {
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{

View File

@ -19,10 +19,10 @@ func NewPruneCommand() *cobra.Command {
Short: "Remove unreferenced blobs", Short: "Remove unreferenced blobs",
Long: `Removes blobs that are not referenced by any snapshot. 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. 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.`, storage space.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@ -39,6 +39,7 @@ storage space.`,
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{
@ -50,7 +51,9 @@ storage space.`,
// Run the prune operation // Run the prune operation
if err := v.PruneBlobs(opts); err != nil { if err := v.PruneBlobs(opts); err != nil {
if err != context.Canceled { if err != context.Canceled {
log.Error("Prune operation failed", "error", err) if !opts.JSON {
log.Error("Prune operation failed", "error", err)
}
os.Exit(1) os.Exit(1)
} }
} }
@ -75,6 +78,7 @@ storage space.`,
} }
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt") cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output pruning stats as JSON")
return cmd return cmd
} }

View File

@ -56,6 +56,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{

View File

@ -76,6 +76,7 @@ Examples:
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{ Modules: []fx.Option{
fx.Provide(fx.Annotate( fx.Provide(fx.Annotate(

View File

@ -13,6 +13,7 @@ type RootFlags struct {
ConfigPath string ConfigPath string
Verbose bool Verbose bool
Debug bool Debug bool
Quiet bool
} }
var rootFlags RootFlags 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().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().BoolVarP(&rootFlags.Verbose, "verbose", "v", false, "Enable verbose output")
cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false, "Enable debug 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 // Add subcommands
cmd.AddCommand( cmd.AddCommand(

View File

@ -62,6 +62,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Cron: opts.Cron, Cron: opts.Cron,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{
@ -127,6 +128,7 @@ func newSnapshotListCommand() *cobra.Command {
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{
@ -194,6 +196,7 @@ func newSnapshotPurgeCommand() *cobra.Command {
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{
@ -233,7 +236,7 @@ func newSnapshotPurgeCommand() *cobra.Command {
// newSnapshotVerifyCommand creates the 'snapshot verify' subcommand // newSnapshotVerifyCommand creates the 'snapshot verify' subcommand
func newSnapshotVerifyCommand() *cobra.Command { func newSnapshotVerifyCommand() *cobra.Command {
var deep bool opts := &vaultik.VerifyOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "verify <snapshot-id>", Use: "verify <snapshot-id>",
@ -255,6 +258,7 @@ func newSnapshotVerifyCommand() *cobra.Command {
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{
@ -262,9 +266,11 @@ 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() {
if err := v.VerifySnapshot(snapshotID, deep); err != nil { if err := v.VerifySnapshotWithOptions(snapshotID, opts); err != nil {
if err != context.Canceled { if err != context.Canceled {
log.Error("Verification failed", "error", err) if !opts.JSON {
log.Error("Verification failed", "error", err)
}
os.Exit(1) 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 return cmd
} }
@ -318,6 +325,7 @@ are still in use, then deletes any blobs that would become orphaned.`,
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{
@ -327,7 +335,9 @@ are still in use, then deletes any blobs that would become orphaned.`,
go func() { go func() {
if _, err := v.RemoveSnapshot(snapshotID, opts); err != nil { if _, err := v.RemoveSnapshot(snapshotID, opts); err != nil {
if err != context.Canceled { 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) 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().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.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 return cmd
} }
@ -377,6 +388,7 @@ accumulate from incomplete backups or deleted snapshots.`,
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []fx.Option{ Invokes: []fx.Option{

View File

@ -127,6 +127,7 @@ func runWithApp(ctx context.Context, fn func(*StoreApp) error) error {
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
}, },
Modules: []fx.Option{ Modules: []fx.Option{
fx.Provide(func(storer storage.Storer, shutdowner fx.Shutdowner) *StoreApp { fx.Provide(func(storer storage.Storer, shutdowner fx.Shutdowner) *StoreApp {

View File

@ -49,6 +49,7 @@ The command will fail immediately on any verification error and exit with non-ze
LogOptions: log.LogOptions{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON, // Suppress log output in JSON mode
}, },
Modules: []fx.Option{}, Modules: []fx.Option{},
Invokes: []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 { if opts.Deep {
err = v.RunDeepVerify(snapshotID, opts) err = v.RunDeepVerify(snapshotID, opts)
} else { } else {
err = v.VerifySnapshot(snapshotID, false) err = v.VerifySnapshotWithOptions(snapshotID, opts)
} }
if err != nil { if err != nil {
if err != context.Canceled { if err != context.Canceled {
log.Error("Verification failed", "error", err) if !opts.JSON {
log.Error("Verification failed", "error", err)
}
os.Exit(1) 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.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 return cmd
} }

View File

@ -10,6 +10,7 @@ import (
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
"git.eeqj.de/sneak/vaultik/internal/log"
"github.com/adrg/xdg" "github.com/adrg/xdg"
"go.uber.org/fx" "go.uber.org/fx"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -213,6 +214,17 @@ func Load(path string) (*Config, error) {
cfg.S3.PartSize = Size(5 * 1024 * 1024) // 5MB 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 { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err) return nil, fmt.Errorf("invalid config: %w", err)
} }

View File

@ -35,6 +35,7 @@ type Config struct {
Verbose bool Verbose bool
Debug bool Debug bool
Cron bool Cron bool
Quiet bool
} }
var logger *slog.Logger var logger *slog.Logger
@ -44,8 +45,8 @@ func Initialize(cfg Config) {
// Determine log level based on configuration // Determine log level based on configuration
var level slog.Level var level slog.Level
if cfg.Cron { if cfg.Cron || cfg.Quiet {
// In cron mode, only show fatal errors (which we'll handle specially) // In quiet/cron mode, only show errors
level = slog.LevelError level = slog.LevelError
} else if cfg.Debug || strings.Contains(os.Getenv("GODEBUG"), "vaultik") { } else if cfg.Debug || strings.Contains(os.Getenv("GODEBUG"), "vaultik") {
level = slog.LevelDebug level = slog.LevelDebug

View File

@ -21,4 +21,5 @@ type LogOptions struct {
Verbose bool Verbose bool
Debug bool Debug bool
Cron bool Cron bool
Quiet bool
} }

View File

@ -1,7 +1,9 @@
package vaultik package vaultik
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"strings" "strings"
"git.eeqj.de/sneak/vaultik/internal/log" "git.eeqj.de/sneak/vaultik/internal/log"
@ -11,6 +13,15 @@ import (
// PruneOptions contains options for the prune command // PruneOptions contains options for the prune command
type PruneOptions struct { type PruneOptions struct {
Force bool 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 // 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 { if len(unreferencedBlobs) == 0 {
log.Info("No unreferenced blobs found") log.Info("No unreferenced blobs found")
if opts.JSON {
return outputPruneBlobsJSON(result)
}
fmt.Println("No unreferenced blobs to remove.") fmt.Println("No unreferenced blobs to remove.")
return nil return nil
} }
// Show what will be deleted // Show what will be deleted
log.Info("Found unreferenced blobs", "count", len(unreferencedBlobs), "total_size", humanize.Bytes(uint64(totalSize))) 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 // Confirm unless --force is used (skip in JSON mode - require --force)
if !opts.Force { if !opts.Force && !opts.JSON {
fmt.Printf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs)) fmt.Printf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
var confirm string var confirm string
if _, err := fmt.Scanln(&confirm); err != nil { 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", log.Info("Prune complete",
"deleted_count", deletedCount, "deleted_count", deletedCount,
"deleted_size", humanize.Bytes(uint64(deletedSize)), "deleted_size", humanize.Bytes(uint64(deletedSize)),
"failed", len(unreferencedBlobs)-deletedCount, "failed", len(unreferencedBlobs)-deletedCount,
) )
if opts.JSON {
return outputPruneBlobsJSON(result)
}
fmt.Printf("\nDeleted %d blob(s) totaling %s\n", deletedCount, humanize.Bytes(uint64(deletedSize))) fmt.Printf("\nDeleted %d blob(s) totaling %s\n", deletedCount, humanize.Bytes(uint64(deletedSize)))
if deletedCount < len(unreferencedBlobs) { if deletedCount < len(unreferencedBlobs) {
fmt.Printf("Failed to delete %d blob(s)\n", len(unreferencedBlobs)-deletedCount) 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 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)
}

View File

@ -545,6 +545,19 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
// 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})
}
// 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 // Parse snapshot ID to extract timestamp
parts := strings.Split(snapshotID, "-") parts := strings.Split(snapshotID, "-")
var snapshotTime time.Time 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 !opts.JSON {
if !snapshotTime.IsZero() { fmt.Printf("Verifying snapshot %s\n", snapshotID)
fmt.Printf("Snapshot time: %s\n", snapshotTime.Format("2006-01-02 15:04:05 MST")) 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 // Download and parse manifest
manifest, err := v.downloadManifest(snapshotID) manifest, err := v.downloadManifest(snapshotID)
if err != nil { 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) return fmt.Errorf("downloading manifest: %w", err)
} }
fmt.Printf("Snapshot information:\n") result.BlobCount = manifest.BlobCount
fmt.Printf(" Blob count: %d\n", manifest.BlobCount) result.TotalSize = manifest.TotalCompressedSize
fmt.Printf(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize)))
if manifest.Timestamp != "" { if !opts.JSON {
if t, err := time.Parse(time.RFC3339, manifest.Timestamp); err == nil { fmt.Printf("Snapshot information:\n")
fmt.Printf(" Created: %s\n", t.Format("2006-01-02 15:04:05 MST")) fmt.Printf(" Blob count: %d\n", manifest.BlobCount)
} fmt.Printf(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize)))
} if manifest.Timestamp != "" {
fmt.Println() 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 missing := 0
verified := 0 verified := 0
missingSize := int64(0) missingSize := int64(0)
@ -592,16 +618,20 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error {
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 deep { if opts.Deep {
// Download and verify hash // Download and verify hash
// TODO: Implement deep verification // 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 return nil
} else { } else {
// Just check existence // Just check existence
_, err := v.Storage.Stat(v.ctx, blobPath) _, err := v.Storage.Stat(v.ctx, blobPath)
if err != nil { 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++ missing++
missingSize += blob.CompressedSize missingSize += blob.CompressedSize
} else { } 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("\nVerification complete:\n")
fmt.Printf(" Verified: %d blobs (%s)\n", verified, fmt.Printf(" Verified: %d blobs (%s)\n", verified,
humanize.Bytes(uint64(manifest.TotalCompressedSize-missingSize))) humanize.Bytes(uint64(manifest.TotalCompressedSize-missingSize)))
@ -629,6 +673,19 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error {
return nil 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 // Helper methods that were previously on SnapshotApp
func (v *Vaultik) getManifestSize(snapshotID string) (int64, error) { func (v *Vaultik) getManifestSize(snapshotID string) (int64, error) {
@ -760,14 +817,16 @@ func (v *Vaultik) syncWithRemote() error {
type RemoveOptions struct { type RemoveOptions struct {
Force bool Force bool
DryRun bool DryRun bool
JSON bool
} }
// RemoveResult contains the result of a snapshot removal // RemoveResult contains the result of a snapshot removal
type RemoveResult struct { type RemoveResult struct {
SnapshotID string SnapshotID string `json:"snapshot_id"`
BlobsDeleted int BlobsDeleted int `json:"blobs_deleted"`
BytesFreed int64 BytesFreed int64 `json:"bytes_freed"`
BlobsFailed int BlobsFailed int `json:"blobs_failed,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
} }
// RemoveSnapshot removes a snapshot and any blobs that become orphaned // 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)), "total_size", humanize.Bytes(uint64(totalSize)),
) )
// Show summary // Show summary (unless JSON mode)
_, _ = fmt.Fprintf(v.Stdout, "\nSnapshot: %s\n", snapshotID) if !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "Blobs in snapshot: %d\n", len(targetBlobs)) _, _ = fmt.Fprintf(v.Stdout, "\nSnapshot: %s\n", snapshotID)
_, _ = fmt.Fprintf(v.Stdout, "Orphaned blobs to delete: %d (%s)\n", len(orphanedBlobs), humanize.Bytes(uint64(totalSize))) _, _ = 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 { if opts.DryRun {
result.DryRun = true
if opts.JSON {
return result, v.outputRemoveJSON(result)
}
_, _ = fmt.Fprintln(v.Stdout, "\n[Dry run - no changes made]") _, _ = fmt.Fprintln(v.Stdout, "\n[Dry run - no changes made]")
return result, nil return result, nil
} }
// Confirm unless --force is used // Confirm unless --force is used (skip in JSON mode - require --force)
if !opts.Force { if !opts.Force && !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "\nDelete snapshot and %d orphaned blob(s)? [y/N] ", len(orphanedBlobs)) _, _ = fmt.Fprintf(v.Stdout, "\nDelete snapshot and %d orphaned blob(s)? [y/N] ", len(orphanedBlobs))
var confirm string var confirm string
if _, err := fmt.Fscanln(v.Stdin, &confirm); err != nil { 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) return result, fmt.Errorf("deleting snapshot metadata: %w", err)
} }
// Output result
if opts.JSON {
return result, v.outputRemoveJSON(result)
}
// Print summary // Print summary
_, _ = fmt.Fprintf(v.Stdout, "\nRemoved snapshot %s\n", snapshotID) _, _ = fmt.Fprintf(v.Stdout, "\nRemoved snapshot %s\n", snapshotID)
_, _ = fmt.Fprintf(v.Stdout, " Blobs deleted: %d\n", result.BlobsDeleted) _, _ = 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 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 // PruneResult contains statistics about the prune operation
type PruneResult struct { type PruneResult struct {
SnapshotsDeleted int64 SnapshotsDeleted int64

View File

@ -18,6 +18,20 @@ import (
// VerifyOptions contains options for the verify command // VerifyOptions contains options for the verify command
type VerifyOptions struct { type VerifyOptions struct {
Deep bool 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 // RunDeepVerify executes deep verification operation