diff --git a/mock_ssh_test.go b/mock_ssh_test.go index 49ba79b..8d5d986 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -900,6 +900,7 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri line := getStringArg(args, "line", "") regexpArg := getStringArg(args, "regexp", "") + searchString := getStringArg(args, "search_string", "") state := getStringArg(args, "state", "present") backup := getBoolArg(args, "backup", false) backrefs := getBoolArg(args, "backrefs", false) @@ -925,6 +926,37 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri } if state == "absent" { + if searchString != "" { + if before == "" || !strings.Contains(before, searchString) { + return &TaskResult{Changed: false}, nil + } + if err := ensureBackup(); err != nil { + return nil, err + } + updated, changed := removeLinesContaining(before, searchString) + if !changed { + return &TaskResult{Changed: false}, nil + } + if err := client.Upload(context.Background(), bytes.NewReader([]byte(updated)), path, 0600); err != nil { + return nil, err + } + 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) + } + result.Data["diff"] = map[string]any{ + "path": path, + "before": before, + "after": after, + } + } + return result, nil + } + if regexpArg != "" { if err := ensureBackup(); err != nil { return nil, err @@ -937,6 +969,78 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri } } else { // state == present + if searchString != "" { + if before != "" && fileContainsExactLine(before, line) { + return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil + } + + if before != "" { + updated, changed := replaceFirstLineContaining(before, searchString, line) + if changed { + if err := ensureBackup(); err != nil { + return nil, err + } + if err := client.Upload(context.Background(), bytes.NewReader([]byte(updated)), path, 0600); err != nil { + return nil, err + } + 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) + } + result.Data["diff"] = map[string]any{ + "path": path, + "before": before, + "after": after, + } + } + return result, 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 + } + + updated := line + if before != "" { + updated = before + if updated != "" && !strings.HasSuffix(updated, "\n") { + updated += "\n" + } + updated += line + } + if before == "" && line != "" { + updated = line + "\n" + } + if err := client.Upload(context.Background(), bytes.NewReader([]byte(updated)), path, 0600); err != nil { + return nil, err + } + 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) + } + result.Data["diff"] = map[string]any{ + "path": path, + "before": before, + "after": after, + } + } + return result, nil + } + if regexpArg != "" { // Replace line matching regexp. escapedLine := replaceAll(line, "/", "\\/") diff --git a/modules.go b/modules.go index 2c1bda8..54f71da 100644 --- a/modules.go +++ b/modules.go @@ -895,6 +895,7 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien line := getStringArg(args, "line", "") regexp := getStringArg(args, "regexp", "") + searchString := getStringArg(args, "search_string", "") state := getStringArg(args, "state", "present") backup := getBoolArg(args, "backup", false) backrefs := getBoolArg(args, "backrefs", false) @@ -928,6 +929,34 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien } if state == "absent" { + if searchString != "" { + if !hasBefore || !strings.Contains(before, searchString) { + return &TaskResult{Changed: false}, nil + } + if err := ensureBackup(); err != nil { + return nil, err + } + + updated, changed := removeLinesContaining(before, searchString) + if !changed { + return &TaskResult{Changed: false}, nil + } + if err := client.Upload(ctx, newReader(updated), path, 0644); err != nil { + return nil, coreerr.E("Executor.moduleLineinfile", "upload lineinfile search_string removal", err) + } + 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 = ensureTaskResultData(result.Data) + result.Data["diff"] = fileDiffData(path, before, after) + } + } + return result, nil + } + if regexp != "" { if content, ok := remoteFileText(ctx, client, path); !ok || !regexpMatchString(regexp, content) { return &TaskResult{Changed: false}, nil @@ -949,6 +978,71 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien } // state == present + if searchString != "" { + if hasBefore && fileContainsExactLine(before, line) { + return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil + } + + if hasBefore { + updated, changed := replaceFirstLineContaining(before, searchString, line) + if changed { + if err := ensureBackup(); err != nil { + return nil, err + } + if err := client.Upload(ctx, newReader(updated), path, 0644); err != nil { + return nil, coreerr.E("Executor.moduleLineinfile", "upload lineinfile search_string replacement", err) + } + 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 = ensureTaskResultData(result.Data) + result.Data["diff"] = fileDiffData(path, before, after) + } + } + return result, 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 + } + + updated := line + if hasBefore { + updated = before + if updated != "" && !strings.HasSuffix(updated, "\n") { + updated += "\n" + } + updated += line + } + if !hasBefore && line != "" { + updated = line + "\n" + } + + if err := client.Upload(ctx, newReader(updated), path, 0644); err != nil { + return nil, coreerr.E("Executor.moduleLineinfile", "upload lineinfile search_string append", err) + } + 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 = ensureTaskResultData(result.Data) + result.Data["diff"] = fileDiffData(path, before, after) + } + } + return result, nil + } + if regexp != "" { escapedLine := replaceAll(line, "/", "\\/") sedFlags := "-i" @@ -1166,6 +1260,51 @@ func insertLineRelativeToMatch(ctx context.Context, client commandRunner, path, return false, nil } +func replaceFirstLineContaining(content, needle, line string) (string, bool) { + if content == "" || needle == "" { + return content, false + } + + lines := strings.Split(content, "\n") + changed := false + for i, current := range lines { + if changed { + continue + } + if strings.Contains(current, needle) { + lines[i] = line + changed = true + } + } + if !changed { + return content, false + } + + return join("\n", lines), true +} + +func removeLinesContaining(content, needle string) (string, bool) { + if content == "" || needle == "" { + return content, false + } + + lines := strings.Split(content, "\n") + kept := make([]string, 0, len(lines)) + removed := false + for _, current := range lines { + if strings.Contains(current, needle) { + removed = true + continue + } + kept = append(kept, current) + } + if !removed { + return content, false + } + + return join("\n", kept), true +} + func buildLineinfileInsertCommand(path, line, anchor string, after, firstMatch bool) string { quotedLine := shellSingleQuote(line) quotedAnchor := shellSingleQuote(anchor) diff --git a/modules_file_test.go b/modules_file_test.go index 4b93165..6977af4 100644 --- a/modules_file_test.go +++ b/modules_file_test.go @@ -810,6 +810,42 @@ func TestModulesFile_ModuleLineinfile_Good_ExactLineAlreadyPresentIsNoOp(t *test assert.Equal(t, 0, mock.commandCount()) } +func TestModulesFile_ModuleLineinfile_Good_SearchStringReplacesMatchingLine(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.addFile("/etc/ssh/sshd_config", []byte("PermitRootLogin yes\nPasswordAuthentication yes\n")) + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/ssh/sshd_config", + "search_string": "PermitRootLogin", + "line": "PermitRootLogin no", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + after, err := mock.Download(context.Background(), "/etc/ssh/sshd_config") + require.NoError(t, err) + assert.Equal(t, "PermitRootLogin no\nPasswordAuthentication yes\n", string(after)) +} + +func TestModulesFile_ModuleLineinfile_Good_SearchStringRemovesMatchingLine(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.addFile("/etc/ssh/sshd_config", []byte("PermitRootLogin yes\nPasswordAuthentication yes\n")) + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/ssh/sshd_config", + "search_string": "PermitRootLogin", + "state": "absent", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + + after, err := mock.Download(context.Background(), "/etc/ssh/sshd_config") + require.NoError(t, err) + assert.Equal(t, "PasswordAuthentication yes\n", string(after)) +} + func TestModulesFile_ModuleLineinfile_Good_BackupExistingFile(t *testing.T) { e, mock := newTestExecutorWithMock("host1") path := "/etc/example.conf"