Architecture
on this page
Preflight is designed for a specific operational niche: Windows exhibit machines that need repeatable configuration, low ceremony, and the option to run in constrained environments.
The architecture reflects that goal. It favors explicit phases, a target-agnostic runner, and a small number of durable abstractions over a large amount of hidden magic.
One term used throughout the docs is defined here: the controller is the machine that runs the preflight CLI — usually an operator’s workstation or a CI runner. Everything else (planning, transports, state) is described relative to it.
The Core Model: Modules, Actions, Playbooks
Preflight splits configuration into three layers:
- Modules are the execution primitives. They are small units of behavior compiled into the binary or supplied by plugins.
- Actions are reusable YAML bundles of tasks with typed inputs.
- Playbooks are the top-level declarations for a machine or environment.
That layering matters because each level changes at a different rate:
- Module behavior is versioned with the binary.
- Actions are a sharing and composition surface.
- Playbooks remain local to an environment or deployment.
Keeping those concerns separate makes the system easier to evolve without turning every change into a schema break.
Why Idempotency Is A Contract
Every module must implement Check() and Apply().
That is not just a stylistic choice. It is the foundation for:
- dry-run mode without a parallel simulation engine
- safe repeated applies
- meaningful state snapshots
- predictable plugin behavior
The runner does not assume that applying twice is harmless. Instead, it requires each module to tell the truth about whether work is needed before it mutates anything.
Why The Runner Is Target-Agnostic
The runner is always constructed with a Target. It does not hardcode localhost assumptions.
That decision protects the long-term shape of the project:
- local execution uses the same pipeline as remote execution
- WinRM and SSH become transport choices instead of alternate runners
- staged bundles can execute through the same abstractions
- plugins fit into the same module contract regardless of transport
The command layer resolves hosts and builds target implementations. The runner then receives one target and stays focused on planning and execution for that single context.
Package Responsibilities
The codebase is intentionally split into a handful of major responsibilities:
| Path | Responsibility |
|---|---|
cmd/ | Cobra command surface and host orchestration |
internal/runner/ | Planning, DAG building, staging, applying, state |
internal/action/ | Playbook loading, action resolution, remote refs, lockfile |
internal/module/ | Built-in module implementations |
internal/target/ | Target interface plus local, WinRM, SSH, and plugin module adapters |
internal/template/ | Variable layering and template rendering |
internal/inventory/ | Inventory parsing and selector resolution |
internal/facts/ | Fact gathering and normalization |
internal/output/ | Text, TUI, and JSON renderers |
internal/plugins/ | Plugin discovery and registry construction |
internal/bundle/ | Staged bundle format and extraction |
pkg/plugin/sdk/ | Go plugin author SDK |
Why The Phases Are Explicit
Preflight models four phases:
Fetch -> Plan -> Stage -> ApplyThat separation is about more than code organization.
- Fetch is dependency acquisition and cache pinning.
- Plan is pure computation and should stay safe to run anywhere.
- Stage is packaging for disconnected or delayed execution.
- Apply is the only phase that should mutate machines.
This is especially valuable in environments where deployment often crosses network boundaries and operational windows are tight, such as museums, galleries, digital signage, and other managed-endpoint fleets.
Why The Embedded Stdlib Ships With The Binary
The embedded stdlib gives users a baseline action library without introducing a package registry as a prerequisite.
That is why the preflight/ namespace resolves first and is versioned with the binary. The tradeoff is deliberate:
- users get predictable built-in actions
- maintainers keep version compatibility simple
- environments that need independently pinned behavior can still use remote actions and
preflight.lock
Why Plugins Are Executables
Go shared-object plugins are not the right portability story for Windows. Executable plugins speaking JSON-RPC keep the extension model cross-language and Windows-friendly.
That choice also keeps the built-in module API honest. If an external process can implement the same idempotent contract, then the core abstractions are staying small and composable.
The Design Boundary To Protect
Two boundaries matter more than almost anything else as the project grows:
- the public YAML schema
- the
Targetabstraction
If those stay stable, Preflight can add richer modules, transports, staging flows, and plugins without forcing users to rewrite their playbooks or abandon the core execution model.