Plugin Porting Guide

Plugin Porting Guide

This guide covers porting Ansible Python plugins to Parallax Go plugins and building new plugins from scratch.

Overview

Parallax plugins are standalone Go binaries that communicate with the host via HashiCorp go-plugin (net/rpc). The module-sdk package (go.digitalxero.dev/parallax/module-sdk) provides all interfaces and helpers.

Supported Plugin Types

TypeSDK InterfaceDescription
modulesdk.ModuleTask execution modules
callbacksdk.CallbackExecution event listeners
filtersdk.FilterPluginJinja2 template filters
lookupsdk.LookupPluginData lookup sources
inventorysdk.InventoryPluginInventory sources
connectionsdk.ConnectionPluginTransport implementations
strategysdk.StrategyPluginTask scheduling strategies

Handshake Protocol

All plugin types share the same magic cookie key with type-specific values:

import (
    sdk "go.digitalxero.dev/parallax/module-sdk"
    "go.digitalxero.dev/parallax/module-sdk/plugin"
)

// Handshake config for any plugin type
handshake := plugin.HandshakeForType(sdk.PluginTypeModule)
// HandshakeConfig{
//   ProtocolVersion:  1,
//   MagicCookieKey:   "PARALLAX_PLUGIN",
//   MagicCookieValue: "module",
// }

Getting Started

Scaffolding

Generate a plugin project skeleton:

parallax plugins init <type> <name> [--module-path <go-module-path>]

# Examples
parallax plugins init module my-custom-module
parallax plugins init filter my-filter --module-path github.com/myorg/my-filter
parallax plugins init callback my-callback

This creates:

my-custom-module/
  go.mod          # Go module with module-sdk dependency
  main.go         # Plugin implementation with interface stubs
  main_test.go    # Test template with compile-time interface check
  plugin.json     # Plugin manifest (name, type, version)
  README.md       # Build and install instructions

Build and Install

cd my-custom-module
go build -o my-custom-module .
cp my-custom-module ~/.parallax/plugins/modules/

Plugin Discovery

Parallax discovers plugins in these directories:

~/.parallax/plugins/<type>/      # User plugins
/usr/local/lib/parallax/plugins/ # System plugins
./plugins/                       # Project-local plugins
./library/                       # Legacy module directory

Modules use the modules/ subdirectory; other types use their type name (e.g., filter/, callback/).

Module Plugins

Modules are the most common plugin type. They execute actions on remote hosts.

Interface

// Module defines the interface that all modules must implement
type Module interface {
    Name() string
    Aliases() []string
    GetArgSpec() []*ArgumentSpec
    GetCode() *ModuleCode
    GenerateCode(dir string) error
    ValidateArgs(args map[string]any) error
    Execute(ctx context.Context, conn Connection, path string, args map[string]any) *Result
}

// ArgumentSpec defines a module argument
type ArgumentSpec struct {
    Name        string `json:"name"`
    Type        string `json:"type"`         // Go types: string, int, bool, []string, map[string]any
    Required    bool   `json:"required"`
    Default     any    `json:"default,omitempty"`
    Description string `json:"description,omitempty"`
}

// Result represents module execution output
type Result struct {
    Changed      bool           `json:"changed"`
    Failed       bool           `json:"failed"`
    Message      string         `json:"message"`
    Data         map[string]any `json:"data,omitempty"`
    Skipped      bool           `json:"skipped,omitempty"`
    Stdout       string         `json:"stdout,omitempty"`
    Stderr       string         `json:"stderr,omitempty"`
    StdoutLines  []string       `json:"stdout_lines,omitempty"`
    StderrLines  []string       `json:"stderr_lines,omitempty"`
    Rc           int            `json:"rc,omitempty"`
}

How Modules Work

Parallax modules follow a compile-and-ship pattern:

  1. GetCode() returns a Go source template
  2. GenerateCode(dir) writes the source to disk, creates go.mod, runs go mod tidy
  3. The compiler builds a standalone binary from the generated source
  4. The binary is shipped to the remote host and executed with JSON args
  5. The binary outputs JSON result to stdout

The generated binary uses sdk.RunModule() to handle arg parsing and result output.

BaseModule Helper

Use sdk.NewBaseModule() for common boilerplate:

import sdk "go.digitalxero.dev/parallax/module-sdk"

func New() sdk.Module {
    return sdk.NewBaseModule(
        "my_module",
        []string{"my_module_alias"},  // optional aliases
        []*sdk.ArgumentSpec{
            {Name: "path", Type: "string", Required: true, Description: "Target path"},
            {Name: "state", Type: "string", Required: false, Default: "present", Description: "Desired state"},
        },
    )
}

