Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ linters:
- fmt
- io
- log
- maps
- os
- path/filepath
- sort
Expand Down
3 changes: 2 additions & 1 deletion backup/cmd/check-coverage.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ func buildCheckCoverageCommand(fs afero.Fs) *cobra.Command {
Short: "Check path coverage",
RunE: func(cmd *cobra.Command, args []string) error {
configPath, _ := cmd.Flags().GetString("config")
overrides := parseSetFlags(cmd)

cfg, err := internal.LoadResolvedConfig(configPath)
cfg, err := internal.LoadResolvedConfig(configPath, overrides)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
Expand Down
3 changes: 2 additions & 1 deletion backup/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ func buildConfigCommand() *cobra.Command {
func configRunE(verb configVerb) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
configPath, _ := cmd.Flags().GetString("config")
overrides := parseSetFlags(cmd)

cfg, err := internal.LoadResolvedConfig(configPath)
cfg, err := internal.LoadResolvedConfig(configPath, overrides)
if err != nil {
return fmt.Errorf("%s: %w", verb.errCtx, err)
}
Expand Down
19 changes: 18 additions & 1 deletion backup/cmd/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log"
"strings"
"time"

"github.com/spf13/afero"
Expand All @@ -25,15 +26,31 @@ type jobCommandOptions struct {
createLogger LoggerFactory
}

// parseSetFlags parses --set flag values (key=value) into a map.
func parseSetFlags(cmd *cobra.Command) map[string]string {
setFlags, _ := cmd.Flags().GetStringArray("set")
overrides := make(map[string]string, len(setFlags))

for _, s := range setFlags {
key, value, ok := strings.Cut(s, "=")
if ok {
overrides[key] = value
}
}

return overrides
}

func buildJobCommand(fs afero.Fs, opts jobCommandOptions) *cobra.Command {
return &cobra.Command{
Use: opts.use,
Short: opts.short,
RunE: func(cmd *cobra.Command, args []string) error {
configPath, _ := cmd.Flags().GetString("config")
rsyncPath, _ := cmd.Flags().GetString("rsync-path")
overrides := parseSetFlags(cmd)

cfg, err := internal.LoadResolvedConfig(configPath)
cfg, err := internal.LoadResolvedConfig(configPath, overrides)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
Expand Down
1 change: 1 addition & 0 deletions backup/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func BuildRootCommandWithDeps(fs afero.Fs, shell internal.Exec) *cobra.Command {

rootCmd.PersistentFlags().String("config", "config.yaml", "Path to the configuration file")
rootCmd.PersistentFlags().String("rsync-path", "/usr/bin/rsync", "Path to the rsync binary")
rootCmd.PersistentFlags().StringArray("set", nil, "Set a variable override (key=value), can be repeated")

rootCmd.AddCommand(
buildListCommand(shell),
Expand Down
178 changes: 178 additions & 0 deletions backup/cmd/test/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,181 @@ func TestRun_LoggerOpenDuringApply(t *testing.T) {
assert.Contains(t, summaryContent, "STATUS [docs]: SUCCESS",
"logger must remain open during cfg.Apply — proves defer cleanup() is function-scoped")
}

// --- --set flag ---

func TestConfigShow_WithSetFlag(t *testing.T) {
cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder().
Source("/home/${user}").Target("/backup/${user}").
Variable("user", "default").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build())

stdout, err := executeCommand(t, "config", "show", "--config", cfgPath, "--set", "user=alice")

require.NoError(t, err)
assert.Contains(t, stdout, "alice_docs")
assert.Contains(t, stdout, "/home/alice/docs/")
assert.Contains(t, stdout, "/backup/alice/docs/")
assert.NotContains(t, stdout, "${user}")
}

func TestConfigValidate_WithSetFlag(t *testing.T) {
cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder().
Source("/home/${user}").Target("/backup/${user}").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build())

stdout, err := executeCommand(t, "config", "validate", "--config", cfgPath, "--set", "user=bob")

require.NoError(t, err)
assert.Contains(t, stdout, "Configuration is valid.")
}

func TestList_WithSetFlag(t *testing.T) {
cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder().
Source("/home/${user}").Target("/backup/${user}").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build())

shell := &stubExec{output: []byte("rsync version 3.2.7 protocol version 31\n")}

fs := afero.NewMemMapFs()
stdout, err := executeCommandWithDeps(t, fs, shell, "list", "--config", cfgPath, "--set", "user=alice")

require.NoError(t, err)
assert.Contains(t, stdout, "Job: alice_docs")
}

// --- template: variables validation at command level ---

func TestConfigValidate_TemplateVarsMissing(t *testing.T) {
cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder().
TemplateVar("user").TemplateVar("user_cap").
Source("/home/${user}").Target("/backup/${user}").
AddJob("docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build())

_, err := executeCommand(t, "config", "validate", "--config", cfgPath)

require.Error(t, err)
assert.Contains(t, err.Error(), "missing required template variables")
}

func TestConfigValidate_TemplateVarsProvidedViaSet(t *testing.T) {
cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder().
TemplateVar("user").
Source("/home/${user}").Target("/backup/${user}").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build())

stdout, err := executeCommand(t, "config", "validate", "--config", cfgPath, "--set", "user=alice")

require.NoError(t, err)
assert.Contains(t, stdout, "Configuration is valid.")
}

func TestConfigShow_TemplateVarsResolved(t *testing.T) {
cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder().
TemplateVar("user").
Source("/home/${user}").Target("/backup/${user}").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build())

stdout, err := executeCommand(t, "config", "show", "--config", cfgPath, "--set", "user=alice")

require.NoError(t, err)
assert.Contains(t, stdout, "alice_docs")
assert.NotContains(t, stdout, "${user}")
}

// --- include at command level ---

func TestConfigShow_WithInclude(t *testing.T) {
dir := t.TempDir()

template := testutil.NewConfigBuilder().
TemplateVar("user").
Source("/home/${user}").Target("/backup/${user}").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build()
testutil.WriteConfigFileInDir(t, dir, "template.yaml", template)

main := testutil.NewConfigBuilder().
AddInclude("template.yaml", map[string]string{"user": "alice"}).
Build()
mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main)

stdout, err := executeCommand(t, "config", "show", "--config", mainPath)

require.NoError(t, err)
assert.Contains(t, stdout, "alice_docs")
assert.Contains(t, stdout, "/home/alice/docs/")
}

func TestConfigValidate_WithInclude(t *testing.T) {
dir := t.TempDir()

template := testutil.NewConfigBuilder().
TemplateVar("user").
Source("/home/${user}").Target("/backup/${user}").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build()
testutil.WriteConfigFileInDir(t, dir, "template.yaml", template)

main := testutil.NewConfigBuilder().
AddInclude("template.yaml", map[string]string{"user": "bob"}).
Build()
mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main)

stdout, err := executeCommand(t, "config", "validate", "--config", mainPath)

require.NoError(t, err)
assert.Contains(t, stdout, "Configuration is valid.")
}

func TestConfigValidate_IncludeMissingVars(t *testing.T) {
dir := t.TempDir()

template := testutil.NewConfigBuilder().
TemplateVar("user").TemplateVar("user_cap").
Source("/home/${user}").Target("/backup/${user}").
AddJob("docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build()
testutil.WriteConfigFileInDir(t, dir, "template.yaml", template)

main := testutil.NewConfigBuilder().
AddInclude("template.yaml", map[string]string{"user": "alice"}).
Build()
mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main)

_, err := executeCommand(t, "config", "validate", "--config", mainPath)

require.Error(t, err)
assert.Contains(t, err.Error(), "missing required template variables")
}

func TestList_WithInclude(t *testing.T) {
dir := t.TempDir()

template := testutil.NewConfigBuilder().
TemplateVar("user").
Source("/home/${user}").Target("/backup/${user}").
AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/").
Build()
testutil.WriteConfigFileInDir(t, dir, "template.yaml", template)

main := testutil.NewConfigBuilder().
AddInclude("template.yaml", map[string]string{"user": "alice"}).
AddInclude("template.yaml", map[string]string{"user": "bob"}).
Build()
mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main)

shell := &stubExec{output: []byte("rsync version 3.2.7 protocol version 31\n")}

stdout, err := executeCommandWithDeps(t, afero.NewMemMapFs(), shell, "list", "--config", mainPath)

require.NoError(t, err)
assert.Contains(t, stdout, "Job: alice_docs")
assert.Contains(t, stdout, "Job: bob_docs")
}
Loading
Loading