From fe0ed9b2ee3cb525798467fa224ca9ba5a6d1c95 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 11:35:42 +0000 Subject: [PATCH] feat(ansible): add diff output for file edits --- mock_ssh_test.go | 30 +++++++- modules.go | 23 +++++- modules_file_test.go | 177 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 4 deletions(-) diff --git a/mock_ssh_test.go b/mock_ssh_test.go index 964a1ad..6b35834 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -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 } diff --git a/modules.go b/modules.go index 2124d33..26f08e0 100644 --- a/modules.go +++ b/modules.go @@ -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 } diff --git a/modules_file_test.go b/modules_file_test.go index a40d014..6ad5099 100644 --- a/modules_file_test.go +++ b/modules_file_test.go @@ -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")