From 09563c390bd29f259e86af0ddf57855def423a57 Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:23:03 +0000 Subject: [PATCH] feat: Add version info using rsync --version --- backup/cmd/root.go | 2 + backup/cmd/run.go | 4 +- backup/cmd/simulate.go | 4 +- backup/cmd/test/root_test.go | 8 ++- backup/cmd/version.go | 31 +++++++++ backup/internal/command_executor.go | 29 +++++++++ backup/internal/helper.go | 12 +++- backup/internal/job.go | 27 +------- backup/internal/rsync.go | 31 +++++++++ backup/internal/test/job_test.go | 43 +----------- backup/internal/test/mock_executor_test.go | 55 ++++++++++++++++ backup/internal/test/rsync_test.go | 76 ++++++++++++++++++++++ 12 files changed, 250 insertions(+), 72 deletions(-) create mode 100644 backup/cmd/version.go create mode 100644 backup/internal/command_executor.go create mode 100644 backup/internal/rsync.go create mode 100644 backup/internal/test/mock_executor_test.go create mode 100644 backup/internal/test/rsync_test.go diff --git a/backup/cmd/root.go b/backup/cmd/root.go index a3523ec..2e2d794 100644 --- a/backup/cmd/root.go +++ b/backup/cmd/root.go @@ -12,6 +12,7 @@ func BuildRootCommand() *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.AddCommand( buildListCommand(), @@ -19,6 +20,7 @@ func BuildRootCommand() *cobra.Command { buildSimulateCommand(), buildConfigCommand(), buildCheckCoverageCommand(), + buildVersionCommand(), ) return rootCmd diff --git a/backup/cmd/run.go b/backup/cmd/run.go index 3c27ba2..9eb5810 100644 --- a/backup/cmd/run.go +++ b/backup/cmd/run.go @@ -12,8 +12,10 @@ func buildRunCommand() *cobra.Command { Short: "Execute the sync jobs", Run: func(cmd *cobra.Command, args []string) { configPath, _ := cmd.Flags().GetString("config") + rsyncPath, _ := cmd.Flags().GetString("rsync-path") + cfg := internal.LoadResolvedConfig(configPath) - internal.ExecuteSyncJobs(cfg, false) + internal.ExecuteSyncJobs(cfg, false, rsyncPath) }, } } diff --git a/backup/cmd/simulate.go b/backup/cmd/simulate.go index 522c924..4c2e798 100644 --- a/backup/cmd/simulate.go +++ b/backup/cmd/simulate.go @@ -12,8 +12,10 @@ func buildSimulateCommand() *cobra.Command { Short: "Simulate the sync jobs", Run: func(cmd *cobra.Command, args []string) { configPath, _ := cmd.Flags().GetString("config") + rsyncPath, _ := cmd.Flags().GetString("rsync-path") + cfg := internal.LoadResolvedConfig(configPath) - internal.ExecuteSyncJobs(cfg, true) + internal.ExecuteSyncJobs(cfg, true, rsyncPath) }, } } diff --git a/backup/cmd/test/root_test.go b/backup/cmd/test/root_test.go index 42746fd..48be131 100644 --- a/backup/cmd/test/root_test.go +++ b/backup/cmd/test/root_test.go @@ -26,11 +26,13 @@ func TestBuildRootCommand_HelpOutput(t *testing.T) { assert.Contains(t, helpOutput, "backup is a CLI tool for managing backups and configurations.", "Help output should contain the long description") assert.Contains(t, helpOutput, "backup [command]", "Help output should contain usage") - assert.Contains(t, helpOutput, "--config string Path to the configuration file (default \"config.yaml\")", - "Help output should contain the persistent flag description") + + // check persistent flags + assert.Contains(t, helpOutput, "--config string Path to the configuration file (default \"config.yaml\")") + assert.Contains(t, helpOutput, "--rsync-path string Path to the rsync binary (default \"/usr/bin/rsync\")") // check each sub-command is listed - subCommands := []string{"list", "run", "simulate", "config", "check-coverage"} + subCommands := []string{"list", "run", "simulate", "config", "check-coverage", "version"} for _, cmdName := range subCommands { assert.Regexp(t, "(?m)^ "+cmdName, helpOutput, "Help output should list the sub-command: "+cmdName) } diff --git a/backup/cmd/version.go b/backup/cmd/version.go new file mode 100644 index 0000000..b3638c1 --- /dev/null +++ b/backup/cmd/version.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "backup-rsync/backup/internal" + + "github.com/spf13/cobra" +) + +func buildVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Prints the rsync version, protocol version, and full path to the rsync binary.", + Run: func(cmd *cobra.Command, args []string) { + var executor internal.CommandExecutor = &internal.RealCommandExecutor{} + + rsyncPath, _ := cmd.Flags().GetString("rsync-path") + + output, err := internal.FetchRsyncVersion(executor, rsyncPath) + if err != nil { + fmt.Printf("%v\n", err) + + return + } + + fmt.Printf("Rsync Binary Path: %s\n", rsyncPath) + fmt.Printf("Version Info: %s", output) + }, + } +} diff --git a/backup/internal/command_executor.go b/backup/internal/command_executor.go new file mode 100644 index 0000000..453466f --- /dev/null +++ b/backup/internal/command_executor.go @@ -0,0 +1,29 @@ +package internal + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// CommandExecutor interface for executing commands. +type CommandExecutor interface { + Execute(name string, args ...string) ([]byte, error) +} + +// RealCommandExecutor implements CommandExecutor using actual os/exec. +type RealCommandExecutor struct{} + +// Execute runs the actual command. +func (r *RealCommandExecutor) Execute(name string, args ...string) ([]byte, error) { + ctx := context.Background() + cmd := exec.CommandContext(ctx, name, args...) + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to execute command '%s %s': %w", name, strings.Join(args, " "), err) + } + + return output, nil +} diff --git a/backup/internal/helper.go b/backup/internal/helper.go index 7b3fd6f..869c29c 100644 --- a/backup/internal/helper.go +++ b/backup/internal/helper.go @@ -28,7 +28,7 @@ func GetLogPath(create bool) string { return logPath } -func ExecuteSyncJobs(cfg Config, simulate bool) { +func ExecuteSyncJobs(cfg Config, simulate bool, rsyncPath string) { logPath := GetLogPath(true) overallLogPath := logPath + "/summary.log" @@ -47,6 +47,16 @@ func ExecuteSyncJobs(cfg Config, simulate bool) { overallLogger := log.New(overallLogFile, "", log.LstdFlags) + var executor CommandExecutor = &RealCommandExecutor{} + + versionInfo, err := FetchRsyncVersion(executor, rsyncPath) + if err != nil { + overallLogger.Printf("Failed to fetch rsync version: %v", err) + } else { + overallLogger.Printf("Rsync Binary Path: %s", rsyncPath) + overallLogger.Printf("Rsync Version Info: %s", versionInfo) + } + for _, job := range cfg.Jobs { jobLogPath := fmt.Sprintf("%s/job-%s.log", logPath, job.Name) status := ExecuteJob(job, simulate, false, jobLogPath) diff --git a/backup/internal/job.go b/backup/internal/job.go index 3a907fd..46e4808 100644 --- a/backup/internal/job.go +++ b/backup/internal/job.go @@ -1,31 +1,12 @@ package internal import ( - "context" "fmt" - "os/exec" "strings" ) -// CommandExecutor interface for executing commands. -type CommandExecutor interface { - Execute(name string, args ...string) ([]byte, error) -} - -// RealCommandExecutor implements CommandExecutor using actual os/exec. -type RealCommandExecutor struct{} - -// Execute runs the actual command. -func (r *RealCommandExecutor) Execute(name string, args ...string) ([]byte, error) { - ctx := context.Background() - cmd := exec.CommandContext(ctx, name, args...) - - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("failed to execute command '%s %s': %w", name, strings.Join(args, " "), err) - } - - return output, nil +func BuildRsyncVersionCmd() []string { + return []string{"--version"} } func BuildRsyncCmd(job Job, simulate bool, logPath string) []string { @@ -51,9 +32,7 @@ func BuildRsyncCmd(job Job, simulate bool, logPath string) []string { } func ExecuteJob(job Job, simulate bool, show bool, logPath string) string { - var osExec CommandExecutor = &RealCommandExecutor{} - - return ExecuteJobWithExecutor(job, simulate, show, logPath, osExec) + return ExecuteJobWithExecutor(job, simulate, show, logPath, &RealCommandExecutor{}) } func ExecuteJobWithExecutor(job Job, simulate bool, show bool, logPath string, executor CommandExecutor) string { diff --git a/backup/internal/rsync.go b/backup/internal/rsync.go new file mode 100644 index 0000000..a20d53b --- /dev/null +++ b/backup/internal/rsync.go @@ -0,0 +1,31 @@ +package internal + +import ( + "errors" + "fmt" + "path/filepath" + "strings" +) + +var ErrInvalidRsyncVersion = errors.New("invalid rsync version output") +var ErrInvalidRsyncPath = errors.New("rsync path must be an absolute path") + +func FetchRsyncVersion(executor CommandExecutor, rsyncPath string) (string, error) { + if !filepath.IsAbs(rsyncPath) { + return "", fmt.Errorf("%w: \"%s\"", ErrInvalidRsyncPath, rsyncPath) + } + + cmdArgs := BuildRsyncVersionCmd() + + output, err := executor.Execute(rsyncPath, cmdArgs...) + if err != nil { + return "", fmt.Errorf("error fetching rsync version: %w", err) + } + + // Validate output + if !strings.Contains(string(output), "rsync") || !strings.Contains(string(output), "protocol version") { + return "", fmt.Errorf("%w: %s", ErrInvalidRsyncVersion, output) + } + + return string(output), nil +} diff --git a/backup/internal/test/job_test.go b/backup/internal/test/job_test.go index b461e36..78aaf9d 100644 --- a/backup/internal/test/job_test.go +++ b/backup/internal/test/job_test.go @@ -1,35 +1,17 @@ package internal_test import ( - "errors" + "backup-rsync/backup/internal" "strings" "testing" - "backup-rsync/backup/internal" - "github.com/stretchr/testify/assert" ) -// Static error for testing. -var ErrExitStatus23 = errors.New("exit status 23") - const statusSuccess = "SUCCESS" -// MockCommandExecutor implements CommandExecutor for testing. -type MockCommandExecutor struct { - CapturedCommands []MockCommand -} - -// MockCommand represents a captured command execution. -type MockCommand struct { - Name string - Args []string -} - -// Option defines a function that modifies a Job. type Option func(*internal.Job) -// NewJob is a job factory with defaults. func NewJob(opts ...Option) *internal.Job { // Default values job := &internal.Job{ @@ -79,29 +61,6 @@ func WithExclusions(exclusions []string) Option { } } -// Execute captures the command and simulates execution. -func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) { - m.CapturedCommands = append(m.CapturedCommands, MockCommand{ - Name: name, - Args: append([]string{}, args...), // Make a copy of args - }) - - if name == "rsync" { - // Simulate different scenarios based on arguments - argsStr := strings.Join(args, " ") - - if strings.Contains(argsStr, "/invalid/source/path") { - errMsg := "rsync: link_stat \"/invalid/source/path\" failed: No such file or directory" - - return []byte(errMsg), ErrExitStatus23 - } - - return []byte("mocked rsync success"), nil - } - - return []byte("command not mocked"), nil -} - func TestBuildRsyncCmd(t *testing.T) { job := *NewJob( WithSource("/home/user/Music/"), diff --git a/backup/internal/test/mock_executor_test.go b/backup/internal/test/mock_executor_test.go new file mode 100644 index 0000000..388f96b --- /dev/null +++ b/backup/internal/test/mock_executor_test.go @@ -0,0 +1,55 @@ +package internal_test + +import ( + "errors" + "strings" +) + +// Static error for testing. +var ErrExitStatus23 = errors.New("exit status 23") + +// MockCommandExecutor implements CommandExecutor for testing. +type MockCommandExecutor struct { + CapturedCommands []MockCommand + Output string + Error error +} + +// MockCommand represents a captured command execution. +type MockCommand struct { + Name string + Args []string +} + +// Execute captures the command and simulates execution. +func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) { + m.CapturedCommands = append(m.CapturedCommands, MockCommand{ + Name: name, + Args: append([]string{}, args...), // Make a copy of args + }) + + // If Error is set, return it. + if m.Error != nil { + return nil, m.Error + } + + // If Output is set, return it. + if m.Output != "" { + return []byte(m.Output), nil + } + + // Simulate specific scenarios for rsync. + if name == "rsync" { + argsStr := strings.Join(args, " ") + + if strings.Contains(argsStr, "/invalid/source/path") { + errMsg := "rsync: link_stat \"/invalid/source/path\" failed: No such file or directory" + + return []byte(errMsg), ErrExitStatus23 + } + + return []byte("mocked rsync success"), nil + } + + return []byte("command not mocked"), nil +} diff --git a/backup/internal/test/rsync_test.go b/backup/internal/test/rsync_test.go new file mode 100644 index 0000000..773a7ae --- /dev/null +++ b/backup/internal/test/rsync_test.go @@ -0,0 +1,76 @@ +package internal_test + +import ( + "backup-rsync/backup/internal" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errCommandNotFound = errors.New("command not found") + +const rsyncPath = "/usr/bin/rsync" + +func TestFetchRsyncVersion_Success(t *testing.T) { + executor := &MockCommandExecutor{ + Output: "rsync version 3.2.3 protocol version 31\n", + Error: nil, + } + + versionInfo, err := internal.FetchRsyncVersion(executor, rsyncPath) + + require.NoError(t, err) + assert.Equal(t, "rsync version 3.2.3 protocol version 31\n", versionInfo) +} + +func TestFetchRsyncVersion_CommandError(t *testing.T) { + executor := &MockCommandExecutor{ + Output: "", + Error: errCommandNotFound, + } + + versionInfo, err := internal.FetchRsyncVersion(executor, rsyncPath) + + require.Error(t, err) + assert.Empty(t, versionInfo) +} + +func TestFetchRsyncVersion_InvalidOutput(t *testing.T) { + executor := &MockCommandExecutor{ + Output: "invalid output", + Error: nil, + } + + versionInfo, err := internal.FetchRsyncVersion(executor, rsyncPath) + + require.Error(t, err) + assert.Empty(t, versionInfo) +} + +func TestFetchRsyncVersion_EmptyPath(t *testing.T) { + executor := &MockCommandExecutor{ + Output: "", + Error: nil, + } + + versionInfo, err := internal.FetchRsyncVersion(executor, "") + + require.Error(t, err) + require.EqualError(t, err, "rsync path must be an absolute path: \"\"") + assert.Empty(t, versionInfo) +} + +func TestFetchRsyncVersion_IncompletePath(t *testing.T) { + executor := &MockCommandExecutor{ + Output: "", + Error: nil, + } + + versionInfo, err := internal.FetchRsyncVersion(executor, "bin/rsync") + + require.Error(t, err) + require.EqualError(t, err, "rsync path must be an absolute path: \"bin/rsync\"") + assert.Empty(t, versionInfo) +}