Write A Plugin

on this page

Use this guide when you want to add a custom module to Preflight as a standalone executable.

Before You Start

You need:

  • Go installed locally
  • A working preflight binary for local testing
  • A plugin name that does not conflict with a built-in module name

Preflight plugins are regular executables, not Go .so plugins. They speak JSON-RPC over stdin/stdout and follow the same Check() then Apply() contract as built-in modules.

How Plugin Execution Works

The plugin process runs on the controller — the machine running preflight. It is never copied to or staged on the target. Check and Apply receive a target handle, and all target effects flow through that handle: RunCommand, PutFile, GetFile, and Output. This is true even when the target is the local machine, so a plugin works identically over local, SSH, and WinRM without knowing which transport it is on.

The handle is the only way a plugin touches the target. Do not read the local filesystem or spawn local processes to affect the target — go through the handle, so your plugin is transport-agnostic.

1. Create A Small Go Module

Create a new repository or directory for the plugin:

bash
mkdir preflight-plugin-marker-file
cd preflight-plugin-marker-file
go mod init example.com/preflight-plugin-marker-file
go get github.com/bluecadet/preflight@latest

That pulls in the Go SDK from github.com/bluecadet/preflight/pkg/plugin/sdk.

2. Implement The Plugin Contract

Create main.go:

go
package main

import (
	"context"
	"errors"
	"strings"

	"github.com/bluecadet/preflight/pkg/plugin/sdk"
)

type markerFileModule struct{}

func (markerFileModule) Name() string    { return "marker_file" }
func (markerFileModule) Version() string { return "0.1.0" }

func (markerFileModule) Check(ctx context.Context, args map[string]any, h sdk.Handle) (sdk.CheckResult, error) {
	path, _ := args["path"].(string)
	if path == "" {
		return sdk.CheckResult{}, errors.New("path is required")
	}

	// Probe the target through the handle — never the local filesystem — and
	// pass ctx so the op can be interrupted.
	res, err := h.RunCommand(ctx, "test -f "+shellQuote(path)+" && echo yes || echo no")
	if err != nil {
		return sdk.CheckResult{}, err
	}
	exists := strings.TrimSpace(res.Stdout) == "yes"
	return sdk.CheckResult{NeedsChange: !exists}, nil
}

func (markerFileModule) Apply(ctx context.Context, args map[string]any, h sdk.Handle) (sdk.ApplyResult, error) {
	path, _ := args["path"].(string)
	script := "printf 'managed by preflight plugin\\n' > " + shellQuote(path)
	if _, err := h.RunCommand(ctx, script); err != nil {
		return sdk.ApplyResult{}, err
	}

	msg := "created " + path
	// NegotiatedFromContext reports what this plugin and the host agreed on
	// during initialize. Gate optional behavior on a capability instead of a
	// protocol version, so a host that does not know about "checksum" yet
	// still works with this plugin unchanged.
	if neg, ok := sdk.NegotiatedFromContext(ctx); ok && neg.HasCapability("marker_file/checksum") {
		sum, err := h.RunCommand(ctx, "sha256sum "+shellQuote(path)+" | cut -d ' ' -f1")
		if err == nil {
			msg += ", sha256=" + strings.TrimSpace(sum.Stdout)
		}
	}
	return sdk.ApplyResult{Message: msg}, nil
}

// shellQuote wraps s in POSIX single quotes so it is safe to interpolate.
func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

func main() {
	// Capabilities is this plugin's half of capability negotiation: it tells
	// any host that also knows about "marker_file/checksum" that Apply will
	// report a checksum, without requiring either side to bump a protocol
	// version for it.
	sdk.Serve(markerFileModule{}, sdk.ServeOptions{
		Capabilities: []string{"marker_file/checksum"},
	})
}

Important details:

  • Name() is the module name users put in module:.
  • Version() is reported by preflight plugin list and preflight plugin info.
  • Check() must return NeedsChange: true only when Apply() should run.
  • Apply() receives the same params: map that was defined in YAML.
  • All target effects go through h (the handle). Use h.RunCommand, h.PutFile, h.GetFile, h.Info, and h.Output.
  • ctx is a real cancellation signal. Pass it to every handle op and check it around anything long-running. See Honouring cancellation.
  • ctx also carries the negotiated handshake result. Call sdk.NegotiatedFromContext(ctx) to read the protocol version and capability set this plugin and the host agreed on. See Capabilities.

3. The Handle API

The handle exposes three target primitives plus streaming output and the target info delivered at initialize.

RunCommand

go
res, err := h.RunCommand(ctx, script)
// res.Stdout, res.Stderr, res.ExitCode

script runs in the target’s native shell: POSIX sh when TargetInfo.RuntimeKind is posix-shell, PowerShell when it is windows-powershell. Branch on h.Info().RuntimeKind (or h.Info().Family) to write portable plugins.

RunCommand is the batching lever. Each handle op is a transport round trip; over SSH or WinRM that round trip has real latency. Prefer one script that does several things over several RunCommand calls. For example, check a file, write it if missing, and set its mode in a single script instead of three.

PutFile / GetFile

go
err := h.PutFile(ctx, "/tmp/config", []byte("contents"))
data, err := h.GetFile(ctx, "/etc/hostname")

PutFile takes raw bytes; the host handles chunking for high-latency transports, so the plugin sees a single call regardless of transport. GetFile returns the file’s bytes.

Info

go
info := h.Info()
// info.Family (windows|linux|darwin)
// info.Name, info.Version, info.Arch, info.Hostname
// info.PackageManager (apt|dnf|"" on POSIX; "" on Windows)
// info.Init (systemd|"" on POSIX; "" on Windows)
// info.RuntimeKind (posix-shell|windows-powershell)

Info() returns the TargetInfo delivered at initialize. Absent signals are empty strings, never missing keys, so you can branch with simple equality:

go
if h.Info().Family == "windows" { ... }
if h.Info().PackageManager == "apt" { ... }
if h.Info().Init == "systemd" { ... }

Do not re-probe what the controller already cached — Info() is the authoritative view.

Output

go
h.Output("progress: 50%")

Output streams a line back to the host’s output channel during Check or Apply. Call it for progress or diagnostics; the host surfaces lines to the user as they arrive.

Honouring Cancellation

Check and Apply receive a context.Context. It is not a placeholder: when Preflight abandons the call — a --timeout expiring, the run being interrupted — it sends a protocol-level cancel notification and the SDK cancels the context inside your plugin process.

Two rules follow.

Pass ctx to every handle op. An op issued with context.Background() cannot be interrupted:

go
// Good: interruptible.
res, err := h.RunCommand(ctx, script)

// Bad: this op runs to completion no matter what the host wants.
res, err := h.RunCommand(context.Background(), script)

Passing ctx also cancels downward. If your Check is waiting on a RunCommand when cancellation arrives, the host cancels the command it was running on the target — the interrupt reaches the process on the far side of SSH or WinRM, not just your plugin.

Check ctx around long work of your own. Anything that is not a handle op — a loop, a sleep, a local computation — should watch ctx.Done():

go
for _, item := range items {
	if err := ctx.Err(); err != nil {
		return sdk.ApplyResult{}, err
	}
	// ...
}

You get a two-second grace window after cancellation to unwind: release locks, remove half-written files, and return. That window is an upper bound, not a promise. A plugin that ignores its context is killed when the window closes, mid-operation, with no chance to clean up.

4. Build The Executable With The Required Name

On macOS or Linux:

bash
go build -o preflight-plugin-marker_file .

On Windows:

bash
go build -o preflight-plugin-marker_file.exe .

The filename matters. Preflight only discovers executables named preflight-plugin-<name> or, on Windows, preflight-plugin-<name>.exe.

The plugin’s Name() result must match that <name> value. Preflight validates that the discovered filename, YAML module name, and reported logical name all line up.

5. Install It Into A Plugin Directory

Preflight scans plugin directories in this order:

  1. alongside the preflight binary
  2. ~/.preflight/plugins
  3. ./plugins relative to the current working directory

For project-local testing:

bash
mkdir -p plugins
mv preflight-plugin-marker_file plugins/

6. Verify Discovery

Check that Preflight can start the plugin and read its metadata:

bash
preflight plugin list
preflight plugin info marker_file

Fix discovery or initialization errors here before using the plugin in a playbook.

7. Call The Plugin From YAML

Use the explicit module plus params form:

yaml
tasks:
  - name: Create marker file through plugin
    module: marker_file
    params:
      path: "/var/lib/preflight/marker.txt"

Then validate or run the playbook as usual:

bash
preflight validate playbooks/lobby.yml
preflight plan playbooks/lobby.yml
preflight apply playbooks/lobby.yml

Protocol Version 2 (Breaking Change)

Plugin protocol v2 is a clean break from v1. If you have a v1 plugin, you must update it:

  • Check and Apply take a context.Context as their first argument. Pass it to every handle op instead of context.Background().
  • The protocol gained a cancel notification, which is what makes that context fire; the SDK wires it up for you.
  • Everything else is unchanged: the Handle still carries all target effects, initialize still delivers protocol_version and TargetInfo, and streaming is still h.Output(line).

Migrating is mechanical:

