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
2 changes: 2 additions & 0 deletions backup/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ 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(),
buildRunCommand(),
buildSimulateCommand(),
buildConfigCommand(),
buildCheckCoverageCommand(),
buildVersionCommand(),
)

return rootCmd
Expand Down
4 changes: 3 additions & 1 deletion backup/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}
}
4 changes: 3 additions & 1 deletion backup/cmd/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}
}
8 changes: 5 additions & 3 deletions backup/cmd/test/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
31 changes: 31 additions & 0 deletions backup/cmd/version.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
29 changes: 29 additions & 0 deletions backup/internal/command_executor.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 11 additions & 1 deletion backup/internal/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
27 changes: 3 additions & 24 deletions backup/internal/job.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions backup/internal/rsync.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 1 addition & 42 deletions backup/internal/test/job_test.go
Original file line number Diff line number Diff line change
@@ -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{
Expand Down Expand Up @@ -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/"),
Expand Down
55 changes: 55 additions & 0 deletions backup/internal/test/mock_executor_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading