diff --git a/README.md b/README.md index 46c90a3..bf9e7d1 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,17 @@ age-keygen -o key.txt # the public key is printed to stdout and also in key.txt # 3. Create a default config file -vaultik init +vaultik config init # Writes to the platform config directory with commented defaults: # macOS: ~/Library/Application Support/vaultik/config.yml # Linux: ~/.config/vaultik/config.yml # root: /etc/vaultik/config.yml # 4. Edit the config: set age_recipients, snapshots, and storage_url -# (init prints the path it wrote to) +vaultik config edit # opens $EDITOR +# or set individual values: +vaultik config set storage_url "file:///mnt/backups" +vaultik config get storage_url # 5. Run your first backup vaultik snapshot create @@ -82,7 +85,10 @@ vaultik snapshot verify ### commands ```sh -vaultik [--config ] init +vaultik [--config ] config init +vaultik [--config ] config edit +vaultik [--config ] config get +vaultik [--config ] config set vaultik [--config ] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than ] [--skip-errors] vaultik [--config ] snapshot list [--json] vaultik [--config ] snapshot verify [--deep] [--json] @@ -114,12 +120,21 @@ vaultik version ### command details -**init**: Write a default config file with commented explanations for every -setting. Writes to the path from `--config`, `$VAULTIK_CONFIG`, or the +**config init**: Write a default config file with commented explanations for +every setting. Writes to the path from `--config`, `$VAULTIK_CONFIG`, or the platform config directory (`~/Library/Application Support/vaultik/` on macOS, `~/.config/vaultik/` on Linux, `/etc/vaultik/` as root). Refuses to overwrite an existing file. Created with mode `0600` since it will contain credentials. +**config edit**: Open the config file in `$EDITOR` (falls back to `vi`). + +**config get**: Print a config value addressed by dotted YAML path +(e.g. `vaultik config get s3.bucket`). Non-scalar values print as YAML. + +**config set**: Set a scalar config value by dotted YAML path +(e.g. `vaultik config set compression_level 9`). Comments and formatting +in the file are preserved; intermediate maps are created as needed. + **snapshot create**: Perform incremental backup of configured snapshots. * Optional snapshot names argument to create specific snapshots (default: all) * `--cron`: Silent unless error (for crontab) diff --git a/internal/cli/init.go b/internal/cli/config.go similarity index 58% rename from internal/cli/init.go rename to internal/cli/config.go index b48ef3d..b284dde 100644 --- a/internal/cli/init.go +++ b/internal/cli/config.go @@ -3,9 +3,12 @@ package cli import ( "fmt" "os" + "os/exec" "path/filepath" + "strings" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) const defaultConfigTemplate = `# vaultik configuration @@ -196,9 +199,25 @@ storage_url: "" # index_path: /path/to/index.sqlite ` -// NewInitCommand creates the init command that writes a default config file. -func NewInitCommand() *cobra.Command { +// NewConfigCommand creates the config command group. +func NewConfigCommand() *cobra.Command { cmd := &cobra.Command{ + Use: "config", + Short: "Manage the configuration file", + Long: "Commands for creating, editing, and querying the vaultik config file.", + } + + cmd.AddCommand(newConfigInitCommand()) + cmd.AddCommand(newConfigEditCommand()) + cmd.AddCommand(newConfigGetCommand()) + cmd.AddCommand(newConfigSetCommand()) + + return cmd +} + +// newConfigInitCommand creates the 'config init' subcommand. +func newConfigInitCommand() *cobra.Command { + return &cobra.Command{ Use: "init", Short: "Write a default config file", Long: `Creates a default configuration file with commented explanations @@ -230,8 +249,220 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, return nil }, } +} - return cmd +// newConfigEditCommand creates the 'config edit' subcommand. +func newConfigEditCommand() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open the config file in $EDITOR", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := ResolveConfigPath() + if err != nil { + return err + } + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + ed := exec.Command(editor, path) + ed.Stdin = os.Stdin + ed.Stdout = os.Stdout + ed.Stderr = os.Stderr + return ed.Run() + }, + } +} + +// newConfigGetCommand creates the 'config get' subcommand. +func newConfigGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Print a config value by dotted path (e.g. s3.bucket)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := ResolveConfigPath() + if err != nil { + return err + } + + root, err := loadYAMLFile(path) + if err != nil { + return err + } + + node, err := yamlPathGet(root, strings.Split(args[0], ".")) + if err != nil { + return err + } + + if node.Kind == yaml.ScalarNode { + fmt.Println(node.Value) + return nil + } + + out, err := yaml.Marshal(node) + if err != nil { + return fmt.Errorf("marshaling value: %w", err) + } + fmt.Print(string(out)) + return nil + }, + } +} + +// newConfigSetCommand creates the 'config set' subcommand. +func newConfigSetCommand() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a config value by dotted path (e.g. compression_level 5)", + Long: `Sets a scalar config value addressed by dotted YAML path and writes +the file back, preserving comments and formatting. Intermediate maps +are created as needed. + +Examples: + vaultik config set compression_level 9 + vaultik config set s3.bucket mybucket + vaultik config set storage_url "file:///mnt/backups"`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := ResolveConfigPath() + if err != nil { + return err + } + + root, err := loadYAMLFile(path) + if err != nil { + return err + } + + if err := yamlPathSet(root, strings.Split(args[0], "."), args[1]); err != nil { + return err + } + + out, err := yaml.Marshal(root) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + mode := os.FileMode(0o600) + if info, err := os.Stat(path); err == nil { + mode = info.Mode().Perm() + } + + if err := os.WriteFile(path, out, mode); err != nil { + return fmt.Errorf("writing config file: %w", err) + } + + fmt.Printf("%s = %s\n", args[0], args[1]) + return nil + }, + } +} + +// loadYAMLFile parses a YAML file into a yaml.Node document tree, +// which preserves comments and ordering for round-tripping. +func loadYAMLFile(path string) (*yaml.Node, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + // An empty file yields a zero node; normalize to an empty mapping document. + if root.Kind == 0 { + root = yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{{Kind: yaml.MappingNode}}, + } + } + + return &root, nil +} + +// yamlPathGet navigates a dotted key path through mapping nodes and +// returns the value node. +func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) { + node := root + if node.Kind == yaml.DocumentNode { + if len(node.Content) == 0 { + return nil, fmt.Errorf("empty config file") + } + node = node.Content[0] + } + + for i, key := range keys { + if node.Kind != yaml.MappingNode { + return nil, fmt.Errorf("key %q is not a map", strings.Join(keys[:i], ".")) + } + found := false + for j := 0; j+1 < len(node.Content); j += 2 { + if node.Content[j].Value == key { + node = node.Content[j+1] + found = true + break + } + } + if !found { + return nil, fmt.Errorf("key not found: %s", strings.Join(keys[:i+1], ".")) + } + } + + return node, nil +} + +// yamlPathSet navigates a dotted key path, creating intermediate maps as +// needed, and sets the final key to the given scalar value. +func yamlPathSet(root *yaml.Node, keys []string, value string) error { + node := root + if node.Kind == yaml.DocumentNode { + if len(node.Content) == 0 { + node.Content = []*yaml.Node{{Kind: yaml.MappingNode}} + } + node = node.Content[0] + } + + for i, key := range keys { + if node.Kind != yaml.MappingNode { + return fmt.Errorf("key %q is not a map", strings.Join(keys[:i], ".")) + } + + last := i == len(keys)-1 + + var valueNode *yaml.Node + for j := 0; j+1 < len(node.Content); j += 2 { + if node.Content[j].Value == key { + valueNode = node.Content[j+1] + break + } + } + + if valueNode == nil { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valueNode = &yaml.Node{Kind: yaml.MappingNode} + if last { + valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value} + } + node.Content = append(node.Content, keyNode, valueNode) + } else if last { + valueNode.Kind = yaml.ScalarNode + valueNode.Tag = "" + valueNode.Value = value + valueNode.Content = nil + valueNode.Style = 0 + } + + node = valueNode + } + + return nil } // configPathForInit returns the config path to write, checking --config flag, diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go new file mode 100644 index 0000000..223345c --- /dev/null +++ b/internal/cli/config_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "strings" + "testing" + + "git.eeqj.de/sneak/vaultik/internal/config" + "gopkg.in/yaml.v3" +) + +// TestDefaultConfigTemplateParses ensures the init template is valid YAML +// that unmarshals into the Config struct with the expected snapshots. +func TestDefaultConfigTemplateParses(t *testing.T) { + var cfg config.Config + if err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg); err != nil { + t.Fatalf("default config template is not valid YAML: %v", err) + } + + if len(cfg.AgeRecipients) != 1 { + t.Errorf("expected 1 placeholder age recipient, got %d", len(cfg.AgeRecipients)) + } + + home, ok := cfg.Snapshots["home"] + if !ok { + t.Fatal("expected 'home' snapshot in default config") + } + if len(home.Paths) == 0 { + t.Error("home snapshot should have at least one path") + } + if len(home.Exclude) == 0 { + t.Error("home snapshot should have exclude patterns") + } + + apps, ok := cfg.Snapshots["apps"] + if !ok { + t.Fatal("expected 'apps' snapshot in default config") + } + if len(apps.Paths) != 1 || apps.Paths[0] != "/Applications" { + t.Errorf("apps snapshot should back up /Applications, got %v", apps.Paths) + } + if len(apps.Exclude) == 0 { + t.Error("apps snapshot should have exclude patterns") + } +} + +const testYAML = `# top comment +compression_level: 3 +s3: + bucket: oldbucket # inline comment + region: us-east-1 +snapshots: + home: + paths: + - "~" +` + +func parseTestYAML(t *testing.T) *yaml.Node { + t.Helper() + var root yaml.Node + if err := yaml.Unmarshal([]byte(testYAML), &root); err != nil { + t.Fatalf("parsing test yaml: %v", err) + } + return &root +} + +func TestYAMLPathGet(t *testing.T) { + root := parseTestYAML(t) + + tests := []struct { + path string + want string + err bool + }{ + {"compression_level", "3", false}, + {"s3.bucket", "oldbucket", false}, + {"s3.region", "us-east-1", false}, + {"s3.nonexistent", "", true}, + {"nonexistent", "", true}, + {"compression_level.sub", "", true}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + node, err := yamlPathGet(root, splitPath(tt.path)) + if tt.err { + if err == nil { + t.Fatalf("expected error for %q", tt.path) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if node.Value != tt.want { + t.Errorf("get %q = %q, want %q", tt.path, node.Value, tt.want) + } + }) + } +} + +func TestYAMLPathSet(t *testing.T) { + root := parseTestYAML(t) + + // Overwrite existing nested value + if err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket"); err != nil { + t.Fatalf("set s3.bucket: %v", err) + } + + // Create new nested key with intermediate map + if err := yamlPathSet(root, splitPath("s3.endpoint"), "s3.example.com"); err != nil { + t.Fatalf("set s3.endpoint: %v", err) + } + if err := yamlPathSet(root, splitPath("newmap.newkey"), "val"); err != nil { + t.Fatalf("set newmap.newkey: %v", err) + } + + // Round-trip and verify values + comment preservation + out, err := yaml.Marshal(root) + if err != nil { + t.Fatalf("marshal: %v", err) + } + text := string(out) + + for _, want := range []string{"newbucket", "s3.example.com", "newkey: val", "# top comment", "# inline comment"} { + if !contains(text, want) { + t.Errorf("round-tripped YAML missing %q:\n%s", want, text) + } + } + + got, err := yamlPathGet(root, splitPath("s3.bucket")) + if err != nil { + t.Fatalf("get after set: %v", err) + } + if got.Value != "newbucket" { + t.Errorf("s3.bucket = %q after set, want newbucket", got.Value) + } +} + +func splitPath(s string) []string { + return strings.Split(s, ".") +} + +func contains(haystack, needle string) bool { + return strings.Contains(haystack, needle) +} diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 06b959e..7a28690 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -18,7 +18,7 @@ func TestCLIEntry(t *testing.T) { } // Verify all subcommands are registered - expectedCommands := []string{"init", "snapshot", "store", "restore", "prune", "info", "version", "remote", "database"} + expectedCommands := []string{"config", "snapshot", "store", "restore", "prune", "info", "version", "remote", "database"} for _, expected := range expectedCommands { found := false for _, cmd := range cmd.Commands() { diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go deleted file mode 100644 index d42e6a9..0000000 --- a/internal/cli/init_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package cli - -import ( - "testing" - - "git.eeqj.de/sneak/vaultik/internal/config" - "gopkg.in/yaml.v3" -) - -// TestDefaultConfigTemplateParses ensures the init template is valid YAML -// that unmarshals into the Config struct with the expected snapshots. -func TestDefaultConfigTemplateParses(t *testing.T) { - var cfg config.Config - if err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg); err != nil { - t.Fatalf("default config template is not valid YAML: %v", err) - } - - if len(cfg.AgeRecipients) != 1 { - t.Errorf("expected 1 placeholder age recipient, got %d", len(cfg.AgeRecipients)) - } - - home, ok := cfg.Snapshots["home"] - if !ok { - t.Fatal("expected 'home' snapshot in default config") - } - if len(home.Paths) == 0 { - t.Error("home snapshot should have at least one path") - } - if len(home.Exclude) == 0 { - t.Error("home snapshot should have exclude patterns") - } - - apps, ok := cfg.Snapshots["apps"] - if !ok { - t.Fatal("expected 'apps' snapshot in default config") - } - if len(apps.Paths) != 1 || apps.Paths[0] != "/Applications" { - t.Errorf("apps snapshot should back up /Applications, got %v", apps.Paths) - } - if len(apps.Exclude) == 0 { - t.Error("apps snapshot should have exclude patterns") - } -} diff --git a/internal/cli/root.go b/internal/cli/root.go index 48686c1..3f090b9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -41,7 +41,7 @@ on the source system.`, // Add subcommands cmd.AddCommand( - NewInitCommand(), + NewConfigCommand(), NewRestoreCommand(), NewPruneCommand(), NewStoreCommand(), @@ -78,7 +78,7 @@ func ResolveConfigPath() (string, error) { } } - return "", fmt.Errorf("no config file found; run 'vaultik init' to create one, or specify with --config") + return "", fmt.Errorf("no config file found; run 'vaultik config init' to create one, or specify with --config") } // defaultConfigPaths returns the ordered list of config paths to search.