From d45f9bb17d1a19af004307d4cdada10c7a3ad4e8 Mon Sep 17 00:00:00 2001 From: Dmitry Kozlov Date: Sat, 30 May 2026 14:12:55 -0700 Subject: [PATCH 1/2] feat: Add Ansible component for playbooks and ad-hoc commands Add a built-in `ansible` action component that runs Ansible playbooks and ad-hoc commands from a Canvas. The SuperPlane container acts as the Ansible control node and reaches managed hosts via the provided inventory (SSH, or ansible_connection=local for a self-contained run). - One component, two modes (playbook / ad-hoc) via conditional config fields, modeled on the existing ssh component. - Inline playbook + inventory written to a per-execution temp dir; playbook runs use the JSON stdout callback so the per-host recap is captured. - Routes success / failed by Ansible exit status; inability to run Ansible (missing binary, timeout) surfaces as the error state. - Security: argv is built as a slice (no shell), module/var names and argv values are validated, and no secrets are logged or emitted. - Adds ansible to the dev-base image as a dedicated layer (DEBIAN_FRONTEND noninteractive) so it does not invalidate the cached toolchain layers. Unit tests cover validation, argv construction, recap parsing, and channel routing. Signed-off-by: Dmitry Kozlov --- Dockerfile | 10 + pkg/components/ansible/ansible.go | 391 +++++++++++++++++++++ pkg/components/ansible/ansible_test.go | 275 +++++++++++++++ pkg/components/ansible/example.go | 18 + pkg/components/ansible/example_output.json | 20 ++ pkg/components/ansible/runner.go | 223 ++++++++++++ pkg/registryimports/registryimports.go | 1 + 7 files changed, 938 insertions(+) create mode 100644 pkg/components/ansible/ansible.go create mode 100644 pkg/components/ansible/ansible_test.go create mode 100644 pkg/components/ansible/example.go create mode 100644 pkg/components/ansible/example_output.json create mode 100644 pkg/components/ansible/runner.go diff --git a/Dockerfile b/Dockerfile index 24277db439..e8f9302f9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,16 @@ WORKDIR /app RUN mkdir -p "${PLAYWRIGHT_BROWSERS_PATH}" RUN playwright install chromium-headless-shell --with-deps + +# Ansible is required by the built-in `ansible` component, which uses this +# container as the Ansible control node. Kept as a dedicated layer so it does +# not invalidate the cache for the expensive toolchain layers above. +# DEBIAN_FRONTEND=noninteractive avoids the tzdata install prompt. +RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ansible && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + RUN rm -rf /opt/install /opt/install-scripts /tmp/* CMD [ "/bin/bash", "-c", "sleep infinity" ] diff --git a/pkg/components/ansible/ansible.go b/pkg/components/ansible/ansible.go new file mode 100644 index 0000000000..3a554146de --- /dev/null +++ b/pkg/components/ansible/ansible.go @@ -0,0 +1,391 @@ +package ansible + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +const ( + channelSuccess = "success" + channelFailed = "failed" + + ModePlaybook = "playbook" + ModeAdhoc = "adhoc" + + ModuleDefault = "shell" + + payloadTypePlaybook = "ansible.playbook.executed" + payloadTypeAdhoc = "ansible.adhoc.executed" + + defaultInventory = "localhost ansible_connection=local" + defaultTimeout = 300 +) + +// moduleNameRegex allows module names like `shell` and fully-qualified ones +// like `ansible.builtin.copy`. +var moduleNameRegex = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.]*$`) + +// extraVarNameRegex matches valid Ansible/YAML variable names. +var extraVarNameRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func init() { + registry.RegisterAction("ansible", &Ansible{}) +} + +// Ansible runs Ansible playbooks and ad-hoc commands. The SuperPlane process +// acts as the Ansible control node and reaches managed hosts via the provided +// inventory. +type Ansible struct { + // runner is injectable for testing; when nil the real execRunner is used. + runner ansibleRunner +} + +type ExtraVar struct { + Name string `json:"name" mapstructure:"name"` + Value string `json:"value" mapstructure:"value"` +} + +type Spec struct { + Mode string `json:"mode" mapstructure:"mode"` + Inventory string `json:"inventory" mapstructure:"inventory"` + Playbook *string `json:"playbook,omitempty" mapstructure:"playbook"` + HostPattern *string `json:"hostPattern,omitempty" mapstructure:"hostPattern"` + Module *string `json:"module,omitempty" mapstructure:"module"` + ModuleArgs *string `json:"moduleArgs,omitempty" mapstructure:"moduleArgs"` + ExtraVars []ExtraVar `json:"extraVars,omitempty" mapstructure:"extraVars"` + Limit *string `json:"limit,omitempty" mapstructure:"limit"` + Become bool `json:"become" mapstructure:"become"` + Verbosity int `json:"verbosity" mapstructure:"verbosity"` + Timeout int `json:"timeout" mapstructure:"timeout"` +} + +func (a *Ansible) Name() string { return "ansible" } +func (a *Ansible) Label() string { return "Ansible" } + +func (a *Ansible) Description() string { + return "Run an Ansible playbook or ad-hoc command against an inventory." +} + +func (a *Ansible) Documentation() string { + return `Run Ansible from a SuperPlane workflow. The SuperPlane node acts as the Ansible control node and reaches managed hosts through the inventory you provide. + +## Modes + +- **Playbook**: Provide playbook YAML inline. It is run with ` + "`ansible-playbook`" + ` and the JSON output callback so the play recap (ok/changed/unreachable/failed per host) is captured. +- **Ad-hoc**: Run a single module against a host pattern, e.g. module ` + "`ping`" + `, or module ` + "`shell`" + ` with arguments ` + "`uptime`" + `. + +## Configuration + +- **Inventory**: Inline inventory (INI or YAML). Defaults to ` + "`localhost ansible_connection=local`" + ` so it works without any remote hosts. +- **Playbook** (playbook mode): The playbook YAML to run. +- **Host pattern / Module / Module arguments** (ad-hoc mode): The target pattern, module name, and module arguments. +- **Extra variables**: Optional ` + "`name=value`" + ` pairs passed via ` + "`-e`" + `. +- **Limit**: Optional ` + "`--limit`" + ` host pattern. +- **Become**: Run with privilege escalation (` + "`--become`" + `). +- **Verbosity**: 0-4, mapped to ` + "`-v`..`-vvvv`" + `. +- **Timeout (seconds)**: Maximum run time before the execution errors out. + +## Output + +- **success**: Ansible exited with status 0. +- **failed**: Ansible ran but exited non-zero (e.g. a task failed or a host was unreachable). + +If Ansible cannot be run at all (binary missing, timeout, invalid working directory), the run finishes in the **error** state. +` +} + +func (a *Ansible) Icon() string { return "server" } +func (a *Ansible) Color() string { return "red" } + +func (a *Ansible) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{ + {Name: channelSuccess, Label: "Success"}, + {Name: channelFailed, Label: "Failed"}, + } +} + +func (a *Ansible) Configuration() []configuration.Field { + playbookOnly := []configuration.VisibilityCondition{{Field: "mode", Values: []string{ModePlaybook}}} + adhocOnly := []configuration.VisibilityCondition{{Field: "mode", Values: []string{ModeAdhoc}}} + + return []configuration.Field{ + { + Name: "mode", + Label: "Mode", + Type: configuration.FieldTypeSelect, + Description: "Run a playbook or a single ad-hoc module", + Required: true, + Default: ModePlaybook, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Playbook", Value: ModePlaybook}, + {Label: "Ad-hoc command", Value: ModeAdhoc}, + }, + }, + }, + }, + { + Name: "inventory", + Label: "Inventory", + Type: configuration.FieldTypeText, + Description: "Inline Ansible inventory (INI or YAML)", + Required: true, + Default: defaultInventory, + }, + { + Name: "playbook", + Label: "Playbook", + Type: configuration.FieldTypeText, + Description: "Playbook YAML to run", + Placeholder: "- hosts: all\n tasks:\n - ping:", + VisibilityConditions: playbookOnly, + RequiredConditions: []configuration.RequiredCondition{{Field: "mode", Values: []string{ModePlaybook}}}, + }, + { + Name: "hostPattern", + Label: "Host pattern", + Type: configuration.FieldTypeString, + Description: "Hosts to target, e.g. all, webservers, or a specific host", + Placeholder: "all", + VisibilityConditions: adhocOnly, + RequiredConditions: []configuration.RequiredCondition{{Field: "mode", Values: []string{ModeAdhoc}}}, + }, + { + Name: "module", + Label: "Module", + Type: configuration.FieldTypeString, + Description: "Ansible module to run, e.g. ping, shell, copy", + Default: ModuleDefault, + VisibilityConditions: adhocOnly, + }, + { + Name: "moduleArgs", + Label: "Module arguments", + Type: configuration.FieldTypeString, + Description: "Arguments passed to the module via -a", + Placeholder: "uptime", + VisibilityConditions: adhocOnly, + }, + { + Name: "extraVars", + Label: "Extra variables", + Type: configuration.FieldTypeList, + Description: "Variables passed to Ansible via -e", + Togglable: true, + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Variable", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "name", + Label: "Name", + Type: configuration.FieldTypeString, + Description: "Variable name (letters, numbers, underscore)", + Required: true, + }, + { + Name: "value", + Label: "Value", + Type: configuration.FieldTypeString, + Description: "Variable value", + Required: true, + }, + }, + }, + }, + }, + }, + { + Name: "limit", + Label: "Limit", + Type: configuration.FieldTypeString, + Description: "Restrict the run to a subset of hosts (--limit)", + Togglable: true, + }, + { + Name: "become", + Label: "Become (privilege escalation)", + Type: configuration.FieldTypeBool, + Description: "Run with --become (e.g. sudo)", + Default: false, + }, + { + Name: "verbosity", + Label: "Verbosity", + Type: configuration.FieldTypeNumber, + Description: "Ansible verbosity level (0-4)", + Togglable: true, + Default: 0, + TypeOptions: &configuration.TypeOptions{ + Number: &configuration.NumberTypeOptions{Min: intPtr(0), Max: intPtr(4)}, + }, + }, + { + Name: "timeout", + Label: "Timeout (seconds)", + Type: configuration.FieldTypeNumber, + Description: "Maximum run time before the execution errors out", + Required: true, + Default: defaultTimeout, + }, + } +} + +func (a *Ansible) Setup(ctx core.SetupContext) error { + spec, err := decodeSpec(ctx.Configuration) + if err != nil { + return err + } + return validate(spec) +} + +func (a *Ansible) Execute(ctx core.ExecutionContext) error { + spec, err := decodeSpec(ctx.Configuration) + if err != nil { + return err + } + if err := validate(spec); err != nil { + return err + } + + runner := a.runner + if runner == nil { + runner = execRunner{} + } + + runCtx, cancel := context.WithTimeout(context.Background(), time.Duration(spec.Timeout)*time.Second) + defer cancel() + + result, err := runner.Run(runCtx, spec, ctx.Logger) + if err != nil { + // Could not run Ansible at all -> error state. + return err + } + + if err := ctx.Metadata.Set(result); err != nil { + return err + } + + payloadType := payloadTypePlaybook + if spec.Mode == ModeAdhoc { + payloadType = payloadTypeAdhoc + } + + channel := channelSuccess + if result.ExitCode != 0 { + channel = channelFailed + } + + return ctx.ExecutionState.Emit(channel, payloadType, []any{result}) +} + +func (a *Ansible) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (a *Ansible) Hooks() []core.Hook { return []core.Hook{} } + +func (a *Ansible) HandleHook(ctx core.ActionHookContext) error { + return fmt.Errorf("unknown action: %s", ctx.Name) +} + +func (a *Ansible) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return 404, nil, fmt.Errorf("ansible component does not handle webhooks") +} + +func (a *Ansible) Cancel(ctx core.ExecutionContext) error { return nil } + +func (a *Ansible) Cleanup(ctx core.SetupContext) error { return nil } + +// decodeSpec decodes the raw configuration into a Spec, applying defaults for +// optional fields. +func decodeSpec(raw any) (Spec, error) { + spec := Spec{} + if err := mapstructure.Decode(raw, &spec); err != nil { + return spec, fmt.Errorf("invalid configuration: %w", err) + } + if spec.Mode == "" { + spec.Mode = ModePlaybook + } + if spec.Timeout == 0 { + spec.Timeout = defaultTimeout + } + return spec, nil +} + +// validate enforces required fields and guards inputs that become argv values +// to prevent them from being interpreted as flags or injecting commands. +func validate(spec Spec) error { + if spec.Mode != ModePlaybook && spec.Mode != ModeAdhoc { + return fmt.Errorf("invalid mode: %s", spec.Mode) + } + if strings.TrimSpace(spec.Inventory) == "" { + return fmt.Errorf("inventory is required") + } + if spec.Timeout < 1 { + return fmt.Errorf("timeout must be at least 1 second") + } + if spec.Verbosity < 0 || spec.Verbosity > 4 { + return fmt.Errorf("verbosity must be between 0 and 4") + } + + switch spec.Mode { + case ModePlaybook: + if spec.Playbook == nil || strings.TrimSpace(*spec.Playbook) == "" { + return fmt.Errorf("playbook is required in playbook mode") + } + case ModeAdhoc: + if spec.HostPattern == nil || strings.TrimSpace(*spec.HostPattern) == "" { + return fmt.Errorf("host pattern is required in ad-hoc mode") + } + if err := validateArgValue("host pattern", *spec.HostPattern); err != nil { + return err + } + if spec.Module != nil && *spec.Module != "" && !moduleNameRegex.MatchString(*spec.Module) { + return fmt.Errorf("invalid module name: %s", *spec.Module) + } + } + + if spec.Limit != nil && *spec.Limit != "" { + if err := validateArgValue("limit", *spec.Limit); err != nil { + return err + } + } + + for _, v := range spec.ExtraVars { + if v.Name == "" { + return fmt.Errorf("extra variable name is required") + } + if !extraVarNameRegex.MatchString(v.Name) { + return fmt.Errorf("invalid extra variable name: %s", v.Name) + } + } + + return nil +} + +// validateArgValue rejects values that would be misread as a command-line flag +// (positional argv values must not start with "-") or that contain newlines. +func validateArgValue(label, value string) error { + if strings.HasPrefix(value, "-") { + return fmt.Errorf("%s must not start with '-'", label) + } + if strings.ContainsAny(value, "\n\r") { + return fmt.Errorf("%s must not contain newlines", label) + } + return nil +} + +func intPtr(v int) *int { return &v } diff --git a/pkg/components/ansible/ansible_test.go b/pkg/components/ansible/ansible_test.go new file mode 100644 index 0000000000..27e2511da9 --- /dev/null +++ b/pkg/components/ansible/ansible_test.go @@ -0,0 +1,275 @@ +package ansible + +import ( + "context" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/superplanehq/superplane/pkg/core" +) + +// --- test doubles ----------------------------------------------------------- + +type testMetadataContext struct { + value any +} + +func (m *testMetadataContext) Get() any { return m.value } +func (m *testMetadataContext) Set(v any) error { m.value = v; return nil } + +type emittedPayload struct { + channel string + payloadType string + payloads []any +} + +type testExecutionState struct { + finished bool + emitted []emittedPayload +} + +func (s *testExecutionState) IsFinished() bool { return s.finished } +func (s *testExecutionState) SetKV(key, value string) error { return nil } +func (s *testExecutionState) GetKV(key string) (string, error) { return "", nil } +func (s *testExecutionState) Pass() error { return nil } +func (s *testExecutionState) Fail(reason, message string) error { return nil } + +func (s *testExecutionState) Emit(channel, payloadType string, payloads []any) error { + s.emitted = append(s.emitted, emittedPayload{channel, payloadType, payloads}) + return nil +} + +type stubRunner struct { + result *RunResult + err error + called bool +} + +func (r *stubRunner) Run(ctx context.Context, spec Spec, logger *log.Entry) (*RunResult, error) { + r.called = true + return r.result, r.err +} + +func strPtr(s string) *string { return &s } + +// --- Setup / validation ----------------------------------------------------- + +func TestSetupValidation(t *testing.T) { + a := &Ansible{} + + cases := []struct { + name string + config map[string]any + wantErr string + }{ + { + name: "missing inventory", + config: map[string]any{"mode": ModePlaybook, "playbook": "- hosts: all", "inventory": "", "timeout": 60}, + wantErr: "inventory is required", + }, + { + name: "invalid mode", + config: map[string]any{"mode": "nope", "inventory": "localhost", "timeout": 60}, + wantErr: "invalid mode", + }, + { + name: "playbook mode without playbook", + config: map[string]any{"mode": ModePlaybook, "inventory": "localhost", "timeout": 60}, + wantErr: "playbook is required", + }, + { + name: "adhoc mode without host pattern", + config: map[string]any{"mode": ModeAdhoc, "inventory": "localhost", "timeout": 60}, + wantErr: "host pattern is required", + }, + { + name: "host pattern starting with dash", + config: map[string]any{"mode": ModeAdhoc, "hostPattern": "--version", "inventory": "localhost", "timeout": 60}, + wantErr: "must not start with '-'", + }, + { + name: "invalid module name", + config: map[string]any{"mode": ModeAdhoc, "hostPattern": "all", "module": "bad module!", "inventory": "localhost", "timeout": 60}, + wantErr: "invalid module name", + }, + { + name: "invalid extra var name", + config: map[string]any{"mode": ModeAdhoc, "hostPattern": "all", "inventory": "localhost", "timeout": 60, "extraVars": []map[string]any{{"name": "BAD-NAME", "value": "x"}}}, + wantErr: "invalid extra variable name", + }, + { + name: "timeout too small", + config: map[string]any{"mode": ModeAdhoc, "hostPattern": "all", "inventory": "localhost", "timeout": -1}, + wantErr: "timeout must be at least", + }, + { + name: "verbosity out of range", + config: map[string]any{"mode": ModeAdhoc, "hostPattern": "all", "inventory": "localhost", "timeout": 60, "verbosity": 9}, + wantErr: "verbosity must be between", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := a.Setup(core.SetupContext{Configuration: tc.config}) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } + + t.Run("valid playbook config", func(t *testing.T) { + err := a.Setup(core.SetupContext{Configuration: map[string]any{ + "mode": ModePlaybook, "playbook": "- hosts: all\n tasks:\n - ping:", "inventory": defaultInventory, "timeout": 300, + }}) + require.NoError(t, err) + }) + + t.Run("valid adhoc config", func(t *testing.T) { + err := a.Setup(core.SetupContext{Configuration: map[string]any{ + "mode": ModeAdhoc, "hostPattern": "all", "module": "ansible.builtin.ping", "inventory": defaultInventory, "timeout": 300, + }}) + require.NoError(t, err) + }) +} + +func TestDecodeSpecDefaults(t *testing.T) { + spec, err := decodeSpec(map[string]any{"inventory": "localhost", "playbook": "x"}) + require.NoError(t, err) + assert.Equal(t, ModePlaybook, spec.Mode) + assert.Equal(t, defaultTimeout, spec.Timeout) +} + +// --- argv construction ------------------------------------------------------ + +func TestBuildPlaybookArgs(t *testing.T) { + spec := Spec{ + Become: true, + Verbosity: 2, + Limit: strPtr("web"), + ExtraVars: []ExtraVar{{Name: "env", Value: "prod"}, {Name: "skip", Value: ""}}, + } + + args := buildPlaybookArgs("/tmp/play.yml", "/tmp/inv", spec) + + assert.Equal(t, []string{ + "-i", "/tmp/inv", + "--limit", "web", + "--become", + "-vv", + "-e", "env=prod", + "-e", "skip=", + "/tmp/play.yml", + }, args) +} + +func TestBuildAdhocArgs(t *testing.T) { + t.Run("defaults module to shell", func(t *testing.T) { + args := buildAdhocArgs("/tmp/inv", Spec{HostPattern: strPtr("all"), ModuleArgs: strPtr("uptime")}) + assert.Equal(t, []string{"all", "-i", "/tmp/inv", "-m", "shell", "-a", "uptime"}, args) + }) + + t.Run("explicit module without args", func(t *testing.T) { + args := buildAdhocArgs("/tmp/inv", Spec{HostPattern: strPtr("web"), Module: strPtr("ping")}) + assert.Equal(t, []string{"web", "-i", "/tmp/inv", "-m", "ping"}, args) + }) +} + +func TestVerbosityFlag(t *testing.T) { + assert.Equal(t, "", verbosityFlag(0)) + assert.Equal(t, "", verbosityFlag(-3)) + assert.Equal(t, "-v", verbosityFlag(1)) + assert.Equal(t, "-vvvv", verbosityFlag(4)) + assert.Equal(t, "-vvvv", verbosityFlag(10)) +} + +func TestExtraVarArgs(t *testing.T) { + // A value containing shell metacharacters is passed verbatim as a single + // argv element (no shell), so it cannot inject extra commands. + args := extraVarArgs([]ExtraVar{{Name: "msg", Value: "a; rm -rf / $(whoami)"}}) + assert.Equal(t, []string{"-e", "msg=a; rm -rf / $(whoami)"}, args) +} + +// --- recap parsing ---------------------------------------------------------- + +func TestParseRecap(t *testing.T) { + t.Run("valid json stats", func(t *testing.T) { + out := []byte(`{"stats":{"localhost":{"ok":3,"changed":1,"unreachable":0,"failures":0,"skipped":2,"rescued":0,"ignored":0}}}`) + recap := parseRecap(out) + require.NotNil(t, recap) + assert.Equal(t, 3, recap["localhost"].Ok) + assert.Equal(t, 1, recap["localhost"].Changed) + assert.Equal(t, 2, recap["localhost"].Skipped) + }) + + t.Run("non-json returns nil", func(t *testing.T) { + assert.Nil(t, parseRecap([]byte("PLAY RECAP *** localhost : ok=3"))) + }) +} + +// --- Execute channel routing ------------------------------------------------ + +func executeWith(t *testing.T, mode string, runner *stubRunner) *testExecutionState { + t.Helper() + state := &testExecutionState{} + a := &Ansible{runner: runner} + + config := map[string]any{"mode": mode, "inventory": defaultInventory, "timeout": 60} + if mode == ModeAdhoc { + config["hostPattern"] = "all" + } else { + config["playbook"] = "- hosts: all\n tasks:\n - ping:" + } + + err := a.Execute(core.ExecutionContext{ + Configuration: config, + Metadata: &testMetadataContext{}, + ExecutionState: state, + Logger: log.NewEntry(log.New()), + }) + require.NoError(t, err) + require.True(t, runner.called) + return state +} + +func TestExecuteRouting(t *testing.T) { + t.Run("exit 0 routes to success", func(t *testing.T) { + state := executeWith(t, ModePlaybook, &stubRunner{result: &RunResult{ExitCode: 0}}) + require.Len(t, state.emitted, 1) + assert.Equal(t, channelSuccess, state.emitted[0].channel) + assert.Equal(t, payloadTypePlaybook, state.emitted[0].payloadType) + }) + + t.Run("non-zero exit routes to failed", func(t *testing.T) { + state := executeWith(t, ModePlaybook, &stubRunner{result: &RunResult{ExitCode: 2}}) + require.Len(t, state.emitted, 1) + assert.Equal(t, channelFailed, state.emitted[0].channel) + }) + + t.Run("adhoc uses adhoc payload type", func(t *testing.T) { + state := executeWith(t, ModeAdhoc, &stubRunner{result: &RunResult{ExitCode: 0}}) + require.Len(t, state.emitted, 1) + assert.Equal(t, payloadTypeAdhoc, state.emitted[0].payloadType) + }) +} + +func TestExecuteRunnerErrorSurfacesAsError(t *testing.T) { + state := &testExecutionState{} + a := &Ansible{runner: &stubRunner{err: assertErr("ansible: command not found")}} + + err := a.Execute(core.ExecutionContext{ + Configuration: map[string]any{"mode": ModePlaybook, "inventory": defaultInventory, "playbook": "- hosts: all", "timeout": 60}, + Metadata: &testMetadataContext{}, + ExecutionState: state, + Logger: log.NewEntry(log.New()), + }) + + require.Error(t, err) + assert.Empty(t, state.emitted, "no channel should be emitted when the component errors") +} + +type assertErr string + +func (e assertErr) Error() string { return string(e) } diff --git a/pkg/components/ansible/example.go b/pkg/components/ansible/example.go new file mode 100644 index 0000000000..0b0d15a021 --- /dev/null +++ b/pkg/components/ansible/example.go @@ -0,0 +1,18 @@ +package ansible + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output.json +var exampleOutputBytes []byte + +var exampleOutputOnce sync.Once +var exampleOutput map[string]any + +func (a *Ansible) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputOnce, exampleOutputBytes, &exampleOutput) +} diff --git a/pkg/components/ansible/example_output.json b/pkg/components/ansible/example_output.json new file mode 100644 index 0000000000..9e8b091d22 --- /dev/null +++ b/pkg/components/ansible/example_output.json @@ -0,0 +1,20 @@ +{ + "type": "ansible.playbook.executed", + "data": { + "exitCode": 0, + "stdout": "{\"stats\": {\"localhost\": {\"ok\": 2, \"changed\": 1, \"unreachable\": 0, \"failures\": 0, \"skipped\": 0, \"rescued\": 0, \"ignored\": 0}}}", + "stderr": "", + "recap": { + "localhost": { + "ok": 2, + "changed": 1, + "unreachable": 0, + "failures": 0, + "skipped": 0, + "rescued": 0, + "ignored": 0 + } + } + }, + "timestamp": "2026-01-19T12:00:00Z" +} diff --git a/pkg/components/ansible/runner.go b/pkg/components/ansible/runner.go new file mode 100644 index 0000000000..872fa33030 --- /dev/null +++ b/pkg/components/ansible/runner.go @@ -0,0 +1,223 @@ +package ansible + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" +) + +const ( + binaryPlaybook = "ansible-playbook" + binaryAdhoc = "ansible" +) + +// RecapStats mirrors the per-host counters Ansible reports in its play recap. +type RecapStats struct { + Ok int `json:"ok"` + Changed int `json:"changed"` + Unreachable int `json:"unreachable"` + Failures int `json:"failures"` + Skipped int `json:"skipped"` + Rescued int `json:"rescued"` + Ignored int `json:"ignored"` +} + +// RunResult is the outcome of a single Ansible invocation. It is the payload +// data emitted downstream, so it must not contain any inventory secrets. +type RunResult struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Recap map[string]RecapStats `json:"recap,omitempty"` +} + +// ansibleRunner runs an Ansible invocation for a decoded Spec. It is an +// interface so Execute can be unit-tested with a stub that returns a fixed +// RunResult without a real ansible binary on the machine. +type ansibleRunner interface { + Run(ctx context.Context, spec Spec, logger *log.Entry) (*RunResult, error) +} + +// execRunner is the production runner: it writes the inventory (and playbook) +// to a temporary directory and shells out to the ansible binaries. +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, spec Spec, logger *log.Entry) (*RunResult, error) { + workDir, err := os.MkdirTemp("", "superplane-ansible-") + if err != nil { + return nil, fmt.Errorf("could not create working directory: %w", err) + } + defer os.RemoveAll(workDir) + + inventoryPath := filepath.Join(workDir, "inventory") + if err := os.WriteFile(inventoryPath, []byte(spec.Inventory), 0o600); err != nil { + return nil, fmt.Errorf("could not write inventory: %w", err) + } + + var binary string + var args []string + switch spec.Mode { + case ModeAdhoc: + binary = binaryAdhoc + args = buildAdhocArgs(inventoryPath, spec) + default: + binary = binaryPlaybook + playbookPath := filepath.Join(workDir, "playbook.yml") + content := "" + if spec.Playbook != nil { + content = *spec.Playbook + } + if err := os.WriteFile(playbookPath, []byte(content), 0o600); err != nil { + return nil, fmt.Errorf("could not write playbook: %w", err) + } + args = buildPlaybookArgs(playbookPath, inventoryPath, spec) + } + + if logger != nil { + logger.Infof("Running %s %s", binary, strings.Join(args, " ")) + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Dir = workDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = ansibleEnv(spec.Mode) + + runErr := cmd.Run() + + // A deadline/cancellation means the component could not complete: surface + // it as an execution error (error state), not a failed outcome. + if ctx.Err() != nil { + return nil, fmt.Errorf("ansible run timed out after %d seconds", spec.Timeout) + } + + exitCode := 0 + if runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + // Ansible ran and reported a non-zero status (e.g. a failed task); + // this is a "failed" outcome, not an execution error. + exitCode = exitErr.ExitCode() + } else { + // Could not start the process at all (e.g. binary missing). + return nil, fmt.Errorf("could not run %s: %w", binary, runErr) + } + } + + result := &RunResult{ + ExitCode: exitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + } + if spec.Mode != ModeAdhoc { + result.Recap = parseRecap(stdout.Bytes()) + } + + return result, nil +} + +// ansibleEnv returns the environment for an Ansible invocation. Host key +// checking is disabled so first-contact SSH does not block on a prompt, and +// the JSON stdout callback is used for playbooks so the recap can be parsed. +func ansibleEnv(mode string) []string { + env := append(os.Environ(), + "ANSIBLE_HOST_KEY_CHECKING=False", + "ANSIBLE_FORCE_COLOR=0", + ) + if mode != ModeAdhoc { + env = append(env, "ANSIBLE_STDOUT_CALLBACK=json") + } + return env +} + +// buildPlaybookArgs builds the argv for `ansible-playbook`. Every value is a +// distinct argv element (no shell is involved), so values cannot break out +// into additional commands. +func buildPlaybookArgs(playbookPath, inventoryPath string, spec Spec) []string { + args := []string{"-i", inventoryPath} + args = append(args, commonArgs(spec)...) + args = append(args, playbookPath) + return args +} + +// buildAdhocArgs builds the argv for `ansible -m -a `. +func buildAdhocArgs(inventoryPath string, spec Spec) []string { + module := ModuleDefault + if spec.Module != nil && *spec.Module != "" { + module = *spec.Module + } + + pattern := "" + if spec.HostPattern != nil { + pattern = *spec.HostPattern + } + + args := []string{pattern, "-i", inventoryPath, "-m", module} + if spec.ModuleArgs != nil && *spec.ModuleArgs != "" { + args = append(args, "-a", *spec.ModuleArgs) + } + args = append(args, commonArgs(spec)...) + return args +} + +// commonArgs builds the flags shared by both modes (limit, become, verbosity, +// extra vars). +func commonArgs(spec Spec) []string { + args := []string{} + if spec.Limit != nil && *spec.Limit != "" { + args = append(args, "--limit", *spec.Limit) + } + if spec.Become { + args = append(args, "--become") + } + if flag := verbosityFlag(spec.Verbosity); flag != "" { + args = append(args, flag) + } + args = append(args, extraVarArgs(spec.ExtraVars)...) + return args +} + +// verbosityFlag maps a 0-4 verbosity level to the matching -v..-vvvv flag. +func verbosityFlag(level int) string { + if level <= 0 { + return "" + } + if level > 4 { + level = 4 + } + return "-" + strings.Repeat("v", level) +} + +// extraVarArgs renders extra variables as repeated `-e name=value` argv pairs. +func extraVarArgs(vars []ExtraVar) []string { + args := []string{} + for _, v := range vars { + if v.Name == "" { + continue + } + args = append(args, "-e", fmt.Sprintf("%s=%s", v.Name, v.Value)) + } + return args +} + +// parseRecap extracts the per-host stats from the JSON stdout callback output. +// It is best-effort: if the output is not the expected JSON (e.g. a different +// callback is configured), it returns nil and the raw stdout is still emitted. +func parseRecap(stdout []byte) map[string]RecapStats { + var payload struct { + Stats map[string]RecapStats `json:"stats"` + } + if err := json.Unmarshal(stdout, &payload); err != nil { + return nil + } + return payload.Stats +} diff --git a/pkg/registryimports/registryimports.go b/pkg/registryimports/registryimports.go index 946ff2b885..7bcea17a42 100644 --- a/pkg/registryimports/registryimports.go +++ b/pkg/registryimports/registryimports.go @@ -5,6 +5,7 @@ package registryimports import ( // These blank imports intentionally trigger init-time registration. _ "github.com/superplanehq/superplane/pkg/components/addmemory" + _ "github.com/superplanehq/superplane/pkg/components/ansible" _ "github.com/superplanehq/superplane/pkg/components/approval" _ "github.com/superplanehq/superplane/pkg/components/deletememory" _ "github.com/superplanehq/superplane/pkg/components/display" From 0682d29b840ec6a53cdcde5dd0d8c5c3b32938e3 Mon Sep 17 00:00:00 2001 From: Dmitry Kozlov Date: Sat, 30 May 2026 17:37:38 -0700 Subject: [PATCH 2/2] docs: Add generated Ansible component docs Regenerated docs/components (scripts/generate_components_docs.go) so the built-in Ansible component is documented in Core.mdx, matching how the other core components (ssh, runner, http) are documented. Keeps the check.components.docs CI gate green. Signed-off-by: Dmitry Kozlov --- docs/components/Core.mdx | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/components/Core.mdx b/docs/components/Core.mdx index 30cbfe544c..80c3012424 100644 --- a/docs/components/Core.mdx +++ b/docs/components/Core.mdx @@ -20,6 +20,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + @@ -267,6 +268,62 @@ A single `memory.added` event is emitted with all written rows and the total `co } ``` + + +## Ansible + +**Component key:** `ansible` + +Run Ansible from a SuperPlane workflow. The SuperPlane node acts as the Ansible control node and reaches managed hosts through the inventory you provide. + +### Modes + +- **Playbook**: Provide playbook YAML inline. It is run with `ansible-playbook` and the JSON output callback so the play recap (ok/changed/unreachable/failed per host) is captured. +- **Ad-hoc**: Run a single module against a host pattern, e.g. module `ping`, or module `shell` with arguments `uptime`. + +### Configuration + +- **Inventory**: Inline inventory (INI or YAML). Defaults to `localhost ansible_connection=local` so it works without any remote hosts. +- **Playbook** (playbook mode): The playbook YAML to run. +- **Host pattern / Module / Module arguments** (ad-hoc mode): The target pattern, module name, and module arguments. +- **Extra variables**: Optional `name=value` pairs passed via `-e`. +- **Limit**: Optional `--limit` host pattern. +- **Become**: Run with privilege escalation (`--become`). +- **Verbosity**: 0-4, mapped to `-v`..`-vvvv`. +- **Timeout (seconds)**: Maximum run time before the execution errors out. + +### Output + +- **success**: Ansible exited with status 0. +- **failed**: Ansible ran but exited non-zero (e.g. a task failed or a host was unreachable). + +If Ansible cannot be run at all (binary missing, timeout, invalid working directory), the run finishes in the **error** state. + +### Example Output + +```json +{ + "data": { + "exitCode": 0, + "recap": { + "localhost": { + "changed": 1, + "failures": 0, + "ignored": 0, + "ok": 2, + "rescued": 0, + "skipped": 0, + "unreachable": 0 + } + }, + "stderr": "", + "stdout": "{\"stats\": {\"localhost\": {\"ok\": 2, \"changed\": 1, \"unreachable\": 0, \"failures\": 0, \"skipped\": 0, \"rescued\": 0, \"ignored\": 0}}}" + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "ansible.playbook.executed" +} +``` + ## Approval