BaseModule provides default implementations of ValidateArgs, GenerateCode, and GetCode. Override Execute to add your module logic.

Code Generation Template

The scaffold template generates a standalone Go binary:

package main

import (
    "go.digitalxero.dev/parallax/module-sdk"
)

type MyModuleArgs struct {
    Path  string `json:"path"`
    State string `json:"state"`
    Env   map[string]string `json:"environment"`
}

func main() {
    var args MyModuleArgs
    sdk.RunModule(&args, handler)
}

func handler(rawArgs any) *sdk.Result {
    args := rawArgs.(*MyModuleArgs)
    // Module logic here
    return &sdk.Result{
        Changed: true,
        Message: "done",
        Data:    map[string]any{"path": args.Path},
    }
}

Field names in the args struct use strings.Title() on the argument name: gather_subset becomes Gather_subset (not GatherSubset). Argument types must be valid Go types (string, int, bool, []string, map[string]any).

ControllerModule

For modules that run on the controller (not remote hosts), implement ControllerModule:

type ControllerModule interface {
    Module
    RunLocal(ctx context.Context, args map[string]any, vars map[string]any) (*Result, error)
}

Controller modules bypass the compile-and-ship pipeline entirely. Examples: set_fact, add_host, assert, fail, debug, meta.

Check Mode

Use sdk.IsCheckMode(args) to detect check mode:

func handler(rawArgs any) *sdk.Result {
    args := rawArgs.(*MyModuleArgs)

    if sdk.IsCheckMode(map[string]any{"_ansible_check_mode": true}) {
        return &sdk.Result{
            Changed: true,
            Message: "would create file",
        }
    }

    // Actual execution
    // ...
}

Read-only modules can be listed in sdk.AlwaysRunInCheckMode to execute even during check mode.

Python-to-Go Example

Python (Ansible):

from ansible.module_utils.basic import AnsibleModule

def main():
    module = AnsibleModule(
        argument_spec=dict(
            name=dict(type='str', required=True),
            state=dict(type='str', default='present', choices=['present', 'absent']),
        ),
    )

    name = module.params['name']
    state = module.params['state']

    if state == 'present':
        # create resource
        module.exit_json(changed=True, msg=f"Created {name}")
    else:
        # remove resource
        module.exit_json(changed=True, msg=f"Removed {name}")

if __name__ == '__main__':
    main()

Go (Parallax) — Generated binary:

package main

import sdk "go.digitalxero.dev/parallax/module-sdk"

type MyResourceArgs struct {
    Name  string `json:"name"`
    State string `json:"state"`
    Env   map[string]string `json:"environment"`
}

func main() {
    var args MyResourceArgs
    sdk.RunModule(&args, handler)
}

func handler(rawArgs any) *sdk.Result {
    args := rawArgs.(*MyResourceArgs)

    if args.State == "" {
        args.State = "present"
    }

    if args.State == "present" {
        return &sdk.Result{Changed: true, Message: "Created " + args.Name}
    }
    return &sdk.Result{Changed: true, Message: "Removed " + args.Name}
}

Callback Plugins

Callback plugins receive notifications during playbook execution.

Interface

type Callback interface {
    Name() string

    // Playbook-level
    OnPlaybookStart(playbookPath string, totalPlays int)
    OnPlaybookEnd(stats map[string]*HostStats, duration time.Duration)

    // Play-level
    OnPlayStart(playName string, hostCount int, serialBatch int, totalBatches int)
    OnPlayEnd(playName string, duration time.Duration)

    // Task-level
    OnTaskStart(taskName string, moduleName string, isHandler bool)
    OnTaskHostOk(taskName string, hostName string, result *Result, duration time.Duration)
    OnTaskHostFailed(taskName string, hostName string, result *Result, duration time.Duration)
    OnTaskHostSkipped(taskName string, hostName string, result *Result)
    OnTaskHostUnreachable(taskName string, hostName string, result *Result)

    // Handler events
    OnHandlerNotified(handlerName string, hostName string)
    OnHandlerFlushStart()
    OnHandlerFlushEnd()

    // Warning
    OnWarning(message string)
}

type HostStats struct {
    Ok          int `json:"ok"`
    Changed     int `json:"changed"`
    Unreachable int `json:"unreachable"`
    Failed      int `json:"failed"`
    Skipped     int `json:"skipped"`
    Rescued     int `json:"rescued"`
    Ignored     int `json:"ignored"`
}

Example

package main

import (
    "fmt"
    "os"
    "time"

    sdk "go.digitalxero.dev/parallax/module-sdk"
    "go.digitalxero.dev/parallax/module-sdk/plugin"
    goplugin "github.com/hashicorp/go-plugin"
)

type myCallback struct{}

func (c *myCallback) Name() string { return "my_callback" }

func (c *myCallback) OnPlaybookStart(path string, totalPlays int) {
    fmt.Fprintf(os.Stderr, "Starting playbook: %s (%d plays)\n", path, totalPlays)
}

func (c *myCallback) OnPlaybookEnd(stats map[string]*sdk.HostStats, duration time.Duration) {
    fmt.Fprintf(os.Stderr, "Playbook finished in %v\n", duration)
}

func (c *myCallback) OnTaskHostOk(task, host string, result *sdk.Result, dur time.Duration) {
    fmt.Fprintf(os.Stderr, "ok: [%s] %s (%v)\n", host, task, dur)
}

func (c *myCallback) OnTaskHostFailed(task, host string, result *sdk.Result, dur time.Duration) {
    fmt.Fprintf(os.Stderr, "FAILED: [%s] %s: %s\n", host, task, result.Message)
}

// Implement remaining methods as no-ops...
func (c *myCallback) OnPlayStart(string, int, int, int)           {}
func (c *myCallback) OnPlayEnd(string, time.Duration)             {}
func (c *myCallback) OnTaskStart(string, string, bool)            {}
func (c *myCallback) OnTaskHostSkipped(string, string, *sdk.Result) {}
func (c *myCallback) OnTaskHostUnreachable(string, string, *sdk.Result) {}
func (c *myCallback) OnHandlerNotified(string, string)            {}
func (c *myCallback) OnHandlerFlushStart()                        {}
func (c *myCallback) OnHandlerFlushEnd()                          {}
func (c *myCallback) OnWarning(string)                            {}

func main() {
    goplugin.Serve(&goplugin.ServeConfig{
        HandshakeConfig: plugin.HandshakeForType(sdk.PluginTypeCallback),
        Plugins:         plugin.PluginMapForType(sdk.PluginTypeCallback),
    })
}

Built-in callbacks: default (Ansible-style banners), minimal, json, timer, profile_tasks. Select via --callback <name>.

Filter Plugins

Filter plugins transform values in Jinja2 templates (e.g., {{ var | my_filter }}).

Interface

type FilterPlugin interface {
    Name() string
    Aliases() []string
    Apply(input any, args ...any) (any, error)
}

Example

package main

import (
    "fmt"
    "strings"

    sdk "go.digitalxero.dev/parallax/module-sdk"
    "go.digitalxero.dev/parallax/module-sdk/plugin"
    goplugin "github.com/hashicorp/go-plugin"
)

type reverseFilter struct{}

func (f *reverseFilter) Name() string      { return "reverse_string" }
func (f *reverseFilter) Aliases() []string { return nil }

func (f *reverseFilter) Apply(input any, args ...any) (any, error) {
    s, ok := input.(string)
    if !ok {
        return nil, fmt.Errorf("reverse_string requires string input, got %T", input)
    }
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes), nil
}

func main() {
    goplugin.Serve(&goplugin.ServeConfig{
        HandshakeConfig: plugin.HandshakeForType(sdk.PluginTypeFilter),
        Plugins:         plugin.PluginMapForType(sdk.PluginTypeFilter),
    })
}

Usage in templates:

- debug:
    msg: "{{ 'hello' | reverse_string }}"

Lookup Plugins

Lookup plugins retrieve data from external sources.

Interface

type LookupPlugin interface {
    Name() string
    Run(ctx context.Context, terms []string, kwargs map[string]any) ([]any, error)
}
  • terms — positional arguments (e.g., file paths, keys)
  • kwargs — keyword arguments (e.g., delimiter, default)
  • Returns a slice of values (one per term, or multiple for wantlist)

Example

package main

import (
    "context"
    "os"

    sdk "go.digitalxero.dev/parallax/module-sdk"
    "go.digitalxero.dev/parallax/module-sdk/plugin"
    goplugin "github.com/hashicorp/go-plugin"
)

type envLookup struct{}

func (l *envLookup) Name() string { return "my_env" }

func (l *envLookup) Run(ctx context.Context, terms []string, kwargs map[string]any) ([]any, error) {
    results := make([]any, 0, len(terms))
    defaultVal, _ := kwargs["default"].(string)

    for _, term := range terms {
        val := os.Getenv(term)
        if val == "" {
            val = defaultVal
        }
        results = append(results, val)
    }
    return results, nil
}

