Implement CLI skeleton with cobra and fx dependency injection

- Set up cobra CLI with all commands (backup, restore, prune, verify, fetch)
- Integrate uber/fx for dependency injection and lifecycle management
- Add globals package with build-time variables (Version, Commit)
- Implement config loading from YAML with validation
- Create core data models (FileInfo, ChunkInfo, BlobInfo, Snapshot)
- Add Makefile with build, test, lint, and clean targets
- Include minimal test suite for compilation verification
- Update documentation with --quick flag for verify command
- Fix markdown numbering in implementation TODO
This commit is contained in:
2025-07-20 09:34:14 +02:00
parent 0df07790ba
commit 3e8b98dec6
20 changed files with 1043 additions and 115 deletions

72
internal/cli/backup.go Normal file
View File

@@ -0,0 +1,72 @@
package cli
import (
"context"
"fmt"
"git.eeqj.de/sneak/vaultik/internal/config"
"git.eeqj.de/sneak/vaultik/internal/globals"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// BackupOptions contains options for the backup command
type BackupOptions struct {
ConfigPath string
Daemon bool
Cron bool
}
// NewBackupCommand creates the backup command
func NewBackupCommand() *cobra.Command {
opts := &BackupOptions{}
cmd := &cobra.Command{
Use: "backup <config.yaml>",
Short: "Perform incremental backup",
Long: `Backup configured directories using incremental deduplication and encryption`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.ConfigPath = args[0]
return runBackup(cmd.Context(), opts)
},
}
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)")
return cmd
}
func runBackup(ctx context.Context, opts *BackupOptions) error {
app := fx.New(
fx.Supply(config.ConfigPath(opts.ConfigPath)),
fx.Provide(globals.New),
config.Module,
// Additional modules will be added here
fx.Invoke(func(g *globals.Globals, cfg *config.Config) error {
// TODO: Implement backup logic
fmt.Printf("Running backup with config: %s\n", opts.ConfigPath)
fmt.Printf("Version: %s, Commit: %s\n", g.Version, g.Commit)
if opts.Daemon {
fmt.Println("Running in daemon mode")
}
if opts.Cron {
fmt.Println("Running in cron mode")
}
return nil
}),
fx.NopLogger,
)
if err := app.Start(ctx); err != nil {
return fmt.Errorf("failed to start backup: %w", err)
}
defer func() {
if err := app.Stop(ctx); err != nil {
fmt.Printf("error stopping app: %v\n", err)
}
}()
return nil
}

13
internal/cli/entry.go Normal file
View File

@@ -0,0 +1,13 @@
package cli
import (
"os"
)
// CLIEntry is the main entry point for the CLI application
func CLIEntry() {
rootCmd := NewRootCommand()
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

View File

@@ -0,0 +1,34 @@
package cli
import (
"testing"
)
// TestCLIEntry ensures the CLI can be imported and basic initialization works
func TestCLIEntry(t *testing.T) {
// This test primarily serves as a compilation test
// to ensure all imports resolve correctly
cmd := NewRootCommand()
if cmd == nil {
t.Fatal("NewRootCommand() returned nil")
}
if cmd.Use != "vaultik" {
t.Errorf("Expected command use to be 'vaultik', got '%s'", cmd.Use)
}
// Verify all subcommands are registered
expectedCommands := []string{"backup", "restore", "prune", "verify", "fetch"}
for _, expected := range expectedCommands {
found := false
for _, cmd := range cmd.Commands() {
if cmd.Use == expected || cmd.Name() == expected {
found = true
break
}
}
if !found {
t.Errorf("Expected command '%s' not found", expected)
}
}
}

71
internal/cli/fetch.go Normal file
View File

@@ -0,0 +1,71 @@
package cli
import (
"context"
"fmt"
"os"
"git.eeqj.de/sneak/vaultik/internal/globals"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// FetchOptions contains options for the fetch command
type FetchOptions struct {
Bucket string
Prefix string
SnapshotID string
FilePath string
Target string
}
// NewFetchCommand creates the fetch command
func NewFetchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "fetch <bucket> <prefix> <snapshot_id> <filepath> <target>",
Short: "Extract single file from backup",
Long: `Download and decrypt a single file from a backup snapshot`,
Args: cobra.ExactArgs(5),
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],
}
return runFetch(cmd.Context(), opts)
},
}
return cmd
}
func runFetch(ctx context.Context, opts *FetchOptions) error {
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
}
app := fx.New(
fx.Supply(opts),
fx.Provide(globals.New),
// Additional modules will be added here
fx.Invoke(func(g *globals.Globals) error {
// TODO: Implement fetch logic
fmt.Printf("Fetching %s from snapshot %s to %s\n", opts.FilePath, opts.SnapshotID, opts.Target)
return nil
}),
fx.NopLogger,
)
if err := app.Start(ctx); err != nil {
return fmt.Errorf("failed to start fetch: %w", err)
}
defer func() {
if err := app.Stop(ctx); err != nil {
fmt.Printf("error stopping app: %v\n", err)
}
}()
return nil
}

71
internal/cli/prune.go Normal file
View File

@@ -0,0 +1,71 @@
package cli
import (
"context"
"fmt"
"os"
"git.eeqj.de/sneak/vaultik/internal/globals"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// PruneOptions contains options for the prune command
type PruneOptions struct {
Bucket string
Prefix string
DryRun bool
}
// NewPruneCommand creates the prune command
func NewPruneCommand() *cobra.Command {
opts := &PruneOptions{}
cmd := &cobra.Command{
Use: "prune <bucket> <prefix>",
Short: "Remove unreferenced blobs",
Long: `Delete blobs that are no longer referenced by any snapshot`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Bucket = args[0]
opts.Prefix = args[1]
return runPrune(cmd.Context(), opts)
},
}
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be deleted without actually deleting")
return cmd
}
func runPrune(ctx context.Context, opts *PruneOptions) error {
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
}
app := fx.New(
fx.Supply(opts),
fx.Provide(globals.New),
// Additional modules will be added here
fx.Invoke(func(g *globals.Globals) error {
// TODO: Implement prune logic
fmt.Printf("Pruning bucket %s with prefix %s\n", opts.Bucket, opts.Prefix)
if opts.DryRun {
fmt.Println("Running in dry-run mode")
}
return nil
}),
fx.NopLogger,
)
if err := app.Start(ctx); err != nil {
return fmt.Errorf("failed to start prune: %w", err)
}
defer func() {
if err := app.Stop(ctx); err != nil {
fmt.Printf("error stopping app: %v\n", err)
}
}()
return nil
}

69
internal/cli/restore.go Normal file
View File

@@ -0,0 +1,69 @@
package cli
import (
"context"
"fmt"
"os"
"git.eeqj.de/sneak/vaultik/internal/globals"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// RestoreOptions contains options for the restore command
type RestoreOptions struct {
Bucket string
Prefix string
SnapshotID string
TargetDir string
}
// NewRestoreCommand creates the restore command
func NewRestoreCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "restore <bucket> <prefix> <snapshot_id> <target_dir>",
Short: "Restore files from backup",
Long: `Download and decrypt files from a backup snapshot`,
Args: cobra.ExactArgs(4),
RunE: func(cmd *cobra.Command, args []string) error {
opts := &RestoreOptions{
Bucket: args[0],
Prefix: args[1],
SnapshotID: args[2],
TargetDir: args[3],
}
return runRestore(cmd.Context(), opts)
},
}
return cmd
}
func runRestore(ctx context.Context, opts *RestoreOptions) error {
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
}
app := fx.New(
fx.Supply(opts),
fx.Provide(globals.New),
// Additional modules will be added here
fx.Invoke(func(g *globals.Globals) error {
// TODO: Implement restore logic
fmt.Printf("Restoring snapshot %s to %s\n", opts.SnapshotID, opts.TargetDir)
return nil
}),
fx.NopLogger,
)
if err := app.Start(ctx); err != nil {
return fmt.Errorf("failed to start restore: %w", err)
}
defer func() {
if err := app.Stop(ctx); err != nil {
fmt.Printf("error stopping app: %v\n", err)
}
}()
return nil
}

28
internal/cli/root.go Normal file
View File

@@ -0,0 +1,28 @@
package cli
import (
"github.com/spf13/cobra"
)
// NewRootCommand creates the root cobra command
func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "vaultik",
Short: "Secure incremental backup tool with asymmetric encryption",
Long: `vaultik is a secure incremental backup daemon that encrypts data using age
public keys and uploads to S3-compatible storage. No private keys are needed
on the source system.`,
SilenceUsage: true,
}
// Add subcommands
cmd.AddCommand(
NewBackupCommand(),
NewRestoreCommand(),
NewPruneCommand(),
NewVerifyCommand(),
NewFetchCommand(),
)
return cmd
}

81
internal/cli/verify.go Normal file
View File

@@ -0,0 +1,81 @@
package cli
import (
"context"
"fmt"
"os"
"git.eeqj.de/sneak/vaultik/internal/globals"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// VerifyOptions contains options for the verify command
type VerifyOptions struct {
Bucket string
Prefix string
SnapshotID string
Quick bool
}
// NewVerifyCommand creates the verify command
func NewVerifyCommand() *cobra.Command {
opts := &VerifyOptions{}
cmd := &cobra.Command{
Use: "verify <bucket> <prefix> [<snapshot_id>]",
Short: "Verify backup integrity",
Long: `Check that all referenced blobs exist and verify metadata integrity`,
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Bucket = args[0]
opts.Prefix = args[1]
if len(args) > 2 {
opts.SnapshotID = args[2]
}
return runVerify(cmd.Context(), opts)
},
}
cmd.Flags().BoolVar(&opts.Quick, "quick", false, "Perform quick verification by checking blob existence and S3 content hashes without downloading")
return cmd
}
func runVerify(ctx context.Context, opts *VerifyOptions) error {
if os.Getenv("VAULTIK_PRIVATE_KEY") == "" {
return fmt.Errorf("VAULTIK_PRIVATE_KEY environment variable must be set")
}
app := fx.New(
fx.Supply(opts),
fx.Provide(globals.New),
// Additional modules will be added here
fx.Invoke(func(g *globals.Globals) error {
// TODO: Implement verify logic
if opts.SnapshotID == "" {
fmt.Printf("Verifying latest snapshot in bucket %s with prefix %s\n", opts.Bucket, opts.Prefix)
} else {
fmt.Printf("Verifying snapshot %s in bucket %s with prefix %s\n", opts.SnapshotID, opts.Bucket, opts.Prefix)
}
if opts.Quick {
fmt.Println("Performing quick verification")
} else {
fmt.Println("Performing deep verification")
}
return nil
}),
fx.NopLogger,
)
if err := app.Start(ctx); err != nil {
return fmt.Errorf("failed to start verify: %w", err)
}
defer func() {
if err := app.Stop(ctx); err != nil {
fmt.Printf("error stopping app: %v\n", err)
}
}()
return nil
}