diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20dfd06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Binary +vaultik + +# Test artifacts +*.out +*.test +coverage.html +coverage.out + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Local config for development +local-config.yaml +dev-config.yaml \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md index 1ca64a0..e3e630b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -257,11 +257,11 @@ Verify runs on a host that has no state, but access to the bucket. ## 6. CLI Commands ``` -vaultik backup /etc/vaultik.yaml [--cron] [--daemon] -vaultik restore -vaultik prune -vaultik verify [] [--quick] -vaultik fetch +vaultik backup [--config ] [--cron] [--daemon] +vaultik restore --bucket --prefix --snapshot --target +vaultik prune --bucket --prefix [--dry-run] +vaultik verify --bucket --prefix [--snapshot ] [--quick] +vaultik fetch --bucket --prefix --snapshot --file --target ``` * `VAULTIK_PRIVATE_KEY` is required for `restore`, `prune`, `verify`, and diff --git a/README.md b/README.md index ee8f27a..9ed734e 100644 --- a/README.md +++ b/README.md @@ -100,20 +100,23 @@ Existing backup software fails under one or more of these conditions: ### commands ```sh -vaultik backup /etc/vaultik.yaml [--cron] [--daemon] -vaultik restore -vaultik prune -vaultik fetch -vaultik verify [] +vaultik backup [--config ] [--cron] [--daemon] +vaultik restore --bucket --prefix --snapshot --target +vaultik prune --bucket --prefix [--dry-run] +vaultik fetch --bucket --prefix --snapshot --file --target +vaultik verify --bucket --prefix [--snapshot ] [--quick] ``` ### environment * `VAULTIK_PRIVATE_KEY`: Required for `restore`, `prune`, `fetch`, and `verify` commands. Contains the age private key for decryption. +* `VAULTIK_CONFIG`: Optional path to config file. If set, `vaultik backup` can be run without specifying the config file path. ### command details **backup**: Perform incremental backup of configured directories +* Config is located at `/etc/vaultik/config.yml` by default +* `--config`: Override config file path * `--cron`: Silent unless error (for crontab) * `--daemon`: Run continuously with inotify monitoring and periodic scans diff --git a/internal/cli/backup.go b/internal/cli/backup.go index c65a7b2..347503b 100644 --- a/internal/cli/backup.go +++ b/internal/cli/backup.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "os" "git.eeqj.de/sneak/vaultik/internal/config" "git.eeqj.de/sneak/vaultik/internal/globals" @@ -22,16 +23,32 @@ func NewBackupCommand() *cobra.Command { opts := &BackupOptions{} cmd := &cobra.Command{ - Use: "backup ", + Use: "backup", Short: "Perform incremental backup", - Long: `Backup configured directories using incremental deduplication and encryption`, - Args: cobra.ExactArgs(1), + Long: `Backup configured directories using incremental deduplication and encryption. + +Config is located at /etc/vaultik/config.yml, 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 { - opts.ConfigPath = args[0] + // If --config not specified, check environment variable + if opts.ConfigPath == "" { + opts.ConfigPath = os.Getenv("VAULTIK_CONFIG") + } + // If still not specified, use default + if opts.ConfigPath == "" { + defaultConfig := "/etc/vaultik/config.yml" + if _, err := os.Stat(defaultConfig); err == nil { + opts.ConfigPath = defaultConfig + } else { + return fmt.Errorf("no config file specified, VAULTIK_CONFIG not set, and %s not found", defaultConfig) + } + } return runBackup(cmd.Context(), opts) }, } + cmd.Flags().StringVar(&opts.ConfigPath, "config", "", "Path to config file") cmd.Flags().BoolVar(&opts.Daemon, "daemon", false, "Run in daemon mode with inotify monitoring") cmd.Flags().BoolVar(&opts.Cron, "cron", false, "Run in cron mode (silent unless error)") diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index fb581bd..170ad4b 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -31,4 +31,20 @@ func TestCLIEntry(t *testing.T) { t.Errorf("Expected command '%s' not found", expected) } } + + // Verify backup command has proper flags + backupCmd, _, err := cmd.Find([]string{"backup"}) + if err != nil { + t.Errorf("Failed to find backup command: %v", err) + } else { + if backupCmd.Flag("config") == nil { + t.Error("Backup command missing --config flag") + } + if backupCmd.Flag("daemon") == nil { + t.Error("Backup command missing --daemon flag") + } + if backupCmd.Flag("cron") == nil { + t.Error("Backup command missing --cron flag") + } + } } \ No newline at end of file diff --git a/internal/cli/fetch.go b/internal/cli/fetch.go index 9028566..f35e85d 100644 --- a/internal/cli/fetch.go +++ b/internal/cli/fetch.go @@ -21,23 +21,40 @@ type FetchOptions struct { // NewFetchCommand creates the fetch command func NewFetchCommand() *cobra.Command { + opts := &FetchOptions{} + cmd := &cobra.Command{ - Use: "fetch ", + Use: "fetch", Short: "Extract single file from backup", Long: `Download and decrypt a single file from a backup snapshot`, - Args: cobra.ExactArgs(5), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - opts := &FetchOptions{ - Bucket: args[0], - Prefix: args[1], - SnapshotID: args[2], - FilePath: args[3], - Target: args[4], + // Validate required flags + if opts.Bucket == "" { + return fmt.Errorf("--bucket is required") + } + if opts.Prefix == "" { + return fmt.Errorf("--prefix is required") + } + if opts.SnapshotID == "" { + return fmt.Errorf("--snapshot is required") + } + if opts.FilePath == "" { + return fmt.Errorf("--file is required") + } + if opts.Target == "" { + return fmt.Errorf("--target is required") } return runFetch(cmd.Context(), opts) }, } + cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name") + cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix") + cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID") + cmd.Flags().StringVar(&opts.FilePath, "file", "", "Path of file to extract from backup") + cmd.Flags().StringVar(&opts.Target, "target", "", "Target path for extracted file") + return cmd } diff --git a/internal/cli/prune.go b/internal/cli/prune.go index 30f76f2..9af6d77 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -22,17 +22,24 @@ func NewPruneCommand() *cobra.Command { opts := &PruneOptions{} cmd := &cobra.Command{ - Use: "prune ", + Use: "prune", Short: "Remove unreferenced blobs", Long: `Delete blobs that are no longer referenced by any snapshot`, - Args: cobra.ExactArgs(2), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - opts.Bucket = args[0] - opts.Prefix = args[1] + // Validate required flags + if opts.Bucket == "" { + return fmt.Errorf("--bucket is required") + } + if opts.Prefix == "" { + return fmt.Errorf("--prefix is required") + } return runPrune(cmd.Context(), opts) }, } + cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name") + cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be deleted without actually deleting") return cmd diff --git a/internal/cli/restore.go b/internal/cli/restore.go index 60db5e9..5aaf891 100644 --- a/internal/cli/restore.go +++ b/internal/cli/restore.go @@ -20,22 +20,36 @@ type RestoreOptions struct { // NewRestoreCommand creates the restore command func NewRestoreCommand() *cobra.Command { + opts := &RestoreOptions{} + cmd := &cobra.Command{ - Use: "restore ", + Use: "restore", Short: "Restore files from backup", Long: `Download and decrypt files from a backup snapshot`, - Args: cobra.ExactArgs(4), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - opts := &RestoreOptions{ - Bucket: args[0], - Prefix: args[1], - SnapshotID: args[2], - TargetDir: args[3], + // Validate required flags + if opts.Bucket == "" { + return fmt.Errorf("--bucket is required") + } + if opts.Prefix == "" { + return fmt.Errorf("--prefix is required") + } + if opts.SnapshotID == "" { + return fmt.Errorf("--snapshot is required") + } + if opts.TargetDir == "" { + return fmt.Errorf("--target is required") } return runRestore(cmd.Context(), opts) }, } + cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name") + cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix") + cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID to restore") + cmd.Flags().StringVar(&opts.TargetDir, "target", "", "Target directory for restore") + return cmd } diff --git a/internal/cli/verify.go b/internal/cli/verify.go index 9f09eb0..b6d9094 100644 --- a/internal/cli/verify.go +++ b/internal/cli/verify.go @@ -23,20 +23,25 @@ func NewVerifyCommand() *cobra.Command { opts := &VerifyOptions{} cmd := &cobra.Command{ - Use: "verify []", + Use: "verify", Short: "Verify backup integrity", Long: `Check that all referenced blobs exist and verify metadata integrity`, - Args: cobra.RangeArgs(2, 3), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - opts.Bucket = args[0] - opts.Prefix = args[1] - if len(args) > 2 { - opts.SnapshotID = args[2] + // Validate required flags + if opts.Bucket == "" { + return fmt.Errorf("--bucket is required") + } + if opts.Prefix == "" { + return fmt.Errorf("--prefix is required") } return runVerify(cmd.Context(), opts) }, } + cmd.Flags().StringVar(&opts.Bucket, "bucket", "", "S3 bucket name") + cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "S3 prefix") + cmd.Flags().StringVar(&opts.SnapshotID, "snapshot", "", "Snapshot ID to verify (optional, defaults to latest)") cmd.Flags().BoolVar(&opts.Quick, "quick", false, "Perform quick verification by checking blob existence and S3 content hashes without downloading") return cmd diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6702574..5c9ece6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,24 +6,23 @@ import ( "testing" ) +func TestMain(m *testing.M) { + // Set up test environment + testConfigPath := filepath.Join("..", "..", "test", "config.yaml") + if absPath, err := filepath.Abs(testConfigPath); err == nil { + _ = os.Setenv("VAULTIK_CONFIG", absPath) + } + + code := m.Run() + os.Exit(code) +} + // TestConfigLoad ensures the config package can be imported and basic functionality works func TestConfigLoad(t *testing.T) { - // Create a temporary config file - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "test-config.yaml") - - configContent := `age_recipient: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -source_dirs: - - /tmp/test -s3: - endpoint: https://s3.example.com - bucket: test-bucket - access_key_id: test-key - secret_access_key: test-secret -` - - if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) + // Use the test config file + configPath := os.Getenv("VAULTIK_CONFIG") + if configPath == "" { + t.Fatal("VAULTIK_CONFIG environment variable not set") } // Test loading the config @@ -37,11 +36,32 @@ s3: t.Errorf("Expected age recipient to be set, got '%s'", cfg.AgeRecipient) } - if len(cfg.SourceDirs) != 1 || cfg.SourceDirs[0] != "/tmp/test" { - t.Errorf("Expected source dirs to be ['/tmp/test'], got %v", cfg.SourceDirs) + if len(cfg.SourceDirs) != 2 { + t.Errorf("Expected 2 source dirs, got %d", len(cfg.SourceDirs)) } - if cfg.S3.Bucket != "test-bucket" { - t.Errorf("Expected S3 bucket to be 'test-bucket', got '%s'", cfg.S3.Bucket) + if cfg.SourceDirs[0] != "/tmp/vaultik-test-source" { + t.Errorf("Expected first source dir to be '/tmp/vaultik-test-source', got '%s'", cfg.SourceDirs[0]) + } + + if cfg.S3.Bucket != "vaultik-test-bucket" { + t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'", cfg.S3.Bucket) + } + + if cfg.Hostname != "test-host" { + t.Errorf("Expected hostname to be 'test-host', got '%s'", cfg.Hostname) + } +} + +// TestConfigFromEnv tests loading config path from environment variable +func TestConfigFromEnv(t *testing.T) { + configPath := os.Getenv("VAULTIK_CONFIG") + if configPath == "" { + t.Skip("VAULTIK_CONFIG not set") + } + + // Verify the file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s", configPath) } } \ No newline at end of file diff --git a/test/config.yaml b/test/config.yaml new file mode 100644 index 0000000..1534e87 --- /dev/null +++ b/test/config.yaml @@ -0,0 +1,27 @@ +age_recipient: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +source_dirs: + - /tmp/vaultik-test-source + - /var/test/data +exclude: + - '*.log' + - '*.tmp' + - '.git' + - 'node_modules' +s3: + endpoint: https://s3.example.com + bucket: vaultik-test-bucket + prefix: test-host/ + access_key_id: test-access-key + secret_access_key: test-secret-key + region: us-east-1 + use_ssl: true + part_size: 5242880 # 5MB +backup_interval: 1h +full_scan_interval: 24h +min_time_between_run: 15m +index_path: /tmp/vaultik-test.sqlite +chunk_size: 10485760 # 10MB +blob_size_limit: 10737418240 # 10GB +index_prefix: index/ +compression_level: 3 +hostname: test-host \ No newline at end of file