go
// v1
func (m myModule) Check(args map[string]any, h sdk.Handle) (sdk.CheckResult, error) {
	res, err := h.RunCommand(context.Background(), script)

// v2
func (m myModule) Check(ctx context.Context, args map[string]any, h sdk.Handle) (sdk.CheckResult, error) {
	res, err := h.RunCommand(ctx, script)

v1 plugins are rejected with a clear plugin_protocol error. There is no compatibility mode — update and rebuild.

How Version Compatibility Works Now

As a plugin author you do not set protocol_version yourself — the SDK you build against advertises it for you during initialize. What matters for forward compatibility is this: initialize no longer requires an exact version match. Host and plugin each advertise a [min_protocol_version, protocol_version] range, and the handshake succeeds as long as the two ranges overlap, negotiating down to the highest version both sides support. A future SDK release can widen its range to keep talking to plugins built against an older SDK, instead of hard-rejecting them the way the v1→v2 change above did.

Rebuilding against the latest SDK is still the right move whenever you can — it is the only way to pick up new capabilities — but a plugin built against an older SDK release is no longer automatically dead on a version bump the way v1 plugins were.

Capabilities

Capabilities are a second, independent extension point alongside the version range: a way for either side to advertise an optional feature by name instead of forcing a protocol version bump for every addition. The handshake is symmetric — both the host and the plugin advertise a set, and both sides see the intersection.

Advertise your plugin’s capabilities in ServeOptions, passed to Serve:

go
sdk.Serve(markerFileModule{}, sdk.ServeOptions{
	Capabilities: []string{"marker_file/checksum"},
})

Read the negotiated outcome — the agreed protocol version and the capability names both sides advertised — from ctx in Check or Apply:

go
if neg, ok := sdk.NegotiatedFromContext(ctx); ok && neg.HasCapability("marker_file/checksum") {
	// Both this plugin and the host know about "marker_file/checksum";
	// do the optional work.
}

ok is only false if ctx did not come from a real Check/Apply call (for example, one built directly in a unit test). The full reference example above wires this up: it advertises marker_file/checksum and reports a checksum in Apply’s message when the host also recognizes it.

A capability your plugin advertises but no current host checks for is not wasted — it documents intent, and it means a future host can start using the feature without requiring you to rebuild the plugin or bump anything.

Batching Guidance For High-Latency Transports

Every handle op is a transport round trip. Over SSH and especially WinRM, round trips dominate runtime. Design your plugin to minimize them:

  • One script, several actions. Instead of RunCommand to check, RunCommand to create, RunCommand to set mode, write one script that does all three and reports the outcome. POSIX sh and PowerShell both let you branch on results within a single script.
  • Avoid redundant GetFile. If RunCommand can read what you need and include it in stdout, do that instead of a separate GetFile round trip.
  • Cache Info() once. Call h.Info() and keep the struct; do not re-fetch per op (it is in-memory, but the call pattern reads better).
  • One in-flight op at a time. The protocol allows one in-flight target op per session. Do not issue a second RunCommand before the first returns.
  • Pass ctx to every op. A long batched script is the right shape, but only if it can be interrupted.

The become Limitation

A plugin task cannot run with become enabled. If you set become: { enabled: true } on a plugin task, Preflight refuses it with a typed plugin_become error before the plugin runs. Run plugins as the connection user (or root directly); privilege escalation through the plugin handle is planned for a future protocol version.

8. Distribute The Plugin

Plugins execute controller-side, so they must run on the machine running preflight. Build the plugin for the controller’s OS and architecture. First-party plugins ship for windows/linux/darwin × amd64/arm64.

Common patterns:

  • publish source plus build instructions for Go users
  • attach prebuilt binaries to a GitHub release
  • ship the plugin executable alongside the preflight binary in an internal tools package
  • commit the executable under a project-local plugins/ directory when the team wants the plugin versioned with the repo

If you use preflight stage, the plugin must be discoverable on the staging machine. Preflight copies referenced plugin executables into the staged bundle automatically, but staging fails if the plugin cannot initialize or reports the wrong logical name.

The discovered executable must also match the staged host’s OS and architecture. During bundle apply, the destination host becomes the controller and starts that executable locally. Preflight rejects cross-platform plugin staging rather than copying a controller-native binary that cannot run on the destination. Stage plugin bundles from a matching platform; cross-platform staging currently supports built-in modules only.

Troubleshooting

preflight plugin list does not show the plugin

Check the filename first. It must start with preflight-plugin-, and on Windows it must end with .exe.

The plugin appears, but the module is unknown in YAML

Use the logical name reported by:

bash
preflight plugin info <name>

That reported name is what belongs in module:.

plugin_protocol error at runtime

Your plugin was built against an older SDK. Update to the current SDK, add the context.Context first argument to Check/Apply, and rebuild.

plugin_become error

A plugin task had become enabled. Remove become from the task (or run the connection account with the privileges the plugin needs). Plugin+become is not supported.