Refactor CLI to use flags instead of positional arguments
- Change all commands to use flags (--bucket, --prefix, etc.) - Add --config flag to backup command - Support VAULTIK_CONFIG environment variable for config path - Use /etc/vaultik/config.yml as default config location - Add test/config.yaml for testing - Update tests to use environment variable for config path - Add .gitignore for build artifacts and local configs - Update documentation to reflect new CLI syntax
This commit is contained in:
parent
3e8b98dec6
commit
bcbc186286
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -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
|
10
DESIGN.md
10
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 <bucket> <prefix> <snapshot_id> <target_dir>
|
||||
vaultik prune <bucket> <prefix>
|
||||
vaultik verify <bucket> <prefix> [<snapshot_id>] [--quick]
|
||||
vaultik fetch <bucket> <prefix> <snapshot_id> <filepath> <target>
|
||||
vaultik backup [--config <path>] [--cron] [--daemon]
|
||||
vaultik restore --bucket <bucket> --prefix <prefix> --snapshot <id> --target <dir>
|
||||
vaultik prune --bucket <bucket> --prefix <prefix> [--dry-run]
|
||||
vaultik verify --bucket <bucket> --prefix <prefix> [--snapshot <id>] [--quick]
|
||||
vaultik fetch --bucket <bucket> --prefix <prefix> --snapshot <id> --file <path> --target <path>
|
||||
```
|
||||
|
||||
* `VAULTIK_PRIVATE_KEY` is required for `restore`, `prune`, `verify`, and
|
||||
|
13
README.md
13
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 <bucket> <prefix> <snapshot_id> <target_dir>
|
||||
vaultik prune <bucket> <prefix>
|
||||
vaultik fetch <bucket> <prefix> <snapshot_id> <filepath> <target_fileordir>
|
||||
vaultik verify <bucket> <prefix> [<snapshot_id>]
|
||||
vaultik backup [--config <path>] [--cron] [--daemon]
|
||||
vaultik restore --bucket <bucket> --prefix <prefix> --snapshot <id> --target <dir>
|
||||
vaultik prune --bucket <bucket> --prefix <prefix> [--dry-run]
|
||||
vaultik fetch --bucket <bucket> --prefix <prefix> --snapshot <id> --file <path> --target <path>
|
||||
vaultik verify --bucket <bucket> --prefix <prefix> [--snapshot <id>] [--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
|
||||
|
||||
|
@ -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 <config.yaml>",
|
||||
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)")
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -21,23 +21,40 @@ type FetchOptions struct {
|
||||
|
||||
// NewFetchCommand creates the fetch command
|
||||
func NewFetchCommand() *cobra.Command {
|
||||
opts := &FetchOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "fetch <bucket> <prefix> <snapshot_id> <filepath> <target>",
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -22,17 +22,24 @@ func NewPruneCommand() *cobra.Command {
|
||||
opts := &PruneOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune <bucket> <prefix>",
|
||||
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
|
||||
|
@ -20,22 +20,36 @@ type RestoreOptions struct {
|
||||
|
||||
// NewRestoreCommand creates the restore command
|
||||
func NewRestoreCommand() *cobra.Command {
|
||||
opts := &RestoreOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore <bucket> <prefix> <snapshot_id> <target_dir>",
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -23,20 +23,25 @@ func NewVerifyCommand() *cobra.Command {
|
||||
opts := &VerifyOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify <bucket> <prefix> [<snapshot_id>]",
|
||||
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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
27
test/config.yaml
Normal file
27
test/config.yaml
Normal file
@ -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
|
Loading…
Reference in New Issue
Block a user