feat(ansible): add lineinfile search_string support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 14:48:28 +00:00
parent 92eaab75ba
commit 28ef1f3d85
3 changed files with 279 additions and 0 deletions

View file

@ -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, "/", "\\/")

View file

@ -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)

View file

@ -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"