Add replace module support
This commit is contained in:
parent
cffc35a973
commit
5609471945
5 changed files with 133 additions and 4 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
64
modules.go
64
modules.go
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
2
types.go
2
types.go
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue