From bfbbd31f09c8c54181bb57bd0d0f2622e36a36a3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 20:22:39 +0000 Subject: [PATCH] Add diff-aware file module output --- cmd/ansible/ansible.go | 13 ++++++++++++ cmd/ansible/cmd.go | 1 + mock_ssh_test.go | 45 ++++++++++++++++++++++++++++++++++++++++-- modules.go | 44 +++++++++++++++++++++++++++++++++++++++-- modules_file_test.go | 42 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 4 deletions(-) diff --git a/cmd/ansible/ansible.go b/cmd/ansible/ansible.go index 4795e79..39a2da2 100644 --- a/cmd/ansible/ansible.go +++ b/cmd/ansible/ansible.go @@ -47,6 +47,7 @@ func runAnsible(opts core.Options) core.Result { // Set options executor.Limit = opts.String("limit") executor.CheckMode = opts.Bool("check") + executor.Diff = opts.Bool("diff") executor.Verbose = opts.Int("verbose") if tags := opts.String("tags"); tags != "" { @@ -138,6 +139,18 @@ func runAnsible(opts core.Options) core.Result { print("stdout: %s", trimSpace(result.Stdout)) } } + + if executor.Diff { + if diff, ok := result.Data["diff"].(map[string]any); ok { + print("diff:") + if before, ok := diff["before"].(string); ok && before != "" { + print("- %s", before) + } + if after, ok := diff["after"].(string); ok && after != "" { + print("+ %s", after) + } + } + } } executor.OnPlayEnd = func(play *ansible.Play) { diff --git a/cmd/ansible/cmd.go b/cmd/ansible/cmd.go index 6aa8c58..e269d8e 100644 --- a/cmd/ansible/cmd.go +++ b/cmd/ansible/cmd.go @@ -22,6 +22,7 @@ func Register(c *core.Core) { core.Option{Key: "extra-vars", Value: ""}, core.Option{Key: "verbose", Value: 0}, core.Option{Key: "check", Value: false}, + core.Option{Key: "diff", Value: false}, ), }) diff --git a/mock_ssh_test.go b/mock_ssh_test.go index a3ad85c..6dd1579 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -608,10 +608,19 @@ func moduleScriptWithClient(_ *Executor, client sshRunner, args map[string]any) type sshFileRunner interface { sshRunner Upload(ctx context.Context, local io.Reader, remote string, mode fs.FileMode) error + Download(ctx context.Context, remote string) ([]byte, error) Stat(ctx context.Context, path string) (map[string]any, error) FileExists(ctx context.Context, path string) (bool, error) } +func mockRemoteFileText(client sshFileRunner, path string) (string, bool) { + data, err := client.Download(context.Background(), path) + if err != nil { + return "", false + } + return string(data), true +} + // --- File module shims --- func moduleCopyWithClient(e *Executor, client sshFileRunner, args map[string]any, host string, task *Task) (*TaskResult, error) { @@ -641,6 +650,13 @@ func moduleCopyWithClient(e *Executor, client sshFileRunner, args map[string]any } } + before, hasBefore := mockRemoteFileText(client, dest) + if hasBefore && before == string(content) { + if getStringArg(args, "owner", "") == "" && getStringArg(args, "group", "") == "" { + return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil + } + } + err = client.Upload(context.Background(), newReader(string(content)), dest, mode) if err != nil { return nil, err @@ -654,7 +670,17 @@ func moduleCopyWithClient(e *Executor, client sshFileRunner, args map[string]any _, _, _, _ = client.Run(context.Background(), sprintf("chgrp %s %q", group, dest)) } - return &TaskResult{Changed: true, Msg: sprintf("copied to %s", dest)}, nil + result := &TaskResult{Changed: true, Msg: sprintf("copied to %s", dest)} + if e.Diff && hasBefore { + result.Data = map[string]any{ + "diff": map[string]any{ + "path": dest, + "before": before, + "after": string(content), + }, + } + } + return result, nil } func moduleTemplateWithClient(e *Executor, client sshFileRunner, args map[string]any, host string, task *Task) (*TaskResult, error) { @@ -677,12 +703,27 @@ func moduleTemplateWithClient(e *Executor, client sshFileRunner, args map[string } } + before, hasBefore := mockRemoteFileText(client, dest) + if hasBefore && before == content { + return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil + } + err = client.Upload(context.Background(), newReader(content), dest, mode) if err != nil { return nil, err } - return &TaskResult{Changed: true, Msg: sprintf("templated to %s", dest)}, nil + result := &TaskResult{Changed: true, Msg: sprintf("templated to %s", dest)} + if e.Diff && hasBefore { + result.Data = map[string]any{ + "diff": map[string]any{ + "path": dest, + "before": before, + "after": content, + }, + } + } + return result, nil } func moduleFileWithClient(_ *Executor, client sshFileRunner, args map[string]any) (*TaskResult, error) { diff --git a/modules.go b/modules.go index d871716..6eb8c16 100644 --- a/modules.go +++ b/modules.go @@ -158,6 +158,22 @@ func (e *Executor) executeModule(ctx context.Context, host string, client sshExe } } +func remoteFileText(ctx context.Context, client sshExecutorClient, path string) (string, bool) { + data, err := client.Download(ctx, path) + if err != nil { + return "", false + } + return string(data), true +} + +func fileDiffData(path, before, after string) map[string]any { + return map[string]any{ + "path": path, + "before": before, + "after": after, + } +} + // templateArgs templates all string values in args. func (e *Executor) templateArgs(args map[string]any, host string, task *Task) map[string]any { // Set inventory_hostname for templating @@ -321,6 +337,13 @@ func (e *Executor) moduleCopy(ctx context.Context, client sshExecutorClient, arg } } + before, hasBefore := remoteFileText(ctx, client, dest) + if hasBefore && before == content { + if getStringArg(args, "owner", "") == "" && getStringArg(args, "group", "") == "" { + return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil + } + } + err = client.Upload(ctx, newReader(content), dest, mode) if err != nil { return nil, err @@ -334,7 +357,13 @@ func (e *Executor) moduleCopy(ctx context.Context, client sshExecutorClient, arg _, _, _, _ = client.Run(ctx, sprintf("chgrp %s %q", group, dest)) } - return &TaskResult{Changed: true, Msg: sprintf("copied to %s", dest)}, nil + result := &TaskResult{Changed: true, Msg: sprintf("copied to %s", dest)} + if e.Diff { + if hasBefore { + result.Data = map[string]any{"diff": fileDiffData(dest, before, content)} + } + } + return result, nil } func (e *Executor) moduleTemplate(ctx context.Context, client sshExecutorClient, args map[string]any, host string, task *Task) (*TaskResult, error) { @@ -357,12 +386,23 @@ func (e *Executor) moduleTemplate(ctx context.Context, client sshExecutorClient, } } + before, hasBefore := remoteFileText(ctx, client, dest) + if hasBefore && before == content { + return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil + } + err = client.Upload(ctx, newReader(content), dest, mode) if err != nil { return nil, err } - return &TaskResult{Changed: true, Msg: sprintf("templated to %s", dest)}, nil + result := &TaskResult{Changed: true, Msg: sprintf("templated to %s", dest)} + if e.Diff { + if hasBefore { + result.Data = map[string]any{"diff": fileDiffData(dest, before, content)} + } + } + return result, nil } func (e *Executor) moduleFile(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { diff --git a/modules_file_test.go b/modules_file_test.go index 2521eb9..a57fd96 100644 --- a/modules_file_test.go +++ b/modules_file_test.go @@ -142,6 +142,22 @@ func TestModulesFile_ModuleCopy_Good_ContentTakesPrecedenceOverSrc(t *testing.T) assert.Equal(t, []byte("from_content"), up.Content) } +func TestModulesFile_ModuleCopy_Good_SkipsUnchangedContent(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + e.Diff = true + mock.addFile("/etc/app/config", []byte("server_name=web01")) + + result, err := moduleCopyWithClient(e, mock, map[string]any{ + "content": "server_name=web01", + "dest": "/etc/app/config", + }, "host1", &Task{}) + + require.NoError(t, err) + assert.False(t, result.Changed) + assert.Equal(t, 0, mock.uploadCount()) + assert.Contains(t, result.Msg, "already up to date") +} + // --- file module --- func TestModulesFile_ModuleFile_Good_StateDirectory(t *testing.T) { @@ -776,6 +792,32 @@ func TestModulesFile_ModuleTemplate_Good_PlainTextNoVars(t *testing.T) { assert.Equal(t, content, string(up.Content)) } +func TestModulesFile_ModuleTemplate_Good_DiffData(t *testing.T) { + tmpDir := t.TempDir() + srcPath := joinPath(tmpDir, "app.conf.j2") + require.NoError(t, writeTestFile(srcPath, []byte("server_name={{ server_name }};"), 0644)) + + e, mock := newTestExecutorWithMock("host1") + e.Diff = true + e.SetVar("server_name", "web01.example.com") + mock.addFile("/etc/nginx/conf.d/app.conf", []byte("server_name=old.example.com;")) + + result, err := moduleTemplateWithClient(e, mock, map[string]any{ + "src": srcPath, + "dest": "/etc/nginx/conf.d/app.conf", + }, "host1", &Task{}) + + 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/nginx/conf.d/app.conf", diff["path"]) + assert.Equal(t, "server_name=old.example.com;", diff["before"]) + assert.Contains(t, diff["after"], "web01.example.com") +} + // --- Cross-module dispatch tests for file modules --- func TestModulesFile_ExecuteModuleWithMock_Good_DispatchCopy(t *testing.T) {