diff --git a/mock_ssh_test.go b/mock_ssh_test.go index 2b56454..636b400 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -890,13 +890,34 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri line := getStringArg(args, "line", "") regexpArg := getStringArg(args, "regexp", "") state := getStringArg(args, "state", "present") + backup := getBoolArg(args, "backup", false) backrefs := getBoolArg(args, "backrefs", false) insertBefore := getStringArg(args, "insertbefore", "") insertAfter := getStringArg(args, "insertafter", "") firstMatch := getBoolArg(args, "firstmatch", false) + var backupPath string + ensureBackup := func() error { + if !backup || backupPath != "" { + return nil + } + + before, hasBefore := mockRemoteFileText(client, path) + if !hasBefore { + return nil + } + + backupPath = sprintf("%s.%s.bak", path, time.Now().UTC().Format("20060102T150405Z")) + if err := client.Upload(context.Background(), bytes.NewReader([]byte(before)), backupPath, 0600); err != nil { + return err + } + return nil + } if state == "absent" { if regexpArg != "" { + if err := ensureBackup(); err != nil { + return nil, err + } cmd := sprintf("sed -i '/%s/d' %q", regexpArg, path) _, stderr, rc, _ := client.Run(context.Background(), cmd) if rc != 0 { @@ -917,22 +938,34 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri } sedFlags = "-E -i" } + if err := ensureBackup(); err != nil { + return nil, err + } cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexpArg, escapedLine, path) _, _, rc, _ := client.Run(context.Background(), cmd) if rc != 0 { if backrefs { return &TaskResult{Changed: false}, nil } + if err := ensureBackup(); err != nil { + return nil, err + } if inserted, err := mockInsertLineRelativeToMatch(context.Background(), client, path, line, insertBefore, insertAfter, firstMatch); err != nil { return nil, err } else if inserted { return &TaskResult{Changed: true}, nil } // Line not found, append. + if err := ensureBackup(); err != nil { + return nil, err + } cmd = sprintf("echo %q >> %q", line, path) _, _, _, _ = client.Run(context.Background(), cmd) } } else if line != "" { + if err := ensureBackup(); err != nil { + return nil, err + } if inserted, err := mockInsertLineRelativeToMatch(context.Background(), client, path, line, insertBefore, insertAfter, firstMatch); err != nil { return nil, err } else if inserted { @@ -946,6 +979,9 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri } result := &TaskResult{Changed: true} + if backupPath != "" { + result.Data = map[string]any{"backup_file": backupPath} + } if after, ok := mockRemoteFileText(client, path); ok && before != after { if result.Data == nil { result.Data = make(map[string]any) diff --git a/modules.go b/modules.go index 4b4c21f..a650a89 100644 --- a/modules.go +++ b/modules.go @@ -831,12 +831,31 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien line := getStringArg(args, "line", "") regexp := getStringArg(args, "regexp", "") state := getStringArg(args, "state", "present") + backup := getBoolArg(args, "backup", false) backrefs := getBoolArg(args, "backrefs", false) create := getBoolArg(args, "create", false) insertBefore := getStringArg(args, "insertbefore", "") insertAfter := getStringArg(args, "insertafter", "") firstMatch := getBoolArg(args, "firstmatch", false) + var backupPath string + ensureBackup := func() error { + if !backup || backupPath != "" { + return nil + } + + var hasCopy bool + var err error + backupPath, hasCopy, err = backupRemoteFile(ctx, client, path) + if err != nil { + return coreerr.E("Executor.moduleLineinfile", "backup remote file", err) + } + if !hasCopy { + backupPath = "" + } + return nil + } + if state != "absent" && line != "" && regexp == "" && insertBefore == "" && insertAfter == "" { if hasBefore && fileContainsExactLine(before, line) { return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil @@ -848,6 +867,9 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien if content, ok := remoteFileText(ctx, client, path); !ok || !regexpMatchString(regexp, content) { return &TaskResult{Changed: false}, nil } + if err := ensureBackup(); err != nil { + return nil, err + } cmd := sprintf("sed -i '/%s/d' %q", regexp, path) _, stderr, rc, _ := client.Run(ctx, cmd) if rc != 0 { @@ -863,7 +885,6 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien // state == present if regexp != "" { - // Replace line matching regexp. escapedLine := replaceAll(line, "/", "\\/") sedFlags := "-i" if backrefs { @@ -876,22 +897,38 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien } sedFlags = "-E -i" } + + if err := ensureBackup(); err != nil { + return nil, err + } + cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexp, escapedLine, path) _, _, rc, _ := client.Run(ctx, cmd) if rc != 0 { if backrefs { return &TaskResult{Changed: false}, nil } + + if err := ensureBackup(); err != nil { + return nil, err + } if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil { return nil, err } else if inserted { return &TaskResult{Changed: true}, nil } + // Line not found, append. + if err := ensureBackup(); err != nil { + return nil, err + } cmd = sprintf("echo %q >> %q", line, path) _, _, _, _ = client.Run(ctx, cmd) } } else if line != "" { + if err := ensureBackup(); err != nil { + return nil, err + } if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil { return nil, err } else if inserted { @@ -905,9 +942,15 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien } result := &TaskResult{Changed: true} + if backupPath != "" { + result.Data = map[string]any{"backup_file": backupPath} + } if e.Diff { if after, ok := remoteFileText(ctx, client, path); ok && before != after { - result.Data = map[string]any{"diff": fileDiffData(path, before, after)} + if result.Data == nil { + result.Data = make(map[string]any) + } + result.Data["diff"] = fileDiffData(path, before, after) } } diff --git a/modules_file_test.go b/modules_file_test.go index f56965d..75f1141 100644 --- a/modules_file_test.go +++ b/modules_file_test.go @@ -791,6 +791,30 @@ func TestModulesFile_ModuleLineinfile_Good_ExactLineAlreadyPresentIsNoOp(t *test assert.Equal(t, 0, mock.commandCount()) } +func TestModulesFile_ModuleLineinfile_Good_BackupExistingFile(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + path := "/etc/example.conf" + mock.addFile(path, []byte("setting=old\n")) + + result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{ + "path": path, + "line": "setting=new", + "backup": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + require.NotNil(t, result.Data) + + backupPath, ok := result.Data["backup_file"].(string) + require.True(t, ok) + assert.Contains(t, backupPath, "/etc/example.conf.") + + backupContent, err := mock.Download(context.Background(), backupPath) + require.NoError(t, err) + assert.Equal(t, []byte("setting=old\n"), backupContent) +} + func TestModulesFile_ModuleLineinfile_Good_DiffData(t *testing.T) { e := NewExecutor("/tmp") e.Diff = true