func main() {
    goplugin.Serve(&goplugin.ServeConfig{
        HandshakeConfig: plugin.HandshakeForType(sdk.PluginTypeLookup),
        Plugins:         plugin.PluginMapForType(sdk.PluginTypeLookup),
    })
}

Usage in templates:

- debug:
    msg: "{{ lookup('my_env', 'HOME', default='/root') }}"

Inventory Plugins

Inventory plugins provide host and group data from external sources.

Interface

type InventoryPlugin interface {
    Name() string
    Verify(ctx context.Context, source string) (bool, error)
    Parse(ctx context.Context, source string) (*InventoryData, error)
}

type InventoryData struct {
    Groups map[string]*InventoryGroupData `json:"groups"`
    Meta   *InventoryMeta                 `json:"_meta,omitempty"`
}

type InventoryGroupData struct {
    Hosts    []string       `json:"hosts,omitempty"`
    Children []string       `json:"children,omitempty"`
    Vars     map[string]any `json:"vars,omitempty"`
}

type InventoryMeta struct {
    HostVars map[string]map[string]any `json:"hostvars,omitempty"`
}

The InventoryData structure matches Ansible’s JSON inventory format, so existing dynamic inventory scripts can serve as a reference.

Example

package main

import (
    "context"
    "encoding/json"
    "os"
    "strings"

    sdk "go.digitalxero.dev/parallax/module-sdk"
    "go.digitalxero.dev/parallax/module-sdk/plugin"
    goplugin "github.com/hashicorp/go-plugin"
)

type jsonInventory struct{}

func (i *jsonInventory) Name() string { return "my_json_inventory" }

func (i *jsonInventory) Verify(ctx context.Context, source string) (bool, error) {
    return strings.HasSuffix(source, ".json"), nil
}

func (i *jsonInventory) Parse(ctx context.Context, source string) (*sdk.InventoryData, error) {
    data, err := os.ReadFile(source)
    if err != nil {
        return nil, err
    }
    var inv sdk.InventoryData
    if err := json.Unmarshal(data, &inv); err != nil {
        return nil, err
    }
    return &inv, nil
}

func main() {
    goplugin.Serve(&goplugin.ServeConfig{
        HandshakeConfig: plugin.HandshakeForType(sdk.PluginTypeInventory),
        Plugins:         plugin.PluginMapForType(sdk.PluginTypeInventory),
    })
}

Verify vs Parse

  • Verify(source) — fast check: can this plugin handle the given source? (e.g., check file extension)
  • Parse(source) — full parse: read the source and return structured inventory data

Connection Plugins

Connection plugins provide transport implementations for executing commands on remote hosts.

Interface

type ConnectionPlugin interface {
    Name() string
    Connect(ctx context.Context) error
    Disconnect() error
    Connection() Connection
}

type Connection interface {
    Execute(ctx context.Context, cmd string) (string, error)
    CopyFile(ctx context.Context, localPath, remotePath string) error
    CopyFileFrom(ctx context.Context, remotePath, localPath string) error
    FileExists(ctx context.Context, path string) (bool, error)
    MkdirAll(ctx context.Context, path string) error
    GetOSType() OSType
}

Example

package main

import (
    "context"
    "os/exec"

    sdk "go.digitalxero.dev/parallax/module-sdk"
    "go.digitalxero.dev/parallax/module-sdk/plugin"
    goplugin "github.com/hashicorp/go-plugin"
)

type localConnection struct{}

func (c *localConnection) Name() string                        { return "my_local" }
func (c *localConnection) Connect(ctx context.Context) error   { return nil }
func (c *localConnection) Disconnect() error                   { return nil }
func (c *localConnection) Connection() sdk.Connection          { return c }

func (c *localConnection) Execute(ctx context.Context, cmd string) (string, error) {
    out, err := exec.CommandContext(ctx, "sh", "-c", cmd).CombinedOutput()
    return string(out), err
}

func (c *localConnection) CopyFile(ctx context.Context, local, remote string) error {
    // implement file copy
    return nil
}

func (c *localConnection) CopyFileFrom(ctx context.Context, remote, local string) error {
    return nil
}

func (c *localConnection) FileExists(ctx context.Context, path string) (bool, error) {
    _, err := exec.CommandContext(ctx, "test", "-e", path).Output()
    return err == nil, nil
}

func (c *localConnection) MkdirAll(ctx context.Context, path string) error {
    return exec.CommandContext(ctx, "mkdir", "-p", path).Run()
}

func (c *localConnection) GetOSType() sdk.OSType { return sdk.OSLinux }

