Compare commits
No commits in common. "dev" and "v0.1.3" have entirely different histories.
38 changed files with 1991 additions and 20437 deletions
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
|
@ -1,54 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event_name != 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dAppCore/build/actions/build/core@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
run-vet: "true"
|
||||
|
||||
auto-fix:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'changes_requested'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: dAppCore/build/actions/fix@dev
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
auto-merge:
|
||||
if: >
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'coderabbitai' &&
|
||||
github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Merge PR
|
||||
run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,4 +1,2 @@
|
|||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
.core/
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
`core/go-ansible` is a pure Go Ansible playbook engine. It parses YAML playbooks, inventories, and roles, then executes tasks on remote hosts via SSH. 42 module handler implementations (plus 3 community modules), Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks. This is a library — there is no standalone binary. The CLI integration lives in `cmd/ansible/` and is compiled as part of the `core` CLI binary.
|
||||
`core/go-ansible` is a pure Go Ansible playbook engine. It parses YAML playbooks, inventories, and roles, then executes tasks on remote hosts via SSH. 174 module implementations, Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks. This is a library — there is no standalone binary. The CLI integration lives in `cmd/ansible/` and is compiled as part of the `core` CLI binary.
|
||||
|
||||
## Build & Test
|
||||
|
||||
|
|
@ -29,10 +29,10 @@ Playbook YAML ──► Parser ──► []Play ──► Executor ──► Mod
|
|||
Inventory YAML ──► Parser ──► Inventory Callbacks (OnPlayStart, OnTaskEnd, ...)
|
||||
```
|
||||
|
||||
- **`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 (68 entries: both FQCN `ansible.builtin.*` and short forms).
|
||||
- **`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`** — 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`.
|
||||
- **`modules.go`** — 41 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 <playbook>` and `ansible test <host>` subcommands with flags for inventory, limit, tags, extra-vars, verbosity, and check mode.
|
||||
|
||||
|
|
@ -63,6 +63,6 @@ If adding new YAML keys to `Task`, update the `knownKeys` map in `Task.Unmarshal
|
|||
|
||||
- **UK English** in comments and documentation (colour, organisation, centre)
|
||||
- Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (edge cases/panics)
|
||||
- Use `coreerr.E(scope, message, err)` from `go-log` for all errors in production code (never `fmt.Errorf`)
|
||||
- Use `log.E(scope, message, err)` from `go-log` for errors in SSH/parser code; `fmt.Errorf` with `%w` in executor code
|
||||
- Tests use `testify/assert` (soft) and `testify/require` (hard)
|
||||
- Licence: EUPL-1.2
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
# Consumers of go-ansible
|
||||
|
||||
These modules import `dappco.re/go/core/ansible`:
|
||||
|
||||
- go-infra
|
||||
|
||||
**Breaking change risk: 1 consumers.**
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
# Convention Drift Report
|
||||
|
||||
Date: 2026-03-23
|
||||
Branch: `agent/convention-drift-check--stdlib-core----u`
|
||||
|
||||
`CODEX.md` is not present in this repository. Conventions were taken from `CLAUDE.md` and `docs/development.md`.
|
||||
|
||||
Commands used for the test-gap pass:
|
||||
|
||||
```bash
|
||||
go test -coverprofile=/tmp/ansible.cover ./...
|
||||
go tool cover -func=/tmp/ansible.cover
|
||||
```
|
||||
|
||||
## `stdlib→core.*`
|
||||
|
||||
No direct `stdlib`-to-`core.*` wrapper drift was found in the Go implementation. The remaining drift is stale migration residue around the `core.*` move:
|
||||
|
||||
- `go.mod:15`, `go.sum:7`, `go.sum:8`
|
||||
Legacy `forge.lthn.ai/core/go-log` references still remain in the dependency graph.
|
||||
- `CLAUDE.md:37`, `docs/development.md:169`
|
||||
Repository guidance still refers to `core/cli`, while the current command registration lives on the `dappco.re/go/core` API at `cmd/ansible/cmd.go:8`.
|
||||
- `CLAUDE.md:66`, `docs/development.md:86`
|
||||
Guidance still calls the logging package `go-log`, while production code imports `dappco.re/go/core/log` at `cmd/ansible/ansible.go:13`, `executor.go:15`, `modules.go:13`, `parser.go:12`, `ssh.go:16`.
|
||||
|
||||
## UK English
|
||||
|
||||
- `executor.go:248`
|
||||
Comment uses US spelling: `Initialize host results`.
|
||||
- `parser.go:321`
|
||||
Comment uses US spelling: `NormalizeModule normalizes a module name to its canonical form.`
|
||||
- `types.go:110`
|
||||
Comment uses US spelling: `LoopControl controls loop behavior.`
|
||||
|
||||
## Missing Tests
|
||||
|
||||
- `cmd/ansible/ansible.go:17`, `cmd/ansible/ansible.go:29`, `cmd/ansible/ansible.go:163`, `cmd/ansible/cmd.go:8`
|
||||
`go tool cover` reports `0.0%` coverage for the entire `cmd/ansible` package, so argument parsing, command registration, playbook execution wiring, and SSH test wiring have no tests.
|
||||
- `executor.go:81`, `executor.go:97`, `executor.go:172`, `executor.go:210`, `executor.go:241`, `executor.go:307`, `executor.go:382`, `executor.go:420`, `executor.go:444`, `executor.go:499`, `executor.go:565`
|
||||
The main execution path is still uncovered: top-level run flow, play execution, roles, host task scheduling, loops, block handling, includes, SSH client creation, and fact gathering are all `0.0%` in the coverage report.
|
||||
- `parser.go:119`
|
||||
`ParseRole` is `0.0%` covered.
|
||||
- `ssh.go:77`, `ssh.go:187`, `ssh.go:200`, `ssh.go:276`, `ssh.go:283`, `ssh.go:377`, `ssh.go:396`, `ssh.go:410`
|
||||
Only constructor/default behaviour is tested; the real SSH transport methods are all `0.0%` covered.
|
||||
- `modules.go:17`, `modules.go:178`, `modules.go:206`, `modules.go:234`, `modules.go:253`, `modules.go:281`, `modules.go:324`, `modules.go:352`, `modules.go:420`, `modules.go:463`, `modules.go:480`, `modules.go:502`, `modules.go:526`, `modules.go:550`, `modules.go:584`, `modules.go:615`, `modules.go:652`, `modules.go:665`, `modules.go:690`, `modules.go:732`, `modules.go:743`, `modules.go:800`, `modules.go:835`, `modules.go:941`, `modules.go:989`, `modules.go:1013`, `modules.go:1042`, `modules.go:1120`, `modules.go:1139`, `modules.go:1172`, `modules.go:1209`, `modules.go:1283`, `modules.go:1288`, `modules.go:1306`, `modules.go:1357`, `modules.go:1408`
|
||||
The real dispatcher and production module handlers are still `0.0%` covered.
|
||||
- `mock_ssh_test.go:347`, `mock_ssh_test.go:433`
|
||||
Existing module tests bypass `Executor.executeModule` and the production handlers by routing through `executeModuleWithMock` and duplicated shim implementations, so module assertions do not exercise the shipped code paths.
|
||||
- `CLAUDE.md:60`, `docs/index.md:141`, `docs/index.md:142`, `modules.go:941`, `modules.go:989`, `modules.go:1120`, `modules.go:1139`, `modules.go:1283`, `modules.go:1288`
|
||||
Documentation advertises support for `pause`, `wait_for`, `hostname`, `sysctl`, `setup`, and `reboot`, but there are no dedicated tests for those production handlers.
|
||||
|
||||
## Missing SPDX Headers
|
||||
|
||||
No tracked text file currently contains an SPDX header.
|
||||
|
||||
- Repo metadata: `.github/workflows/ci.yml:1`, `.gitignore:1`, `go.mod:1`, `go.sum:1`
|
||||
- Documentation: `CLAUDE.md:1`, `CONSUMERS.md:1`, `docs/architecture.md:1`, `docs/development.md:1`, `docs/index.md:1`, `kb/Executor.md:1`, `kb/Home.md:1`
|
||||
- Go source: `cmd/ansible/ansible.go:1`, `cmd/ansible/cmd.go:1`, `executor.go:1`, `modules.go:1`, `parser.go:1`, `ssh.go:1`, `types.go:1`
|
||||
- Go tests: `executor_extra_test.go:1`, `executor_test.go:1`, `mock_ssh_test.go:1`, `modules_adv_test.go:1`, `modules_cmd_test.go:1`, `modules_file_test.go:1`, `modules_infra_test.go:1`, `modules_svc_test.go:1`, `parser_test.go:1`, `ssh_test.go:1`, `types_test.go:1`
|
||||
|
|
@ -1,394 +1,163 @@
|
|||
package ansiblecmd
|
||||
package anscmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/ansible"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
ansible "forge.lthn.ai/core/go-ansible"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
type playbookCommandOptions struct {
|
||||
playbookPath string
|
||||
basePath string
|
||||
limit string
|
||||
tags []string
|
||||
skipTags []string
|
||||
extraVars map[string]any
|
||||
verbose int
|
||||
checkMode bool
|
||||
diff bool
|
||||
var (
|
||||
ansibleInventory string
|
||||
ansibleLimit string
|
||||
ansibleTags string
|
||||
ansibleSkipTags string
|
||||
ansibleVars []string
|
||||
ansibleVerbose int
|
||||
ansibleCheck bool
|
||||
)
|
||||
|
||||
var ansibleCmd = &cli.Command{
|
||||
Use: "ansible <playbook>",
|
||||
Short: "Run Ansible playbooks natively (no Python required)",
|
||||
Long: `Execute Ansible playbooks using a pure Go implementation.
|
||||
|
||||
This command parses Ansible YAML playbooks and executes them natively,
|
||||
without requiring Python or ansible-playbook to be installed.
|
||||
|
||||
Supported modules:
|
||||
- shell, command, raw, script
|
||||
- copy, template, file, lineinfile, stat, slurp, fetch, get_url
|
||||
- apt, apt_key, apt_repository, package, pip
|
||||
- service, systemd
|
||||
- user, group
|
||||
- uri, wait_for, git, unarchive
|
||||
- debug, fail, assert, set_fact, pause
|
||||
|
||||
Examples:
|
||||
core ansible playbooks/coolify/create.yml -i inventory/
|
||||
core ansible site.yml -l production
|
||||
core ansible deploy.yml -e "version=1.2.3" -e "env=prod"`,
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: runAnsible,
|
||||
}
|
||||
|
||||
func splitCommaSeparatedOption(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, item := range split(value, ",") {
|
||||
if trimmed := trimSpace(item); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
var ansibleTestCmd = &cli.Command{
|
||||
Use: "test <host>",
|
||||
Short: "Test SSH connectivity to a host",
|
||||
Long: `Test SSH connection and gather facts from a host.
|
||||
|
||||
Examples:
|
||||
core ansible test linux.snider.dev -u claude -p claude
|
||||
core ansible test server.example.com -i ~/.ssh/id_rsa`,
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: runAnsibleTest,
|
||||
}
|
||||
|
||||
// positionalArgs extracts all positional arguments from Options.
|
||||
func positionalArgs(opts core.Options) []string {
|
||||
var out []string
|
||||
for _, o := range opts.Items() {
|
||||
if o.Key == "_arg" {
|
||||
if s, ok := o.Value.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
var (
|
||||
testUser string
|
||||
testPassword string
|
||||
testKeyFile string
|
||||
testPort int
|
||||
)
|
||||
|
||||
func init() {
|
||||
// ansible command flags
|
||||
ansibleCmd.Flags().StringVarP(&ansibleInventory, "inventory", "i", "", "Inventory file or directory")
|
||||
ansibleCmd.Flags().StringVarP(&ansibleLimit, "limit", "l", "", "Limit to specific hosts")
|
||||
ansibleCmd.Flags().StringVarP(&ansibleTags, "tags", "t", "", "Only run plays and tasks tagged with these values")
|
||||
ansibleCmd.Flags().StringVar(&ansibleSkipTags, "skip-tags", "", "Skip plays and tasks tagged with these values")
|
||||
ansibleCmd.Flags().StringArrayVarP(&ansibleVars, "extra-vars", "e", nil, "Set additional variables (key=value)")
|
||||
ansibleCmd.Flags().CountVarP(&ansibleVerbose, "verbose", "v", "Increase verbosity")
|
||||
ansibleCmd.Flags().BoolVar(&ansibleCheck, "check", false, "Don't make any changes (dry run)")
|
||||
|
||||
// test command flags
|
||||
ansibleTestCmd.Flags().StringVarP(&testUser, "user", "u", "root", "SSH user")
|
||||
ansibleTestCmd.Flags().StringVarP(&testPassword, "password", "p", "", "SSH password")
|
||||
ansibleTestCmd.Flags().StringVarP(&testKeyFile, "key", "i", "", "SSH private key file")
|
||||
ansibleTestCmd.Flags().IntVar(&testPort, "port", 22, "SSH port")
|
||||
|
||||
// Add subcommands
|
||||
ansibleCmd.AddCommand(ansibleTestCmd)
|
||||
}
|
||||
|
||||
// firstStringOption returns the first non-empty string for any of the provided keys.
|
||||
func firstStringOption(opts core.Options, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := opts.String(key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func runAnsible(cmd *cli.Command, args []string) error {
|
||||
playbookPath := args[0]
|
||||
|
||||
// firstBoolOption returns true when any of the provided keys is set to true.
|
||||
func firstBoolOption(opts core.Options, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if opts.Bool(key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collectStringOptionValues returns every string value for any of the provided
|
||||
// keys, preserving the original option order.
|
||||
func collectStringOptionValues(opts core.Options, keys ...string) []string {
|
||||
var out []string
|
||||
|
||||
for _, o := range opts.Items() {
|
||||
matched := false
|
||||
for _, key := range keys {
|
||||
if o.Key == key {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := o.Value.(type) {
|
||||
case string:
|
||||
out = append(out, v)
|
||||
case []string:
|
||||
out = append(out, v...)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve playbook path
|
||||
if !filepath.IsAbs(playbookPath) {
|
||||
cwd, _ := os.Getwd()
|
||||
playbookPath = filepath.Join(cwd, playbookPath)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// joinedStringOption joins every non-empty string value for the provided keys.
|
||||
func joinedStringOption(opts core.Options, keys ...string) string {
|
||||
values := collectStringOptionValues(opts, keys...)
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("playbook not found: %s", playbookPath)
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, value := range values {
|
||||
if trimmed := trimSpace(value); trimmed != "" {
|
||||
filtered = append(filtered, trimmed)
|
||||
}
|
||||
}
|
||||
return strings.Join(filtered, ",")
|
||||
}
|
||||
|
||||
// verbosityLevel resolves the effective verbosity from parsed options and the
|
||||
// raw command line arguments. The core CLI parser does not preserve repeated
|
||||
// `-v` tokens, so we count them from os.Args as a fallback.
|
||||
func verbosityLevel(opts core.Options, rawArgs []string) int {
|
||||
level := opts.Int("verbose")
|
||||
if firstBoolOption(opts, "v") && level < 1 {
|
||||
level = 1
|
||||
}
|
||||
|
||||
for _, arg := range rawArgs {
|
||||
switch {
|
||||
case arg == "-v" || arg == "--verbose":
|
||||
level++
|
||||
case strings.HasPrefix(arg, "--verbose="):
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(arg, "--verbose=")); err == nil && n > level {
|
||||
level = n
|
||||
}
|
||||
case strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--"):
|
||||
short := strings.TrimPrefix(arg, "-")
|
||||
if short != "" && strings.Trim(short, "v") == "" {
|
||||
if n := len([]rune(short)); n > level {
|
||||
level = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return level
|
||||
}
|
||||
|
||||
// extraVars collects all repeated extra-vars values from Options.
|
||||
func extraVars(opts core.Options) (map[string]any, error) {
|
||||
vars := make(map[string]any)
|
||||
|
||||
for _, o := range opts.Items() {
|
||||
if o.Key != "extra-vars" && o.Key != "e" {
|
||||
continue
|
||||
}
|
||||
|
||||
var values []string
|
||||
switch v := o.Value.(type) {
|
||||
case string:
|
||||
values = append(values, v)
|
||||
case []string:
|
||||
values = append(values, v...)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
values = append(values, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
parsed, err := parseExtraVarsValue(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, parsedValue := range parsed {
|
||||
vars[key] = parsedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vars, nil
|
||||
}
|
||||
|
||||
func parseExtraVarsValue(value string) (map[string]any, error) {
|
||||
trimmed := trimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "@") {
|
||||
filePath := trimSpace(strings.TrimPrefix(trimmed, "@"))
|
||||
if filePath == "" {
|
||||
return nil, coreerr.E("parseExtraVarsValue", "extra vars file path required", nil)
|
||||
}
|
||||
|
||||
data, err := coreio.Local.Read(filePath)
|
||||
if err != nil {
|
||||
return nil, coreerr.E("parseExtraVarsValue", "read extra vars file", err)
|
||||
}
|
||||
|
||||
return parseExtraVarsValue(string(data))
|
||||
}
|
||||
|
||||
if structured, ok := parseStructuredExtraVars(trimmed); ok {
|
||||
return structured, nil
|
||||
}
|
||||
|
||||
if strings.Contains(trimmed, "=") {
|
||||
return parseKeyValueExtraVars(trimmed), nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parseStructuredExtraVars(value string) (map[string]any, bool) {
|
||||
var parsed map[string]any
|
||||
if json.Valid([]byte(value)) {
|
||||
if err := yaml.Unmarshal([]byte(value), &parsed); err == nil && len(parsed) > 0 {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(value), &parsed); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseKeyValueExtraVars(value string) map[string]any {
|
||||
vars := make(map[string]any)
|
||||
|
||||
for _, pair := range split(value, ",") {
|
||||
pair = trimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := splitN(pair, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := trimSpace(parts[0])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
vars[key] = parseExtraVarsScalar(trimSpace(parts[1]))
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
func parseExtraVarsScalar(value string) any {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parsed any
|
||||
if err := yaml.Unmarshal([]byte(value), &parsed); err == nil {
|
||||
switch parsed.(type) {
|
||||
case map[string]any, []any:
|
||||
return value
|
||||
default:
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// Example:
|
||||
//
|
||||
// core ansible test server.example.com -i ~/.ssh/id_ed25519
|
||||
func resolveSSHTestKeyFile(opts core.Options) string {
|
||||
if key := opts.String("key"); key != "" {
|
||||
return key
|
||||
}
|
||||
return opts.String("i")
|
||||
}
|
||||
|
||||
func buildPlaybookCommandSettings(opts core.Options, rawArgs []string) (playbookCommandOptions, error) {
|
||||
positional := positionalArgs(opts)
|
||||
if len(positional) < 1 {
|
||||
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", "usage: ansible <playbook>", nil)
|
||||
}
|
||||
playbookPath := positional[0]
|
||||
|
||||
if !pathIsAbs(playbookPath) {
|
||||
playbookPath = absPath(playbookPath)
|
||||
}
|
||||
|
||||
if !coreio.Local.Exists(playbookPath) {
|
||||
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", sprintf("playbook not found: %s", playbookPath), nil)
|
||||
}
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
if err != nil {
|
||||
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", "parse extra vars", err)
|
||||
}
|
||||
|
||||
return playbookCommandOptions{
|
||||
playbookPath: playbookPath,
|
||||
basePath: pathDir(playbookPath),
|
||||
limit: joinedStringOption(opts, "limit", "l"),
|
||||
tags: splitCommaSeparatedOption(joinedStringOption(opts, "tags", "t")),
|
||||
skipTags: splitCommaSeparatedOption(joinedStringOption(opts, "skip-tags")),
|
||||
extraVars: vars,
|
||||
verbose: verbosityLevel(opts, rawArgs),
|
||||
checkMode: opts.Bool("check"),
|
||||
diff: opts.Bool("diff"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func diffOutputLines(diff map[string]any) []string {
|
||||
lines := []string{"diff:"}
|
||||
|
||||
if path, ok := diff["path"].(string); ok && path != "" {
|
||||
lines = append(lines, sprintf("path: %s", path))
|
||||
}
|
||||
if before, ok := diff["before"].(string); ok && before != "" {
|
||||
lines = append(lines, sprintf("- %s", before))
|
||||
}
|
||||
if after, ok := diff["after"].(string); ok && after != "" {
|
||||
lines = append(lines, sprintf("+ %s", after))
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func runPlaybookCommand(opts core.Options) core.Result {
|
||||
settings, err := buildPlaybookCommandSettings(opts, os.Args[1:])
|
||||
if err != nil {
|
||||
return core.Result{Value: err}
|
||||
}
|
||||
|
||||
executor := ansible.NewExecutor(settings.basePath)
|
||||
// Create executor
|
||||
basePath := filepath.Dir(playbookPath)
|
||||
executor := ansible.NewExecutor(basePath)
|
||||
defer executor.Close()
|
||||
|
||||
executor.Limit = settings.limit
|
||||
executor.CheckMode = settings.checkMode
|
||||
executor.Diff = settings.diff
|
||||
executor.Verbose = settings.verbose
|
||||
executor.Tags = settings.tags
|
||||
executor.SkipTags = settings.skipTags
|
||||
// Set options
|
||||
executor.Limit = ansibleLimit
|
||||
executor.CheckMode = ansibleCheck
|
||||
executor.Verbose = ansibleVerbose
|
||||
|
||||
for key, value := range settings.extraVars {
|
||||
executor.SetVar(key, value)
|
||||
if ansibleTags != "" {
|
||||
executor.Tags = strings.Split(ansibleTags, ",")
|
||||
}
|
||||
if ansibleSkipTags != "" {
|
||||
executor.SkipTags = strings.Split(ansibleSkipTags, ",")
|
||||
}
|
||||
|
||||
// Parse extra vars
|
||||
for _, v := range ansibleVars {
|
||||
parts := strings.SplitN(v, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
executor.SetVar(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Load inventory
|
||||
if inventoryPath := firstStringOption(opts, "inventory", "i"); inventoryPath != "" {
|
||||
if !pathIsAbs(inventoryPath) {
|
||||
inventoryPath = absPath(inventoryPath)
|
||||
if ansibleInventory != "" {
|
||||
invPath := ansibleInventory
|
||||
if !filepath.IsAbs(invPath) {
|
||||
cwd, _ := os.Getwd()
|
||||
invPath = filepath.Join(cwd, invPath)
|
||||
}
|
||||
|
||||
if !coreio.Local.Exists(inventoryPath) {
|
||||
return core.Result{Value: coreerr.E("runPlaybookCommand", sprintf("inventory not found: %s", inventoryPath), nil)}
|
||||
// Check if it's a directory
|
||||
info, err := os.Stat(invPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inventory not found: %s", invPath)
|
||||
}
|
||||
|
||||
if coreio.Local.IsDir(inventoryPath) {
|
||||
if info.IsDir() {
|
||||
// Look for inventory.yml or hosts.yml
|
||||
for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} {
|
||||
candidatePath := joinPath(inventoryPath, name)
|
||||
if coreio.Local.Exists(candidatePath) {
|
||||
inventoryPath = candidatePath
|
||||
p := filepath.Join(invPath, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
invPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := executor.SetInventory(inventoryPath); err != nil {
|
||||
return core.Result{Value: coreerr.E("runPlaybookCommand", "load inventory", err)}
|
||||
if err := executor.SetInventory(invPath); err != nil {
|
||||
return fmt.Errorf("load inventory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up callbacks
|
||||
executor.OnPlayStart = func(play *ansible.Play) {
|
||||
print("")
|
||||
print("PLAY [%s]", play.Name)
|
||||
print("%s", repeat("*", 70))
|
||||
fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("PLAY"), cli.BoldStyle.Render("["+play.Name+"]"))
|
||||
fmt.Println(strings.Repeat("*", 70))
|
||||
}
|
||||
|
||||
executor.OnTaskStart = func(host string, task *ansible.Task) {
|
||||
|
|
@ -396,92 +165,85 @@ func runPlaybookCommand(opts core.Options) core.Result {
|
|||
if taskName == "" {
|
||||
taskName = task.Module
|
||||
}
|
||||
print("")
|
||||
print("TASK [%s]", taskName)
|
||||
if executor.Verbose > 0 {
|
||||
print("host: %s", host)
|
||||
fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("TASK"), cli.BoldStyle.Render("["+taskName+"]"))
|
||||
if ansibleVerbose > 0 {
|
||||
fmt.Printf("%s\n", cli.DimStyle.Render("host: "+host))
|
||||
}
|
||||
}
|
||||
|
||||
executor.OnTaskEnd = func(host string, task *ansible.Task, result *ansible.TaskResult) {
|
||||
status := "ok"
|
||||
style := cli.SuccessStyle
|
||||
|
||||
if result.Failed {
|
||||
status = "failed"
|
||||
style = cli.ErrorStyle
|
||||
} else if result.Skipped {
|
||||
status = "skipping"
|
||||
style = cli.DimStyle
|
||||
} else if result.Changed {
|
||||
status = "changed"
|
||||
style = cli.WarningStyle
|
||||
}
|
||||
|
||||
line := sprintf("%s: [%s]", status, host)
|
||||
if result.Msg != "" && executor.Verbose > 0 {
|
||||
line = sprintf("%s => %s", line, result.Msg)
|
||||
fmt.Printf("%s: [%s]", style.Render(status), host)
|
||||
if result.Msg != "" && ansibleVerbose > 0 {
|
||||
fmt.Printf(" => %s", result.Msg)
|
||||
}
|
||||
if result.Duration > 0 && executor.Verbose > 1 {
|
||||
line = sprintf("%s (%s)", line, result.Duration.Round(time.Millisecond))
|
||||
if result.Duration > 0 && ansibleVerbose > 1 {
|
||||
fmt.Printf(" (%s)", result.Duration.Round(time.Millisecond))
|
||||
}
|
||||
print("%s", line)
|
||||
fmt.Println()
|
||||
|
||||
if result.Failed && result.Stderr != "" {
|
||||
print("%s", result.Stderr)
|
||||
fmt.Printf("%s\n", cli.ErrorStyle.Render(result.Stderr))
|
||||
}
|
||||
|
||||
if executor.Verbose > 1 {
|
||||
if ansibleVerbose > 1 {
|
||||
if result.Stdout != "" {
|
||||
print("stdout: %s", trimSpace(result.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
if executor.Diff {
|
||||
if diff, ok := result.Data["diff"].(map[string]any); ok {
|
||||
for _, line := range diffOutputLines(diff) {
|
||||
print("%s", line)
|
||||
}
|
||||
fmt.Printf("stdout: %s\n", strings.TrimSpace(result.Stdout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executor.OnPlayEnd = func(play *ansible.Play) {
|
||||
print("")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Run playbook
|
||||
ctx := context.Background()
|
||||
start := time.Now()
|
||||
|
||||
print("Running playbook: %s", settings.playbookPath)
|
||||
fmt.Printf("%s Running playbook: %s\n", cli.BoldStyle.Render("▶"), playbookPath)
|
||||
|
||||
if err := executor.Run(ctx, settings.playbookPath); err != nil {
|
||||
return core.Result{Value: coreerr.E("runPlaybookCommand", "playbook failed", err)}
|
||||
if err := executor.Run(ctx, playbookPath); err != nil {
|
||||
return fmt.Errorf("playbook failed: %w", err)
|
||||
}
|
||||
|
||||
print("")
|
||||
print("Playbook completed in %s", time.Since(start).Round(time.Millisecond))
|
||||
fmt.Printf("\n%s Playbook completed in %s\n",
|
||||
cli.SuccessStyle.Render("✓"),
|
||||
time.Since(start).Round(time.Millisecond))
|
||||
|
||||
return core.Result{OK: true}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSSHTestCommand(opts core.Options) core.Result {
|
||||
positional := positionalArgs(opts)
|
||||
if len(positional) < 1 {
|
||||
return core.Result{Value: coreerr.E("runSSHTestCommand", "usage: ansible test <host>", nil)}
|
||||
}
|
||||
host := positional[0]
|
||||
func runAnsibleTest(cmd *cli.Command, args []string) error {
|
||||
host := args[0]
|
||||
|
||||
print("Testing SSH connection to %s...", host)
|
||||
fmt.Printf("Testing SSH connection to %s...\n", cli.BoldStyle.Render(host))
|
||||
|
||||
config := ansible.SSHConfig{
|
||||
cfg := ansible.SSHConfig{
|
||||
Host: host,
|
||||
Port: opts.Int("port"),
|
||||
User: firstStringOption(opts, "user", "u"),
|
||||
Password: opts.String("password"),
|
||||
KeyFile: resolveSSHTestKeyFile(opts),
|
||||
Port: testPort,
|
||||
User: testUser,
|
||||
Password: testPassword,
|
||||
KeyFile: testKeyFile,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ansible.NewSSHClient(config)
|
||||
client, err := ansible.NewSSHClient(cfg)
|
||||
if err != nil {
|
||||
return core.Result{Value: coreerr.E("runSSHTestCommand", "create client", err)}
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
|
|
@ -491,52 +253,58 @@ func runSSHTestCommand(opts core.Options) core.Result {
|
|||
// Test connection
|
||||
start := time.Now()
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return core.Result{Value: coreerr.E("runSSHTestCommand", "connect failed", err)}
|
||||
return fmt.Errorf("connect failed: %w", err)
|
||||
}
|
||||
connectTime := time.Since(start)
|
||||
|
||||
print("Connected in %s", connectTime.Round(time.Millisecond))
|
||||
fmt.Printf("%s Connected in %s\n", cli.SuccessStyle.Render("✓"), connectTime.Round(time.Millisecond))
|
||||
|
||||
// Gather facts
|
||||
print("")
|
||||
print("Gathering facts...")
|
||||
fmt.Println("\nGathering facts...")
|
||||
|
||||
// Hostname
|
||||
stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
|
||||
print(" Hostname: %s", trimSpace(stdout))
|
||||
fmt.Printf(" Hostname: %s\n", cli.BoldStyle.Render(strings.TrimSpace(stdout)))
|
||||
|
||||
// OS
|
||||
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
|
||||
if stdout != "" {
|
||||
print(" OS: %s", trimSpace(stdout))
|
||||
fmt.Printf(" OS: %s\n", strings.TrimSpace(stdout))
|
||||
}
|
||||
|
||||
// Kernel
|
||||
stdout, _, _, _ = client.Run(ctx, "uname -r")
|
||||
print(" Kernel: %s", trimSpace(stdout))
|
||||
fmt.Printf(" Kernel: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Architecture
|
||||
stdout, _, _, _ = client.Run(ctx, "uname -m")
|
||||
print(" Architecture: %s", trimSpace(stdout))
|
||||
fmt.Printf(" Architecture: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Memory
|
||||
stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'")
|
||||
print(" Memory: %s", trimSpace(stdout))
|
||||
fmt.Printf(" Memory: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Disk
|
||||
stdout, _, _, _ = client.Run(ctx, "df -h / | tail -1 | awk '{print $2 \" total, \" $4 \" available\"}'")
|
||||
print(" Disk: %s", trimSpace(stdout))
|
||||
fmt.Printf(" Disk: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Docker
|
||||
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null")
|
||||
if err == nil {
|
||||
print(" Docker: %s", trimSpace(stdout))
|
||||
fmt.Printf(" Docker: %s\n", cli.SuccessStyle.Render(strings.TrimSpace(stdout)))
|
||||
} else {
|
||||
print(" Docker: not installed")
|
||||
fmt.Printf(" Docker: %s\n", cli.DimStyle.Render("not installed"))
|
||||
}
|
||||
|
||||
// Check if Coolify is running
|
||||
stdout, _, _, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'")
|
||||
if trimSpace(stdout) == "running" {
|
||||
print(" Coolify: running")
|
||||
if strings.TrimSpace(stdout) == "running" {
|
||||
fmt.Printf(" Coolify: %s\n", cli.SuccessStyle.Render("running"))
|
||||
} else {
|
||||
print(" Coolify: not installed")
|
||||
fmt.Printf(" Coolify: %s\n", cli.DimStyle.Render("not installed"))
|
||||
}
|
||||
|
||||
print("")
|
||||
print("SSH test passed")
|
||||
fmt.Printf("\n%s SSH test passed\n", cli.SuccessStyle.Render("✓"))
|
||||
|
||||
return core.Result{OK: true}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,371 +0,0 @@
|
|||
package ansiblecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtraVars_Good_RepeatableAndCommaSeparated(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "extra-vars", Value: "version=1.2.3,env=prod"},
|
||||
core.Option{Key: "extra-vars", Value: "region=us-east-1"},
|
||||
core.Option{Key: "extra-vars", Value: []string{"build=42"}},
|
||||
)
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"version": "1.2.3",
|
||||
"env": "prod",
|
||||
"region": "us-east-1",
|
||||
"build": 42,
|
||||
}, vars)
|
||||
}
|
||||
|
||||
func TestExtraVars_Good_UsesShortAlias(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "e", Value: "version=1.2.3,env=prod"},
|
||||
)
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"version": "1.2.3",
|
||||
"env": "prod",
|
||||
}, vars)
|
||||
}
|
||||
|
||||
func TestExtraVars_Good_TrimsWhitespaceAroundPairs(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "extra-vars", Value: " version = 1.2.3 , env = prod , empty = "},
|
||||
)
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"version": "1.2.3",
|
||||
"env": "prod",
|
||||
"empty": "",
|
||||
}, vars)
|
||||
}
|
||||
|
||||
func TestExtraVars_Good_IgnoresMalformedPairs(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "extra-vars", Value: "missing_equals,keep=this"},
|
||||
core.Option{Key: "extra-vars", Value: "also_bad="},
|
||||
)
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"keep": "this",
|
||||
"also_bad": "",
|
||||
}, vars)
|
||||
}
|
||||
|
||||
func TestExtraVars_Good_ParsesYAMLScalarsInKeyValuePairs(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "extra-vars", Value: "enabled=true,count=42,threshold=3.5"},
|
||||
)
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"enabled": true,
|
||||
"count": 42,
|
||||
"threshold": 3.5,
|
||||
}, vars)
|
||||
}
|
||||
|
||||
func TestSplitCommaSeparatedOption_Good_TrimsWhitespace(t *testing.T) {
|
||||
assert.Equal(t, []string{"deploy", "setup", "smoke"}, splitCommaSeparatedOption(" deploy, setup ,smoke "))
|
||||
}
|
||||
|
||||
func TestExtraVars_Good_SupportsStructuredYAMLAndJSON(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "extra-vars", Value: "app:\n port: 8080\n debug: true"},
|
||||
core.Option{Key: "extra-vars", Value: `{"image":"nginx:latest","replicas":3}`},
|
||||
)
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"app": map[string]any{
|
||||
"port": int(8080),
|
||||
"debug": true,
|
||||
},
|
||||
"image": "nginx:latest",
|
||||
"replicas": int(3),
|
||||
}, vars)
|
||||
}
|
||||
|
||||
func TestExtraVars_Good_LoadsFileReferences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "vars.yml")
|
||||
require.NoError(t, os.WriteFile(path, []byte("deploy_env: prod\nrelease: 42\n"), 0644))
|
||||
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "extra-vars", Value: "@" + path},
|
||||
)
|
||||
|
||||
vars, err := extraVars(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"deploy_env": "prod",
|
||||
"release": int(42),
|
||||
}, vars)
|
||||
}
|
||||
|
||||
func TestExtraVars_Bad_MissingFile(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "extra-vars", Value: "@/definitely/missing/vars.yml"},
|
||||
)
|
||||
|
||||
_, err := extraVars(opts)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read extra vars file")
|
||||
}
|
||||
|
||||
func TestFirstString_Good_PrefersFirstNonEmptyKey(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "inventory", Value: ""},
|
||||
core.Option{Key: "i", Value: "/tmp/inventory.yml"},
|
||||
)
|
||||
|
||||
assert.Equal(t, "/tmp/inventory.yml", firstStringOption(opts, "inventory", "i"))
|
||||
}
|
||||
|
||||
func TestFirstBool_Good_UsesAlias(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "v", Value: true},
|
||||
)
|
||||
|
||||
assert.True(t, firstBoolOption(opts, "verbose", "v"))
|
||||
}
|
||||
|
||||
func TestVerbosityLevel_Good_CountsStackedShortFlags(t *testing.T) {
|
||||
opts := core.NewOptions()
|
||||
|
||||
assert.Equal(t, 3, verbosityLevel(opts, []string{"-vvv"}))
|
||||
}
|
||||
|
||||
func TestVerbosityLevel_Good_CountsLongForm(t *testing.T) {
|
||||
opts := core.NewOptions()
|
||||
|
||||
assert.Equal(t, 1, verbosityLevel(opts, []string{"--verbose"}))
|
||||
}
|
||||
|
||||
func TestVerbosityLevel_Good_PreservesExplicitNumericLevel(t *testing.T) {
|
||||
opts := core.NewOptions(core.Option{Key: "verbose", Value: 2})
|
||||
|
||||
assert.Equal(t, 2, verbosityLevel(opts, nil))
|
||||
}
|
||||
|
||||
func TestBuildPlaybookCommandSettings_Good_AppliesFlags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
playbookPath := filepath.Join(dir, "site.yml")
|
||||
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
|
||||
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "_arg", Value: playbookPath},
|
||||
core.Option{Key: "limit", Value: "web1"},
|
||||
core.Option{Key: "tags", Value: "deploy,setup"},
|
||||
core.Option{Key: "skip-tags", Value: "slow"},
|
||||
core.Option{Key: "extra-vars", Value: "version=1.2.3"},
|
||||
core.Option{Key: "check", Value: true},
|
||||
core.Option{Key: "diff", Value: true},
|
||||
)
|
||||
|
||||
settings, err := buildPlaybookCommandSettings(opts, []string{"-vvv"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, playbookPath, settings.playbookPath)
|
||||
assert.Equal(t, dir, settings.basePath)
|
||||
assert.Equal(t, "web1", settings.limit)
|
||||
assert.Equal(t, []string{"deploy", "setup"}, settings.tags)
|
||||
assert.Equal(t, []string{"slow"}, settings.skipTags)
|
||||
assert.Equal(t, 3, settings.verbose)
|
||||
assert.True(t, settings.checkMode)
|
||||
assert.True(t, settings.diff)
|
||||
assert.Equal(t, map[string]any{"version": "1.2.3"}, settings.extraVars)
|
||||
}
|
||||
|
||||
func TestBuildPlaybookCommandSettings_Good_MergesRepeatedListFlags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
playbookPath := filepath.Join(dir, "site.yml")
|
||||
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
|
||||
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "_arg", Value: playbookPath},
|
||||
core.Option{Key: "limit", Value: "web1"},
|
||||
core.Option{Key: "limit", Value: []string{"web2"}},
|
||||
core.Option{Key: "tags", Value: "deploy,setup"},
|
||||
core.Option{Key: "tags", Value: []string{"smoke"}},
|
||||
core.Option{Key: "skip-tags", Value: "slow"},
|
||||
core.Option{Key: "skip-tags", Value: []string{"flaky,experimental"}},
|
||||
)
|
||||
|
||||
settings, err := buildPlaybookCommandSettings(opts, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "web1,web2", settings.limit)
|
||||
assert.Equal(t, []string{"deploy", "setup", "smoke"}, settings.tags)
|
||||
assert.Equal(t, []string{"slow", "flaky", "experimental"}, settings.skipTags)
|
||||
}
|
||||
|
||||
func TestBuildPlaybookCommandSettings_Bad_MissingPlaybook(t *testing.T) {
|
||||
_, err := buildPlaybookCommandSettings(core.NewOptions(), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
|
||||
}
|
||||
|
||||
func TestDiffOutputLines_Good_IncludesPathAndBeforeAfter(t *testing.T) {
|
||||
lines := diffOutputLines(map[string]any{
|
||||
"path": "/etc/nginx/conf.d/app.conf",
|
||||
"before": "server_name=old.example.com;",
|
||||
"after": "server_name=web01.example.com;",
|
||||
})
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"diff:",
|
||||
"path: /etc/nginx/conf.d/app.conf",
|
||||
"- server_name=old.example.com;",
|
||||
"+ server_name=web01.example.com;",
|
||||
}, lines)
|
||||
}
|
||||
|
||||
func TestTestKeyFile_Good_PrefersExplicitKey(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "key", Value: "/tmp/id_ed25519"},
|
||||
core.Option{Key: "i", Value: "/tmp/ignored"},
|
||||
)
|
||||
|
||||
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
|
||||
}
|
||||
|
||||
func TestTestKeyFile_Good_FallsBackToShortAlias(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "i", Value: "/tmp/id_ed25519"},
|
||||
)
|
||||
|
||||
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
|
||||
}
|
||||
|
||||
func TestFirstString_Good_ResolvesShortUserAlias(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "u", Value: "deploy"},
|
||||
)
|
||||
|
||||
cfgUser := firstStringOption(opts, "user", "u")
|
||||
|
||||
assert.Equal(t, "deploy", cfgUser)
|
||||
}
|
||||
|
||||
func TestRegister_Good_RegistersAnsibleCommands(t *testing.T) {
|
||||
app := core.New()
|
||||
|
||||
Register(app)
|
||||
|
||||
ansible := app.Command("ansible")
|
||||
require.True(t, ansible.OK)
|
||||
ansibleCmd := ansible.Value.(*core.Command)
|
||||
|
||||
assert.Equal(t, "ansible", ansibleCmd.Path)
|
||||
assert.Equal(t, "ansible", ansibleCmd.Name)
|
||||
assert.Equal(t, "Run Ansible playbooks natively (no Python required)", ansibleCmd.Description)
|
||||
require.NotNil(t, ansibleCmd.Action)
|
||||
|
||||
test := app.Command("ansible/test")
|
||||
require.True(t, test.OK)
|
||||
testCmd := test.Value.(*core.Command)
|
||||
|
||||
assert.Equal(t, "ansible/test", testCmd.Path)
|
||||
assert.Equal(t, "test", testCmd.Name)
|
||||
assert.Equal(t, "Test SSH connectivity to a host", testCmd.Description)
|
||||
require.NotNil(t, testCmd.Action)
|
||||
|
||||
paths := app.Commands()
|
||||
assert.Contains(t, paths, "ansible")
|
||||
assert.Contains(t, paths, "ansible/test")
|
||||
}
|
||||
|
||||
func TestRegister_Good_ExposesExpectedFlags(t *testing.T) {
|
||||
app := core.New()
|
||||
|
||||
Register(app)
|
||||
|
||||
ansibleCmd := app.Command("ansible").Value.(*core.Command)
|
||||
assert.True(t, ansibleCmd.Flags.Has("inventory"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("i"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("limit"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("l"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("tags"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("t"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("skip-tags"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("extra-vars"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("e"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("verbose"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("v"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("check"))
|
||||
assert.True(t, ansibleCmd.Flags.Has("diff"))
|
||||
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("inventory"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("i"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("limit"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("l"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("tags"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("t"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("skip-tags"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("extra-vars"))
|
||||
assert.Equal(t, "", ansibleCmd.Flags.String("e"))
|
||||
assert.Equal(t, 0, ansibleCmd.Flags.Int("verbose"))
|
||||
assert.False(t, ansibleCmd.Flags.Bool("v"))
|
||||
assert.False(t, ansibleCmd.Flags.Bool("check"))
|
||||
assert.False(t, ansibleCmd.Flags.Bool("diff"))
|
||||
|
||||
testCmd := app.Command("ansible/test").Value.(*core.Command)
|
||||
assert.True(t, testCmd.Flags.Has("user"))
|
||||
assert.True(t, testCmd.Flags.Has("u"))
|
||||
assert.True(t, testCmd.Flags.Has("password"))
|
||||
assert.True(t, testCmd.Flags.Has("key"))
|
||||
assert.True(t, testCmd.Flags.Has("i"))
|
||||
assert.True(t, testCmd.Flags.Has("port"))
|
||||
|
||||
assert.Equal(t, "root", testCmd.Flags.String("user"))
|
||||
assert.Equal(t, "root", testCmd.Flags.String("u"))
|
||||
assert.Equal(t, "", testCmd.Flags.String("password"))
|
||||
assert.Equal(t, "", testCmd.Flags.String("key"))
|
||||
assert.Equal(t, "", testCmd.Flags.String("i"))
|
||||
assert.Equal(t, 22, testCmd.Flags.Int("port"))
|
||||
}
|
||||
|
||||
func TestRunAnsible_Bad_MissingPlaybook(t *testing.T) {
|
||||
result := runPlaybookCommand(core.NewOptions())
|
||||
|
||||
require.False(t, result.OK)
|
||||
err, ok := result.Value.(error)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
|
||||
}
|
||||
|
||||
func TestRunAnsibleTest_Bad_MissingHost(t *testing.T) {
|
||||
result := runSSHTestCommand(core.NewOptions())
|
||||
|
||||
require.False(t, result.OK)
|
||||
err, ok := result.Value.(error)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, err.Error(), "usage: ansible test <host>")
|
||||
}
|
||||
|
|
@ -1,46 +1,14 @@
|
|||
package ansiblecmd
|
||||
package anscmd
|
||||
|
||||
import (
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// Register registers the `ansible` command and its `ansible/test` subcommand.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var app core.Core
|
||||
// Register(&app)
|
||||
func Register(c *core.Core) {
|
||||
c.Command("ansible", core.Command{
|
||||
Description: "Run Ansible playbooks natively (no Python required)",
|
||||
Action: runPlaybookCommand,
|
||||
Flags: core.NewOptions(
|
||||
core.Option{Key: "inventory", Value: ""},
|
||||
core.Option{Key: "i", Value: ""},
|
||||
core.Option{Key: "limit", Value: ""},
|
||||
core.Option{Key: "l", Value: ""},
|
||||
core.Option{Key: "tags", Value: ""},
|
||||
core.Option{Key: "t", Value: ""},
|
||||
core.Option{Key: "skip-tags", Value: ""},
|
||||
core.Option{Key: "extra-vars", Value: ""},
|
||||
core.Option{Key: "e", Value: ""},
|
||||
core.Option{Key: "verbose", Value: 0},
|
||||
core.Option{Key: "v", Value: false},
|
||||
core.Option{Key: "check", Value: false},
|
||||
core.Option{Key: "diff", Value: false},
|
||||
),
|
||||
})
|
||||
|
||||
c.Command("ansible/test", core.Command{
|
||||
Description: "Test SSH connectivity to a host",
|
||||
Action: runSSHTestCommand,
|
||||
Flags: core.NewOptions(
|
||||
core.Option{Key: "user", Value: "root"},
|
||||
core.Option{Key: "u", Value: "root"},
|
||||
core.Option{Key: "password", Value: ""},
|
||||
core.Option{Key: "key", Value: ""},
|
||||
core.Option{Key: "i", Value: ""},
|
||||
core.Option{Key: "port", Value: 22},
|
||||
),
|
||||
})
|
||||
func init() {
|
||||
cli.RegisterCommands(AddAnsibleCommands)
|
||||
}
|
||||
|
||||
// AddAnsibleCommands registers the 'ansible' command and all subcommands.
|
||||
func AddAnsibleCommands(root *cli.Command) {
|
||||
root.AddCommand(ansibleCmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
package ansiblecmd
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
func absPath(path string) string {
|
||||
if path == "" {
|
||||
return core.Env("DIR_CWD")
|
||||
}
|
||||
if core.PathIsAbs(path) {
|
||||
return cleanPath(path)
|
||||
}
|
||||
|
||||
cwd := core.Env("DIR_CWD")
|
||||
if cwd == "" {
|
||||
cwd = "."
|
||||
}
|
||||
return joinPath(cwd, path)
|
||||
}
|
||||
|
||||
func joinPath(parts ...string) string {
|
||||
ds := dirSep()
|
||||
path := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
path = part
|
||||
continue
|
||||
}
|
||||
|
||||
path = core.TrimSuffix(path, ds)
|
||||
part = core.TrimPrefix(part, ds)
|
||||
path = core.Concat(path, ds, part)
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return "."
|
||||
}
|
||||
return core.CleanPath(path, ds)
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
if path == "" {
|
||||
return "."
|
||||
}
|
||||
return core.CleanPath(path, dirSep())
|
||||
}
|
||||
|
||||
func pathDir(path string) string {
|
||||
return core.PathDir(path)
|
||||
}
|
||||
|
||||
func pathIsAbs(path string) bool {
|
||||
return core.PathIsAbs(path)
|
||||
}
|
||||
|
||||
func sprintf(format string, args ...any) string {
|
||||
return core.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
func split(s, sep string) []string {
|
||||
return core.Split(s, sep)
|
||||
}
|
||||
|
||||
func splitN(s, sep string, n int) []string {
|
||||
return core.SplitN(s, sep, n)
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
return core.Trim(s)
|
||||
}
|
||||
|
||||
func repeat(s string, count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
buf := core.NewBuilder()
|
||||
for i := 0; i < count; i++ {
|
||||
buf.WriteString(s)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func print(format string, args ...any) {
|
||||
core.Print(nil, format, args...)
|
||||
}
|
||||
|
||||
func println(args ...any) {
|
||||
core.Println(args...)
|
||||
}
|
||||
|
||||
func dirSep() string {
|
||||
ds := core.Env("DS")
|
||||
if ds == "" {
|
||||
return "/"
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
func containsRune(cutset string, target rune) bool {
|
||||
for _, candidate := range cutset {
|
||||
if candidate == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trimCutset(s, cutset string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
|
||||
for start < end {
|
||||
r, size := utf8.DecodeRuneInString(s[start:end])
|
||||
if !containsRune(cutset, r) {
|
||||
break
|
||||
}
|
||||
start += size
|
||||
}
|
||||
|
||||
for start < end {
|
||||
r, size := utf8.DecodeLastRuneInString(s[start:end])
|
||||
if !containsRune(cutset, r) {
|
||||
break
|
||||
}
|
||||
end -= size
|
||||
}
|
||||
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func fields(s string) []string {
|
||||
var out []string
|
||||
start := -1
|
||||
|
||||
for i, r := range s {
|
||||
if unicode.IsSpace(r) {
|
||||
if start >= 0 {
|
||||
out = append(out, s[start:i])
|
||||
start = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if start < 0 {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
|
||||
if start >= 0 {
|
||||
out = append(out, s[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
type stringBuffer interface {
|
||||
Write([]byte) (int, error)
|
||||
WriteString(string) (int, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
func dirSep() string {
|
||||
ds := core.Env("DS")
|
||||
if ds == "" {
|
||||
return "/"
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
func corexAbsPath(path string) string {
|
||||
if path == "" {
|
||||
return core.Env("DIR_CWD")
|
||||
}
|
||||
if core.PathIsAbs(path) {
|
||||
return corexCleanPath(path)
|
||||
}
|
||||
|
||||
cwd := core.Env("DIR_CWD")
|
||||
if cwd == "" {
|
||||
cwd = "."
|
||||
}
|
||||
return corexJoinPath(cwd, path)
|
||||
}
|
||||
|
||||
func corexJoinPath(parts ...string) string {
|
||||
ds := dirSep()
|
||||
path := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
path = part
|
||||
continue
|
||||
}
|
||||
|
||||
path = core.TrimSuffix(path, ds)
|
||||
part = core.TrimPrefix(part, ds)
|
||||
path = core.Concat(path, ds, part)
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return "."
|
||||
}
|
||||
return core.CleanPath(path, ds)
|
||||
}
|
||||
|
||||
func corexCleanPath(path string) string {
|
||||
if path == "" {
|
||||
return "."
|
||||
}
|
||||
return core.CleanPath(path, dirSep())
|
||||
}
|
||||
|
||||
func corexPathDir(path string) string {
|
||||
return core.PathDir(path)
|
||||
}
|
||||
|
||||
func corexPathBase(path string) string {
|
||||
return core.PathBase(path)
|
||||
}
|
||||
|
||||
func corexPathIsAbs(path string) bool {
|
||||
return core.PathIsAbs(path)
|
||||
}
|
||||
|
||||
func corexEnv(key string) string {
|
||||
return core.Env(key)
|
||||
}
|
||||
|
||||
func corexSprintf(format string, args ...any) string {
|
||||
return core.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
func corexSprint(args ...any) string {
|
||||
return core.Sprint(args...)
|
||||
}
|
||||
|
||||
func corexContains(s, substr string) bool {
|
||||
return core.Contains(s, substr)
|
||||
}
|
||||
|
||||
func corexHasPrefix(s, prefix string) bool {
|
||||
return core.HasPrefix(s, prefix)
|
||||
}
|
||||
|
||||
func corexHasSuffix(s, suffix string) bool {
|
||||
return core.HasSuffix(s, suffix)
|
||||
}
|
||||
|
||||
func corexSplit(s, sep string) []string {
|
||||
return core.Split(s, sep)
|
||||
}
|
||||
|
||||
func corexSplitN(s, sep string, n int) []string {
|
||||
return core.SplitN(s, sep, n)
|
||||
}
|
||||
|
||||
func corexJoin(sep string, parts []string) string {
|
||||
return core.Join(sep, parts...)
|
||||
}
|
||||
|
||||
func corexLower(s string) string {
|
||||
return core.Lower(s)
|
||||
}
|
||||
|
||||
func corexReplaceAll(s, old, new string) string {
|
||||
return core.Replace(s, old, new)
|
||||
}
|
||||
|
||||
func corexReplaceN(s, old, new string, n int) string {
|
||||
if n == 0 || old == "" {
|
||||
return s
|
||||
}
|
||||
if n < 0 {
|
||||
return corexReplaceAll(s, old, new)
|
||||
}
|
||||
|
||||
result := s
|
||||
for i := 0; i < n; i++ {
|
||||
index := corexStringIndex(result, old)
|
||||
if index < 0 {
|
||||
break
|
||||
}
|
||||
result = core.Concat(result[:index], new, result[index+len(old):])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func corexTrimSpace(s string) string {
|
||||
return core.Trim(s)
|
||||
}
|
||||
|
||||
func corexTrimPrefix(s, prefix string) string {
|
||||
return core.TrimPrefix(s, prefix)
|
||||
}
|
||||
|
||||
func corexTrimCutset(s, cutset string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
|
||||
for start < end {
|
||||
r, size := utf8.DecodeRuneInString(s[start:end])
|
||||
if !corexContainsRune(cutset, r) {
|
||||
break
|
||||
}
|
||||
start += size
|
||||
}
|
||||
|
||||
for start < end {
|
||||
r, size := utf8.DecodeLastRuneInString(s[start:end])
|
||||
if !corexContainsRune(cutset, r) {
|
||||
break
|
||||
}
|
||||
end -= size
|
||||
}
|
||||
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func corexRepeat(s string, count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
buf := core.NewBuilder()
|
||||
for i := 0; i < count; i++ {
|
||||
buf.WriteString(s)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func corexFields(s string) []string {
|
||||
var out []string
|
||||
start := -1
|
||||
|
||||
for i, r := range s {
|
||||
if unicode.IsSpace(r) {
|
||||
if start >= 0 {
|
||||
out = append(out, s[start:i])
|
||||
start = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if start < 0 {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
|
||||
if start >= 0 {
|
||||
out = append(out, s[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func corexNewBuilder() stringBuffer {
|
||||
return core.NewBuilder()
|
||||
}
|
||||
|
||||
func corexNewReader(s string) interface {
|
||||
Read([]byte) (int, error)
|
||||
} {
|
||||
return core.NewReader(s)
|
||||
}
|
||||
|
||||
func corexReadAllString(reader any) (string, error) {
|
||||
result := core.ReadAll(reader)
|
||||
if !result.OK {
|
||||
if err, ok := result.Value.(error); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", core.NewError("read content")
|
||||
}
|
||||
|
||||
if data, ok := result.Value.(string); ok {
|
||||
return data, nil
|
||||
}
|
||||
return corexSprint(result.Value), nil
|
||||
}
|
||||
|
||||
func corexWriteString(writer interface {
|
||||
Write([]byte) (int, error)
|
||||
}, value string) {
|
||||
_, _ = writer.Write([]byte(value))
|
||||
}
|
||||
|
||||
func corexContainsRune(cutset string, target rune) bool {
|
||||
for _, candidate := range cutset {
|
||||
if candidate == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func corexStringIndex(s, needle string) int {
|
||||
if needle == "" {
|
||||
return 0
|
||||
}
|
||||
if len(needle) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i+len(needle) <= len(s); i++ {
|
||||
if s[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func absPath(path string) string { return corexAbsPath(path) }
|
||||
func joinPath(parts ...string) string { return corexJoinPath(parts...) }
|
||||
func cleanPath(path string) string { return corexCleanPath(path) }
|
||||
func pathDir(path string) string { return corexPathDir(path) }
|
||||
func pathBase(path string) string { return corexPathBase(path) }
|
||||
func pathIsAbs(path string) bool { return corexPathIsAbs(path) }
|
||||
func env(key string) string { return corexEnv(key) }
|
||||
func sprintf(format string, args ...any) string { return corexSprintf(format, args...) }
|
||||
func sprint(args ...any) string { return corexSprint(args...) }
|
||||
func contains(s, substr string) bool { return corexContains(s, substr) }
|
||||
func hasSuffix(s, suffix string) bool { return corexHasSuffix(s, suffix) }
|
||||
func split(s, sep string) []string { return corexSplit(s, sep) }
|
||||
func splitN(s, sep string, n int) []string { return corexSplitN(s, sep, n) }
|
||||
func join(sep string, parts []string) string { return corexJoin(sep, parts) }
|
||||
func lower(s string) string { return corexLower(s) }
|
||||
func replaceAll(s, old, new string) string { return corexReplaceAll(s, old, new) }
|
||||
func replaceN(s, old, new string, n int) string { return corexReplaceN(s, old, new, n) }
|
||||
func trimSpace(s string) string { return corexTrimSpace(s) }
|
||||
func trimCutset(s, cutset string) string { return corexTrimCutset(s, cutset) }
|
||||
func repeat(s string, count int) string { return corexRepeat(s, count) }
|
||||
func fields(s string) []string { return corexFields(s) }
|
||||
func newBuilder() stringBuffer { return corexNewBuilder() }
|
||||
func newReader(s string) interface{ Read([]byte) (int, error) } { return corexNewReader(s) }
|
||||
func readAllString(reader any) (string, error) { return corexReadAllString(reader) }
|
||||
func writeString(writer interface{ Write([]byte) (int, error) }, value string) {
|
||||
corexWriteString(writer, value)
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
# API Contract
|
||||
|
||||
`CODEX.md` is not present in this repository. This extraction follows the repo conventions documented in `CLAUDE.md`.
|
||||
|
||||
Function and method coverage percentages below come from `go test -coverprofile=/tmp/ansible.cover ./...` run on 2026-03-23. Type rows list the tests that exercise or validate each type because Go coverage does not report percentages for type declarations. Exported variables are intentionally excluded because the task requested exported types, functions, and methods only.
|
||||
|
||||
## Package `ansible`: Types
|
||||
|
||||
| Name | Signature | Description | Test coverage |
|
||||
| --- | --- | --- | --- |
|
||||
| `Playbook` | `type Playbook struct` | Top-level playbook container for an inline list of plays. | No direct tests; parser tests work with `[]Play` rather than `Playbook`. |
|
||||
| `Play` | `type Play struct` | Declares play metadata, host targeting, vars, lifecycle task lists, roles, handlers, tags, and execution settings. | Exercised by `parser_test.go` playbook cases and `executor_extra_test.go: TestParsePlaybookIter_Good`. |
|
||||
| `RoleRef` | `type RoleRef struct` | Represents a role reference, including string and map forms plus role-scoped vars, tags, and `when`. | Directly validated by `types_test.go: TestRoleRef_UnmarshalYAML_*`; also parsed in `parser_test.go: TestParsePlaybook_Good_RoleRefs`. |
|
||||
| `Task` | `type Task struct` | Represents a task, including module selection, args, conditions, loops, block/rescue/always sections, includes, notify, and privilege controls. | Directly validated by `types_test.go: TestTask_UnmarshalYAML_*`; also exercised throughout `parser_test.go` and module dispatch tests. |
|
||||
| `LoopControl` | `type LoopControl struct` | Configures loop variable names, labels, pauses, and extended loop metadata. | No dedicated tests; only reachable through `Task.loop_control` parsing. |
|
||||
| `TaskResult` | `type TaskResult struct` | Standard task execution result with change/failure state, output streams, loop subresults, module data, and duration. | Directly validated by `types_test.go: TestTaskResult_*`; also used across executor and module tests. |
|
||||
| `Inventory` | `type Inventory struct` | Root Ansible inventory object keyed under `all`. | Directly validated by `types_test.go: TestInventory_UnmarshalYAML_Good_Complex`; also exercised by parser and host-resolution tests. |
|
||||
| `InventoryGroup` | `type InventoryGroup struct` | Inventory group containing hosts, child groups, and inherited vars. | Exercised by `parser_test.go` inventory and host-matching cases plus `executor_extra_test.go: TestAllHostsIter_Good`. |
|
||||
| `Host` | `type Host struct` | Host-level connection settings plus inline custom vars. | Exercised by inventory parsing and host-var inheritance tests in `types_test.go` and `parser_test.go`. |
|
||||
| `Facts` | `type Facts struct` | Captures gathered host facts such as identity, OS, kernel, memory, CPUs, and primary IPv4. | Directly validated by `types_test.go: TestFacts_Struct`; also exercised by fact resolution tests in `modules_infra_test.go` and `executor_extra_test.go`. |
|
||||
| `Parser` | `type Parser struct` | Stateful YAML parser for playbooks, inventories, task files, and roles. | Directly exercised by `parser_test.go: TestNewParser_Good` and all parser method tests. |
|
||||
| `Executor` | `type Executor struct` | Playbook execution engine holding parser/inventory state, callbacks, vars/results/facts, SSH clients, and run options. | Directly exercised by `executor_test.go`, `executor_extra_test.go`, and module tests; the public `Run` entrypoint itself is untested. |
|
||||
| `SSHClient` | `type SSHClient struct` | Lazy SSH client that tracks connection, auth, privilege-escalation state, and timeout. | Constructor and become state are covered in `ssh_test.go` and `modules_infra_test.go`; network and file-transfer methods are untested. |
|
||||
| `SSHConfig` | `type SSHConfig struct` | Public configuration for SSH connection, auth, become, and timeout defaults. | Directly exercised by `ssh_test.go: TestNewSSHClient`, `TestSSHConfig_Defaults`, and become-state tests in `modules_infra_test.go`. |
|
||||
|
||||
## Package `ansible`: Parser and Inventory API
|
||||
|
||||
| Name | Signature | Description | Test coverage |
|
||||
| --- | --- | --- | --- |
|
||||
| `(*RoleRef).UnmarshalYAML` | `func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error` | Accepts either a scalar role name or a structured role reference and normalises `name` into `Role`. | `91.7%`; covered by `types_test.go: TestRoleRef_UnmarshalYAML_*`. |
|
||||
| `(*Task).UnmarshalYAML` | `func (t *Task) UnmarshalYAML(node *yaml.Node) error` | Decodes known task fields, extracts the module key dynamically, and converts `with_items` into `Loop`. | `87.5%`; covered by `types_test.go: TestTask_UnmarshalYAML_*`. |
|
||||
| `NewParser` | `func NewParser(basePath string) *Parser` | Constructs a parser rooted at `basePath` with an empty variable map. | `100.0%`; covered by `parser_test.go: TestNewParser_Good`. |
|
||||
| `(*Parser).ParsePlaybook` | `func (p *Parser) ParsePlaybook(path string) ([]Play, error)` | Reads a playbook YAML file into plays and post-processes each play's tasks. | `90.0%`; covered by `parser_test.go: TestParsePlaybook_*`. |
|
||||
| `(*Parser).ParsePlaybookIter` | `func (p *Parser) ParsePlaybookIter(path string) (iter.Seq[Play], error)` | Wraps `ParsePlaybook` with an iterator over plays. | `85.7%`; covered by `executor_extra_test.go: TestParsePlaybookIter_*`. |
|
||||
| `(*Parser).ParseInventory` | `func (p *Parser) ParseInventory(path string) (*Inventory, error)` | Reads an inventory YAML file into the public inventory model. | `100.0%`; covered by `parser_test.go: TestParseInventory_*`. |
|
||||
| `(*Parser).ParseTasks` | `func (p *Parser) ParseTasks(path string) ([]Task, error)` | Reads a task file and extracts module metadata for each task. | `90.0%`; covered by `parser_test.go: TestParseTasks_*`. |
|
||||
| `(*Parser).ParseTasksIter` | `func (p *Parser) ParseTasksIter(path string) (iter.Seq[Task], error)` | Wraps `ParseTasks` with an iterator over tasks. | `85.7%`; covered by `executor_extra_test.go: TestParseTasksIter_*`. |
|
||||
| `(*Parser).ParseRole` | `func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error)` | Resolves a role across several search paths, loads role defaults and vars, then parses the requested task file. | `0.0%`; no automated tests found. |
|
||||
| `NormalizeModule` | `func NormalizeModule(name string) string` | Canonicalises short module names to `ansible.builtin.*` while leaving dotted names unchanged. | `100.0%`; covered by `parser_test.go: TestNormalizeModule_*`. |
|
||||
| `GetHosts` | `func GetHosts(inv *Inventory, pattern string) []string` | Resolves `all`, `localhost`, group names, or explicit host names from inventory. | `100.0%`; covered by `parser_test.go: TestGetHosts_*`. |
|
||||
| `GetHostsIter` | `func GetHostsIter(inv *Inventory, pattern string) iter.Seq[string]` | Iterator wrapper over `GetHosts`. | `80.0%`; covered by `executor_extra_test.go: TestGetHostsIter_Good`. |
|
||||
| `AllHostsIter` | `func AllHostsIter(group *InventoryGroup) iter.Seq[string]` | Iterates every host in a group tree with deterministic key ordering. | `84.6%`; covered by `executor_extra_test.go: TestAllHostsIter_*`. |
|
||||
| `GetHostVars` | `func GetHostVars(inv *Inventory, hostname string) map[string]any` | Collects effective variables for a host by merging group ancestry with host-specific connection vars and inline vars. | `100.0%`; covered by `parser_test.go: TestGetHostVars_*`. |
|
||||
|
||||
## Package `ansible`: Executor API
|
||||
|
||||
| Name | Signature | Description | Test coverage |
|
||||
| --- | --- | --- | --- |
|
||||
| `NewExecutor` | `func NewExecutor(basePath string) *Executor` | Constructs an executor with parser state, variable stores, handler tracking, and SSH client cache. | `100.0%`; covered by `executor_test.go: TestNewExecutor_Good`. |
|
||||
| `(*Executor).SetInventory` | `func (e *Executor) SetInventory(path string) error` | Loads inventory from disk via the embedded parser and stores it on the executor. | `100.0%`; covered by `executor_extra_test.go: TestSetInventory_*`. |
|
||||
| `(*Executor).SetInventoryDirect` | `func (e *Executor) SetInventoryDirect(inv *Inventory)` | Replaces the executor inventory with a caller-supplied value. | `100.0%`; covered by `executor_test.go: TestSetInventoryDirect_Good`. |
|
||||
| `(*Executor).SetVar` | `func (e *Executor) SetVar(key string, value any)` | Stores a variable in the executor-scoped variable map under lock. | `100.0%`; covered by `executor_test.go: TestSetVar_Good`. |
|
||||
| `(*Executor).Run` | `func (e *Executor) Run(ctx context.Context, playbookPath string) error` | Parses a playbook and executes each play in order. | `0.0%`; no automated tests found for the public run path. |
|
||||
| `(*Executor).Close` | `func (e *Executor) Close()` | Closes all cached SSH clients and resets the client cache. | `80.0%`; covered by `executor_test.go: TestClose_Good_EmptyClients`. |
|
||||
| `(*Executor).TemplateFile` | `func (e *Executor) TemplateFile(src, host string, task *Task) (string, error)` | Reads a template, performs a basic Jinja2-to-Go-template conversion, and falls back to string substitution if parsing or execution fails. | `75.0%`; exercised indirectly by `modules_file_test.go: TestModuleTemplate_*`. |
|
||||
|
||||
## Package `ansible`: SSH API
|
||||
|
||||
| Name | Signature | Description | Test coverage |
|
||||
| --- | --- | --- | --- |
|
||||
| `NewSSHClient` | `func NewSSHClient(cfg SSHConfig) (*SSHClient, error)` | Applies SSH defaults and constructs a client from the supplied config. | `100.0%`; covered by `ssh_test.go: TestNewSSHClient`, `TestSSHConfig_Defaults`, plus become-state tests in `modules_infra_test.go`. |
|
||||
| `(*SSHClient).Connect` | `func (c *SSHClient) Connect(ctx context.Context) error` | Lazily establishes an SSH connection using key, password, and `known_hosts` handling. | `0.0%`; no automated tests found. |
|
||||
| `(*SSHClient).Close` | `func (c *SSHClient) Close() error` | Closes the active SSH connection if one exists. | `0.0%`; no automated tests found. |
|
||||
| `(*SSHClient).Run` | `func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error)` | Executes a remote command, optionally wrapped with `sudo`, and returns streams plus exit code. | `0.0%`; no automated tests found for the concrete SSH implementation. |
|
||||
| `(*SSHClient).RunScript` | `func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error)` | Executes a remote shell script through a heredoc wrapper. | `0.0%`; no automated tests found. |
|
||||
| `(*SSHClient).Upload` | `func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error` | Uploads content to a remote file, creating parent directories and handling `sudo` writes when needed. | `0.0%`; no automated tests found. |
|
||||
| `(*SSHClient).Download` | `func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error)` | Downloads a remote file by reading it with `cat`. | `0.0%`; no automated tests found. |
|
||||
| `(*SSHClient).FileExists` | `func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error)` | Checks remote path existence with `test -e`. | `0.0%`; no automated tests found. |
|
||||
| `(*SSHClient).Stat` | `func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error)` | Returns a minimal remote stat map describing existence and directory state. | `0.0%`; no automated tests found. |
|
||||
| `(*SSHClient).SetBecome` | `func (c *SSHClient) SetBecome(become bool, user, password string)` | Updates the privilege-escalation settings stored on the client. | `100.0%`; covered by `modules_infra_test.go: TestBecome_Infra_Good_*`. |
|
||||
|
||||
## Package `ansiblecmd`: CLI API
|
||||
|
||||
| Name | Signature | Description | Test coverage |
|
||||
| --- | --- | --- | --- |
|
||||
| `Register` | `func Register(c *core.Core)` | Registers the `ansible` and `ansible/test` CLI commands and their flags on a `core.Core` instance. | `0.0%`; `cmd/ansible` has no test files. |
|
||||
|
|
@ -222,7 +222,6 @@ The `evaluateWhen` method processes `when:` clauses. It supports:
|
|||
|
||||
- Boolean literals: `true`, `false`, `True`, `False`
|
||||
- Negation: `not <condition>`
|
||||
- Inline boolean expressions with `and`, `or`, and parentheses
|
||||
- Registered variable checks: `result is defined`, `result is success`, `result is failed`, `result is changed`, `result is skipped`
|
||||
- Variable truthiness: checks `vars` map for the condition as a key, evaluating booleans, non-empty strings, and non-zero integers
|
||||
- Default filter handling: `var | default(value)` always evaluates to true (permissive)
|
||||
|
|
|
|||
|
|
@ -155,13 +155,13 @@ func TestModuleHostname_Bad_MissingName(t *testing.T) {
|
|||
|
||||
```
|
||||
go-ansible/
|
||||
go.mod Module definition (dappco.re/go/core/ansible)
|
||||
go.mod Module definition (forge.lthn.ai/core/go-ansible)
|
||||
go.sum Dependency checksums
|
||||
CLAUDE.md AI assistant context file
|
||||
types.go Core data types and KnownModules registry
|
||||
parser.go YAML parsing (playbooks, inventories, roles)
|
||||
executor.go Execution engine (orchestration, templating, conditions)
|
||||
modules.go 49 module handler implementations
|
||||
modules.go 41 module handler implementations
|
||||
ssh.go SSH client (auth, commands, file transfer, become)
|
||||
*_test.go Test files (see table above)
|
||||
cmd/
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ description: A pure Go Ansible playbook engine -- parses YAML playbooks, invento
|
|||
|
||||
# go-ansible
|
||||
|
||||
`dappco.re/go/core/ansible` is a pure Go implementation of an Ansible playbook engine. It parses standard Ansible YAML playbooks, inventories, and roles, then executes tasks against remote hosts over SSH -- with no dependency on Python or the upstream `ansible-playbook` binary.
|
||||
`forge.lthn.ai/core/go-ansible` is a pure Go implementation of an Ansible playbook engine. It parses standard Ansible YAML playbooks, inventories, and roles, then executes tasks against remote hosts over SSH -- with no dependency on Python or the upstream `ansible-playbook` binary.
|
||||
|
||||
## Module Path
|
||||
|
||||
```
|
||||
dappco.re/go/core/ansible
|
||||
forge.lthn.ai/core/go-ansible
|
||||
```
|
||||
|
||||
Requires **Go 1.26+**.
|
||||
|
|
@ -26,7 +26,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
ansible "dappco.re/go/core/ansible"
|
||||
ansible "forge.lthn.ai/core/go-ansible"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -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 50 module implementations (shell, apt, replace, docker-compose, setup, etc.)
|
||||
modules.go 41 module implementations (shell, apt, docker-compose, 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,20 +126,20 @@ go-ansible/
|
|||
|
||||
## Supported Modules
|
||||
|
||||
50 module handlers are implemented, covering the most commonly used Ansible modules:
|
||||
41 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`, `replace`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
|
||||
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip`, `rpm` |
|
||||
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
|
||||
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip` |
|
||||
| **Service management** | `service`, `systemd` |
|
||||
| **User and group** | `user`, `group` |
|
||||
| **HTTP** | `uri` |
|
||||
| **Source control** | `git` |
|
||||
| **Archive** | `unarchive` |
|
||||
| **System** | `hostname`, `sysctl`, `cron`, `reboot`, `setup` |
|
||||
| **Flow control** | `debug`, `fail`, `assert`, `set_fact`, `add_host`, `pause`, `wait_for`, `meta`, `include_vars` |
|
||||
| **Flow control** | `debug`, `fail`, `assert`, `set_fact`, `pause`, `wait_for`, `meta`, `include_vars` |
|
||||
| **Community** | `community.general.ufw`, `ansible.posix.authorized_key`, `community.docker.docker_compose` |
|
||||
|
||||
Both fully-qualified collection names (e.g. `ansible.builtin.shell`) and short-form names (e.g. `shell`) are accepted.
|
||||
|
|
@ -148,8 +148,8 @@ Both fully-qualified collection names (e.g. `ansible.builtin.shell`) and short-f
|
|||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `dappco.re/go/core` | Core framework (command registration, flags) |
|
||||
| `dappco.re/go/core/log` | Structured logging and contextual error helper (`log.E()`) |
|
||||
| `forge.lthn.ai/core/cli` | CLI framework (command registration, flags, styled output) |
|
||||
| `forge.lthn.ai/core/go-log` | Structured logging and contextual error helper (`log.E()`) |
|
||||
| `golang.org/x/crypto` | SSH protocol implementation (`crypto/ssh`, `crypto/ssh/knownhosts`) |
|
||||
| `gopkg.in/yaml.v3` | YAML parsing for playbooks, inventories, and role files |
|
||||
| `github.com/stretchr/testify` | Test assertions (test-only) |
|
||||
|
|
|
|||
3987
executor.go
3987
executor.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,137 +0,0 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type becomeRecordingClient struct {
|
||||
mu sync.Mutex
|
||||
become bool
|
||||
becomeUser string
|
||||
becomePass string
|
||||
runBecomeSeen []bool
|
||||
runBecomePass []string
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) Run(_ context.Context, _ string) (string, string, int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.runBecomeSeen = append(c.runBecomeSeen, c.become)
|
||||
c.runBecomePass = append(c.runBecomePass, c.becomePass)
|
||||
return "", "", 0, nil
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) RunScript(_ context.Context, _ string) (string, string, int, error) {
|
||||
return c.Run(context.Background(), "")
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) Upload(_ context.Context, _ io.Reader, _ string, _ fs.FileMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) Download(_ context.Context, _ string) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) FileExists(_ context.Context, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) Stat(_ context.Context, _ string) (map[string]any, error) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) BecomeState() (bool, string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.become, c.becomeUser, c.becomePass
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) SetBecome(become bool, user, password string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.become = become
|
||||
if !become {
|
||||
c.becomeUser = ""
|
||||
c.becomePass = ""
|
||||
return
|
||||
}
|
||||
if user != "" {
|
||||
c.becomeUser = user
|
||||
}
|
||||
if password != "" {
|
||||
c.becomePass = password
|
||||
}
|
||||
}
|
||||
|
||||
func (c *becomeRecordingClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExecutor_RunTaskOnHost_Good_TaskBecomeFalseOverridesPlayBecome(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.SetInventoryDirect(&Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{
|
||||
"host1": {AnsibleHost: "127.0.0.1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client := &becomeRecordingClient{}
|
||||
client.SetBecome(true, "root", "secret")
|
||||
e.clients["host1"] = client
|
||||
|
||||
play := &Play{Become: true, BecomeUser: "admin"}
|
||||
task := &Task{
|
||||
Name: "Disable become for this task",
|
||||
Module: "command",
|
||||
Args: map[string]any{"cmd": "echo ok"},
|
||||
Become: func() *bool { v := false; return &v }(),
|
||||
}
|
||||
|
||||
require.NoError(t, e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play))
|
||||
require.Len(t, client.runBecomeSeen, 1)
|
||||
assert.False(t, client.runBecomeSeen[0])
|
||||
assert.True(t, client.become)
|
||||
assert.Equal(t, "admin", client.becomeUser)
|
||||
assert.Equal(t, "secret", client.becomePass)
|
||||
}
|
||||
|
||||
func TestExecutor_RunTaskOnHost_Good_TaskBecomeUsesInventoryPassword(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.SetInventoryDirect(&Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{
|
||||
"host1": {
|
||||
AnsibleHost: "127.0.0.1",
|
||||
AnsibleBecomePassword: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client := &becomeRecordingClient{}
|
||||
e.clients["host1"] = client
|
||||
|
||||
play := &Play{}
|
||||
task := &Task{
|
||||
Name: "Enable become for this task",
|
||||
Module: "command",
|
||||
Args: map[string]any{"cmd": "echo ok"},
|
||||
Become: func() *bool { v := true; return &v }(),
|
||||
}
|
||||
|
||||
require.NoError(t, e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play))
|
||||
require.Len(t, client.runBecomeSeen, 1)
|
||||
assert.True(t, client.runBecomeSeen[0])
|
||||
require.Len(t, client.runBecomePass, 1)
|
||||
assert.Equal(t, "secret", client.runBecomePass[0])
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
2486
executor_test.go
2486
executor_test.go
File diff suppressed because it is too large
Load diff
40
go.mod
40
go.mod
|
|
@ -1,19 +1,49 @@
|
|||
module dappco.re/go/core/ansible
|
||||
module forge.lthn.ai/core/go-ansible
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/io v0.2.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
forge.lthn.ai/core/cli v0.3.1
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
forge.lthn.ai/core/go v0.3.1 // indirect
|
||||
forge.lthn.ai/core/go-crypt v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go-i18n v0.1.4 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
||||
forge.lthn.ai/core/go-io v0.1.2 // indirect
|
||||
forge.lthn.ai/core/go-process v0.2.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
|
|
|||
78
go.sum
78
go.sum
|
|
@ -1,29 +1,95 @@
|
|||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
forge.lthn.ai/core/cli v0.3.1 h1:ZpHhaDrdbaV98JDxj/f0E5nytYk9tTMRu3qohGyK4M0=
|
||||
forge.lthn.ai/core/cli v0.3.1/go.mod h1:28cOl9eK0H033Otkjrv9f/QCmtHcJl+IIx4om8JskOg=
|
||||
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
|
||||
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
|
||||
forge.lthn.ai/core/go-crypt v0.1.7 h1:tyDFnXjEksHFQpkFwCpEn+x7zvwh4LnaU+/fP3WmqZc=
|
||||
forge.lthn.ai/core/go-crypt v0.1.7/go.mod h1:mQdr6K8lWOcyHmSEW24vZPTThQF8fteVgZi8CO+Ko3Y=
|
||||
forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ=
|
||||
forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U=
|
||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
|
||||
forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
forge.lthn.ai/core/go-process v0.2.2 h1:bnHFtzg92udochDDB6bD2luzzmr9ETKWmGzSsGjFFYE=
|
||||
forge.lthn.ai/core/go-process v0.2.2/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
|
||||
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
||||
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
# Executor
|
||||
|
||||
Module: `dappco.re/go/core/ansible`
|
||||
|
||||
The `Executor` is the main playbook runner. It manages SSH connections, variable resolution, conditional evaluation, loops, blocks, roles, handlers, and module execution.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
1. Parse playbook YAML into `[]Play`
|
||||
2. For each play:
|
||||
- Resolve target hosts from inventory (apply `Limit` filter)
|
||||
- Merge play variables
|
||||
- Gather facts (SSH into hosts, collect OS/hostname/kernel info)
|
||||
- Execute `pre_tasks`, `roles`, `tasks`, `post_tasks`
|
||||
- Run notified handlers
|
||||
3. Each task goes through:
|
||||
- Tag matching (`Tags`, `SkipTags`)
|
||||
- Block/rescue/always handling
|
||||
- Include/import resolution
|
||||
- `when` condition evaluation
|
||||
- Loop expansion
|
||||
- Module execution via SSH
|
||||
- Result registration and handler notification
|
||||
|
||||
## Templating
|
||||
|
||||
Jinja2-like `{{ var }}` syntax is supported:
|
||||
|
||||
- Variable resolution from play vars, task vars, host vars, facts, registered results
|
||||
- Dotted access: `{{ result.stdout }}`, `{{ result.rc }}`
|
||||
- Filters: `| default(value)`, `| bool`, `| trim`
|
||||
- Lookups: `lookup('env', 'HOME')`, `lookup('file', '/path')`
|
||||
|
||||
## Conditionals
|
||||
|
||||
`when` supports:
|
||||
|
||||
- Boolean literals: `true`, `false`
|
||||
- Inline boolean expressions with `and`, `or`, and parentheses
|
||||
- Registered variable checks: `result is success`, `result is failed`, `result is changed`, `result is defined`
|
||||
- Negation: `not condition`
|
||||
- Variable truthiness checks
|
||||
|
||||
## SSH Client Features
|
||||
|
||||
- Key-based and password authentication
|
||||
- Known hosts verification
|
||||
- Privilege escalation (`become`/`sudo`) with password support
|
||||
- File upload via `cat` (no SCP dependency)
|
||||
- File download, stat, exists checks
|
||||
- Context-based timeout and cancellation
|
||||
64
kb/Home.md
64
kb/Home.md
|
|
@ -1,64 +0,0 @@
|
|||
# go-ansible
|
||||
|
||||
Module: `dappco.re/go/core/ansible`
|
||||
|
||||
Pure Go Ansible executor that parses and runs Ansible playbooks without requiring the Python ansible binary. Supports SSH-based remote execution, inventory parsing, Jinja2-like templating, module execution, roles, handlers, loops, blocks, and conditionals.
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `types.go` | Data types: `Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`, `KnownModules` |
|
||||
| `parser.go` | YAML parsing for playbooks, inventory, roles, and task files |
|
||||
| `executor.go` | Playbook execution engine with SSH client management, templating, conditionals |
|
||||
| `ssh.go` | `SSHClient` for remote command execution, file upload/download |
|
||||
| `modules.go` | Ansible module implementations (shell, copy, template, file, service, etc.) |
|
||||
|
||||
CLI registration in `cmd/ansible/`.
|
||||
|
||||
## Key Types
|
||||
|
||||
### Core Types
|
||||
|
||||
- **`Executor`** — Runs playbooks: `Run()`, `SetInventory()`, `SetVar()`. Supports callbacks: `OnPlayStart`, `OnTaskStart`, `OnTaskEnd`, `OnPlayEnd`. Options: `Limit`, `Tags`, `SkipTags`, `CheckMode`, `Diff`, `Verbose`
|
||||
- **`Parser`** — Parses YAML: `ParsePlaybook()`, `ParseInventory()`, `ParseRole()`, `ParseTasks()`
|
||||
- **`SSHClient`** — SSH operations: `Connect()`, `Run()`, `RunScript()`, `Upload()`, `Download()`, `FileExists()`, `Stat()`, `SetBecome()`
|
||||
- **`SSHConfig`** — Connection config: `Host`, `Port`, `User`, `Password`, `KeyFile`, `Become`, `BecomeUser`, `BecomePass`, `Timeout`
|
||||
|
||||
### Playbook Types
|
||||
|
||||
- **`Play`** — Single play: `Name`, `Hosts`, `Become`, `Vars`, `PreTasks`, `Tasks`, `PostTasks`, `Roles`, `Handlers`
|
||||
- **`Task`** — Single task: `Name`, `Module`, `Args`, `Register`, `When`, `Loop`, `LoopControl`, `Block`, `Rescue`, `Always`, `Notify`, `IncludeTasks`, `ImportTasks`
|
||||
- **`TaskResult`** — Execution result: `Changed`, `Failed`, `Skipped`, `Msg`, `Stdout`, `Stderr`, `RC`, `Results` (for loops)
|
||||
- **`RoleRef`** — Role reference with vars and conditions
|
||||
|
||||
### Inventory Types
|
||||
|
||||
- **`Inventory`** — Top-level with `All` group
|
||||
- **`InventoryGroup`** — `Hosts`, `Children`, `Vars`
|
||||
- **`Host`** — Connection details: `AnsibleHost`, `AnsiblePort`, `AnsibleUser`, `AnsibleSSHPrivateKeyFile`
|
||||
- **`Facts`** — Gathered facts: `Hostname`, `FQDN`, `OS`, `Distribution`, `Architecture`, `Kernel`, `Memory`, `CPUs`
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
import "dappco.re/go/core/ansible"
|
||||
|
||||
executor := ansible.NewExecutor("/path/to/playbooks")
|
||||
executor.SetInventory("inventory/hosts.yml")
|
||||
executor.SetVar("deploy_version", "1.2.3")
|
||||
|
||||
executor.OnTaskStart = func(host string, task *ansible.Task) {
|
||||
fmt.Printf("[%s] %s\n", host, task.Name)
|
||||
}
|
||||
|
||||
err := executor.Run(ctx, "deploy.yml")
|
||||
defer executor.Close()
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `dappco.re/go/core/log` — Structured logging and errors
|
||||
- `golang.org/x/crypto/ssh` — SSH client
|
||||
- `golang.org/x/crypto/ssh/knownhosts` — Host key verification
|
||||
- `gopkg.in/yaml.v3` — YAML parsing
|
||||
171
local_client.go
171
local_client.go
|
|
@ -1,171 +0,0 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// localClient executes commands and file operations on the controller host.
|
||||
// It satisfies sshExecutorClient so the executor can reuse the same module
|
||||
// handlers for `connection: local` playbooks.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// client := newLocalClient()
|
||||
type localClient struct {
|
||||
mu sync.Mutex
|
||||
become bool
|
||||
becomeUser string
|
||||
becomePass string
|
||||
}
|
||||
|
||||
// newLocalClient creates a controller-side client for `connection: local`.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// client := newLocalClient()
|
||||
func newLocalClient() *localClient {
|
||||
return &localClient{}
|
||||
}
|
||||
|
||||
func (c *localClient) BecomeState() (bool, string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.become, c.becomeUser, c.becomePass
|
||||
}
|
||||
|
||||
func (c *localClient) SetBecome(become bool, user, password string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.become = become
|
||||
if !become {
|
||||
c.becomeUser = ""
|
||||
c.becomePass = ""
|
||||
return
|
||||
}
|
||||
if user != "" {
|
||||
c.becomeUser = user
|
||||
}
|
||||
if password != "" {
|
||||
c.becomePass = password
|
||||
}
|
||||
}
|
||||
|
||||
func (c *localClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *localClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
||||
c.mu.Lock()
|
||||
become, becomeUser, becomePass := c.becomeStateLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
command := cmd
|
||||
if become {
|
||||
command = wrapLocalBecomeCommand(command, becomeUser, becomePass)
|
||||
}
|
||||
|
||||
if become {
|
||||
return runLocalShell(ctx, command, becomePass)
|
||||
}
|
||||
return runLocalShell(ctx, command, "")
|
||||
}
|
||||
|
||||
func (c *localClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
|
||||
return c.Run(ctx, "bash <<'ANSIBLE_SCRIPT_EOF'\n"+script+"\nANSIBLE_SCRIPT_EOF")
|
||||
}
|
||||
|
||||
func (c *localClient) Upload(_ context.Context, localReader io.Reader, remote string, mode os.FileMode) error {
|
||||
content, err := io.ReadAll(localReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(remote), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(remote, content, mode)
|
||||
}
|
||||
|
||||
func (c *localClient) Download(_ context.Context, remote string) ([]byte, error) {
|
||||
return os.ReadFile(remote)
|
||||
}
|
||||
|
||||
func (c *localClient) FileExists(_ context.Context, path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (c *localClient) Stat(_ context.Context, path string) (map[string]any, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]any{"exists": false}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"exists": true,
|
||||
"isdir": info.IsDir(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *localClient) becomeStateLocked() (bool, string, string) {
|
||||
return c.become, c.becomeUser, c.becomePass
|
||||
}
|
||||
|
||||
func runLocalShell(ctx context.Context, command, password string) (stdout, stderr string, exitCode int, err error) {
|
||||
cmd := exec.CommandContext(ctx, "bash", "-lc", command)
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if password != "" {
|
||||
stdin, stdinErr := cmd.StdinPipe()
|
||||
if stdinErr != nil {
|
||||
return "", "", -1, stdinErr
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
_, _ = io.WriteString(stdin, password+"\n")
|
||||
}()
|
||||
}
|
||||
|
||||
err = cmd.Run()
|
||||
stdout = stdoutBuf.String()
|
||||
stderr = stderrBuf.String()
|
||||
if err == nil {
|
||||
return stdout, stderr, 0, nil
|
||||
}
|
||||
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return stdout, stderr, exitErr.ExitCode(), nil
|
||||
}
|
||||
|
||||
return stdout, stderr, -1, err
|
||||
}
|
||||
|
||||
func wrapLocalBecomeCommand(command, user, password string) string {
|
||||
if user == "" {
|
||||
user = "root"
|
||||
}
|
||||
|
||||
escaped := strings.ReplaceAll(command, "'", "'\\''")
|
||||
if password != "" {
|
||||
return "sudo -S -u " + user + " bash -lc '" + escaped + "'"
|
||||
}
|
||||
return "sudo -n -u " + user + " bash -lc '" + escaped + "'"
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLocalClient_Good_RunAndRunScript(t *testing.T) {
|
||||
client := newLocalClient()
|
||||
|
||||
stdout, stderr, rc, err := client.Run(context.Background(), "printf 'hello\\n'")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello\n", stdout)
|
||||
assert.Equal(t, "", stderr)
|
||||
assert.Equal(t, 0, rc)
|
||||
|
||||
stdout, stderr, rc, err = client.RunScript(context.Background(), "printf 'script\\n'")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "script\n", stdout)
|
||||
assert.Equal(t, "", stderr)
|
||||
assert.Equal(t, 0, rc)
|
||||
}
|
||||
|
||||
func TestLocalClient_Good_FileOperations(t *testing.T) {
|
||||
client := newLocalClient()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nested", "file.txt")
|
||||
|
||||
require.NoError(t, client.Upload(context.Background(), newReader("content"), path, 0o644))
|
||||
|
||||
exists, err := client.FileExists(context.Background(), path)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
info, err := client.Stat(context.Background(), path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, info["exists"])
|
||||
assert.Equal(t, false, info["isdir"])
|
||||
|
||||
content, err := client.Download(context.Background(), path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("content"), content)
|
||||
}
|
||||
|
||||
func TestExecutor_RunTaskOnHost_Good_LocalConnection(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
|
||||
task := &Task{
|
||||
Name: "Local shell",
|
||||
Module: "shell",
|
||||
Args: map[string]any{"_raw_params": "printf 'local ok\\n'"},
|
||||
Register: "local_result",
|
||||
}
|
||||
play := &Play{Connection: "local"}
|
||||
|
||||
require.NoError(t, e.runTaskOnHosts(context.Background(), []string{"host1"}, task, play))
|
||||
|
||||
result := e.results["host1"]["local_result"]
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "local ok\n", result.Stdout)
|
||||
assert.False(t, result.Failed)
|
||||
|
||||
_, ok := e.clients["host1"].(*localClient)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestExecutor_GatherFacts_Good_LocalConnection(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
|
||||
require.NoError(t, e.gatherFacts(context.Background(), "host1", &Play{Connection: "local"}))
|
||||
|
||||
facts := e.facts["host1"]
|
||||
require.NotNil(t, facts)
|
||||
assert.NotEmpty(t, facts.Hostname)
|
||||
assert.NotEmpty(t, facts.Kernel)
|
||||
}
|
||||
|
||||
func TestLocalClient_Good_SetBecomeResetsStateWhenDisabled(t *testing.T) {
|
||||
client := newLocalClient()
|
||||
|
||||
client.SetBecome(true, "admin", "secret")
|
||||
become, user, password := client.BecomeState()
|
||||
assert.True(t, become)
|
||||
assert.Equal(t, "admin", user)
|
||||
assert.Equal(t, "secret", password)
|
||||
|
||||
client.SetBecome(false, "", "")
|
||||
become, user, password = client.BecomeState()
|
||||
assert.False(t, become)
|
||||
assert.Empty(t, user)
|
||||
assert.Empty(t, password)
|
||||
}
|
||||
1288
mock_ssh_test.go
1288
mock_ssh_test.go
File diff suppressed because it is too large
Load diff
4011
modules.go
4011
modules.go
File diff suppressed because it is too large
Load diff
1575
modules_adv_test.go
1575
modules_adv_test.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,8 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -14,7 +15,7 @@ import (
|
|||
|
||||
// --- MockSSHClient basic tests ---
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_RunRecordsExecution(t *testing.T) {
|
||||
func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand("echo hello", "hello\n", "", 0)
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ func TestModulesCmd_MockSSHClient_Good_RunRecordsExecution(t *testing.T) {
|
|||
assert.Equal(t, "echo hello", mock.lastCommand().Cmd)
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
|
||||
func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand("set -e", "ok", "", 0)
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ func TestModulesCmd_MockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
|
|||
assert.Equal(t, "RunScript", mock.lastCommand().Method)
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
|
||||
func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
// No expectations registered — should return empty success
|
||||
|
|
@ -54,7 +55,7 @@ func TestModulesCmd_MockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
|
|||
assert.Equal(t, 0, rc)
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_LastMatchWins(t *testing.T) {
|
||||
func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand("echo", "first", "", 0)
|
||||
mock.expectCommand("echo", "second", "", 0)
|
||||
|
|
@ -64,7 +65,7 @@ func TestModulesCmd_MockSSHClient_Good_LastMatchWins(t *testing.T) {
|
|||
assert.Equal(t, "second", stdout)
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_FileOperations(t *testing.T) {
|
||||
func TestMockSSHClient_Good_FileOperations(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
// File does not exist initially
|
||||
|
|
@ -90,7 +91,7 @@ func TestModulesCmd_MockSSHClient_Good_FileOperations(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_StatWithExplicit(t *testing.T) {
|
||||
func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.addStat("/var/log", map[string]any{"exists": true, "isdir": true})
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ func TestModulesCmd_MockSSHClient_Good_StatWithExplicit(t *testing.T) {
|
|||
assert.Equal(t, true, info["isdir"])
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_StatFallback(t *testing.T) {
|
||||
func TestMockSSHClient_Good_StatFallback(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
|
||||
|
||||
|
|
@ -114,7 +115,7 @@ func TestModulesCmd_MockSSHClient_Good_StatFallback(t *testing.T) {
|
|||
assert.Equal(t, false, info["exists"])
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_BecomeTracking(t *testing.T) {
|
||||
func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
assert.False(t, mock.become)
|
||||
|
|
@ -127,26 +128,7 @@ func TestModulesCmd_MockSSHClient_Good_BecomeTracking(t *testing.T) {
|
|||
assert.Equal(t, "secret", mock.becomePass)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_RelativePathResolvedAgainstBasePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
scriptPath := joinPath(dir, "scripts", "deploy.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("echo deploy"), 0755))
|
||||
|
||||
e := NewExecutor(dir)
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand("echo deploy", "deploy\n", "", 0)
|
||||
|
||||
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||
"_raw_params": "scripts/deploy.sh",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, "deploy\n", result.Stdout)
|
||||
assert.True(t, mock.hasExecuted("echo deploy"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_HasExecuted(t *testing.T) {
|
||||
func TestMockSSHClient_Good_HasExecuted(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
_, _, _, _ = mock.Run(nil, "systemctl restart nginx")
|
||||
_, _, _, _ = mock.Run(nil, "apt-get update")
|
||||
|
|
@ -156,7 +138,7 @@ func TestModulesCmd_MockSSHClient_Good_HasExecuted(t *testing.T) {
|
|||
assert.False(t, mock.hasExecuted("yum"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_HasExecutedMethod(t *testing.T) {
|
||||
func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
_, _, _, _ = mock.Run(nil, "echo run")
|
||||
_, _, _, _ = mock.RunScript(nil, "echo script")
|
||||
|
|
@ -167,7 +149,7 @@ func TestModulesCmd_MockSSHClient_Good_HasExecutedMethod(t *testing.T) {
|
|||
assert.False(t, mock.hasExecutedMethod("RunScript", "echo run"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_Reset(t *testing.T) {
|
||||
func TestMockSSHClient_Good_Reset(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
_, _, _, _ = mock.Run(nil, "echo hello")
|
||||
assert.Equal(t, 1, mock.commandCount())
|
||||
|
|
@ -176,7 +158,7 @@ func TestModulesCmd_MockSSHClient_Good_Reset(t *testing.T) {
|
|||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModulesCmd_MockSSHClient_Good_ErrorExpectation(t *testing.T) {
|
||||
func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError("bad cmd", assert.AnError)
|
||||
|
||||
|
|
@ -186,7 +168,7 @@ func TestModulesCmd_MockSSHClient_Good_ErrorExpectation(t *testing.T) {
|
|||
|
||||
// --- command module ---
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_BasicCommand(t *testing.T) {
|
||||
func TestModuleCommand_Good_BasicCommand(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("ls -la /tmp", "total 0\n", "", 0)
|
||||
|
||||
|
|
@ -205,7 +187,7 @@ func TestModulesCmd_ModuleCommand_Good_BasicCommand(t *testing.T) {
|
|||
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_CmdArg(t *testing.T) {
|
||||
func TestModuleCommand_Good_CmdArg(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("whoami", "root\n", "", 0)
|
||||
|
||||
|
|
@ -219,26 +201,7 @@ func TestModulesCmd_ModuleCommand_Good_CmdArg(t *testing.T) {
|
|||
assert.True(t, mock.hasExecutedMethod("Run", "whoami"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_Argv(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`"echo".*"hello world"`, "hello world\n", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "command",
|
||||
Args: map[string]any{
|
||||
"argv": []any{"echo", "hello world"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, "hello world\n", result.Stdout)
|
||||
assert.True(t, mock.hasExecuted(`hello world`))
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_WithChdir(t *testing.T) {
|
||||
func TestModuleCommand_Good_WithChdir(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`cd "/var/log" && ls`, "syslog\n", "", 0)
|
||||
|
||||
|
|
@ -256,26 +219,7 @@ func TestModulesCmd_ModuleCommand_Good_WithChdir(t *testing.T) {
|
|||
assert.Contains(t, last.Cmd, "ls")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_WithStdin(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("cat", "input\n", "", 0)
|
||||
|
||||
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "cat",
|
||||
"stdin": "payload",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, "input\n", result.Stdout)
|
||||
last := mock.lastCommand()
|
||||
assert.Equal(t, "Run", last.Method)
|
||||
assert.Contains(t, last.Cmd, "printf %s")
|
||||
assert.Contains(t, last.Cmd, "| cat")
|
||||
assert.Contains(t, last.Cmd, "payload\n")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Bad_NoCommand(t *testing.T) {
|
||||
func TestModuleCommand_Bad_NoCommand(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -285,7 +229,7 @@ func TestModulesCmd_ModuleCommand_Bad_NoCommand(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "no command specified")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_NonZeroRC(t *testing.T) {
|
||||
func TestModuleCommand_Good_NonZeroRC(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("false", "", "error occurred", 1)
|
||||
|
||||
|
|
@ -299,7 +243,7 @@ func TestModulesCmd_ModuleCommand_Good_NonZeroRC(t *testing.T) {
|
|||
assert.Equal(t, "error occurred", result.Stderr)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_SSHError(t *testing.T) {
|
||||
func TestModuleCommand_Good_SSHError(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError(".*", assert.AnError)
|
||||
|
|
@ -313,7 +257,7 @@ func TestModulesCmd_ModuleCommand_Good_SSHError(t *testing.T) {
|
|||
assert.Contains(t, result.Msg, assert.AnError.Error())
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
|
||||
func TestModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("from_raw", "raw\n", "", 0)
|
||||
|
||||
|
|
@ -327,47 +271,9 @@ func TestModulesCmd_ModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted("from_raw"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_SkipsWhenCreatesExists(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.addFile("/tmp/output.txt", []byte("done"))
|
||||
|
||||
task := &Task{
|
||||
Module: "ansible.builtin.command",
|
||||
Args: map[string]any{
|
||||
"_raw_params": "echo should-not-run",
|
||||
"creates": "/tmp/output.txt",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Changed)
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_SkipsWhenCreatesExistsUnderChdir(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.addFile("/app/build/output.txt", []byte("done"))
|
||||
|
||||
task := &Task{
|
||||
Module: "ansible.builtin.command",
|
||||
Args: map[string]any{
|
||||
"_raw_params": "echo should-not-run",
|
||||
"creates": "build/output.txt",
|
||||
"chdir": "/app",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Changed)
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
// --- shell module ---
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_BasicShell(t *testing.T) {
|
||||
func TestModuleShell_Good_BasicShell(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo hello", "hello\n", "", 0)
|
||||
|
||||
|
|
@ -385,7 +291,7 @@ func TestModulesCmd_ModuleShell_Good_BasicShell(t *testing.T) {
|
|||
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_CmdArg(t *testing.T) {
|
||||
func TestModuleShell_Good_CmdArg(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("date", "Thu Feb 20\n", "", 0)
|
||||
|
||||
|
|
@ -398,7 +304,7 @@ func TestModulesCmd_ModuleShell_Good_CmdArg(t *testing.T) {
|
|||
assert.True(t, mock.hasExecutedMethod("RunScript", "date"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_WithChdir(t *testing.T) {
|
||||
func TestModuleShell_Good_WithChdir(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`cd "/app" && npm install`, "done\n", "", 0)
|
||||
|
||||
|
|
@ -415,27 +321,7 @@ func TestModulesCmd_ModuleShell_Good_WithChdir(t *testing.T) {
|
|||
assert.Contains(t, last.Cmd, "npm install")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_ExecutableUsesRun(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`/bin/dash.*echo test`, "test\n", "", 0)
|
||||
|
||||
result, err := e.moduleShell(context.Background(), mock, map[string]any{
|
||||
"_raw_params": "echo test",
|
||||
"executable": "/bin/dash",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
last := mock.lastCommand()
|
||||
require.NotNil(t, last)
|
||||
assert.Equal(t, "Run", last.Method)
|
||||
assert.Contains(t, last.Cmd, "/bin/dash")
|
||||
assert.Contains(t, last.Cmd, "-c")
|
||||
assert.Contains(t, last.Cmd, "echo test")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Bad_NoCommand(t *testing.T) {
|
||||
func TestModuleShell_Bad_NoCommand(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -445,7 +331,7 @@ func TestModulesCmd_ModuleShell_Bad_NoCommand(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "no command specified")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_NonZeroRC(t *testing.T) {
|
||||
func TestModuleShell_Good_NonZeroRC(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("exit 2", "", "failed", 2)
|
||||
|
||||
|
|
@ -458,24 +344,7 @@ func TestModulesCmd_ModuleShell_Good_NonZeroRC(t *testing.T) {
|
|||
assert.Equal(t, 2, result.RC)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_SkipsWhenRemovesMissing(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
Module: "ansible.builtin.shell",
|
||||
Args: map[string]any{
|
||||
"_raw_params": "echo should-not-run",
|
||||
"removes": "/tmp/missing.txt",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Changed)
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_SSHError(t *testing.T) {
|
||||
func TestModuleShell_Good_SSHError(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError(".*", assert.AnError)
|
||||
|
|
@ -488,7 +357,7 @@ func TestModulesCmd_ModuleShell_Good_SSHError(t *testing.T) {
|
|||
assert.True(t, result.Failed)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleShell_Good_PipelineCommand(t *testing.T) {
|
||||
func TestModuleShell_Good_PipelineCommand(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`cat /etc/passwd \| grep root`, "root:x:0:0\n", "", 0)
|
||||
|
||||
|
|
@ -504,7 +373,7 @@ func TestModulesCmd_ModuleShell_Good_PipelineCommand(t *testing.T) {
|
|||
|
||||
// --- raw module ---
|
||||
|
||||
func TestModulesCmd_ModuleRaw_Good_BasicRaw(t *testing.T) {
|
||||
func TestModuleRaw_Good_BasicRaw(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("uname -a", "Linux host1 5.15\n", "", 0)
|
||||
|
||||
|
|
@ -521,7 +390,7 @@ func TestModulesCmd_ModuleRaw_Good_BasicRaw(t *testing.T) {
|
|||
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleRaw_Bad_NoCommand(t *testing.T) {
|
||||
func TestModuleRaw_Bad_NoCommand(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -531,7 +400,7 @@ func TestModulesCmd_ModuleRaw_Bad_NoCommand(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "no command specified")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleRaw_Good_NoChdir(t *testing.T) {
|
||||
func TestModuleRaw_Good_NoChdir(t *testing.T) {
|
||||
// Raw module does NOT support chdir — it should ignore it
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
|
@ -549,7 +418,7 @@ func TestModulesCmd_ModuleRaw_Good_NoChdir(t *testing.T) {
|
|||
assert.NotContains(t, last.Cmd, "cd")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleRaw_Good_NonZeroRC(t *testing.T) {
|
||||
func TestModuleRaw_Good_NonZeroRC(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("invalid", "", "not found", 127)
|
||||
|
||||
|
|
@ -563,7 +432,7 @@ func TestModulesCmd_ModuleRaw_Good_NonZeroRC(t *testing.T) {
|
|||
assert.Equal(t, "not found", result.Stderr)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleRaw_Good_SSHError(t *testing.T) {
|
||||
func TestModuleRaw_Good_SSHError(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError(".*", assert.AnError)
|
||||
|
|
@ -576,7 +445,7 @@ func TestModulesCmd_ModuleRaw_Good_SSHError(t *testing.T) {
|
|||
assert.True(t, result.Failed)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
|
||||
func TestModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
|
||||
// Raw should pass the command exactly as given — no wrapping
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
complexCmd := `/usr/bin/python3 -c 'import sys; print(sys.version)'`
|
||||
|
|
@ -594,12 +463,12 @@ func TestModulesCmd_ModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
|
|||
|
||||
// --- script module ---
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_BasicScript(t *testing.T) {
|
||||
func TestModuleScript_Good_BasicScript(t *testing.T) {
|
||||
// Create a temporary script file
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "setup.sh")
|
||||
scriptPath := filepath.Join(tmpDir, "setup.sh")
|
||||
scriptContent := "#!/bin/bash\necho 'setup complete'\nexit 0"
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte(scriptContent), 0755))
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("setup complete", "setup complete\n", "", 0)
|
||||
|
|
@ -621,73 +490,7 @@ func TestModulesCmd_ModuleScript_Good_BasicScript(t *testing.T) {
|
|||
assert.Equal(t, scriptContent, last.Cmd)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_CreatesSkipsExecution(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "setup.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("echo should-not-run"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.addFile("/tmp/already-there", []byte("present"))
|
||||
|
||||
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||
"_raw_params": scriptPath,
|
||||
"creates": "/tmp/already-there",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Changed)
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_ChdirPrefixesScript(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "work.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("pwd"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||
"_raw_params": scriptPath,
|
||||
"chdir": "/opt/app",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
last := mock.lastCommand()
|
||||
require.NotNil(t, last)
|
||||
assert.Equal(t, "RunScript", last.Method)
|
||||
assert.Equal(t, `cd "/opt/app" && pwd`, last.Cmd)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_ExecutableUsesRun(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "dash.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("echo script works"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`/bin/dash.*echo script works`, "script works\n", "", 0)
|
||||
|
||||
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||
"_raw_params": scriptPath,
|
||||
"executable": "/bin/dash",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
last := mock.lastCommand()
|
||||
require.NotNil(t, last)
|
||||
assert.Equal(t, "Run", last.Method)
|
||||
assert.Contains(t, last.Cmd, "/bin/dash")
|
||||
assert.Contains(t, last.Cmd, "-c")
|
||||
assert.Contains(t, last.Cmd, "echo script works")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Bad_NoScript(t *testing.T) {
|
||||
func TestModuleScript_Bad_NoScript(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -697,7 +500,7 @@ func TestModulesCmd_ModuleScript_Bad_NoScript(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "no script specified")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Bad_FileNotFound(t *testing.T) {
|
||||
func TestModuleScript_Bad_FileNotFound(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -709,10 +512,10 @@ func TestModulesCmd_ModuleScript_Bad_FileNotFound(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "read script")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_NonZeroRC(t *testing.T) {
|
||||
func TestModuleScript_Good_NonZeroRC(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "fail.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("exit 1"), 0755))
|
||||
scriptPath := filepath.Join(tmpDir, "fail.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("exit 1"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("exit 1", "", "script failed", 1)
|
||||
|
|
@ -726,11 +529,11 @@ func TestModulesCmd_ModuleScript_Good_NonZeroRC(t *testing.T) {
|
|||
assert.Equal(t, 1, result.RC)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_MultiLineScript(t *testing.T) {
|
||||
func TestModuleScript_Good_MultiLineScript(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "multi.sh")
|
||||
scriptPath := filepath.Join(tmpDir, "multi.sh")
|
||||
scriptContent := "#!/bin/bash\nset -e\napt-get update\napt-get install -y nginx\nsystemctl start nginx"
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte(scriptContent), 0755))
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("apt-get", "done\n", "", 0)
|
||||
|
|
@ -748,10 +551,10 @@ func TestModulesCmd_ModuleScript_Good_MultiLineScript(t *testing.T) {
|
|||
assert.Equal(t, scriptContent, last.Cmd)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleScript_Good_SSHError(t *testing.T) {
|
||||
func TestModuleScript_Good_SSHError(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "ok.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("echo ok"), 0755))
|
||||
scriptPath := filepath.Join(tmpDir, "ok.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo ok"), 0755))
|
||||
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
|
@ -767,7 +570,7 @@ func TestModulesCmd_ModuleScript_Good_SSHError(t *testing.T) {
|
|||
|
||||
// --- Cross-module differentiation tests ---
|
||||
|
||||
func TestModulesCmd_ModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
|
||||
func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
|
|
@ -778,7 +581,7 @@ func TestModulesCmd_ModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
|
|||
assert.Equal(t, "Run", cmds[0].Method, "command module must use Run()")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
|
||||
func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
|
|
@ -789,23 +592,7 @@ func TestModulesCmd_ModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T)
|
|||
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must use RunScript()")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleDifferentiation_Good_ShellWithStdinStillUsesRunScript(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
_, _ = moduleShellWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "echo test",
|
||||
"stdin": "payload",
|
||||
})
|
||||
|
||||
cmds := mock.executedCommands()
|
||||
require.Len(t, cmds, 1)
|
||||
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must still use RunScript()")
|
||||
assert.Contains(t, cmds[0].Cmd, "printf %s")
|
||||
assert.Contains(t, cmds[0].Cmd, "| echo test")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
|
||||
func TestModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
|
|
@ -816,10 +603,10 @@ func TestModulesCmd_ModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
|
|||
assert.Equal(t, "Run", cmds[0].Method, "raw module must use Run()")
|
||||
}
|
||||
|
||||
func TestModulesCmd_ModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
|
||||
func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "test.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("echo test"), 0755))
|
||||
scriptPath := filepath.Join(tmpDir, "test.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo test"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
|
@ -833,7 +620,7 @@ func TestModulesCmd_ModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T)
|
|||
|
||||
// --- executeModuleWithMock dispatch tests ---
|
||||
|
||||
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("uptime", "up 5 days\n", "", 0)
|
||||
|
||||
|
|
@ -849,7 +636,7 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
|
|||
assert.Equal(t, "up 5 days\n", result.Stdout)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("ps aux", "root.*bash\n", "", 0)
|
||||
|
||||
|
|
@ -864,7 +651,7 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
|
|||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("cat /etc/hostname", "web01\n", "", 0)
|
||||
|
||||
|
|
@ -880,10 +667,10 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
|
|||
assert.Equal(t, "web01\n", result.Stdout)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := joinPath(tmpDir, "deploy.sh")
|
||||
require.NoError(t, writeTestFile(scriptPath, []byte("echo deploying"), 0755))
|
||||
scriptPath := filepath.Join(tmpDir, "deploy.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo deploying"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("deploying", "deploying\n", "", 0)
|
||||
|
|
@ -899,7 +686,7 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
|
|||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestModulesCmd_ExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
|
|
@ -911,12 +698,11 @@ func TestModulesCmd_ExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
|
|||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported module")
|
||||
assert.Contains(t, err.Error(), "ansible.builtin.hostname")
|
||||
}
|
||||
|
||||
// --- Template integration tests ---
|
||||
|
||||
func TestModulesCmd_ModuleCommand_Good_TemplatedArgs(t *testing.T) {
|
||||
func TestModuleCommand_Good_TemplatedArgs(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
e.SetVar("service_name", "nginx")
|
||||
mock.expectCommand("systemctl status nginx", "active\n", "", 0)
|
||||
|
|
|
|||
1020
modules_file_test.go
1020
modules_file_test.go
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
// --- service module ---
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_Start(t *testing.T) {
|
||||
func TestModuleService_Good_Start(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start nginx`, "Started", "", 0)
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ func TestModulesSvc_ModuleService_Good_Start(t *testing.T) {
|
|||
assert.Equal(t, 1, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_Stop(t *testing.T) {
|
||||
func TestModuleService_Good_Stop(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl stop nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ func TestModulesSvc_ModuleService_Good_Stop(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl stop nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_Restart(t *testing.T) {
|
||||
func TestModuleService_Good_Restart(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ func TestModulesSvc_ModuleService_Good_Restart(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_Reload(t *testing.T) {
|
||||
func TestModuleService_Good_Reload(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl reload nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ func TestModulesSvc_ModuleService_Good_Reload(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl reload nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_Enable(t *testing.T) {
|
||||
func TestModuleService_Good_Enable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ func TestModulesSvc_ModuleService_Good_Enable(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_Disable(t *testing.T) {
|
||||
func TestModuleService_Good_Disable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl disable nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ func TestModulesSvc_ModuleService_Good_Disable(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl disable nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_StartAndEnable(t *testing.T) {
|
||||
func TestModuleService_Good_StartAndEnable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
||||
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
||||
|
|
@ -123,7 +123,7 @@ func TestModulesSvc_ModuleService_Good_StartAndEnable(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_RestartAndDisable(t *testing.T) {
|
||||
func TestModuleService_Good_RestartAndDisable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart sshd`, "", "", 0)
|
||||
mock.expectCommand(`systemctl disable sshd`, "", "", 0)
|
||||
|
|
@ -142,7 +142,7 @@ func TestModulesSvc_ModuleService_Good_RestartAndDisable(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl disable sshd`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Bad_MissingName(t *testing.T) {
|
||||
func TestModuleService_Bad_MissingName(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ func TestModulesSvc_ModuleService_Bad_MissingName(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "name required")
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_NoStateNoEnabled(t *testing.T) {
|
||||
func TestModuleService_Good_NoStateNoEnabled(t *testing.T) {
|
||||
// When neither state nor enabled is provided, no commands run
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ func TestModulesSvc_ModuleService_Good_NoStateNoEnabled(t *testing.T) {
|
|||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_CommandFailure(t *testing.T) {
|
||||
func TestModuleService_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start.*`, "", "Failed to start nginx.service", 1)
|
||||
|
||||
|
|
@ -183,7 +183,7 @@ func TestModulesSvc_ModuleService_Good_CommandFailure(t *testing.T) {
|
|||
assert.Equal(t, 1, result.RC)
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
|
||||
func TestModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
|
||||
// When state command fails, enable should not run
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start`, "", "unit not found", 5)
|
||||
|
|
@ -203,7 +203,7 @@ func TestModulesSvc_ModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T)
|
|||
|
||||
// --- systemd module ---
|
||||
|
||||
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
|
||||
func TestModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
||||
|
|
@ -225,7 +225,7 @@ func TestModulesSvc_ModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
|
|||
assert.Contains(t, cmds[1].Cmd, "systemctl start nginx")
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
|
||||
func TestModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ func TestModulesSvc_ModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleSystemd_Good_DelegationToService(t *testing.T) {
|
||||
func TestModuleSystemd_Good_DelegationToService(t *testing.T) {
|
||||
// Without daemon_reload, systemd delegates entirely to service
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
||||
|
|
@ -261,7 +261,7 @@ func TestModulesSvc_ModuleSystemd_Good_DelegationToService(t *testing.T) {
|
|||
assert.False(t, mock.hasExecuted(`daemon-reload`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
|
||||
func TestModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
mock.expectCommand(`systemctl enable myapp`, "", "", 0)
|
||||
|
|
@ -281,7 +281,7 @@ func TestModulesSvc_ModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
|
|||
|
||||
// --- apt module ---
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_InstallPresent(t *testing.T) {
|
||||
func TestModuleApt_Good_InstallPresent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq nginx`, "installed", "", 0)
|
||||
|
||||
|
|
@ -296,7 +296,7 @@ func TestModulesSvc_ModuleApt_Good_InstallPresent(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_InstallInstalled(t *testing.T) {
|
||||
func TestModuleApt_Good_InstallInstalled(t *testing.T) {
|
||||
// state=installed is an alias for present
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq curl`, "", "", 0)
|
||||
|
|
@ -312,7 +312,7 @@ func TestModulesSvc_ModuleApt_Good_InstallInstalled(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq curl`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_RemoveAbsent(t *testing.T) {
|
||||
func TestModuleApt_Good_RemoveAbsent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -327,7 +327,7 @@ func TestModulesSvc_ModuleApt_Good_RemoveAbsent(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_RemoveRemoved(t *testing.T) {
|
||||
func TestModuleApt_Good_RemoveRemoved(t *testing.T) {
|
||||
// state=removed is an alias for absent
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
||||
|
|
@ -342,7 +342,7 @@ func TestModulesSvc_ModuleApt_Good_RemoveRemoved(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_UpgradeLatest(t *testing.T) {
|
||||
func TestModuleApt_Good_UpgradeLatest(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq --only-upgrade nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -357,7 +357,7 @@ func TestModulesSvc_ModuleApt_Good_UpgradeLatest(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
|
||||
func TestModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
||||
|
|
@ -379,7 +379,7 @@ func TestModulesSvc_ModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
|
|||
assert.Contains(t, cmds[1].Cmd, "apt-get install")
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_UpdateCacheOnly(t *testing.T) {
|
||||
func TestModuleApt_Good_UpdateCacheOnly(t *testing.T) {
|
||||
// update_cache with no name means update only, no install
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
|
@ -395,7 +395,7 @@ func TestModulesSvc_ModuleApt_Good_UpdateCacheOnly(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get update`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_CommandFailure(t *testing.T) {
|
||||
func TestModuleApt_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install`, "", "E: Unable to locate package badpkg", 100)
|
||||
|
||||
|
|
@ -410,7 +410,7 @@ func TestModulesSvc_ModuleApt_Good_CommandFailure(t *testing.T) {
|
|||
assert.Equal(t, 100, result.RC)
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
|
||||
func TestModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
|
||||
// If no state is given, default is "present" (install)
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
|
||||
|
|
@ -424,22 +424,9 @@ func TestModulesSvc_ModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleApt_Good_InstallMultiplePackages(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq nginx curl`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": []any{"nginx", "curl"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq nginx curl`))
|
||||
}
|
||||
|
||||
// --- apt_key module ---
|
||||
|
||||
func TestModulesSvc_ModuleAptKey_Good_AddWithKeyring(t *testing.T) {
|
||||
func TestModuleAptKey_Good_AddWithKeyring(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl -fsSL.*gpg --dearmor`, "", "", 0)
|
||||
|
||||
|
|
@ -456,7 +443,7 @@ func TestModulesSvc_ModuleAptKey_Good_AddWithKeyring(t *testing.T) {
|
|||
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/example.gpg"))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
|
||||
func TestModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl -fsSL.*apt-key add -`, "", "", 0)
|
||||
|
||||
|
|
@ -470,7 +457,7 @@ func TestModulesSvc_ModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-key add -`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptKey_Good_RemoveKey(t *testing.T) {
|
||||
func TestModuleAptKey_Good_RemoveKey(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||
|
|
@ -485,7 +472,7 @@ func TestModulesSvc_ModuleAptKey_Good_RemoveKey(t *testing.T) {
|
|||
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/old.gpg"))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
|
||||
func TestModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
|
||||
// Absent with no keyring — still succeeds, just no rm command
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
|
|
@ -498,7 +485,7 @@ func TestModulesSvc_ModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
|
|||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptKey_Bad_MissingURL(t *testing.T) {
|
||||
func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -510,7 +497,7 @@ func TestModulesSvc_ModuleAptKey_Bad_MissingURL(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "url required")
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptKey_Good_CommandFailure(t *testing.T) {
|
||||
func TestModuleAptKey_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl`, "", "curl: (22) 404 Not Found", 22)
|
||||
|
||||
|
|
@ -526,7 +513,7 @@ func TestModulesSvc_ModuleAptKey_Good_CommandFailure(t *testing.T) {
|
|||
|
||||
// --- apt_repository module ---
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Good_AddRepository(t *testing.T) {
|
||||
func TestModuleAptRepository_Good_AddRepository(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo.*sources\.list\.d`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
|
@ -542,7 +529,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AddRepository(t *testing.T) {
|
|||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/example.list"))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Good_RemoveRepository(t *testing.T) {
|
||||
func TestModuleAptRepository_Good_RemoveRepository(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
|
|
@ -558,7 +545,7 @@ func TestModulesSvc_ModuleAptRepository_Good_RemoveRepository(t *testing.T) {
|
|||
assert.True(t, mock.containsSubstring("example.list"))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
|
||||
func TestModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
|
@ -577,7 +564,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get update`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
|
||||
func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
|
||||
|
|
@ -595,7 +582,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T)
|
|||
assert.False(t, mock.hasExecuted(`apt-get update`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Good_CustomFilename(t *testing.T) {
|
||||
func TestModuleAptRepository_Good_CustomFilename(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
|
@ -610,7 +597,7 @@ func TestModulesSvc_ModuleAptRepository_Good_CustomFilename(t *testing.T) {
|
|||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/custom-ppa.list"))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
|
||||
func TestModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
|
||||
// When no filename is given, it auto-generates from the repo string
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
|
|
@ -626,7 +613,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T)
|
|||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/"))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Bad_MissingRepo(t *testing.T) {
|
||||
func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
|
|
@ -638,7 +625,7 @@ func TestModulesSvc_ModuleAptRepository_Bad_MissingRepo(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "repo required")
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleAptRepository_Good_WriteFailure(t *testing.T) {
|
||||
func TestModuleAptRepository_Good_WriteFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "permission denied", 1)
|
||||
|
||||
|
|
@ -654,7 +641,7 @@ func TestModulesSvc_ModuleAptRepository_Good_WriteFailure(t *testing.T) {
|
|||
|
||||
// --- package module ---
|
||||
|
||||
func TestModulesSvc_ModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
|
||||
func TestModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
// First command: which apt-get returns the path
|
||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||
|
|
@ -673,7 +660,7 @@ func TestModulesSvc_ModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq htop`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePackage_Good_FallbackToApt(t *testing.T) {
|
||||
func TestModulePackage_Good_FallbackToApt(t *testing.T) {
|
||||
// When which returns nothing (no package manager found), still falls back to apt
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get`, "", "", 1)
|
||||
|
|
@ -690,7 +677,7 @@ func TestModulesSvc_ModulePackage_Good_FallbackToApt(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePackage_Good_RemovePackage(t *testing.T) {
|
||||
func TestModulePackage_Good_RemovePackage(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||
mock.expectCommand(`apt-get remove -y -qq nano`, "", "", 0)
|
||||
|
|
@ -705,113 +692,9 @@ func TestModulesSvc_ModulePackage_Good_RemovePackage(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nano`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePackage_Good_DetectYumAndDelegate(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get yum dnf`, "/usr/bin/yum", "", 0)
|
||||
mock.expectCommand(`yum install -y -q htop`, "", "", 0)
|
||||
|
||||
result, err := modulePackageWithClient(e, mock, map[string]any{
|
||||
"name": "htop",
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`yum install -y -q htop`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePackage_Good_DetectDnfAndDelegate(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get yum dnf`, "/usr/bin/dnf", "", 0)
|
||||
mock.expectCommand(`dnf upgrade -y -q vim`, "", "", 0)
|
||||
|
||||
result, err := modulePackageWithClient(e, mock, map[string]any{
|
||||
"name": "vim",
|
||||
"state": "latest",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`dnf upgrade -y -q vim`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchYum(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`yum install -y -q htop`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "yum",
|
||||
Args: map[string]any{
|
||||
"name": "htop",
|
||||
"state": "present",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`yum install -y -q htop`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchDnf(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`dnf remove -y -q nano`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "dnf",
|
||||
Args: map[string]any{
|
||||
"name": "nano",
|
||||
"state": "absent",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`dnf remove -y -q nano`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModuleRpm_Good_InstallPackage(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand(`rpm -ivh /tmp/nginx.rpm`, "", "", 0)
|
||||
|
||||
result, err := moduleRPMWithClient(mock, map[string]any{
|
||||
"name": "/tmp/nginx.rpm",
|
||||
"state": "present",
|
||||
}, "rpm")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`rpm -ivh /tmp/nginx.rpm`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchRpm(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`rpm -e nginx`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "rpm",
|
||||
Args: map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "absent",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`rpm -e nginx`))
|
||||
}
|
||||
|
||||
// --- pip module ---
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_InstallPresent(t *testing.T) {
|
||||
func TestModulePip_Good_InstallPresent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install flask`, "Successfully installed", "", 0)
|
||||
|
||||
|
|
@ -826,7 +709,7 @@ func TestModulesSvc_ModulePip_Good_InstallPresent(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`pip3 install flask`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_UninstallAbsent(t *testing.T) {
|
||||
func TestModulePip_Good_UninstallAbsent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 uninstall -y flask`, "Successfully uninstalled", "", 0)
|
||||
|
||||
|
|
@ -841,7 +724,7 @@ func TestModulesSvc_ModulePip_Good_UninstallAbsent(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`pip3 uninstall -y flask`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_UpgradeLatest(t *testing.T) {
|
||||
func TestModulePip_Good_UpgradeLatest(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install --upgrade flask`, "Successfully installed", "", 0)
|
||||
|
||||
|
|
@ -856,7 +739,7 @@ func TestModulesSvc_ModulePip_Good_UpgradeLatest(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`pip3 install --upgrade flask`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_CustomExecutable(t *testing.T) {
|
||||
func TestModulePip_Good_CustomExecutable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
|
||||
|
||||
|
|
@ -872,37 +755,7 @@ func TestModulesSvc_ModulePip_Good_CustomExecutable(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_RequirementsFile(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install -r "/tmp/requirements.txt"`, "", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"requirements": "/tmp/requirements.txt",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 install -r "/tmp/requirements.txt"`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_VirtualenvUsesVenvPip(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "requests",
|
||||
"state": "present",
|
||||
"virtualenv": "/opt/venv",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_DefaultStateIsPresent(t *testing.T) {
|
||||
func TestModulePip_Good_DefaultStateIsPresent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install django`, "", "", 0)
|
||||
|
||||
|
|
@ -915,20 +768,7 @@ func TestModulesSvc_ModulePip_Good_DefaultStateIsPresent(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`pip3 install django`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_InstallMultiplePackages(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install requests flask`, "", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": []any{"requests", "flask"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 install requests flask`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_CommandFailure(t *testing.T) {
|
||||
func TestModulePip_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install`, "", "ERROR: No matching distribution found", 1)
|
||||
|
||||
|
|
@ -942,7 +782,7 @@ func TestModulesSvc_ModulePip_Good_CommandFailure(t *testing.T) {
|
|||
assert.Contains(t, result.Msg, "No matching distribution found")
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_InstalledAlias(t *testing.T) {
|
||||
func TestModulePip_Good_InstalledAlias(t *testing.T) {
|
||||
// state=installed is an alias for present
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install boto3`, "", "", 0)
|
||||
|
|
@ -957,7 +797,7 @@ func TestModulesSvc_ModulePip_Good_InstalledAlias(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`pip3 install boto3`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ModulePip_Good_RemovedAlias(t *testing.T) {
|
||||
func TestModulePip_Good_RemovedAlias(t *testing.T) {
|
||||
// state=removed is an alias for absent
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 uninstall -y boto3`, "", "", 0)
|
||||
|
|
@ -974,7 +814,7 @@ func TestModulesSvc_ModulePip_Good_RemovedAlias(t *testing.T) {
|
|||
|
||||
// --- Cross-module dispatch tests ---
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -993,7 +833,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl restart nginx`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
mock.expectCommand(`systemctl start myapp`, "", "", 0)
|
||||
|
|
@ -1015,7 +855,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`systemctl start myapp`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
||||
|
||||
|
|
@ -1034,7 +874,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
|
|||
assert.True(t, mock.hasExecuted(`apt-get install`))
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl.*gpg`, "", "", 0)
|
||||
|
||||
|
|
@ -1052,7 +892,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
|
|||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
|
@ -1071,7 +911,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.
|
|||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||
mock.expectCommand(`apt-get install -y -qq git`, "", "", 0)
|
||||
|
|
@ -1090,7 +930,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
|
|||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
|
||||
func TestExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install ansible`, "", "", 0)
|
||||
|
||||
|
|
|
|||
491
parser_test.go
491
parser_test.go
|
|
@ -2,6 +2,7 @@ package ansible
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -10,9 +11,9 @@ import (
|
|||
|
||||
// --- ParsePlaybook ---
|
||||
|
||||
func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
|
||||
func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Configure webserver
|
||||
|
|
@ -24,7 +25,7 @@ func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
|
|||
name: nginx
|
||||
state: present
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -41,9 +42,9 @@ func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
|
|||
assert.Equal(t, "present", plays[0].Tasks[0].Args["state"])
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
||||
func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Play one
|
||||
|
|
@ -61,7 +62,7 @@ func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
|||
debug:
|
||||
msg: "Goodbye"
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -75,156 +76,9 @@ func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
|||
assert.Equal(t, "local", plays[1].Connection)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_ImportPlaybook(t *testing.T) {
|
||||
func TestParsePlaybook_Good_WithVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mainPath := joinPath(dir, "site.yml")
|
||||
importDir := joinPath(dir, "plays")
|
||||
importPath := joinPath(importDir, "web.yml")
|
||||
|
||||
yamlMain := `---
|
||||
- name: Before import
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Say before
|
||||
debug:
|
||||
msg: "before"
|
||||
|
||||
- import_playbook: plays/web.yml
|
||||
|
||||
- name: After import
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Say after
|
||||
debug:
|
||||
msg: "after"
|
||||
`
|
||||
yamlImported := `---
|
||||
- name: Imported play
|
||||
hosts: webservers
|
||||
tasks:
|
||||
- name: Say imported
|
||||
debug:
|
||||
msg: "imported"
|
||||
`
|
||||
require.NoError(t, os.MkdirAll(importDir, 0755))
|
||||
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook("site.yml")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 3)
|
||||
assert.Equal(t, "Before import", plays[0].Name)
|
||||
assert.Equal(t, "Imported play", plays[1].Name)
|
||||
assert.Equal(t, "After import", plays[2].Name)
|
||||
assert.Equal(t, "webservers", plays[1].Hosts)
|
||||
assert.Len(t, plays[1].Tasks, 1)
|
||||
assert.Equal(t, "Say imported", plays[1].Tasks[0].Name)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_TemplatedImportPlaybook(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mainPath := joinPath(dir, "site.yml")
|
||||
importDir := joinPath(dir, "plays")
|
||||
importPath := joinPath(importDir, "web.yml")
|
||||
|
||||
yamlMain := `---
|
||||
- import_playbook: "{{ playbook_dir }}/plays/web.yml"
|
||||
`
|
||||
yamlImported := `---
|
||||
- name: Imported play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Say imported
|
||||
debug:
|
||||
msg: "imported"
|
||||
`
|
||||
require.NoError(t, os.MkdirAll(importDir, 0755))
|
||||
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook("site.yml")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.Equal(t, "Imported play", plays[0].Name)
|
||||
assert.Equal(t, "all", plays[0].Hosts)
|
||||
require.NotNil(t, plays[0].Vars)
|
||||
assert.Equal(t, dir, plays[0].Vars["playbook_dir"])
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_FQCNImportPlaybook(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mainPath := joinPath(dir, "site.yml")
|
||||
importDir := joinPath(dir, "plays")
|
||||
importPath := joinPath(importDir, "web.yml")
|
||||
|
||||
yamlMain := `---
|
||||
- ansible.builtin.import_playbook: plays/web.yml
|
||||
`
|
||||
yamlImported := `---
|
||||
- name: Imported play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Say imported
|
||||
debug:
|
||||
msg: "imported"
|
||||
`
|
||||
require.NoError(t, os.MkdirAll(importDir, 0755))
|
||||
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(mainPath)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.Equal(t, "Imported play", plays[0].Name)
|
||||
assert.Equal(t, "all", plays[0].Hosts)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_NestedImportPlaybookDirScope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mainPath := joinPath(dir, "site.yml")
|
||||
outerDir := joinPath(dir, "plays")
|
||||
outerPath := joinPath(outerDir, "outer.yml")
|
||||
innerDir := joinPath(outerDir, "nested")
|
||||
innerPath := joinPath(innerDir, "inner.yml")
|
||||
|
||||
yamlMain := `---
|
||||
- import_playbook: plays/outer.yml
|
||||
`
|
||||
yamlOuter := `---
|
||||
- import_playbook: "{{ playbook_dir }}/nested/inner.yml"
|
||||
`
|
||||
yamlInner := `---
|
||||
- name: Inner play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Say inner
|
||||
debug:
|
||||
msg: "inner"
|
||||
`
|
||||
require.NoError(t, os.MkdirAll(innerDir, 0755))
|
||||
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||
require.NoError(t, writeTestFile(outerPath, []byte(yamlOuter), 0644))
|
||||
require.NoError(t, writeTestFile(innerPath, []byte(yamlInner), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook("site.yml")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.Equal(t, "Inner play", plays[0].Name)
|
||||
require.NotNil(t, plays[0].Vars)
|
||||
assert.Equal(t, dir, plays[0].Vars["playbook_dir"])
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With vars
|
||||
|
|
@ -237,7 +91,7 @@ func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
|
|||
debug:
|
||||
msg: "Port is {{ http_port }}"
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -248,9 +102,9 @@ func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
|
|||
assert.Equal(t, "myapp", plays[0].Vars["app_name"])
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
||||
func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Full lifecycle
|
||||
|
|
@ -268,7 +122,7 @@ func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
|||
debug:
|
||||
msg: "post"
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -283,9 +137,9 @@ func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
|||
assert.Equal(t, "Post task", plays[0].PostTasks[0].Name)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
|
||||
func TestParsePlaybook_Good_Handlers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With handlers
|
||||
|
|
@ -301,7 +155,7 @@ func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
|
|||
name: nginx
|
||||
state: restarted
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -313,9 +167,9 @@ func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
|
|||
assert.Equal(t, "service", plays[0].Handlers[0].Module)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
||||
func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Shell tasks
|
||||
|
|
@ -326,7 +180,7 @@ func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
|||
- name: Run raw command
|
||||
command: ls -la /tmp
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -339,9 +193,9 @@ func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
|||
assert.Equal(t, "ls -la /tmp", plays[0].Tasks[1].Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
|
||||
func TestParsePlaybook_Good_WithTags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Tagged play
|
||||
|
|
@ -356,7 +210,7 @@ func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
|
|||
- debug
|
||||
- always
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -366,9 +220,9 @@ func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
|
|||
assert.Equal(t, []string{"debug", "always"}, plays[0].Tasks[0].Tags)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
||||
func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With blocks
|
||||
|
|
@ -387,7 +241,7 @@ func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
|||
debug:
|
||||
msg: "always"
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -402,9 +256,9 @@ func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
|||
assert.Equal(t, "Always runs", task.Always[0].Name)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
|
||||
func TestParsePlaybook_Good_WithLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Loop test
|
||||
|
|
@ -419,7 +273,7 @@ func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
|
|||
- curl
|
||||
- git
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -432,9 +286,9 @@ func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
|
|||
assert.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
|
||||
func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With roles
|
||||
|
|
@ -447,7 +301,7 @@ func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
|
|||
tags:
|
||||
- web
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -460,9 +314,9 @@ func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
|
|||
assert.Equal(t, []string{"web"}, plays[0].Roles[1].Tags)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
||||
func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: FQCN modules
|
||||
|
|
@ -475,7 +329,7 @@ func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
|||
- name: Run shell
|
||||
ansible.builtin.shell: echo hello
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -487,9 +341,9 @@ func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
|||
assert.Equal(t, "echo hello", plays[0].Tasks[1].Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
||||
func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Conditional play
|
||||
|
|
@ -504,7 +358,7 @@ func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
|||
msg: "File exists"
|
||||
when: nginx_conf.stat.exists
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -514,11 +368,11 @@ func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
|||
assert.NotNil(t, plays[0].Tasks[1].When)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
|
||||
func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
require.NoError(t, writeTestFile(path, []byte("---\n[]"), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte("---\n[]"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -527,11 +381,11 @@ func TestParser_ParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
|
|||
assert.Empty(t, plays)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Bad_InvalidYAML(t *testing.T) {
|
||||
func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "bad.yml")
|
||||
path := filepath.Join(dir, "bad.yml")
|
||||
|
||||
require.NoError(t, writeTestFile(path, []byte("{{invalid yaml}}"), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte("{{invalid yaml}}"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
_, err := p.ParsePlaybook(path)
|
||||
|
|
@ -540,7 +394,7 @@ func TestParser_ParsePlaybook_Bad_InvalidYAML(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "parse playbook")
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Bad_FileNotFound(t *testing.T) {
|
||||
func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
|
||||
p := NewParser(t.TempDir())
|
||||
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
|
||||
|
||||
|
|
@ -548,9 +402,9 @@ func TestParser_ParsePlaybook_Bad_FileNotFound(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "read playbook")
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
||||
func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: No facts
|
||||
|
|
@ -558,7 +412,7 @@ func TestParser_ParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
|||
gather_facts: false
|
||||
tasks: []
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
|
@ -568,33 +422,11 @@ func TestParser_ParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
|||
assert.False(t, *plays[0].GatherFacts)
|
||||
}
|
||||
|
||||
func TestParser_ParsePlaybook_Good_ForceHandlers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Handler control
|
||||
hosts: all
|
||||
force_handlers: true
|
||||
any_errors_fatal: true
|
||||
tasks: []
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.True(t, plays[0].ForceHandlers)
|
||||
assert.True(t, plays[0].AnyErrorsFatal)
|
||||
}
|
||||
|
||||
// --- ParseInventory ---
|
||||
|
||||
func TestParser_ParseInventory_Good_SimpleInventory(t *testing.T) {
|
||||
func TestParseInventory_Good_SimpleInventory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "inventory.yml")
|
||||
path := filepath.Join(dir, "inventory.yml")
|
||||
|
||||
yaml := `---
|
||||
all:
|
||||
|
|
@ -604,7 +436,7 @@ all:
|
|||
web2:
|
||||
ansible_host: 192.168.1.11
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(path)
|
||||
|
|
@ -616,32 +448,9 @@ all:
|
|||
assert.Equal(t, "192.168.1.11", inv.All.Hosts["web2"].AnsibleHost)
|
||||
}
|
||||
|
||||
func TestParser_ParseInventory_Good_DirectoryInventory(t *testing.T) {
|
||||
func TestParseInventory_Good_WithGroups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventoryDir := joinPath(dir, "inventory")
|
||||
require.NoError(t, os.MkdirAll(inventoryDir, 0755))
|
||||
|
||||
path := joinPath(inventoryDir, "hosts.yml")
|
||||
yaml := `---
|
||||
all:
|
||||
hosts:
|
||||
web1:
|
||||
ansible_host: 192.168.1.10
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(inventoryDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inv.All)
|
||||
require.Contains(t, inv.All.Hosts, "web1")
|
||||
assert.Equal(t, "192.168.1.10", inv.All.Hosts["web1"].AnsibleHost)
|
||||
}
|
||||
|
||||
func TestParser_ParseInventory_Good_WithGroups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "inventory.yml")
|
||||
path := filepath.Join(dir, "inventory.yml")
|
||||
|
||||
yaml := `---
|
||||
all:
|
||||
|
|
@ -657,7 +466,7 @@ all:
|
|||
db1:
|
||||
ansible_host: 10.0.1.1
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(path)
|
||||
|
|
@ -669,44 +478,9 @@ all:
|
|||
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
|
||||
}
|
||||
|
||||
func TestParser_ParseInventory_Good_TopLevelGroups(t *testing.T) {
|
||||
func TestParseInventory_Good_WithVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "inventory.yml")
|
||||
|
||||
yaml := `---
|
||||
webservers:
|
||||
vars:
|
||||
tier: web
|
||||
hosts:
|
||||
web1:
|
||||
ansible_host: 10.0.0.1
|
||||
web2:
|
||||
ansible_host: 10.0.0.2
|
||||
databases:
|
||||
hosts:
|
||||
db1:
|
||||
ansible_host: 10.0.1.1
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inv.All)
|
||||
require.NotNil(t, inv.All.Children["webservers"])
|
||||
require.NotNil(t, inv.All.Children["databases"])
|
||||
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
|
||||
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
|
||||
assert.Equal(t, "web", inv.All.Children["webservers"].Vars["tier"])
|
||||
assert.ElementsMatch(t, []string{"web1", "web2", "db1"}, GetHosts(inv, "all"))
|
||||
assert.Equal(t, []string{"web1", "web2"}, GetHosts(inv, "webservers"))
|
||||
assert.Equal(t, "web", GetHostVars(inv, "web1")["tier"])
|
||||
}
|
||||
|
||||
func TestParser_ParseInventory_Good_WithVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "inventory.yml")
|
||||
path := filepath.Join(dir, "inventory.yml")
|
||||
|
||||
yaml := `---
|
||||
all:
|
||||
|
|
@ -721,7 +495,7 @@ all:
|
|||
ansible_host: 10.0.0.1
|
||||
ansible_port: 2222
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(path)
|
||||
|
|
@ -732,11 +506,11 @@ all:
|
|||
assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort)
|
||||
}
|
||||
|
||||
func TestParser_ParseInventory_Bad_InvalidYAML(t *testing.T) {
|
||||
func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "bad.yml")
|
||||
path := filepath.Join(dir, "bad.yml")
|
||||
|
||||
require.NoError(t, writeTestFile(path, []byte("{{{bad"), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte("{{{bad"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
_, err := p.ParseInventory(path)
|
||||
|
|
@ -745,7 +519,7 @@ func TestParser_ParseInventory_Bad_InvalidYAML(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "parse inventory")
|
||||
}
|
||||
|
||||
func TestParser_ParseInventory_Bad_FileNotFound(t *testing.T) {
|
||||
func TestParseInventory_Bad_FileNotFound(t *testing.T) {
|
||||
p := NewParser(t.TempDir())
|
||||
_, err := p.ParseInventory("/nonexistent/inventory.yml")
|
||||
|
||||
|
|
@ -755,9 +529,9 @@ func TestParser_ParseInventory_Bad_FileNotFound(t *testing.T) {
|
|||
|
||||
// --- ParseTasks ---
|
||||
|
||||
func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
|
||||
func TestParseTasks_Good_TaskFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "tasks.yml")
|
||||
path := filepath.Join(dir, "tasks.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: First task
|
||||
|
|
@ -767,7 +541,7 @@ func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
|
|||
src: /tmp/a
|
||||
dest: /tmp/b
|
||||
`
|
||||
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
tasks, err := p.ParseTasks(path)
|
||||
|
|
@ -780,11 +554,11 @@ func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
|
|||
assert.Equal(t, "/tmp/a", tasks[1].Args["src"])
|
||||
}
|
||||
|
||||
func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) {
|
||||
func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := joinPath(dir, "bad.yml")
|
||||
path := filepath.Join(dir, "bad.yml")
|
||||
|
||||
require.NoError(t, writeTestFile(path, []byte("not: [valid: tasks"), 0644))
|
||||
require.NoError(t, os.WriteFile(path, []byte("not: [valid: tasks"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
_, err := p.ParseTasks(path)
|
||||
|
|
@ -792,40 +566,9 @@ func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParser_ParseRole_Good_LoadsRoleVarsIntoParserContext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "tasks", "main.yml"), []byte(`---
|
||||
- name: Role task
|
||||
debug:
|
||||
msg: "{{ role_default }} {{ role_value }} {{ shared_value }}"
|
||||
`), 0644))
|
||||
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "defaults", "main.yml"), []byte(`---
|
||||
role_default: default-value
|
||||
shared_value: default-shared
|
||||
`), 0644))
|
||||
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "vars", "main.yml"), []byte(`---
|
||||
role_value: vars-value
|
||||
shared_value: role-shared
|
||||
`), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
p.vars["existing_value"] = "keep-me"
|
||||
|
||||
tasks, err := p.ParseRole("web", "main.yml")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 1)
|
||||
assert.Equal(t, "debug", tasks[0].Module)
|
||||
assert.Equal(t, "keep-me", p.vars["existing_value"])
|
||||
assert.Equal(t, "default-value", p.vars["role_default"])
|
||||
assert.Equal(t, "vars-value", p.vars["role_value"])
|
||||
assert.Equal(t, "role-shared", p.vars["shared_value"])
|
||||
}
|
||||
|
||||
// --- GetHosts ---
|
||||
|
||||
func TestParser_GetHosts_Good_AllPattern(t *testing.T) {
|
||||
func TestGetHosts_Good_AllPattern(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{
|
||||
|
|
@ -841,13 +584,13 @@ func TestParser_GetHosts_Good_AllPattern(t *testing.T) {
|
|||
assert.Contains(t, hosts, "host2")
|
||||
}
|
||||
|
||||
func TestParser_GetHosts_Good_LocalhostPattern(t *testing.T) {
|
||||
func TestGetHosts_Good_LocalhostPattern(t *testing.T) {
|
||||
inv := &Inventory{All: &InventoryGroup{}}
|
||||
hosts := GetHosts(inv, "localhost")
|
||||
assert.Equal(t, []string{"localhost"}, hosts)
|
||||
}
|
||||
|
||||
func TestParser_GetHosts_Good_GroupPattern(t *testing.T) {
|
||||
func TestGetHosts_Good_GroupPattern(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Children: map[string]*InventoryGroup{
|
||||
|
|
@ -872,7 +615,7 @@ func TestParser_GetHosts_Good_GroupPattern(t *testing.T) {
|
|||
assert.Contains(t, hosts, "web2")
|
||||
}
|
||||
|
||||
func TestParser_GetHosts_Good_SpecificHost(t *testing.T) {
|
||||
func TestGetHosts_Good_SpecificHost(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Children: map[string]*InventoryGroup{
|
||||
|
|
@ -889,39 +632,7 @@ func TestParser_GetHosts_Good_SpecificHost(t *testing.T) {
|
|||
assert.Equal(t, []string{"myhost"}, hosts)
|
||||
}
|
||||
|
||||
func TestParser_GetHosts_Good_ColonUnionIntersectionExclusion(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Children: map[string]*InventoryGroup{
|
||||
"web": {
|
||||
Hosts: map[string]*Host{
|
||||
"web1": {},
|
||||
"web2": {},
|
||||
},
|
||||
},
|
||||
"db": {
|
||||
Hosts: map[string]*Host{
|
||||
"db1": {},
|
||||
"web2": {},
|
||||
},
|
||||
},
|
||||
"canary": {
|
||||
Hosts: map[string]*Host{
|
||||
"web2": {},
|
||||
"db1": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"web1", "web2", "db1"}, GetHosts(inv, "web:db"))
|
||||
assert.Equal(t, []string{"web2"}, GetHosts(inv, "web:&db"))
|
||||
assert.Equal(t, []string{"web1"}, GetHosts(inv, "web:!canary"))
|
||||
assert.Equal(t, []string{"web1"}, GetHosts(inv, "web:db:!canary"))
|
||||
}
|
||||
|
||||
func TestParser_GetHosts_Good_AllIncludesChildren(t *testing.T) {
|
||||
func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{"top": {}},
|
||||
|
|
@ -939,7 +650,7 @@ func TestParser_GetHosts_Good_AllIncludesChildren(t *testing.T) {
|
|||
assert.Contains(t, hosts, "child1")
|
||||
}
|
||||
|
||||
func TestParser_GetHosts_Bad_NoMatch(t *testing.T) {
|
||||
func TestGetHosts_Bad_NoMatch(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{"host1": {}},
|
||||
|
|
@ -950,7 +661,7 @@ func TestParser_GetHosts_Bad_NoMatch(t *testing.T) {
|
|||
assert.Empty(t, hosts)
|
||||
}
|
||||
|
||||
func TestParser_GetHosts_Bad_NilGroup(t *testing.T) {
|
||||
func TestGetHosts_Bad_NilGroup(t *testing.T) {
|
||||
inv := &Inventory{All: nil}
|
||||
hosts := GetHosts(inv, "all")
|
||||
assert.Empty(t, hosts)
|
||||
|
|
@ -958,16 +669,15 @@ func TestParser_GetHosts_Bad_NilGroup(t *testing.T) {
|
|||
|
||||
// --- GetHostVars ---
|
||||
|
||||
func TestParser_GetHostVars_Good_DirectHost(t *testing.T) {
|
||||
func TestGetHostVars_Good_DirectHost(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Vars: map[string]any{"global_var": "global"},
|
||||
Hosts: map[string]*Host{
|
||||
"myhost": {
|
||||
AnsibleHost: "10.0.0.1",
|
||||
AnsiblePort: 2222,
|
||||
AnsibleUser: "deploy",
|
||||
AnsibleBecomePassword: "secret",
|
||||
AnsibleHost: "10.0.0.1",
|
||||
AnsiblePort: 2222,
|
||||
AnsibleUser: "deploy",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -977,11 +687,10 @@ func TestParser_GetHostVars_Good_DirectHost(t *testing.T) {
|
|||
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
|
||||
assert.Equal(t, 2222, vars["ansible_port"])
|
||||
assert.Equal(t, "deploy", vars["ansible_user"])
|
||||
assert.Equal(t, "secret", vars["ansible_become_password"])
|
||||
assert.Equal(t, "global", vars["global_var"])
|
||||
}
|
||||
|
||||
func TestParser_GetHostVars_Good_InheritedGroupVars(t *testing.T) {
|
||||
func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Vars: map[string]any{"level": "all"},
|
||||
|
|
@ -1003,7 +712,7 @@ func TestParser_GetHostVars_Good_InheritedGroupVars(t *testing.T) {
|
|||
assert.Equal(t, "prod", vars["env"])
|
||||
}
|
||||
|
||||
func TestParser_GetHostVars_Good_HostNotFound(t *testing.T) {
|
||||
func TestGetHostVars_Good_HostNotFound(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{"other": {}},
|
||||
|
|
@ -1016,7 +725,7 @@ func TestParser_GetHostVars_Good_HostNotFound(t *testing.T) {
|
|||
|
||||
// --- isModule ---
|
||||
|
||||
func TestParser_IsModule_Good_KnownModules(t *testing.T) {
|
||||
func TestIsModule_Good_KnownModules(t *testing.T) {
|
||||
assert.True(t, isModule("shell"))
|
||||
assert.True(t, isModule("command"))
|
||||
assert.True(t, isModule("copy"))
|
||||
|
|
@ -1024,71 +733,43 @@ func TestParser_IsModule_Good_KnownModules(t *testing.T) {
|
|||
assert.True(t, isModule("apt"))
|
||||
assert.True(t, isModule("service"))
|
||||
assert.True(t, isModule("systemd"))
|
||||
assert.True(t, isModule("rpm"))
|
||||
assert.True(t, isModule("debug"))
|
||||
assert.True(t, isModule("set_fact"))
|
||||
assert.True(t, isModule("ping"))
|
||||
}
|
||||
|
||||
func TestParser_IsModule_Good_FQCN(t *testing.T) {
|
||||
func TestIsModule_Good_FQCN(t *testing.T) {
|
||||
assert.True(t, isModule("ansible.builtin.shell"))
|
||||
assert.True(t, isModule("ansible.builtin.copy"))
|
||||
assert.True(t, isModule("ansible.builtin.apt"))
|
||||
assert.True(t, isModule("ansible.builtin.rpm"))
|
||||
}
|
||||
|
||||
func TestParser_IsModule_Good_DottedUnknown(t *testing.T) {
|
||||
func TestIsModule_Good_DottedUnknown(t *testing.T) {
|
||||
// Any key with dots is considered a module
|
||||
assert.True(t, isModule("community.general.ufw"))
|
||||
assert.True(t, isModule("ansible.posix.authorized_key"))
|
||||
}
|
||||
|
||||
func TestParser_IsModule_Bad_NotAModule(t *testing.T) {
|
||||
func TestIsModule_Bad_NotAModule(t *testing.T) {
|
||||
assert.False(t, isModule("some_random_key"))
|
||||
assert.False(t, isModule("foobar"))
|
||||
}
|
||||
|
||||
// --- NormalizeModule ---
|
||||
|
||||
func TestParser_NormalizeModule_Good(t *testing.T) {
|
||||
func TestNormalizeModule_Good(t *testing.T) {
|
||||
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("shell"))
|
||||
assert.Equal(t, "ansible.builtin.copy", NormalizeModule("copy"))
|
||||
assert.Equal(t, "ansible.builtin.apt", NormalizeModule("apt"))
|
||||
assert.Equal(t, "ansible.builtin.rpm", NormalizeModule("rpm"))
|
||||
assert.Equal(t, "ansible.builtin.ping", NormalizeModule("ping"))
|
||||
}
|
||||
|
||||
func TestParser_NormalizeModule_Good_CommunityAliases(t *testing.T) {
|
||||
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("authorized_key"))
|
||||
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("ansible.builtin.authorized_key"))
|
||||
assert.Equal(t, "community.general.ufw", NormalizeModule("ufw"))
|
||||
assert.Equal(t, "community.general.ufw", NormalizeModule("ansible.builtin.ufw"))
|
||||
assert.Equal(t, "community.docker.docker_compose", NormalizeModule("docker_compose"))
|
||||
assert.Equal(t, "community.docker.docker_compose_v2", NormalizeModule("docker_compose_v2"))
|
||||
assert.Equal(t, "community.docker.docker_compose", NormalizeModule("ansible.builtin.docker_compose"))
|
||||
assert.Equal(t, "community.docker.docker_compose_v2", NormalizeModule("ansible.builtin.docker_compose_v2"))
|
||||
}
|
||||
|
||||
func TestParser_NormalizeModule_Good_AlreadyFQCN(t *testing.T) {
|
||||
func TestNormalizeModule_Good_AlreadyFQCN(t *testing.T) {
|
||||
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("ansible.builtin.shell"))
|
||||
assert.Equal(t, "community.general.ufw", NormalizeModule("community.general.ufw"))
|
||||
}
|
||||
|
||||
func TestParser_IsModule_Good_AdditionalFQCN(t *testing.T) {
|
||||
assert.True(t, isModule("ansible.builtin.hostname"))
|
||||
assert.True(t, isModule("ansible.builtin.sysctl"))
|
||||
assert.True(t, isModule("ansible.builtin.reboot"))
|
||||
}
|
||||
|
||||
func TestParser_NormalizeModule_Good_LegacyNamespace(t *testing.T) {
|
||||
assert.Equal(t, "ansible.builtin.command", NormalizeModule("ansible.legacy.command"))
|
||||
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("ansible.legacy.authorized_key"))
|
||||
assert.Equal(t, "community.general.ufw", NormalizeModule("ansible.legacy.ufw"))
|
||||
}
|
||||
|
||||
// --- NewParser ---
|
||||
|
||||
func TestParser_NewParser_Good(t *testing.T) {
|
||||
func TestNewParser_Good(t *testing.T) {
|
||||
p := NewParser("/some/path")
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, "/some/path", p.basePath)
|
||||
|
|
|
|||
221
ssh.go
221
ssh.go
|
|
@ -3,23 +3,21 @@ package ansible
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/go-log"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// SSHClient handles SSH connections to remote hosts.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// client, _ := NewSSHClient(SSHConfig{Host: "web1"})
|
||||
type SSHClient struct {
|
||||
host string
|
||||
port int
|
||||
|
|
@ -35,10 +33,6 @@ type SSHClient struct {
|
|||
}
|
||||
|
||||
// SSHConfig holds SSH connection configuration.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// config := SSHConfig{Host: "web1", User: "deploy", Port: 22}
|
||||
type SSHConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
|
|
@ -52,41 +46,33 @@ type SSHConfig struct {
|
|||
}
|
||||
|
||||
// NewSSHClient creates a new SSH client.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// client, err := NewSSHClient(SSHConfig{Host: "web1", User: "deploy"})
|
||||
func NewSSHClient(config SSHConfig) (*SSHClient, error) {
|
||||
if config.Port == 0 {
|
||||
config.Port = 22
|
||||
func NewSSHClient(cfg SSHConfig) (*SSHClient, error) {
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 22
|
||||
}
|
||||
if config.User == "" {
|
||||
config.User = "root"
|
||||
if cfg.User == "" {
|
||||
cfg.User = "root"
|
||||
}
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 30 * time.Second
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
client := &SSHClient{
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
user: config.User,
|
||||
password: config.Password,
|
||||
keyFile: config.KeyFile,
|
||||
become: config.Become,
|
||||
becomeUser: config.BecomeUser,
|
||||
becomePass: config.BecomePass,
|
||||
timeout: config.Timeout,
|
||||
host: cfg.Host,
|
||||
port: cfg.Port,
|
||||
user: cfg.User,
|
||||
password: cfg.Password,
|
||||
keyFile: cfg.KeyFile,
|
||||
become: cfg.Become,
|
||||
becomeUser: cfg.BecomeUser,
|
||||
becomePass: cfg.BecomePass,
|
||||
timeout: cfg.Timeout,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Connect establishes the SSH connection.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = client.Connect(context.Background())
|
||||
func (c *SSHClient) Connect(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
|
@ -100,12 +86,13 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
|||
// Try key-based auth first
|
||||
if c.keyFile != "" {
|
||||
keyPath := c.keyFile
|
||||
if corexHasPrefix(keyPath, "~") {
|
||||
keyPath = joinPath(env("DIR_HOME"), keyPath[1:])
|
||||
if strings.HasPrefix(keyPath, "~") {
|
||||
home, _ := os.UserHomeDir()
|
||||
keyPath = filepath.Join(home, keyPath[1:])
|
||||
}
|
||||
|
||||
if key, err := coreio.Local.Read(keyPath); err == nil {
|
||||
if signer, err := ssh.ParsePrivateKey([]byte(key)); err == nil {
|
||||
if key, err := os.ReadFile(keyPath); err == nil {
|
||||
if signer, err := ssh.ParsePrivateKey(key); err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
}
|
||||
|
|
@ -113,14 +100,14 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
|||
|
||||
// Try default SSH keys
|
||||
if len(authMethods) == 0 {
|
||||
home := env("DIR_HOME")
|
||||
home, _ := os.UserHomeDir()
|
||||
defaultKeys := []string{
|
||||
joinPath(home, ".ssh", "id_ed25519"),
|
||||
joinPath(home, ".ssh", "id_rsa"),
|
||||
filepath.Join(home, ".ssh", "id_ed25519"),
|
||||
filepath.Join(home, ".ssh", "id_rsa"),
|
||||
}
|
||||
for _, keyPath := range defaultKeys {
|
||||
if key, err := coreio.Local.Read(keyPath); err == nil {
|
||||
if signer, err := ssh.ParsePrivateKey([]byte(key)); err == nil {
|
||||
if key, err := os.ReadFile(keyPath); err == nil {
|
||||
if signer, err := ssh.ParsePrivateKey(key); err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
break
|
||||
}
|
||||
|
|
@ -141,31 +128,31 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
|||
}
|
||||
|
||||
if len(authMethods) == 0 {
|
||||
return coreerr.E("ssh.Connect", "no authentication method available", nil)
|
||||
return log.E("ssh.Connect", "no authentication method available", nil)
|
||||
}
|
||||
|
||||
// Host key verification
|
||||
var hostKeyCallback ssh.HostKeyCallback
|
||||
|
||||
home := env("DIR_HOME")
|
||||
if home == "" {
|
||||
return coreerr.E("ssh.Connect", "failed to get user home dir", nil)
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return log.E("ssh.Connect", "failed to get user home dir", err)
|
||||
}
|
||||
knownHostsPath := joinPath(home, ".ssh", "known_hosts")
|
||||
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
|
||||
|
||||
// Ensure known_hosts file exists
|
||||
if !coreio.Local.Exists(knownHostsPath) {
|
||||
if err := coreio.Local.EnsureDir(pathDir(knownHostsPath)); err != nil {
|
||||
return coreerr.E("ssh.Connect", "failed to create .ssh dir", err)
|
||||
if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil {
|
||||
return log.E("ssh.Connect", "failed to create .ssh dir", err)
|
||||
}
|
||||
if err := coreio.Local.Write(knownHostsPath, ""); err != nil {
|
||||
return coreerr.E("ssh.Connect", "failed to create known_hosts file", err)
|
||||
if err := os.WriteFile(knownHostsPath, nil, 0600); err != nil {
|
||||
return log.E("ssh.Connect", "failed to create known_hosts file", err)
|
||||
}
|
||||
}
|
||||
|
||||
cb, err := knownhosts.New(knownHostsPath)
|
||||
if err != nil {
|
||||
return coreerr.E("ssh.Connect", "failed to load known_hosts", err)
|
||||
return log.E("ssh.Connect", "failed to load known_hosts", err)
|
||||
}
|
||||
hostKeyCallback = cb
|
||||
|
||||
|
|
@ -176,19 +163,19 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
|||
Timeout: c.timeout,
|
||||
}
|
||||
|
||||
addr := sprintf("%s:%d", c.host, c.port)
|
||||
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||
|
||||
// Connect with context timeout
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return coreerr.E("ssh.Connect", sprintf("dial %s", addr), err)
|
||||
return log.E("ssh.Connect", fmt.Sprintf("dial %s", addr), err)
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
if err != nil {
|
||||
// conn is closed by NewClientConn on error
|
||||
return coreerr.E("ssh.Connect", sprintf("ssh connect %s", addr), err)
|
||||
return log.E("ssh.Connect", fmt.Sprintf("ssh connect %s", addr), err)
|
||||
}
|
||||
|
||||
c.client = ssh.NewClient(sshConn, chans, reqs)
|
||||
|
|
@ -196,10 +183,6 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
|||
}
|
||||
|
||||
// Close closes the SSH connection.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = client.Close()
|
||||
func (c *SSHClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
|
@ -212,22 +195,7 @@ func (c *SSHClient) Close() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// BecomeState returns the current privilege escalation settings.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// become, user, password := client.BecomeState()
|
||||
func (c *SSHClient) BecomeState() (bool, string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.become, c.becomeUser, c.becomePass
|
||||
}
|
||||
|
||||
// Run executes a command on the remote host.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// stdout, stderr, rc, err := client.Run(context.Background(), "hostname")
|
||||
func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
return "", "", -1, err
|
||||
|
|
@ -235,7 +203,7 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
|
|||
|
||||
session, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return "", "", -1, coreerr.E("ssh.Run", "new session", err)
|
||||
return "", "", -1, log.E("ssh.Run", "new session", err)
|
||||
}
|
||||
defer func() { _ = session.Close() }()
|
||||
|
||||
|
|
@ -250,33 +218,33 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
|
|||
becomeUser = "root"
|
||||
}
|
||||
// Escape single quotes in the command
|
||||
escapedCmd := replaceAll(cmd, "'", "'\\''")
|
||||
escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''")
|
||||
if c.becomePass != "" {
|
||||
// Use sudo with password via stdin (-S flag)
|
||||
// We launch a goroutine to write the password to stdin
|
||||
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
|
||||
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
writeString(stdin, c.becomePass+"\n")
|
||||
_, _ = io.WriteString(stdin, c.becomePass+"\n")
|
||||
}()
|
||||
} else if c.password != "" {
|
||||
// Try using connection password for sudo
|
||||
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
|
||||
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
writeString(stdin, c.password+"\n")
|
||||
_, _ = io.WriteString(stdin, c.password+"\n")
|
||||
}()
|
||||
} else {
|
||||
// Try passwordless sudo
|
||||
cmd = sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -304,44 +272,36 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
|
|||
}
|
||||
|
||||
// RunScript runs a script on the remote host.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// stdout, stderr, rc, err := client.RunScript(context.Background(), "echo hello")
|
||||
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
|
||||
// Escape the script for heredoc
|
||||
cmd := sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
|
||||
cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
|
||||
return c.Run(ctx, cmd)
|
||||
}
|
||||
|
||||
// Upload copies a file to the remote host.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := client.Upload(context.Background(), newReader("hello"), "/tmp/hello.txt", 0644)
|
||||
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode fs.FileMode) error {
|
||||
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error {
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read content
|
||||
content, err := readAllString(local)
|
||||
content, err := io.ReadAll(local)
|
||||
if err != nil {
|
||||
return coreerr.E("ssh.Upload", "read content", err)
|
||||
return log.E("ssh.Upload", "read content", err)
|
||||
}
|
||||
|
||||
// Create parent directory
|
||||
dir := pathDir(remote)
|
||||
dirCmd := sprintf("mkdir -p %q", dir)
|
||||
dir := filepath.Dir(remote)
|
||||
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
|
||||
if c.become {
|
||||
dirCmd = sprintf("sudo mkdir -p %q", dir)
|
||||
dirCmd = fmt.Sprintf("sudo mkdir -p %q", dir)
|
||||
}
|
||||
if _, _, _, err := c.Run(ctx, dirCmd); err != nil {
|
||||
return coreerr.E("ssh.Upload", "create parent dir", err)
|
||||
return log.E("ssh.Upload", "create parent dir", err)
|
||||
}
|
||||
|
||||
// Use cat to write the file (simpler than SCP)
|
||||
writeCmd := sprintf("cat > %q && chmod %o %q", remote, mode, remote)
|
||||
writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote)
|
||||
|
||||
// If become is needed, we construct a command that reads password then content from stdin
|
||||
// But we need to be careful with handling stdin for sudo + cat.
|
||||
|
|
@ -349,13 +309,13 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
|
|||
|
||||
session2, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return coreerr.E("ssh.Upload", "new session for write", err)
|
||||
return log.E("ssh.Upload", "new session for write", err)
|
||||
}
|
||||
defer func() { _ = session2.Close() }()
|
||||
|
||||
stdin, err := session2.StdinPipe()
|
||||
if err != nil {
|
||||
return coreerr.E("ssh.Upload", "stdin pipe", err)
|
||||
return log.E("ssh.Upload", "stdin pipe", err)
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
|
|
@ -374,74 +334,66 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
|
|||
|
||||
if pass != "" {
|
||||
// Use sudo -S with password from stdin
|
||||
writeCmd = sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||
writeCmd = fmt.Sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||
becomeUser, remote, mode, remote)
|
||||
} else {
|
||||
// Use passwordless sudo (sudo -n) to avoid consuming file content as password
|
||||
writeCmd = sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||
writeCmd = fmt.Sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||
becomeUser, remote, mode, remote)
|
||||
}
|
||||
|
||||
if err := session2.Start(writeCmd); err != nil {
|
||||
return coreerr.E("ssh.Upload", "start write", err)
|
||||
return log.E("ssh.Upload", "start write", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
if pass != "" {
|
||||
writeString(stdin, pass+"\n")
|
||||
_, _ = io.WriteString(stdin, pass+"\n")
|
||||
}
|
||||
_, _ = stdin.Write([]byte(content))
|
||||
_, _ = stdin.Write(content)
|
||||
}()
|
||||
} else {
|
||||
// Normal write
|
||||
if err := session2.Start(writeCmd); err != nil {
|
||||
return coreerr.E("ssh.Upload", "start write", err)
|
||||
return log.E("ssh.Upload", "start write", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
_, _ = stdin.Write([]byte(content))
|
||||
_, _ = stdin.Write(content)
|
||||
}()
|
||||
}
|
||||
|
||||
if err := session2.Wait(); err != nil {
|
||||
return coreerr.E("ssh.Upload", sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
|
||||
return log.E("ssh.Upload", fmt.Sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download copies a file from the remote host.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// data, err := client.Download(context.Background(), "/etc/hostname")
|
||||
func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) {
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := sprintf("cat %q", remote)
|
||||
cmd := fmt.Sprintf("cat %q", remote)
|
||||
|
||||
stdout, stderr, exitCode, err := c.Run(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exitCode != 0 {
|
||||
return nil, coreerr.E("ssh.Download", sprintf("cat failed: %s", stderr), nil)
|
||||
return nil, log.E("ssh.Download", fmt.Sprintf("cat failed: %s", stderr), nil)
|
||||
}
|
||||
|
||||
return []byte(stdout), nil
|
||||
}
|
||||
|
||||
// FileExists checks if a file exists on the remote host.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ok, err := client.FileExists(context.Background(), "/etc/hosts")
|
||||
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
||||
cmd := sprintf("test -e %q && echo yes || echo no", path)
|
||||
cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path)
|
||||
stdout, _, exitCode, err := c.Run(ctx, cmd)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -450,17 +402,13 @@ func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
|||
// test command failed but didn't error - file doesn't exist
|
||||
return false, nil
|
||||
}
|
||||
return corexTrimSpace(stdout) == "yes", nil
|
||||
return strings.TrimSpace(stdout) == "yes", nil
|
||||
}
|
||||
|
||||
// Stat returns file info from the remote host.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// info, err := client.Stat(context.Background(), "/etc/hosts")
|
||||
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
|
||||
// Simple approach - get basic file info
|
||||
cmd := sprintf(`
|
||||
cmd := fmt.Sprintf(`
|
||||
if [ -e %q ]; then
|
||||
if [ -d %q ]; then
|
||||
echo "exists=true isdir=true"
|
||||
|
|
@ -478,9 +426,9 @@ fi
|
|||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
parts := fields(corexTrimSpace(stdout))
|
||||
parts := strings.Fields(strings.TrimSpace(stdout))
|
||||
for _, part := range parts {
|
||||
kv := splitN(part, "=", 2)
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
if len(kv) == 2 {
|
||||
result[kv[0]] = kv[1] == "true"
|
||||
}
|
||||
|
|
@ -490,19 +438,10 @@ fi
|
|||
}
|
||||
|
||||
// SetBecome enables privilege escalation.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// client.SetBecome(true, "root", "")
|
||||
func (c *SSHClient) SetBecome(become bool, user, password string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.become = become
|
||||
if !become {
|
||||
c.becomeUser = ""
|
||||
c.becomePass = ""
|
||||
return
|
||||
}
|
||||
if user != "" {
|
||||
c.becomeUser = user
|
||||
}
|
||||
|
|
|
|||
20
ssh_test.go
20
ssh_test.go
|
|
@ -7,7 +7,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSSH_NewSSHClient_Good_CustomConfig(t *testing.T) {
|
||||
func TestNewSSHClient(t *testing.T) {
|
||||
cfg := SSHConfig{
|
||||
Host: "localhost",
|
||||
Port: 2222,
|
||||
|
|
@ -23,7 +23,7 @@ func TestSSH_NewSSHClient_Good_CustomConfig(t *testing.T) {
|
|||
assert.Equal(t, 30*time.Second, client.timeout)
|
||||
}
|
||||
|
||||
func TestSSH_NewSSHClient_Good_Defaults(t *testing.T) {
|
||||
func TestSSHConfig_Defaults(t *testing.T) {
|
||||
cfg := SSHConfig{
|
||||
Host: "localhost",
|
||||
}
|
||||
|
|
@ -34,19 +34,3 @@ func TestSSH_NewSSHClient_Good_Defaults(t *testing.T) {
|
|||
assert.Equal(t, "root", client.user)
|
||||
assert.Equal(t, 30*time.Second, client.timeout)
|
||||
}
|
||||
|
||||
func TestSSH_SetBecome_Good_DisablesAndClearsState(t *testing.T) {
|
||||
client := &SSHClient{}
|
||||
|
||||
client.SetBecome(true, "admin", "secret")
|
||||
become, user, password := client.BecomeState()
|
||||
assert.True(t, become)
|
||||
assert.Equal(t, "admin", user)
|
||||
assert.Equal(t, "secret", password)
|
||||
|
||||
client.SetBecome(false, "", "")
|
||||
become, user, password = client.BecomeState()
|
||||
assert.False(t, become)
|
||||
assert.Empty(t, user)
|
||||
assert.Empty(t, password)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
coreio "dappco.re/go/core/io"
|
||||
)
|
||||
|
||||
func readTestFile(path string) ([]byte, error) {
|
||||
content, err := coreio.Local.Read(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
func writeTestFile(path string, content []byte, mode fs.FileMode) error {
|
||||
return coreio.Local.WriteMode(path, string(content), mode)
|
||||
}
|
||||
|
||||
func joinStrings(parts []string, sep string) string {
|
||||
return join(sep, parts)
|
||||
}
|
||||
403
types.go
403
types.go
|
|
@ -2,95 +2,44 @@ package ansible
|
|||
|
||||
import (
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Playbook represents an Ansible playbook.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// playbook := Playbook{Plays: []Play{{Name: "Bootstrap", Hosts: "all"}}}
|
||||
type Playbook struct {
|
||||
Plays []Play `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Play represents a single play in a playbook.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// play := Play{Name: "Configure web", Hosts: "webservers", Become: true}
|
||||
type Play struct {
|
||||
Name string `yaml:"name"`
|
||||
Hosts string `yaml:"hosts"`
|
||||
ImportPlaybook string `yaml:"import_playbook,omitempty"`
|
||||
Connection string `yaml:"connection,omitempty"`
|
||||
Become bool `yaml:"become,omitempty"`
|
||||
BecomeUser string `yaml:"become_user,omitempty"`
|
||||
GatherFacts *bool `yaml:"gather_facts,omitempty"`
|
||||
ForceHandlers bool `yaml:"force_handlers,omitempty"`
|
||||
AnyErrorsFatal bool `yaml:"any_errors_fatal,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
VarsFiles any `yaml:"vars_files,omitempty"` // string or []string
|
||||
ModuleDefaults map[string]map[string]any `yaml:"module_defaults,omitempty"`
|
||||
PreTasks []Task `yaml:"pre_tasks,omitempty"`
|
||||
Tasks []Task `yaml:"tasks,omitempty"`
|
||||
PostTasks []Task `yaml:"post_tasks,omitempty"`
|
||||
Roles []RoleRef `yaml:"roles,omitempty"`
|
||||
Handlers []Task `yaml:"handlers,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
Serial any `yaml:"serial,omitempty"` // int or string
|
||||
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalYAML handles play-level aliases such as ansible.builtin.import_playbook.
|
||||
func (p *Play) UnmarshalYAML(node *yaml.Node) error {
|
||||
type rawPlay Play
|
||||
var raw rawPlay
|
||||
if err := node.Decode(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = Play(raw)
|
||||
|
||||
var fields map[string]any
|
||||
if err := node.Decode(&fields); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if value, ok := directiveValue(fields, "import_playbook"); ok && p.ImportPlaybook == "" {
|
||||
p.ImportPlaybook = sprintf("%v", value)
|
||||
}
|
||||
|
||||
return nil
|
||||
Name string `yaml:"name"`
|
||||
Hosts string `yaml:"hosts"`
|
||||
Connection string `yaml:"connection,omitempty"`
|
||||
Become bool `yaml:"become,omitempty"`
|
||||
BecomeUser string `yaml:"become_user,omitempty"`
|
||||
GatherFacts *bool `yaml:"gather_facts,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
PreTasks []Task `yaml:"pre_tasks,omitempty"`
|
||||
Tasks []Task `yaml:"tasks,omitempty"`
|
||||
PostTasks []Task `yaml:"post_tasks,omitempty"`
|
||||
Roles []RoleRef `yaml:"roles,omitempty"`
|
||||
Handlers []Task `yaml:"handlers,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
Serial any `yaml:"serial,omitempty"` // int or string
|
||||
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
|
||||
}
|
||||
|
||||
// RoleRef represents a role reference in a play.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// role := RoleRef{Role: "nginx", TasksFrom: "install.yml"}
|
||||
type RoleRef struct {
|
||||
Role string `yaml:"role,omitempty"`
|
||||
Name string `yaml:"name,omitempty"` // Alternative to role
|
||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||
DefaultsFrom string `yaml:"defaults_from,omitempty"`
|
||||
VarsFrom string `yaml:"vars_from,omitempty"`
|
||||
HandlersFrom string `yaml:"handlers_from,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
Apply *TaskApply `yaml:"apply,omitempty"`
|
||||
Public bool `yaml:"public,omitempty"`
|
||||
When any `yaml:"when,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Role string `yaml:"role,omitempty"`
|
||||
Name string `yaml:"name,omitempty"` // Alternative to role
|
||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
When any `yaml:"when,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalYAML handles both string and struct role refs.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var ref RoleRef
|
||||
// _ = yaml.Unmarshal([]byte("common"), &ref)
|
||||
func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
// Try string first
|
||||
var s string
|
||||
|
|
@ -112,77 +61,53 @@ func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func directiveValue(fields map[string]any, name string) (any, bool) {
|
||||
if fields == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, key := range []string{name, "ansible.builtin." + name, "ansible.legacy." + name} {
|
||||
if value, ok := fields[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Task represents an Ansible task.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// task := Task{Name: "Install nginx", Module: "apt", Args: map[string]any{"name": "nginx"}}
|
||||
type Task struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Module string `yaml:"-"` // Derived from the module key
|
||||
Args map[string]any `yaml:"-"` // Module arguments
|
||||
Register string `yaml:"register,omitempty"`
|
||||
When any `yaml:"when,omitempty"` // string or []string
|
||||
CheckMode *bool `yaml:"check_mode,omitempty"`
|
||||
Diff *bool `yaml:"diff,omitempty"`
|
||||
Loop any `yaml:"loop,omitempty"` // string or []any
|
||||
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
ChangedWhen any `yaml:"changed_when,omitempty"`
|
||||
FailedWhen any `yaml:"failed_when,omitempty"`
|
||||
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
|
||||
NoLog bool `yaml:"no_log,omitempty"`
|
||||
Become *bool `yaml:"become,omitempty"`
|
||||
BecomeUser string `yaml:"become_user,omitempty"`
|
||||
Delegate string `yaml:"delegate_to,omitempty"`
|
||||
DelegateFacts bool `yaml:"delegate_facts,omitempty"`
|
||||
RunOnce bool `yaml:"run_once,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Block []Task `yaml:"block,omitempty"`
|
||||
Rescue []Task `yaml:"rescue,omitempty"`
|
||||
Always []Task `yaml:"always,omitempty"`
|
||||
Notify any `yaml:"notify,omitempty"` // string or []string
|
||||
Listen any `yaml:"listen,omitempty"` // string or []string
|
||||
Retries int `yaml:"retries,omitempty"`
|
||||
Delay int `yaml:"delay,omitempty"`
|
||||
Until string `yaml:"until,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Module string `yaml:"-"` // Derived from the module key
|
||||
Args map[string]any `yaml:"-"` // Module arguments
|
||||
Register string `yaml:"register,omitempty"`
|
||||
When any `yaml:"when,omitempty"` // string or []string
|
||||
Loop any `yaml:"loop,omitempty"` // string or []any
|
||||
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
ChangedWhen any `yaml:"changed_when,omitempty"`
|
||||
FailedWhen any `yaml:"failed_when,omitempty"`
|
||||
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
|
||||
NoLog bool `yaml:"no_log,omitempty"`
|
||||
Become *bool `yaml:"become,omitempty"`
|
||||
BecomeUser string `yaml:"become_user,omitempty"`
|
||||
Delegate string `yaml:"delegate_to,omitempty"`
|
||||
RunOnce bool `yaml:"run_once,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Block []Task `yaml:"block,omitempty"`
|
||||
Rescue []Task `yaml:"rescue,omitempty"`
|
||||
Always []Task `yaml:"always,omitempty"`
|
||||
Notify any `yaml:"notify,omitempty"` // string or []string
|
||||
Retries int `yaml:"retries,omitempty"`
|
||||
Delay int `yaml:"delay,omitempty"`
|
||||
Until string `yaml:"until,omitempty"`
|
||||
|
||||
// Include/import directives
|
||||
IncludeTasks string `yaml:"include_tasks,omitempty"`
|
||||
ImportTasks string `yaml:"import_tasks,omitempty"`
|
||||
Apply *TaskApply `yaml:"apply,omitempty"`
|
||||
WithFile any `yaml:"with_file,omitempty"`
|
||||
WithFileGlob any `yaml:"with_fileglob,omitempty"`
|
||||
WithSequence any `yaml:"with_sequence,omitempty"`
|
||||
WithTogether any `yaml:"with_together,omitempty"`
|
||||
WithSubelements any `yaml:"with_subelements,omitempty"`
|
||||
IncludeRole *RoleRef `yaml:"include_role,omitempty"`
|
||||
ImportRole *RoleRef `yaml:"import_role,omitempty"`
|
||||
IncludeTasks string `yaml:"include_tasks,omitempty"`
|
||||
ImportTasks string `yaml:"import_tasks,omitempty"`
|
||||
IncludeRole *struct {
|
||||
Name string `yaml:"name"`
|
||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
} `yaml:"include_role,omitempty"`
|
||||
ImportRole *struct {
|
||||
Name string `yaml:"name"`
|
||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
} `yaml:"import_role,omitempty"`
|
||||
|
||||
// Raw YAML for module extraction
|
||||
raw map[string]any
|
||||
}
|
||||
|
||||
// LoopControl controls loop behaviour.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// loop := LoopControl{LoopVar: "item", IndexVar: "idx"}
|
||||
// LoopControl controls loop behavior.
|
||||
type LoopControl struct {
|
||||
LoopVar string `yaml:"loop_var,omitempty"`
|
||||
IndexVar string `yaml:"index_var,omitempty"`
|
||||
|
|
@ -191,30 +116,7 @@ type LoopControl struct {
|
|||
Extended bool `yaml:"extended,omitempty"`
|
||||
}
|
||||
|
||||
// TaskApply captures role-level task defaults from include_role/import_role.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// apply := TaskApply{Tags: []string{"deploy"}}
|
||||
type TaskApply struct {
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
When any `yaml:"when,omitempty"`
|
||||
Become *bool `yaml:"become,omitempty"`
|
||||
BecomeUser string `yaml:"become_user,omitempty"`
|
||||
Delegate string `yaml:"delegate_to,omitempty"`
|
||||
DelegateFacts bool `yaml:"delegate_facts,omitempty"`
|
||||
RunOnce bool `yaml:"run_once,omitempty"`
|
||||
NoLog bool `yaml:"no_log,omitempty"`
|
||||
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
|
||||
}
|
||||
|
||||
// TaskResult holds the result of executing a task.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := TaskResult{Changed: true, Stdout: "ok"}
|
||||
type TaskResult struct {
|
||||
Changed bool `json:"changed"`
|
||||
Failed bool `json:"failed"`
|
||||
|
|
@ -229,116 +131,11 @@ type TaskResult struct {
|
|||
}
|
||||
|
||||
// Inventory represents Ansible inventory.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// inventory := Inventory{All: &InventoryGroup{Hosts: map[string]*Host{"web1": {AnsibleHost: "10.0.0.1"}}}}
|
||||
type Inventory struct {
|
||||
All *InventoryGroup `yaml:"all"`
|
||||
}
|
||||
|
||||
// UnmarshalYAML supports both the explicit `all:` root and inventories that
|
||||
// declare top-level groups directly.
|
||||
func (i *Inventory) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
var raw map[string]any
|
||||
if err := unmarshal(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := &InventoryGroup{}
|
||||
rootInput := make(map[string]any)
|
||||
if all, ok := raw["all"]; ok {
|
||||
group, err := decodeInventoryGroupValue(all)
|
||||
if err != nil {
|
||||
return coreerr.E("Inventory.UnmarshalYAML", "decode all group", err)
|
||||
}
|
||||
root = group
|
||||
}
|
||||
|
||||
for name, value := range raw {
|
||||
if name == "all" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "hosts", "children", "vars":
|
||||
rootInput[name] = value
|
||||
continue
|
||||
}
|
||||
|
||||
group, err := decodeInventoryGroupValue(value)
|
||||
if err != nil {
|
||||
return coreerr.E("Inventory.UnmarshalYAML", "decode group "+name, err)
|
||||
}
|
||||
|
||||
if root.Children == nil {
|
||||
root.Children = make(map[string]*InventoryGroup)
|
||||
}
|
||||
root.Children[name] = group
|
||||
}
|
||||
|
||||
if len(rootInput) > 0 {
|
||||
extra, err := decodeInventoryGroupValue(rootInput)
|
||||
if err != nil {
|
||||
return coreerr.E("Inventory.UnmarshalYAML", "decode root group", err)
|
||||
}
|
||||
mergeInventoryGroups(root, extra)
|
||||
}
|
||||
|
||||
i.All = root
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeInventoryGroupValue(value any) (*InventoryGroup, error) {
|
||||
if value == nil {
|
||||
return &InventoryGroup{}, nil
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var group InventoryGroup
|
||||
if err := yaml.Unmarshal(data, &group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func mergeInventoryGroups(dst, src *InventoryGroup) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if dst.Hosts == nil && len(src.Hosts) > 0 {
|
||||
dst.Hosts = make(map[string]*Host, len(src.Hosts))
|
||||
}
|
||||
for name, host := range src.Hosts {
|
||||
dst.Hosts[name] = host
|
||||
}
|
||||
|
||||
if dst.Children == nil && len(src.Children) > 0 {
|
||||
dst.Children = make(map[string]*InventoryGroup, len(src.Children))
|
||||
}
|
||||
for name, child := range src.Children {
|
||||
dst.Children[name] = child
|
||||
}
|
||||
|
||||
if dst.Vars == nil && len(src.Vars) > 0 {
|
||||
dst.Vars = make(map[string]any, len(src.Vars))
|
||||
}
|
||||
for key, value := range src.Vars {
|
||||
dst.Vars[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// InventoryGroup represents a group in inventory.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// group := InventoryGroup{Hosts: map[string]*Host{"db1": {AnsibleHost: "10.0.1.10"}}}
|
||||
type InventoryGroup struct {
|
||||
Hosts map[string]*Host `yaml:"hosts,omitempty"`
|
||||
Children map[string]*InventoryGroup `yaml:"children,omitempty"`
|
||||
|
|
@ -346,10 +143,6 @@ type InventoryGroup struct {
|
|||
}
|
||||
|
||||
// Host represents a host in inventory.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// host := Host{AnsibleHost: "192.168.1.10", AnsibleUser: "deploy"}
|
||||
type Host struct {
|
||||
AnsibleHost string `yaml:"ansible_host,omitempty"`
|
||||
AnsiblePort int `yaml:"ansible_port,omitempty"`
|
||||
|
|
@ -364,32 +157,20 @@ type Host struct {
|
|||
}
|
||||
|
||||
// Facts holds gathered facts about a host.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// facts := Facts{Hostname: "web1", Distribution: "Ubuntu", Kernel: "Linux"}
|
||||
type Facts struct {
|
||||
Hostname string `json:"ansible_hostname"`
|
||||
FQDN string `json:"ansible_fqdn"`
|
||||
OS string `json:"ansible_os_family"`
|
||||
Distribution string `json:"ansible_distribution"`
|
||||
Version string `json:"ansible_distribution_version"`
|
||||
Architecture string `json:"ansible_architecture"`
|
||||
Kernel string `json:"ansible_kernel"`
|
||||
VirtualizationRole string `json:"ansible_virtualization_role"`
|
||||
VirtualizationType string `json:"ansible_virtualization_type"`
|
||||
Memory int64 `json:"ansible_memtotal_mb"`
|
||||
CPUs int `json:"ansible_processor_vcpus"`
|
||||
IPv4 string `json:"ansible_default_ipv4_address"`
|
||||
Hostname string `json:"ansible_hostname"`
|
||||
FQDN string `json:"ansible_fqdn"`
|
||||
OS string `json:"ansible_os_family"`
|
||||
Distribution string `json:"ansible_distribution"`
|
||||
Version string `json:"ansible_distribution_version"`
|
||||
Architecture string `json:"ansible_architecture"`
|
||||
Kernel string `json:"ansible_kernel"`
|
||||
Memory int64 `json:"ansible_memtotal_mb"`
|
||||
CPUs int `json:"ansible_processor_vcpus"`
|
||||
IPv4 string `json:"ansible_default_ipv4_address"`
|
||||
}
|
||||
|
||||
// KnownModules lists the Ansible module names recognized by the parser.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if slices.Contains(KnownModules, "ansible.builtin.command") {
|
||||
// // parser accepts command tasks
|
||||
// }
|
||||
// Known Ansible modules
|
||||
var KnownModules = []string{
|
||||
// Builtin
|
||||
"ansible.builtin.shell",
|
||||
|
|
@ -400,7 +181,6 @@ var KnownModules = []string{
|
|||
"ansible.builtin.template",
|
||||
"ansible.builtin.file",
|
||||
"ansible.builtin.lineinfile",
|
||||
"ansible.builtin.replace",
|
||||
"ansible.builtin.blockinfile",
|
||||
"ansible.builtin.stat",
|
||||
"ansible.builtin.slurp",
|
||||
|
|
@ -412,7 +192,6 @@ var KnownModules = []string{
|
|||
"ansible.builtin.apt_repository",
|
||||
"ansible.builtin.yum",
|
||||
"ansible.builtin.dnf",
|
||||
"ansible.builtin.rpm",
|
||||
"ansible.builtin.package",
|
||||
"ansible.builtin.pip",
|
||||
"ansible.builtin.service",
|
||||
|
|
@ -426,25 +205,14 @@ var KnownModules = []string{
|
|||
"ansible.builtin.debug",
|
||||
"ansible.builtin.fail",
|
||||
"ansible.builtin.assert",
|
||||
"ansible.builtin.ping",
|
||||
"ansible.builtin.pause",
|
||||
"ansible.builtin.wait_for",
|
||||
"ansible.builtin.wait_for_connection",
|
||||
"ansible.builtin.set_fact",
|
||||
"ansible.builtin.include_vars",
|
||||
"ansible.builtin.add_host",
|
||||
"ansible.builtin.group_by",
|
||||
"ansible.builtin.meta",
|
||||
"ansible.builtin.setup",
|
||||
"community.general.ufw",
|
||||
"ansible.posix.authorized_key",
|
||||
"ansible.builtin.docker_compose",
|
||||
"ansible.builtin.docker_compose_v2",
|
||||
"ansible.builtin.hostname",
|
||||
"ansible.builtin.sysctl",
|
||||
"ansible.builtin.reboot",
|
||||
"community.docker.docker_compose",
|
||||
"community.docker.docker_compose_v2",
|
||||
|
||||
// Short forms (legacy)
|
||||
"shell",
|
||||
|
|
@ -455,7 +223,6 @@ var KnownModules = []string{
|
|||
"template",
|
||||
"file",
|
||||
"lineinfile",
|
||||
"replace",
|
||||
"blockinfile",
|
||||
"stat",
|
||||
"slurp",
|
||||
|
|
@ -467,7 +234,6 @@ var KnownModules = []string{
|
|||
"apt_repository",
|
||||
"yum",
|
||||
"dnf",
|
||||
"rpm",
|
||||
"package",
|
||||
"pip",
|
||||
"service",
|
||||
|
|
@ -481,39 +247,12 @@ var KnownModules = []string{
|
|||
"debug",
|
||||
"fail",
|
||||
"assert",
|
||||
"ping",
|
||||
"pause",
|
||||
"wait_for",
|
||||
"wait_for_connection",
|
||||
"set_fact",
|
||||
"include_vars",
|
||||
"add_host",
|
||||
"group_by",
|
||||
"meta",
|
||||
"setup",
|
||||
"hostname",
|
||||
"sysctl",
|
||||
"reboot",
|
||||
"authorized_key",
|
||||
"ufw",
|
||||
"docker_compose",
|
||||
"docker_compose_v2",
|
||||
}
|
||||
|
||||
// ModuleAliases maps accepted short-form module names to their canonical
|
||||
// fully-qualified collection names.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// module := ModuleAliases["ansible.builtin.authorized_key"]
|
||||
var ModuleAliases = map[string]string{
|
||||
"authorized_key": "ansible.posix.authorized_key",
|
||||
"ansible.builtin.authorized_key": "ansible.posix.authorized_key",
|
||||
"ufw": "community.general.ufw",
|
||||
"ansible.builtin.ufw": "community.general.ufw",
|
||||
"docker_compose": "community.docker.docker_compose",
|
||||
"docker_compose_v2": "community.docker.docker_compose_v2",
|
||||
"ansible.builtin.docker_compose": "community.docker.docker_compose",
|
||||
"ansible.builtin.docker_compose_v2": "community.docker.docker_compose_v2",
|
||||
"rpm": "ansible.builtin.rpm",
|
||||
}
|
||||
|
|
|
|||
673
types_test.go
673
types_test.go
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
// --- RoleRef UnmarshalYAML ---
|
||||
|
||||
func TestTypes_RoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
|
||||
func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
|
||||
input := `common`
|
||||
var ref RoleRef
|
||||
err := yaml.Unmarshal([]byte(input), &ref)
|
||||
|
|
@ -19,7 +19,7 @@ func TestTypes_RoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
|
|||
assert.Equal(t, "common", ref.Role)
|
||||
}
|
||||
|
||||
func TestTypes_RoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
|
||||
func TestRoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
|
||||
input := `
|
||||
role: webserver
|
||||
vars:
|
||||
|
|
@ -36,7 +36,7 @@ tags:
|
|||
assert.Equal(t, []string{"web"}, ref.Tags)
|
||||
}
|
||||
|
||||
func TestTypes_RoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
|
||||
func TestRoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
|
||||
// Some playbooks use "name:" instead of "role:"
|
||||
input := `
|
||||
name: myapp
|
||||
|
|
@ -50,7 +50,7 @@ tasks_from: install.yml
|
|||
assert.Equal(t, "install.yml", ref.TasksFrom)
|
||||
}
|
||||
|
||||
func TestTypes_RoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||
func TestRoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||
input := `
|
||||
role: conditional_role
|
||||
when: ansible_os_family == "Debian"
|
||||
|
|
@ -63,28 +63,9 @@ when: ansible_os_family == "Debian"
|
|||
assert.NotNil(t, ref.When)
|
||||
}
|
||||
|
||||
func TestTypes_RoleRef_UnmarshalYAML_Good_CustomRoleFiles(t *testing.T) {
|
||||
input := `
|
||||
name: web
|
||||
tasks_from: setup.yml
|
||||
defaults_from: custom-defaults.yml
|
||||
vars_from: custom-vars.yml
|
||||
public: true
|
||||
`
|
||||
var ref RoleRef
|
||||
err := yaml.Unmarshal([]byte(input), &ref)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "web", ref.Role)
|
||||
assert.Equal(t, "setup.yml", ref.TasksFrom)
|
||||
assert.Equal(t, "custom-defaults.yml", ref.DefaultsFrom)
|
||||
assert.Equal(t, "custom-vars.yml", ref.VarsFrom)
|
||||
assert.True(t, ref.Public)
|
||||
}
|
||||
|
||||
// --- Task UnmarshalYAML ---
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
|
||||
input := `
|
||||
name: Install nginx
|
||||
apt:
|
||||
|
|
@ -101,7 +82,7 @@ apt:
|
|||
assert.Equal(t, "present", task.Args["state"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
|
||||
input := `
|
||||
name: Run command
|
||||
shell: echo hello world
|
||||
|
|
@ -114,7 +95,7 @@ shell: echo hello world
|
|||
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
|
||||
input := `
|
||||
name: Gather facts
|
||||
setup:
|
||||
|
|
@ -127,7 +108,7 @@ setup:
|
|||
assert.NotNil(t, task.Args)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithRegister(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_WithRegister(t *testing.T) {
|
||||
input := `
|
||||
name: Check file
|
||||
stat:
|
||||
|
|
@ -142,7 +123,7 @@ register: stat_result
|
|||
assert.Equal(t, "stat", task.Module)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||
input := `
|
||||
name: Conditional task
|
||||
debug:
|
||||
|
|
@ -156,24 +137,7 @@ when: some_var is defined
|
|||
assert.NotNil(t, task.When)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithCheckModeAndDiff(t *testing.T) {
|
||||
input := `
|
||||
name: Force a dry run
|
||||
shell: echo hello
|
||||
check_mode: false
|
||||
diff: true
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.CheckMode)
|
||||
require.NotNil(t, task.Diff)
|
||||
assert.False(t, *task.CheckMode)
|
||||
assert.True(t, *task.Diff)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithLoop(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_WithLoop(t *testing.T) {
|
||||
input := `
|
||||
name: Install packages
|
||||
apt:
|
||||
|
|
@ -192,7 +156,7 @@ loop:
|
|||
assert.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithItems(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_WithItems(t *testing.T) {
|
||||
// with_items should be converted to loop
|
||||
input := `
|
||||
name: Old-style loop
|
||||
|
|
@ -212,324 +176,7 @@ with_items:
|
|||
assert.Len(t, items, 2)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithDict(t *testing.T) {
|
||||
input := `
|
||||
name: Old-style dict loop
|
||||
debug:
|
||||
msg: "{{ item.key }}={{ item.value }}"
|
||||
with_dict:
|
||||
alpha: one
|
||||
beta: two
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
items, ok := task.Loop.([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, items, 2)
|
||||
|
||||
first, ok := items[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "alpha", first["key"])
|
||||
assert.Equal(t, "one", first["value"])
|
||||
|
||||
second, ok := items[1].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "beta", second["key"])
|
||||
assert.Equal(t, "two", second["value"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithIndexedItems(t *testing.T) {
|
||||
input := `
|
||||
name: Indexed loop
|
||||
debug:
|
||||
msg: "{{ item.0 }}={{ item.1 }}"
|
||||
with_indexed_items:
|
||||
- apple
|
||||
- banana
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
items, ok := task.Loop.([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, items, 2)
|
||||
|
||||
first, ok := items[0].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 0, first[0])
|
||||
assert.Equal(t, "apple", first[1])
|
||||
|
||||
second, ok := items[1].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 1, second[0])
|
||||
assert.Equal(t, "banana", second[1])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithFile(t *testing.T) {
|
||||
input := `
|
||||
name: Read files
|
||||
debug:
|
||||
msg: "{{ item }}"
|
||||
with_file:
|
||||
- templates/a.txt
|
||||
- templates/b.txt
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.WithFile)
|
||||
files, ok := task.WithFile.([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"templates/a.txt", "templates/b.txt"}, files)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithFileGlob(t *testing.T) {
|
||||
input := `
|
||||
name: Read globbed files
|
||||
debug:
|
||||
msg: "{{ item }}"
|
||||
with_fileglob:
|
||||
- templates/*.txt
|
||||
- files/*.yml
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.WithFileGlob)
|
||||
files, ok := task.WithFileGlob.([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"templates/*.txt", "files/*.yml"}, files)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithSequence(t *testing.T) {
|
||||
input := `
|
||||
name: Read sequence values
|
||||
debug:
|
||||
msg: "{{ item }}"
|
||||
with_sequence: "start=1 end=3 format=%02d"
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.WithSequence)
|
||||
sequence, ok := task.WithSequence.(string)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "start=1 end=3 format=%02d", sequence)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ActionAlias(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy action
|
||||
action: command echo hello world
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "command", task.Module)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasFQCN(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy action
|
||||
ansible.builtin.action: command echo hello world
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "command", task.Module)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasKeyValue(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy action with args
|
||||
action: module=copy src=/tmp/source dest=/tmp/dest mode=0644
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "copy", task.Module)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "/tmp/source", task.Args["src"])
|
||||
assert.Equal(t, "/tmp/dest", task.Args["dest"])
|
||||
assert.Equal(t, "0644", task.Args["mode"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasMixedArgs(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy action with mixed args
|
||||
action: command chdir=/tmp echo hello world
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "command", task.Module)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "/tmp", task.Args["chdir"])
|
||||
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_LocalAction(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy local action
|
||||
local_action: shell echo local
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "shell", task.Module)
|
||||
assert.Equal(t, "localhost", task.Delegate)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "echo local", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_LocalActionFQCN(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy local action
|
||||
ansible.legacy.local_action: shell echo local
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "shell", task.Module)
|
||||
assert.Equal(t, "localhost", task.Delegate)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "echo local", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_LocalActionKeyValue(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy local action with args
|
||||
local_action: module=command chdir=/tmp
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "command", task.Module)
|
||||
assert.Equal(t, "localhost", task.Delegate)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "/tmp", task.Args["chdir"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_LocalActionMixedArgs(t *testing.T) {
|
||||
input := `
|
||||
name: Legacy local action with mixed args
|
||||
local_action: command chdir=/var/tmp echo local
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "command", task.Module)
|
||||
assert.Equal(t, "localhost", task.Delegate)
|
||||
require.NotNil(t, task.Args)
|
||||
assert.Equal(t, "/var/tmp", task.Args["chdir"])
|
||||
assert.Equal(t, "echo local", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithNested(t *testing.T) {
|
||||
input := `
|
||||
name: Nested loop values
|
||||
debug:
|
||||
msg: "{{ item.0 }} {{ item.1 }}"
|
||||
with_nested:
|
||||
- - red
|
||||
- blue
|
||||
- - small
|
||||
- large
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
items, ok := task.Loop.([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, items, 4)
|
||||
|
||||
first, ok := items[0].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"red", "small"}, first)
|
||||
|
||||
second, ok := items[1].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"red", "large"}, second)
|
||||
|
||||
third, ok := items[2].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"blue", "small"}, third)
|
||||
|
||||
fourth, ok := items[3].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"blue", "large"}, fourth)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithTogether(t *testing.T) {
|
||||
input := `
|
||||
name: Together loop values
|
||||
debug:
|
||||
msg: "{{ item.0 }} {{ item.1 }}"
|
||||
with_together:
|
||||
- - red
|
||||
- blue
|
||||
- - small
|
||||
- large
|
||||
- medium
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.WithTogether)
|
||||
|
||||
items, ok := task.Loop.([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, items, 2)
|
||||
|
||||
first, ok := items[0].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"red", "small"}, first)
|
||||
|
||||
second, ok := items[1].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"blue", "large"}, second)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithSubelements(t *testing.T) {
|
||||
input := `
|
||||
name: Subelement loop values
|
||||
debug:
|
||||
msg: "{{ item.0.name }} {{ item.1 }}"
|
||||
with_subelements:
|
||||
- users
|
||||
- authorized
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.WithSubelements)
|
||||
values, ok := task.WithSubelements.([]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []any{"users", "authorized"}, values)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithNotify(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_WithNotify(t *testing.T) {
|
||||
input := `
|
||||
name: Install package
|
||||
apt:
|
||||
|
|
@ -543,116 +190,7 @@ notify: restart nginx
|
|||
assert.Equal(t, "restart nginx", task.Notify)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithListen(t *testing.T) {
|
||||
input := `
|
||||
name: Restart service
|
||||
debug:
|
||||
msg: "handler"
|
||||
listen: reload nginx
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "reload nginx", task.Listen)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ShortFormSystemModules(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantModule string
|
||||
}{
|
||||
{
|
||||
name: "hostname",
|
||||
input: `
|
||||
name: Set hostname
|
||||
hostname:
|
||||
name: web01
|
||||
`,
|
||||
wantModule: "hostname",
|
||||
},
|
||||
{
|
||||
name: "sysctl",
|
||||
input: `
|
||||
name: Tune kernel
|
||||
sysctl:
|
||||
name: net.ipv4.ip_forward
|
||||
value: "1"
|
||||
`,
|
||||
wantModule: "sysctl",
|
||||
},
|
||||
{
|
||||
name: "reboot",
|
||||
input: `
|
||||
name: Reboot host
|
||||
reboot:
|
||||
`,
|
||||
wantModule: "reboot",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(tc.input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantModule, task.Module)
|
||||
assert.NotNil(t, task.Args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ShortFormCommunityModules(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantModule string
|
||||
}{
|
||||
{
|
||||
name: "authorized_key",
|
||||
input: `
|
||||
name: Install SSH key
|
||||
authorized_key:
|
||||
user: deploy
|
||||
key: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD
|
||||
`,
|
||||
wantModule: "authorized_key",
|
||||
},
|
||||
{
|
||||
name: "ufw",
|
||||
input: `
|
||||
name: Allow SSH
|
||||
ufw:
|
||||
rule: allow
|
||||
port: "22"
|
||||
`,
|
||||
wantModule: "ufw",
|
||||
},
|
||||
{
|
||||
name: "docker_compose",
|
||||
input: `
|
||||
name: Start stack
|
||||
docker_compose:
|
||||
project_src: /opt/app
|
||||
`,
|
||||
wantModule: "docker_compose",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(tc.input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantModule, task.Module)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
|
||||
input := `
|
||||
name: Install package
|
||||
apt:
|
||||
|
|
@ -670,7 +208,7 @@ notify:
|
|||
assert.Len(t, notifyList, 2)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
|
||||
input := `
|
||||
name: Include tasks
|
||||
include_tasks: other-tasks.yml
|
||||
|
|
@ -682,76 +220,12 @@ include_tasks: other-tasks.yml
|
|||
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksFQCN(t *testing.T) {
|
||||
input := `
|
||||
name: Include tasks
|
||||
ansible.builtin.include_tasks: other-tasks.yml
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksApply(t *testing.T) {
|
||||
input := `
|
||||
name: Include tasks
|
||||
include_tasks: other-tasks.yml
|
||||
apply:
|
||||
tags:
|
||||
- deploy
|
||||
become: true
|
||||
become_user: root
|
||||
delegate_facts: true
|
||||
environment:
|
||||
APP_ENV: production
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.Apply)
|
||||
assert.Equal(t, []string{"deploy"}, task.Apply.Tags)
|
||||
require.NotNil(t, task.Apply.Become)
|
||||
assert.True(t, *task.Apply.Become)
|
||||
assert.Equal(t, "root", task.Apply.BecomeUser)
|
||||
assert.True(t, task.Apply.DelegateFacts)
|
||||
assert.Equal(t, "production", task.Apply.Environment["APP_ENV"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_DelegateFacts(t *testing.T) {
|
||||
input := `
|
||||
name: Gather delegated facts
|
||||
delegate_to: delegate1
|
||||
delegate_facts: true
|
||||
setup:
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, task.DelegateFacts)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
|
||||
input := `
|
||||
name: Include role
|
||||
include_role:
|
||||
name: common
|
||||
tasks_from: setup.yml
|
||||
defaults_from: defaults.yml
|
||||
vars_from: vars.yml
|
||||
handlers_from: handlers.yml
|
||||
public: true
|
||||
apply:
|
||||
tags:
|
||||
- deploy
|
||||
when: apply_enabled
|
||||
become: true
|
||||
become_user: root
|
||||
environment:
|
||||
APP_ENV: production
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
|
@ -760,75 +234,9 @@ include_role:
|
|||
require.NotNil(t, task.IncludeRole)
|
||||
assert.Equal(t, "common", task.IncludeRole.Name)
|
||||
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
|
||||
assert.Equal(t, "defaults.yml", task.IncludeRole.DefaultsFrom)
|
||||
assert.Equal(t, "vars.yml", task.IncludeRole.VarsFrom)
|
||||
assert.Equal(t, "handlers.yml", task.IncludeRole.HandlersFrom)
|
||||
assert.True(t, task.IncludeRole.Public)
|
||||
require.NotNil(t, task.IncludeRole.Apply)
|
||||
assert.Equal(t, []string{"deploy"}, task.IncludeRole.Apply.Tags)
|
||||
assert.Equal(t, "apply_enabled", task.IncludeRole.Apply.When)
|
||||
require.NotNil(t, task.IncludeRole.Apply.Become)
|
||||
assert.True(t, *task.IncludeRole.Apply.Become)
|
||||
assert.Equal(t, "root", task.IncludeRole.Apply.BecomeUser)
|
||||
assert.Equal(t, "production", task.IncludeRole.Apply.Environment["APP_ENV"])
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_IncludeRoleStringForm(t *testing.T) {
|
||||
input := `
|
||||
name: Include role
|
||||
include_role: common
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.IncludeRole)
|
||||
assert.Equal(t, "common", task.IncludeRole.Role)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_IncludeRoleFQCN(t *testing.T) {
|
||||
input := `
|
||||
name: Include role
|
||||
ansible.builtin.include_role:
|
||||
name: common
|
||||
tasks_from: setup.yml
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.IncludeRole)
|
||||
assert.Equal(t, "common", task.IncludeRole.Role)
|
||||
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleStringForm(t *testing.T) {
|
||||
input := `
|
||||
name: Import role
|
||||
import_role: common
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.ImportRole)
|
||||
assert.Equal(t, "common", task.ImportRole.Role)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleFQCN(t *testing.T) {
|
||||
input := `
|
||||
name: Import role
|
||||
ansible.builtin.import_role: common
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.ImportRole)
|
||||
assert.Equal(t, "common", task.ImportRole.Role)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
|
||||
input := `
|
||||
name: Privileged task
|
||||
shell: systemctl restart nginx
|
||||
|
|
@ -844,7 +252,7 @@ become_user: root
|
|||
assert.Equal(t, "root", task.BecomeUser)
|
||||
}
|
||||
|
||||
func TestTypes_Task_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
|
||||
func TestTask_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
|
||||
input := `
|
||||
name: Might fail
|
||||
shell: some risky command
|
||||
|
|
@ -859,7 +267,7 @@ ignore_errors: true
|
|||
|
||||
// --- Inventory data structure ---
|
||||
|
||||
func TestTypes_Inventory_UnmarshalYAML_Good_Complex(t *testing.T) {
|
||||
func TestInventory_UnmarshalYAML_Good_Complex(t *testing.T) {
|
||||
input := `
|
||||
all:
|
||||
vars:
|
||||
|
|
@ -909,35 +317,31 @@ all:
|
|||
|
||||
// --- Facts ---
|
||||
|
||||
func TestTypes_Facts_Good_Struct(t *testing.T) {
|
||||
func TestFacts_Struct(t *testing.T) {
|
||||
facts := Facts{
|
||||
Hostname: "web1",
|
||||
FQDN: "web1.example.com",
|
||||
OS: "Debian",
|
||||
Distribution: "ubuntu",
|
||||
Version: "24.04",
|
||||
Architecture: "x86_64",
|
||||
Kernel: "6.8.0",
|
||||
VirtualizationRole: "guest",
|
||||
VirtualizationType: "docker",
|
||||
Memory: 16384,
|
||||
CPUs: 4,
|
||||
IPv4: "10.0.0.1",
|
||||
Hostname: "web1",
|
||||
FQDN: "web1.example.com",
|
||||
OS: "Debian",
|
||||
Distribution: "ubuntu",
|
||||
Version: "24.04",
|
||||
Architecture: "x86_64",
|
||||
Kernel: "6.8.0",
|
||||
Memory: 16384,
|
||||
CPUs: 4,
|
||||
IPv4: "10.0.0.1",
|
||||
}
|
||||
|
||||
assert.Equal(t, "web1", facts.Hostname)
|
||||
assert.Equal(t, "web1.example.com", facts.FQDN)
|
||||
assert.Equal(t, "ubuntu", facts.Distribution)
|
||||
assert.Equal(t, "x86_64", facts.Architecture)
|
||||
assert.Equal(t, "guest", facts.VirtualizationRole)
|
||||
assert.Equal(t, "docker", facts.VirtualizationType)
|
||||
assert.Equal(t, int64(16384), facts.Memory)
|
||||
assert.Equal(t, 4, facts.CPUs)
|
||||
}
|
||||
|
||||
// --- TaskResult ---
|
||||
|
||||
func TestTypes_TaskResult_Good_Struct(t *testing.T) {
|
||||
func TestTaskResult_Struct(t *testing.T) {
|
||||
result := TaskResult{
|
||||
Changed: true,
|
||||
Failed: false,
|
||||
|
|
@ -954,7 +358,7 @@ func TestTypes_TaskResult_Good_Struct(t *testing.T) {
|
|||
assert.Equal(t, 0, result.RC)
|
||||
}
|
||||
|
||||
func TestTypes_TaskResult_Good_WithLoopResults(t *testing.T) {
|
||||
func TestTaskResult_WithLoopResults(t *testing.T) {
|
||||
result := TaskResult{
|
||||
Changed: true,
|
||||
Results: []TaskResult{
|
||||
|
|
@ -971,7 +375,7 @@ func TestTypes_TaskResult_Good_WithLoopResults(t *testing.T) {
|
|||
|
||||
// --- KnownModules ---
|
||||
|
||||
func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
|
||||
func TestKnownModules_ContainsExpected(t *testing.T) {
|
||||
// Verify both FQCN and short forms are present
|
||||
fqcnModules := []string{
|
||||
"ansible.builtin.shell",
|
||||
|
|
@ -981,19 +385,8 @@ func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
|
|||
"ansible.builtin.apt",
|
||||
"ansible.builtin.service",
|
||||
"ansible.builtin.systemd",
|
||||
"ansible.builtin.rpm",
|
||||
"ansible.builtin.debug",
|
||||
"ansible.builtin.set_fact",
|
||||
"ansible.builtin.ping",
|
||||
"community.general.ufw",
|
||||
"ansible.posix.authorized_key",
|
||||
"ansible.builtin.docker_compose",
|
||||
"ansible.builtin.docker_compose_v2",
|
||||
"ansible.builtin.hostname",
|
||||
"ansible.builtin.sysctl",
|
||||
"ansible.builtin.reboot",
|
||||
"community.docker.docker_compose",
|
||||
"community.docker.docker_compose_v2",
|
||||
}
|
||||
for _, mod := range fqcnModules {
|
||||
assert.Contains(t, KnownModules, mod, "expected FQCN module %s", mod)
|
||||
|
|
@ -1001,7 +394,7 @@ func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
|
|||
|
||||
shortModules := []string{
|
||||
"shell", "command", "copy", "file", "apt", "service",
|
||||
"systemd", "rpm", "debug", "set_fact", "ping", "template", "user", "group",
|
||||
"systemd", "debug", "set_fact", "template", "user", "group",
|
||||
}
|
||||
for _, mod := range shortModules {
|
||||
assert.Contains(t, KnownModules, mod, "expected short-form module %s", mod)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue