feat(ansible): add diff output for file edits
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run

This commit is contained in:
Virgil 2026-04-03 11:35:42 +00:00
parent 324411bb95
commit fe0ed9b2ee
3 changed files with 226 additions and 4 deletions

View file

@ -860,7 +860,7 @@ func moduleFileWithClient(_ *Executor, client sshFileRunner, args map[string]any
return &TaskResult{Changed: true}, nil
}
func moduleLineinfileWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) {
func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) {
path := getStringArg(args, "path", "")
if path == "" {
path = getStringArg(args, "dest", "")
@ -869,6 +869,8 @@ func moduleLineinfileWithClient(_ *Executor, client sshRunner, args map[string]a
return nil, mockError("moduleLineinfileWithClient", "lineinfile: path required")
}
before, _ := mockRemoteFileText(client, path)
line := getStringArg(args, "line", "")
regexpArg := getStringArg(args, "regexp", "")
state := getStringArg(args, "state", "present")
@ -927,7 +929,19 @@ func moduleLineinfileWithClient(_ *Executor, client sshRunner, args map[string]a
}
}
return &TaskResult{Changed: true}, nil
result := &TaskResult{Changed: true}
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
}
func moduleBlockinfileWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) {
@ -939,6 +953,8 @@ func moduleBlockinfileWithClient(_ *Executor, client sshFileRunner, args map[str
return nil, mockError("moduleBlockinfileWithClient", "blockinfile: path required")
}
before, _ := mockRemoteFileText(client, path)
block := getStringArg(args, "block", "")
marker := getStringArg(args, "marker", "# {mark} ANSIBLE MANAGED BLOCK")
state := getStringArg(args, "state", "present")
@ -1003,6 +1019,16 @@ BLOCK_EOF
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
}

View file

@ -741,6 +741,8 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
return nil, coreerr.E("Executor.moduleLineinfile", "path required", nil)
}
before, hasBefore := remoteFileText(ctx, client, path)
line := getStringArg(args, "line", "")
regexp := getStringArg(args, "regexp", "")
state := getStringArg(args, "state", "present")
@ -751,7 +753,7 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
firstMatch := getBoolArg(args, "firstmatch", false)
if state != "absent" && line != "" && regexp == "" && insertBefore == "" && insertAfter == "" {
if content, ok := remoteFileText(ctx, client, path); ok && fileContainsExactLine(content, line) {
if hasBefore && fileContainsExactLine(before, line) {
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil
}
}
@ -817,7 +819,14 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
}
}
return &TaskResult{Changed: true}, nil
result := &TaskResult{Changed: true}
if e.Diff {
if after, ok := remoteFileText(ctx, client, path); ok && before != after {
result.Data = map[string]any{"diff": fileDiffData(path, before, after)}
}
}
return result, nil
}
func fileContainsExactLine(content, line string) bool {
@ -2614,6 +2623,8 @@ func (e *Executor) moduleBlockinfile(ctx context.Context, client sshExecutorClie
return nil, coreerr.E("Executor.moduleBlockinfile", "path required", nil)
}
before, _ := remoteFileText(ctx, client, path)
block := getStringArg(args, "block", "")
marker := getStringArg(args, "marker", "# {mark} ANSIBLE MANAGED BLOCK")
state := getStringArg(args, "state", "present")
@ -2676,6 +2687,14 @@ BLOCK_EOF
if backupPath != "" {
result.Data = map[string]any{"backup_file": backupPath}
}
if e.Diff {
if after, ok := remoteFileText(ctx, client, path); ok && before != after {
if result.Data == nil {
result.Data = make(map[string]any)
}
result.Data["diff"] = fileDiffData(path, before, after)
}
}
return result, nil
}

View file