func main() {
    goplugin.Serve(&goplugin.ServeConfig{
        HandshakeConfig: plugin.HandshakeForType(sdk.PluginTypeConnection),
        Plugins:         plugin.PluginMapForType(sdk.PluginTypeConnection),
    })
}

Built-in connections: SSH, Local, WinRM, Docker/Podman.

Strategy Plugins

Strategy plugins control the order in which tasks are executed across hosts.

Interface

type StrategyPlugin interface {
    Name() string
    Plan(ctx context.Context, tasks []TaskExecution) ([]TaskHostPair, error)
}

type TaskExecution struct {
    TaskName string         `json:"task_name"`
    Module   string         `json:"module"`
    Args     map[string]any `json:"args"`
    Hosts    []string       `json:"hosts"`
}

type TaskHostPair struct {
    TaskIndex int    `json:"task_index"`
    HostName  string `json:"host_name"`
}

Plan() receives the full list of tasks with their target hosts and returns an ordered slice of task-host pairs. The executor runs pairs in the returned order.

Example

package main

import (
    "context"
    "math/rand"

    sdk "go.digitalxero.dev/parallax/module-sdk"
    "go.digitalxero.dev/parallax/module-sdk/plugin"
    goplugin "github.com/hashicorp/go-plugin"
)

type shuffleStrategy struct{}

func (s *shuffleStrategy) Name() string { return "shuffle" }

func (s *shuffleStrategy) Plan(ctx context.Context, tasks []sdk.TaskExecution) ([]sdk.TaskHostPair, error) {
    var pairs []sdk.TaskHostPair
    for i, task := range tasks {
        for _, host := range task.Hosts {
            pairs = append(pairs, sdk.TaskHostPair{TaskIndex: i, HostName: host})
        }
    }
    rand.Shuffle(len(pairs), func(i, j int) {
        pairs[i], pairs[j] = pairs[j], pairs[i]
    })
    return pairs, nil
}

func main() {
    goplugin.Serve(&goplugin.ServeConfig{
        HandshakeConfig: plugin.HandshakeForType(sdk.PluginTypeStrategy),
        Plugins:         plugin.PluginMapForType(sdk.PluginTypeStrategy),
    })
}

Built-in strategies: linear (default), free, host_pinned.

SDK Helpers

The module-sdk provides utility functions:

ParseVariablePath / GetNestedValue

path := sdk.ParseVariablePath("result.data.output")
// ["result", "data", "output"]

val, ok := sdk.GetNestedValue(data, path)

JSONArgs

Serialize module args to a JSON string safe for shell usage:

jsonStr, err := sdk.JSONArgs(args)

ParseModuleOutput

Parse JSON output from a compiled module binary:

result := sdk.ParseModuleOutput(stdout, args)

GenerateCode

Generate a standalone module binary from a Module’s code template:

err := sdk.GenerateCode(myModule, "/tmp/module-build")

IsCheckMode

if sdk.IsCheckMode(args) {
    // return predicted result without making changes
}

Testing

Use standard Go test patterns with testify/assert and testify/require:

package main_test

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestMyFilter(t *testing.T) {
    f := &reverseFilter{}

    result, err := f.Apply("hello")
    require.NoError(t, err)
    assert.Equal(t, "olleh", result)
}

func TestMyFilterInvalidInput(t *testing.T) {
    f := &reverseFilter{}

    _, err := f.Apply(42)
    assert.Error(t, err)
}

For modules, test the handler function directly:

func TestHandler(t *testing.T) {
    args := &MyModuleArgs{Path: "/tmp/test", State: "present"}
    result := handler(args)

    assert.True(t, result.Changed)
    assert.False(t, result.Failed)
    assert.Contains(t, result.Message, "/tmp/test")
}

Plugin Distribution

Plugin Manifest

Each plugin includes a plugin.json:

{
  "name": "my-plugin",
  "type": "filter",
  "version": "1.0.0",
  "description": "My custom filter plugin",
  "author": "Your Name"
}

Installing Plugins

# From a git repository
parallax plugins add https://github.com/user/my-plugin.git

# With version
parallax plugins add https://github.com/user/my-plugin.git --version v1.0.0

# List installed plugins
parallax plugins list

# Remove a plugin
parallax plugins remove my-plugin

# Upgrade a plugin
parallax plugins upgrade my-plugin

Plugin CLI Reference

parallax plugins init <type> <name>     # Scaffold a new plugin
parallax plugins add <source>           # Install from git
parallax plugins remove <name>          # Remove a plugin
parallax plugins upgrade <name>         # Upgrade a plugin
parallax plugins list [--type <type>]   # List installed plugins