diff --git a/mock_ssh_test.go b/mock_ssh_test.go index 4fe1a80..9603d49 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -752,6 +752,7 @@ func moduleLineinfileWithClient(_ *Executor, client sshRunner, args map[string]a line := getStringArg(args, "line", "") regexpArg := getStringArg(args, "regexp", "") state := getStringArg(args, "state", "present") + backrefs := getBoolArg(args, "backrefs", false) if state == "absent" { if regexpArg != "" { @@ -764,12 +765,24 @@ func moduleLineinfileWithClient(_ *Executor, client sshRunner, args map[string]a } else { // state == present if regexpArg != "" { - // Replace line matching regexp + // Replace line matching regexp. escapedLine := replaceAll(line, "/", "\\/") - cmd := sprintf("sed -i 's/%s/%s/' %q", regexpArg, escapedLine, path) + sedFlags := "-i" + if backrefs { + matchCmd := sprintf("grep -Eq %q %q", regexpArg, path) + _, _, matchRC, _ := client.Run(context.Background(), matchCmd) + if matchRC != 0 { + return &TaskResult{Changed: false}, nil + } + sedFlags = "-E -i" + } + cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexpArg, escapedLine, path) _, _, rc, _ := client.Run(context.Background(), cmd) if rc != 0 { - // Line not found, append + if backrefs { + return &TaskResult{Changed: false}, nil + } + // Line not found, append. cmd = sprintf("echo %q >> %q", line, path) _, _, _, _ = client.Run(context.Background(), cmd) } diff --git a/modules.go b/modules.go index b3e99e6..18b3c0e 100644 --- a/modules.go +++ b/modules.go @@ -438,6 +438,7 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client *SSHClient, args line := getStringArg(args, "line", "") regexp := getStringArg(args, "regexp", "") state := getStringArg(args, "state", "present") + backrefs := getBoolArg(args, "backrefs", false) if state == "absent" { if regexp != "" { @@ -450,12 +451,26 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client *SSHClient, args } else { // state == present if regexp != "" { - // Replace line matching regexp + // Replace line matching regexp. escapedLine := replaceAll(line, "/", "\\/") - cmd := sprintf("sed -i 's/%s/%s/' %q", regexp, escapedLine, path) + sedFlags := "-i" + if backrefs { + // When backrefs is enabled, Ansible only replaces matching lines + // and does not append a new line when the pattern is absent. + matchCmd := sprintf("grep -Eq %q %q", regexp, path) + _, _, matchRC, _ := client.Run(ctx, matchCmd) + if matchRC != 0 { + return &TaskResult{Changed: false}, nil + } + sedFlags = "-E -i" + } + cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexp, escapedLine, path) _, _, rc, _ := client.Run(ctx, cmd) if rc != 0 { - // Line not found, append + if backrefs { + return &TaskResult{Changed: false}, nil + } + // Line not found, append. cmd = sprintf("echo %q >> %q", line, path) _, _, _, _ = client.Run(ctx, cmd) } diff --git a/modules_file_test.go b/modules_file_test.go index e1a3e56..2521eb9 100644 --- a/modules_file_test.go +++ b/modules_file_test.go @@ -387,6 +387,46 @@ func TestModulesFile_ModuleLineinfile_Good_RegexpFallsBackToAppend(t *testing.T) assert.True(t, mock.hasExecuted(`echo`)) } +func TestModulesFile_ModuleLineinfile_Good_BackrefsReplaceMatchOnly(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/example.conf", + "regexp": "^(foo=).*$", + "line": "\\1bar", + "backrefs": true, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`grep -Eq`)) + cmd := mock.lastCommand() + assert.Equal(t, "Run", cmd.Method) + assert.Contains(t, cmd.Cmd, "sed -E -i") + assert.Contains(t, cmd.Cmd, "s/^(foo=).*$") + assert.Contains(t, cmd.Cmd, "\\1bar") + assert.Contains(t, cmd.Cmd, `"/etc/example.conf"`) + assert.False(t, mock.hasExecuted(`echo`)) +} + +func TestModulesFile_ModuleLineinfile_Good_BackrefsNoMatchNoAppend(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand("grep -Eq", "", "", 1) + + result, err := moduleLineinfileWithClient(e, mock, map[string]any{ + "path": "/etc/example.conf", + "regexp": "^(foo=).*$", + "line": "\\1bar", + "backrefs": true, + }) + + require.NoError(t, err) + assert.False(t, result.Changed) + assert.Equal(t, 1, mock.commandCount()) + assert.Contains(t, mock.lastCommand().Cmd, "grep -Eq") + assert.False(t, mock.hasExecuted(`echo`)) +} + func TestModulesFile_ModuleLineinfile_Bad_MissingPath(t *testing.T) { e, mock := newTestExecutorWithMock("host1")