feat(ansible): add lineinfile backup support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 14:24:26 +00:00
parent 3eca6b15cb
commit 1e5bdc08dd
3 changed files with 105 additions and 2 deletions

View file

@ -890,13 +890,34 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri
line := getStringArg(args, "line", "") line := getStringArg(args, "line", "")
regexpArg := getStringArg(args, "regexp", "") regexpArg := getStringArg(args, "regexp", "")
state := getStringArg(args, "state", "present") state := getStringArg(args, "state", "present")
backup := getBoolArg(args, "backup", false)
backrefs := getBoolArg(args, "backrefs", false) backrefs := getBoolArg(args, "backrefs", false)
insertBefore := getStringArg(args, "insertbefore", "") insertBefore := getStringArg(args, "insertbefore", "")
insertAfter := getStringArg(args, "insertafter", "") insertAfter := getStringArg(args, "insertafter", "")
firstMatch := getBoolArg(args, "firstmatch", false) firstMatch := getBoolArg(args, "firstmatch", false)
var backupPath string
ensureBackup := func() error {
if !backup || backupPath != "" {
return nil
}
before, hasBefore := mockRemoteFileText(client, path)
if !hasBefore {
return nil
}
backupPath = sprintf("%s.%s.bak", path, time.Now().UTC().Format("20060102T150405Z"))
if err := client.Upload(context.Background(), bytes.NewReader([]byte(before)), backupPath, 0600); err != nil {
return err
}
return nil
}
if state == "absent" { if state == "absent" {
if regexpArg != "" { if regexpArg != "" {
if err := ensureBackup(); err != nil {
return nil, err
}
cmd := sprintf("sed -i '/%s/d' %q", regexpArg, path) cmd := sprintf("sed -i '/%s/d' %q", regexpArg, path)
_, stderr, rc, _ := client.Run(context.Background(), cmd) _, stderr, rc, _ := client.Run(context.Background(), cmd)
if rc != 0 { if rc != 0 {
@ -917,22 +938,34 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri
} }
sedFlags = "-E -i" sedFlags = "-E -i"
} }
if err := ensureBackup(); err != nil {
return nil, err
}
cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexpArg, escapedLine, path) cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexpArg, escapedLine, path)
_, _, rc, _ := client.Run(context.Background(), cmd) _, _, rc, _ := client.Run(context.Background(), cmd)
if rc != 0 { if rc != 0 {
if backrefs { if backrefs {
return &TaskResult{Changed: false}, nil return &TaskResult{Changed: false}, nil
} }
if err := ensureBackup(); err != nil {
return nil, err
}
if inserted, err := mockInsertLineRelativeToMatch(context.Background(), client, path, line, insertBefore, insertAfter, firstMatch); err != nil { if inserted, err := mockInsertLineRelativeToMatch(context.Background(), client, path, line, insertBefore, insertAfter, firstMatch); err != nil {
return nil, err return nil, err
} else if inserted { } else if inserted {
return &TaskResult{Changed: true}, nil return &TaskResult{Changed: true}, nil
} }
// Line not found, append. // Line not found, append.
if err := ensureBackup(); err != nil {
return nil, err
}
cmd = sprintf("echo %q >> %q", line, path) cmd = sprintf("echo %q >> %q", line, path)
_, _, _, _ = client.Run(context.Background(), cmd) _, _, _, _ = client.Run(context.Background(), cmd)
} }
} else if line != "" { } else if line != "" {
if err := ensureBackup(); err != nil {
return nil, err
}
if inserted, err := mockInsertLineRelativeToMatch(context.Background(), client, path, line, insertBefore, insertAfter, firstMatch); err != nil { if inserted, err := mockInsertLineRelativeToMatch(context.Background(), client, path, line, insertBefore, insertAfter, firstMatch); err != nil {
return nil, err return nil, err
} else if inserted { } else if inserted {
@ -946,6 +979,9 @@ func moduleLineinfileWithClient(_ *Executor, client sshFileRunner, args map[stri
} }
result := &TaskResult{Changed: true} result := &TaskResult{Changed: true}
if backupPath != "" {
result.Data = map[string]any{"backup_file": backupPath}
}
if after, ok := mockRemoteFileText(client, path); ok && before != after { if after, ok := mockRemoteFileText(client, path); ok && before != after {
if result.Data == nil { if result.Data == nil {
result.Data = make(map[string]any) result.Data = make(map[string]any)

View file

@ -831,12 +831,31 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
line := getStringArg(args, "line", "") line := getStringArg(args, "line", "")
regexp := getStringArg(args, "regexp", "") regexp := getStringArg(args, "regexp", "")
state := getStringArg(args, "state", "present") state := getStringArg(args, "state", "present")
backup := getBoolArg(args, "backup", false)
backrefs := getBoolArg(args, "backrefs", false) backrefs := getBoolArg(args, "backrefs", false)
create := getBoolArg(args, "create", false) create := getBoolArg(args, "create", false)
insertBefore := getStringArg(args, "insertbefore", "") insertBefore := getStringArg(args, "insertbefore", "")
insertAfter := getStringArg(args, "insertafter", "") insertAfter := getStringArg(args, "insertafter", "")
firstMatch := getBoolArg(args, "firstmatch", false) firstMatch := getBoolArg(args, "firstmatch", false)
var backupPath string
ensureBackup := func() error {
if !backup || backupPath != "" {
return nil
}
var hasCopy bool
var err error
backupPath, hasCopy, err = backupRemoteFile(ctx, client, path)
if err != nil {
return coreerr.E("Executor.moduleLineinfile", "backup remote file", err)
}
if !hasCopy {
backupPath = ""
}
return nil
}
if state != "absent" && line != "" && regexp == "" && insertBefore == "" && insertAfter == "" { if state != "absent" && line != "" && regexp == "" && insertBefore == "" && insertAfter == "" {
if hasBefore && fileContainsExactLine(before, line) { if hasBefore && fileContainsExactLine(before, line) {
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil
@ -848,6 +867,9 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
if content, ok := remoteFileText(ctx, client, path); !ok || !regexpMatchString(regexp, content) { if content, ok := remoteFileText(ctx, client, path); !ok || !regexpMatchString(regexp, content) {
return &TaskResult{Changed: false}, nil return &TaskResult{Changed: false}, nil
} }
if err := ensureBackup(); err != nil {
return nil, err
}
cmd := sprintf("sed -i '/%s/d' %q", regexp, path) cmd := sprintf("sed -i '/%s/d' %q", regexp, path)
_, stderr, rc, _ := client.Run(ctx, cmd) _, stderr, rc, _ := client.Run(ctx, cmd)
if rc != 0 { if rc != 0 {
@ -863,7 +885,6 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
// state == present // state == present
if regexp != "" { if regexp != "" {
// Replace line matching regexp.
escapedLine := replaceAll(line, "/", "\\/") escapedLine := replaceAll(line, "/", "\\/")
sedFlags := "-i" sedFlags := "-i"
if backrefs { if backrefs {
@ -876,22 +897,38 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
} }
sedFlags = "-E -i" sedFlags = "-E -i"
} }
if err := ensureBackup(); err != nil {
return nil, err
}
cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexp, escapedLine, path) cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexp, escapedLine, path)
_, _, rc, _ := client.Run(ctx, cmd) _, _, rc, _ := client.Run(ctx, cmd)
if rc != 0 { if rc != 0 {
if backrefs { if backrefs {
return &TaskResult{Changed: false}, nil return &TaskResult{Changed: false}, nil
} }
if err := ensureBackup(); err != nil {
return nil, err
}
if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil { if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil {
return nil, err return nil, err
} else if inserted { } else if inserted {
return &TaskResult{Changed: true}, nil return &TaskResult{Changed: true}, nil
} }
// Line not found, append. // Line not found, append.
if err := ensureBackup(); err != nil {
return nil, err
}
cmd = sprintf("echo %q >> %q", line, path) cmd = sprintf("echo %q >> %q", line, path)
_, _, _, _ = client.Run(ctx, cmd) _, _, _, _ = client.Run(ctx, cmd)
} }
} else if line != "" { } else if line != "" {
if err := ensureBackup(); err != nil {
return nil, err
}
if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil { if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil {
return nil, err return nil, err
} else if inserted { } else if inserted {
@ -905,9 +942,15 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
} }
result := &TaskResult{Changed: true} result := &TaskResult{Changed: true}
if backupPath != "" {
result.Data = map[string]any{"backup_file": backupPath}
}
if e.Diff { if e.Diff {
if after, ok := remoteFileText(ctx, client, path); ok && before != after { if after, ok := remoteFileText(ctx, client, path); ok && before != after {
result.Data = map[string]any{"diff": fileDiffData(path, before, after)} if result.Data == nil {
result.Data = make(map[string]any)
}
result.Data["diff"] = fileDiffData(path, before, after)
} }
} }

View file

@ -791,6 +791,30 @@ func TestModulesFile_ModuleLineinfile_Good_ExactLineAlreadyPresentIsNoOp(t *test
assert.Equal(t, 0, mock.commandCount()) assert.Equal(t, 0, mock.commandCount())
} }
func TestModulesFile_ModuleLineinfile_Good_BackupExistingFile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
path := "/etc/example.conf"
mock.addFile(path, []byte("setting=old\n"))
result, err := e.moduleLineinfile(context.Background(), mock, map[string]any{
"path": path,
"line": "setting=new",
"backup": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
require.NotNil(t, result.Data)
backupPath, ok := result.Data["backup_file"].(string)
require.True(t, ok)
assert.Contains(t, backupPath, "/etc/example.conf.")
backupContent, err := mock.Download(context.Background(), backupPath)
require.NoError(t, err)
assert.Equal(t, []byte("setting=old\n"), backupContent)
}
func TestModulesFile_ModuleLineinfile_Good_DiffData(t *testing.T) { func TestModulesFile_ModuleLineinfile_Good_DiffData(t *testing.T) {
e := NewExecutor("/tmp") e := NewExecutor("/tmp")
e.Diff = true e.Diff = true