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
preflightbinary 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:
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@latestThat pulls in the Go SDK from github.com/bluecadet/preflight/pkg/plugin/sdk.
2. Implement The Plugin Contract
Create main.go:
package main
import (
"context"
"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(args map[string]any, h sdk.Handle) (sdk.CheckResult, error) {
path, _ := args["path"].(string)
if path == "" {
return sdk.CheckResult{}, fmt.Errorf("path is required")
}
// Probe the target through the handle — never the local filesystem.
res, err := h.RunCommand(context.Background(), "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(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(context.Background(), script); err != nil {
return sdk.ApplyResult{}, err
}
return sdk.ApplyResult{Message: "created " + path}, nil
}
func main() {
sdk.Serve(markerFileModule{})
}Important details:
Name()is the module name users put inmodule:.Version()is reported bypreflight plugin listandpreflight plugin info.Check()must returnNeedsChange: trueonly whenApply()should run.Apply()receives the sameparams:map that was defined in YAML.- All target effects go through
h(the handle). Useh.RunCommand,h.PutFile,h.GetFile,h.Info, andh.Output.
3. The Handle API
The handle exposes three target primitives plus streaming output and the target info delivered at initialize.
RunCommand
res, err := h.RunCommand(ctx, script)
// res.Stdout, res.Stderr, res.ExitCodescript 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
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
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:
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
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.
4. Build The Executable With The Required Name
On macOS or Linux:
go build -o preflight-plugin-marker_file .On Windows:
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:
- alongside the
preflightbinary ~/.preflight/plugins./pluginsrelative to the current working directory
For project-local testing:
mkdir -p plugins
mv preflight-plugin-marker_file plugins/6. Verify Discovery
Check that Preflight can start the plugin and read its metadata:
preflight plugin list
preflight plugin info marker_fileFix 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:
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:
preflight validate playbooks/lobby.yml
preflight plan playbooks/lobby.yml
preflight apply playbooks/lobby.ymlProtocol Version 1 (Breaking Change)
Plugin protocol v1 is a clean break from the pre-v1 protocol. If you have an existing plugin, you must update it:
CheckandApplynow take aHandleas their second argument. All target effects go through it.initializenow carriesprotocol_versionand the enrichedTargetInfo; your plugin receives them automatically through the SDK.- Streaming output is no longer a separate
StreamingModuleinterface — callh.Output(line)fromCheck/Apply. sdk.Servehandles the wire protocol; your plugin only implementsModule.
Pre-v1 plugins are rejected with a clear plugin_protocol error. There is no compatibility mode — update and rebuild.
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
RunCommandto check,RunCommandto create,RunCommandto set mode, write one script that does all three and reports the outcome. POSIXshand PowerShell both let you branch on results within a single script. - Avoid redundant
GetFile. IfRunCommandcan read what you need and include it in stdout, do that instead of a separateGetFileround trip. - Cache
Info()once. Callh.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
RunCommandbefore the first returns.
The become Limitation
A plugin task cannot run with become enabled in v1. 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
preflightbinary 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:
preflight plugin info <name>That reported name is what belongs in module:.
plugin_protocol error at runtime
Your plugin was built against a pre-v1 SDK. Update to the current SDK, add the Handle 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 in v1.