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
| Type | SDK Interface | Description |
|---|---|---|
module | sdk.Module | Task execution modules |
callback | sdk.Callback | Execution event listeners |
filter | sdk.FilterPlugin | Jinja2 template filters |
lookup | sdk.LookupPlugin | Data lookup sources |
inventory | sdk.InventoryPlugin | Inventory sources |
connection | sdk.ConnectionPlugin | Transport implementations |
strategy | sdk.StrategyPlugin | Task 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:
GetCode()returns a Go source templateGenerateCode(dir)writes the source to disk, creates go.mod, runsgo mod tidy- The compiler builds a standalone binary from the generated source
- The binary is shipped to the remote host and executed with JSON args
- 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