@ -2,14 +2,143 @@ package ansible
import (
"context"
"io"
"io/fs"
"regexp"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type diffFileClient struct {
mu sync.Mutex
files map[string]string
}
func newDiffFileClient(initial map[string]string) *diffFileClient {
files := make(map[string]string, len(initial))
for path, content := range initial {
files[path] = content
}
return &diffFileClient{files: files}
}
func (c *diffFileClient) Run(_ context.Context, cmd string) (string, string, int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if strings.Contains(cmd, `|| echo `) && strings.Contains(cmd, `grep -qxF `) {
re := regexp.MustCompile(`grep -qxF "([^"]*)" "([^"]*)" \|\| echo "([^"]*)" >> "([^"]*)"`)
match := re.FindStringSubmatch(cmd)
if len(match) == 5 {
line := match[3]
path := match[4]
if c.files[path] == "" {
c.files[path] = line + "\n"
} else if !strings.Contains(c.files[path], line+"\n") && c.files[path] != line {
if !strings.HasSuffix(c.files[path], "\n") {
c.files[path] += "\n"
}
c.files[path] += line + "\n"
}
}
}
if strings.Contains(cmd, `sed -i '/`) && strings.Contains(cmd, `/d' `) {
re := regexp.MustCompile(`sed -i '/([^']*)/d' "([^"]*)"`)
match := re.FindStringSubmatch(cmd)
if len(match) == 3 {
pattern := match[1]
path := match[2]
lines := strings.Split(c.files[path], "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
if line == "" {
continue
}
if strings.Contains(line, pattern) {
continue
}
out = append(out, line)
}
if len(out) > 0 {
c.files[path] = strings.Join(out, "\n") + "\n"
} else {
c.files[path] = ""
}
}
}
return "", "", 0, nil
}
func (c *diffFileClient) RunScript(_ context.Context, script string) (string, string, int, error) {
c.mu.Lock()
defer c.mu.Unlock()
re := regexp.MustCompile("(?s)cat >> \"([^\"]+)\" << 'BLOCK_EOF'\\n(.*)\\nBLOCK_EOF")
match := re.FindStringSubmatch(script)
if len(match) == 3 {
path := match[1]
block := match[2]
c.files[path] = block + "\n"
}
return "", "", 0, nil
}
func (c *diffFileClient) Upload(_ context.Context, local io.Reader, remote string, _ fs.FileMode) error {
c.mu.Lock()
defer c.mu.Unlock()
content, err := io.ReadAll(local)
if err != nil {
return err
}
c.files[remote] = string(content)
return nil
}
func (c *diffFileClient) Download(_ context.Context, remote string) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
content, ok := c.files[remote]
if !ok {
return nil, mockError("diffFileClient.Download", "file not found: "+remote)
}
return []byte(content), nil
}
func (c *diffFileClient) Stat(_ context.Context, path string) (map[string]any, error) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.files[path]; ok {
return map[string]any{"exists": true}, nil
}
return map[string]any{"exists": false}, nil
}
func (c *diffFileClient) FileExists(_ context.Context, path string) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
_, ok := c.files[path]
return ok, nil
}
func (c *diffFileClient) BecomeState() (bool, string, string) {
return false, "", ""
}
func (c *diffFileClient) SetBecome(bool, string, string) {}
func (c *diffFileClient) Close() error { return nil }
// ============================================================
// Step 1.2: copy / template / file / lineinfile / blockinfile / stat module tests
// ============================================================
@ -636,6 +765,30 @@ func TestModulesFile_ModuleLineinfile_Good_ExactLineAlreadyPresentIsNoOp(t *test
assert.Equal(t, 0, mock.commandCount())
}
func TestModulesFile_ModuleLineinfile_Good_DiffData(t *testing.T) {
e := NewExecutor("/tmp")
e.Diff = true
client := newDiffFileClient(map[string]string{
"/etc/example.conf": "setting=old\n",
})
result, err := e.moduleLineinfile(context.Background(), client, map[string]any{
"path": "/etc/example.conf",
"line": "setting=new",
})
require.NoError(t, err)
assert.True(t, result.Changed)
require.NotNil(t, result.Data)
diff, ok := result.Data["diff"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "/etc/example.conf", diff["path"])
assert.Equal(t, "setting=old\n", diff["before"])
assert.Contains(t, diff["after"], "setting=new")
}
// --- blockinfile module ---
func TestModulesFile_ModuleBlockinfile_Good_InsertBlock(t *testing.T) {
@ -744,6 +897,30 @@ func TestModulesFile_ModuleBlockinfile_Good_BackupExistingDest(t *testing.T) {
assert.Equal(t, []byte("old block contents"), backupContent)
}
func TestModulesFile_ModuleBlockinfile_Good_DiffData(t *testing.T) {
e := NewExecutor("/tmp")
e.Diff = true
client := newDiffFileClient(map[string]string{
"/etc/config": "old block contents\n",
})
result, err := e.moduleBlockinfile(context.Background(), client, map[string]any{
"path": "/etc/config",
"block": "new block contents",
})
require.NoError(t, err)
assert.True(t, result.Changed)
require.NotNil(t, result.Data)
diff, ok := result.Data["diff"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "/etc/config", diff["path"])
assert.Equal(t, "old block contents\n", diff["before"])
assert.Contains(t, diff["after"], "new block contents")
}
func TestModulesFile_ModuleBlockinfile_Bad_MissingPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")