Add replace module 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

This commit is contained in:
Virgil 2026-04-03 13:31:06 +00:00
parent cffc35a973
commit 5609471945
5 changed files with 133 additions and 4 deletions

View file

@ -32,7 +32,7 @@ Inventory YAML ──► Parser ──► Inventory Callbacks (OnPlayStar
- **`types.go`** — Core structs (`Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`) and `KnownModules` registry (96 entries: both FQCN `ansible.builtin.*` and short forms, plus compatibility aliases). - **`types.go`** — Core structs (`Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`) and `KnownModules` registry (96 entries: both FQCN `ansible.builtin.*` and short forms, plus compatibility aliases).
- **`parser.go`** — YAML parsing for playbooks, inventories, tasks, and roles. Custom `Task.UnmarshalYAML` scans map keys against `KnownModules` to extract the module name and args (since Ansible embeds the module name as a YAML key, not a fixed field). Free-form syntax (`shell: echo hello`) is stored as `Args["_raw_params"]`. Iterator variants (`ParsePlaybookIter`, `ParseTasksIter`, etc.) return `iter.Seq` values. - **`parser.go`** — YAML parsing for playbooks, inventories, tasks, and roles. Custom `Task.UnmarshalYAML` scans map keys against `KnownModules` to extract the module name and args (since Ansible embeds the module name as a YAML key, not a fixed field). Free-form syntax (`shell: echo hello`) is stored as `Args["_raw_params"]`. Iterator variants (`ParsePlaybookIter`, `ParseTasksIter`, etc.) return `iter.Seq` values.
- **`executor.go`** — Orchestration engine: host resolution from inventory, play execution order (gather facts → pre_tasks → roles → tasks → post_tasks → notified handlers), `when:` condition evaluation, `{{ }}` Jinja2-style templating with filter support, loop execution, block/rescue/always, handler notification. - **`executor.go`** — Orchestration engine: host resolution from inventory, play execution order (gather facts → pre_tasks → roles → tasks → post_tasks → notified handlers), `when:` condition evaluation, `{{ }}` Jinja2-style templating with filter support, loop execution, block/rescue/always, handler notification.
- **`modules.go`** — 49 module handler implementations dispatched via a `switch` on the normalised module name. Each handler extracts args via `getStringArg`/`getBoolArg`, constructs shell commands, runs them via SSH, and returns a `TaskResult`. - **`modules.go`** — 50 module handler implementations dispatched via a `switch` on the normalised module name. Each handler extracts args via `getStringArg`/`getBoolArg`, constructs shell commands, runs them via SSH, and returns a `TaskResult`.
- **`ssh.go`** — SSH client with lazy connection, auth chain (key file → default keys → password), `known_hosts` verification, become/sudo wrapping, file transfer via `cat >` piped through stdin. - **`ssh.go`** — SSH client with lazy connection, auth chain (key file → default keys → password), `known_hosts` verification, become/sudo wrapping, file transfer via `cat >` piped through stdin.
- **`cmd/ansible/`** — CLI command registration via `core/cli`. Provides `ansible <playbook>` and `ansible test <host>` subcommands with flags for inventory, limit, tags, extra-vars, verbosity, and check mode. - **`cmd/ansible/`** — CLI command registration via `core/cli`. Provides `ansible <playbook>` and `ansible test <host>` subcommands with flags for inventory, limit, tags, extra-vars, verbosity, and check mode.

View file

@ -110,7 +110,7 @@ go-ansible/
types.go Core data types: Playbook, Play, Task, Inventory, Host, Facts types.go Core data types: Playbook, Play, Task, Inventory, Host, Facts
parser.go YAML parser for playbooks, inventories, tasks, roles parser.go YAML parser for playbooks, inventories, tasks, roles
executor.go Execution engine: module dispatch, templating, conditions, loops executor.go Execution engine: module dispatch, templating, conditions, loops
modules.go 49 module implementations (shell, apt, docker-compose, setup, etc.) modules.go 50 module implementations (shell, apt, replace, docker-compose, setup, etc.)
ssh.go SSH client with key/password auth, become/sudo, file transfer ssh.go SSH client with key/password auth, become/sudo, file transfer
types_test.go Tests for data types and YAML unmarshalling types_test.go Tests for data types and YAML unmarshalling
parser_test.go Tests for the YAML parser parser_test.go Tests for the YAML parser
@ -126,12 +126,12 @@ go-ansible/
## Supported Modules ## Supported Modules
49 module handlers are implemented, covering the most commonly used Ansible modules: 50 module handlers are implemented, covering the most commonly used Ansible modules:
| Category | Modules | | Category | Modules |
|----------|---------| |----------|---------|
| **Command execution** | `shell`, `command`, `raw`, `script` | | **Command execution** | `shell`, `command`, `raw`, `script` |
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` | | **File operations** | `copy`, `template`, `file`, `lineinfile`, `replace`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip`, `rpm` | | **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip`, `rpm` |
| **Service management** | `service`, `systemd` | | **Service management** | `service`, `systemd` |
| **User and group** | `user`, `group` | | **User and group** | `user`, `group` |

View file

@ -92,6 +92,8 @@ func (e *Executor) executeModule(ctx context.Context, host string, client sshExe
return e.moduleFile(ctx, client, args) return e.moduleFile(ctx, client, args)
case "ansible.builtin.lineinfile": case "ansible.builtin.lineinfile":
return e.moduleLineinfile(ctx, client, args) return e.moduleLineinfile(ctx, client, args)
case "ansible.builtin.replace":
return e.moduleReplace(ctx, client, args)
case "ansible.builtin.stat": case "ansible.builtin.stat":
return e.moduleStat(ctx, client, args) return e.moduleStat(ctx, client, args)
case "ansible.builtin.slurp": case "ansible.builtin.slurp":
@ -901,6 +903,68 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien
return result, nil return result, nil
} }
func (e *Executor) moduleReplace(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
path := getStringArg(args, "path", "")
if path == "" {
path = getStringArg(args, "dest", "")
}
if path == "" {
return nil, coreerr.E("Executor.moduleReplace", "path required", nil)
}
pattern := getStringArg(args, "regexp", "")
if pattern == "" {
return nil, coreerr.E("Executor.moduleReplace", "regexp required", nil)
}
replacement := getStringArg(args, "replace", "")
backup := getBoolArg(args, "backup", false)
before, ok := remoteFileText(ctx, client, path)
if !ok {
return &TaskResult{Failed: true, Msg: sprintf("file not found: %s", path)}, nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, coreerr.E("Executor.moduleReplace", "compile regexp", err)
}
after := re.ReplaceAllString(before, replacement)
if after == before {
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil
}
result := &TaskResult{Changed: true}
if backup {
backupPath, hasBefore, err := backupRemoteFile(ctx, client, path)
if err != nil {
return nil, coreerr.E("Executor.moduleReplace", "backup remote file", err)
}
if hasBefore {
result.Data = map[string]any{"backup_file": backupPath}
}
}
if err := client.Upload(ctx, bytes.NewReader([]byte(after)), path, 0644); err != nil {
return nil, coreerr.E("Executor.moduleReplace", "upload replacement", err)
}
if e.Diff {
result.Data = ensureTaskResultData(result.Data)
result.Data["diff"] = fileDiffData(path, before, after)
}
return result, nil
}
func ensureTaskResultData(data map[string]any) map[string]any {
if data != nil {
return data
}
return make(map[string]any)
}
func fileContainsExactLine(content, line string) bool { func fileContainsExactLine(content, line string) bool {
if content == "" || line == "" { if content == "" || line == "" {
return false return false

View file

@ -789,6 +789,69 @@ func TestModulesFile_ModuleLineinfile_Good_DiffData(t *testing.T) {
assert.Contains(t, diff["after"], "setting=new") assert.Contains(t, diff["after"], "setting=new")
} }
// --- replace module ---
func TestModulesFile_ModuleReplace_Good_RegexpReplacementWithBackupAndDiff(t *testing.T) {
e := NewExecutor("/tmp")
e.Diff = true
client := newDiffFileClient(map[string]string{
"/etc/app.conf": "port=8080\nmode=prod\n",
})
result, err := e.moduleReplace(context.Background(), client, map[string]any{
"path": "/etc/app.conf",
"regexp": `port=(\d+)`,
"replace": "port=9090",
"backup": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
require.NotNil(t, result.Data)
assert.Contains(t, result.Data, "backup_file")
assert.Contains(t, result.Data, "diff")
after, err := client.Download(context.Background(), "/etc/app.conf")
require.NoError(t, err)
assert.Equal(t, "port=9090\nmode=prod\n", string(after))
backupPath, _ := result.Data["backup_file"].(string)
require.NotEmpty(t, backupPath)
backup, err := client.Download(context.Background(), backupPath)
require.NoError(t, err)
assert.Equal(t, "port=8080\nmode=prod\n", string(backup))
}
func TestModulesFile_ModuleReplace_Good_NoOpWhenPatternMissing(t *testing.T) {
e := NewExecutor("/tmp")
client := newDiffFileClient(map[string]string{
"/etc/app.conf": "port=8080\n",
})
result, err := e.moduleReplace(context.Background(), client, map[string]any{
"path": "/etc/app.conf",
"regexp": `mode=.+`,
"replace": "mode=prod",
})
require.NoError(t, err)
assert.False(t, result.Changed)
assert.Contains(t, result.Msg, "already up to date")
}
func TestModulesFile_ModuleReplace_Bad_MissingPath(t *testing.T) {
e := NewExecutor("/tmp")
client := newDiffFileClient(nil)
_, err := e.moduleReplace(context.Background(), client, map[string]any{
"regexp": `mode=.+`,
"replace": "mode=prod",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "path required")
}
// --- blockinfile module --- // --- blockinfile module ---
func TestModulesFile_ModuleBlockinfile_Good_InsertBlock(t *testing.T) { func TestModulesFile_ModuleBlockinfile_Good_InsertBlock(t *testing.T) {

View file

@ -396,6 +396,7 @@ var KnownModules = []string{
"ansible.builtin.template", "ansible.builtin.template",
"ansible.builtin.file", "ansible.builtin.file",
"ansible.builtin.lineinfile", "ansible.builtin.lineinfile",
"ansible.builtin.replace",
"ansible.builtin.blockinfile", "ansible.builtin.blockinfile",
"ansible.builtin.stat", "ansible.builtin.stat",
"ansible.builtin.slurp", "ansible.builtin.slurp",
@ -449,6 +450,7 @@ var KnownModules = []string{
"template", "template",
"file", "file",
"lineinfile", "lineinfile",
"replace",
"blockinfile", "blockinfile",
"stat", "stat",
"slurp", "slurp",