From 5609471945c258a8ff3c4d3c5de5b334f39ebd52 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 13:31:06 +0000 Subject: [PATCH] Add replace module support --- CLAUDE.md | 2 +- docs/index.md | 6 ++--- modules.go | 64 ++++++++++++++++++++++++++++++++++++++++++++ modules_file_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++ types.go | 2 ++ 5 files changed, 133 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 222bb62..5eae5a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). - **`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. -- **`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. - **`cmd/ansible/`** — CLI command registration via `core/cli`. Provides `ansible ` and `ansible test ` subcommands with flags for inventory, limit, tags, extra-vars, verbosity, and check mode. diff --git a/docs/index.md b/docs/index.md index 8e7a237..6c2ae99 100644 --- a/docs/index.md +++ b/docs/index.md @@ -110,7 +110,7 @@ go-ansible/ types.go Core data types: Playbook, Play, Task, Inventory, Host, Facts parser.go YAML parser for playbooks, inventories, tasks, roles 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 types_test.go Tests for data types and YAML unmarshalling parser_test.go Tests for the YAML parser @@ -126,12 +126,12 @@ go-ansible/ ## 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 | |----------|---------| | **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` | | **Service management** | `service`, `systemd` | | **User and group** | `user`, `group` | diff --git a/modules.go b/modules.go index 022fdea..a36ea9d 100644 --- a/modules.go +++ b/modules.go @@ -92,6 +92,8 @@ func (e *Executor) executeModule(ctx context.Context, host string, client sshExe return e.moduleFile(ctx, client, args) case "ansible.builtin.lineinfile": return e.moduleLineinfile(ctx, client, args) + case "ansible.builtin.replace": + return e.moduleReplace(ctx, client, args) case "ansible.builtin.stat": return e.moduleStat(ctx, client, args) case "ansible.builtin.slurp": @@ -901,6 +903,68 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClien 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 { if content == "" || line == "" { return false diff --git a/modules_file_test.go b/modules_file_test.go index 6ad5099..99b3d5b 100644 --- a/modules_file_test.go +++ b/modules_file_test.go @@ -789,6 +789,69 @@ func TestModulesFile_ModuleLineinfile_Good_DiffData(t *testing.T) { 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 --- func TestModulesFile_ModuleBlockinfile_Good_InsertBlock(t *testing.T) { diff --git a/types.go b/types.go index 6100d4b..2762c4b 100644 --- a/types.go +++ b/types.go @@ -396,6 +396,7 @@ var KnownModules = []string{ "ansible.builtin.template", "ansible.builtin.file", "ansible.builtin.lineinfile", + "ansible.builtin.replace", "ansible.builtin.blockinfile", "ansible.builtin.stat", "ansible.builtin.slurp", @@ -449,6 +450,7 @@ var KnownModules = []string{ "template", "file", "lineinfile", + "replace", "blockinfile", "stat", "slurp",