Add diff-aware file module output

This commit is contained in:
Virgil 2026-04-01 20:22:39 +00:00
parent 2965d93ca8
commit bfbbd31f09
5 changed files with 141 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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