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:
parent
417b25a5f5
commit
bdaaadf990
94
TODO.md
94
TODO.md
@ -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
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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 {
|
||||||
|
if !opts.JSON {
|
||||||
log.Error("Prune operation failed", "error", err)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 {
|
||||||
|
if !opts.JSON {
|
||||||
log.Error("Verification failed", "error", err)
|
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 {
|
||||||
|
if !opts.JSON {
|
||||||
log.Error("Failed to remove snapshot", "error", err)
|
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{
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
if !opts.JSON {
|
||||||
log.Error("Verification failed", "error", err)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -21,4 +21,5 @@ type LogOptions struct {
|
|||||||
Verbose bool
|
Verbose bool
|
||||||
Debug bool
|
Debug bool
|
||||||
Cron bool
|
Cron bool
|
||||||
|
Quiet bool
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)))
|
||||||
|
if !opts.JSON {
|
||||||
fmt.Printf("Found %d unreferenced blob(s) totaling %s\n", len(unreferencedBlobs), humanize.Bytes(uint64(totalSize)))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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,18 +574,29 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !opts.JSON {
|
||||||
fmt.Printf("Verifying snapshot %s\n", snapshotID)
|
fmt.Printf("Verifying snapshot %s\n", snapshotID)
|
||||||
if !snapshotTime.IsZero() {
|
if !snapshotTime.IsZero() {
|
||||||
fmt.Printf("Snapshot time: %s\n", snapshotTime.Format("2006-01-02 15:04:05 MST"))
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.BlobCount = manifest.BlobCount
|
||||||
|
result.TotalSize = manifest.TotalCompressedSize
|
||||||
|
|
||||||
|
if !opts.JSON {
|
||||||
fmt.Printf("Snapshot information:\n")
|
fmt.Printf("Snapshot information:\n")
|
||||||
fmt.Printf(" Blob count: %d\n", manifest.BlobCount)
|
fmt.Printf(" Blob count: %d\n", manifest.BlobCount)
|
||||||
fmt.Printf(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize)))
|
fmt.Printf(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize)))
|
||||||
@ -585,6 +609,8 @@ func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error {
|
|||||||
|
|
||||||
// Check each blob exists
|
// Check each blob exists
|
||||||
fmt.Printf("Checking blob existence...\n")
|
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
|
||||||
|
if !opts.JSON {
|
||||||
fmt.Printf("Deep verification not yet implemented\n")
|
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 {
|
||||||
|
if !opts.JSON {
|
||||||
fmt.Printf(" Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize)))
|
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)
|
||||||
|
if !opts.JSON {
|
||||||
_, _ = fmt.Fprintf(v.Stdout, "\nSnapshot: %s\n", snapshotID)
|
_, _ = fmt.Fprintf(v.Stdout, "\nSnapshot: %s\n", snapshotID)
|
||||||
_, _ = fmt.Fprintf(v.Stdout, "Blobs in snapshot: %d\n", len(targetBlobs))
|
_, _ = 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)))
|
_, _ = 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user