Skip to content

How to write a SoulModule

SoulModule is a plugin that implements a single Destiny step: it brings a resource on the host to the desired state. The binary is named soul-mod-<name> and is launched by the soul agent. This section is about how to write such a module; the general plugin model is in the extending overview.

First check whether what you need is already in the core module catalog: packages, files, services, users, cron, mounts, firewall, git, archives, and sysctl are already covered by built-in modules. A SoulModule plugin is for integrations that are not in core — for example a specific database, a reverse proxy, or a container runtime.

A module implements an interface from the SDK. Two key methods:

  • Validate — checks the step’s input parameters (no side effects). Invalid input is rejected before applying. The same contract gives soul-lint its static check via the manifest.
  • Apply — brings the resource to the desired state and reports what changed.

This is the same interface the built-in core modules implement — a plugin and a core module are indistinguishable to the agent.

Apply must be idempotent: first read the resource’s current state, and change it only if it differs from the desired state. The step’s result is a changed flag:

  • changed = true — the resource’s state was changed by this run.
  • changed = false — the resource was already in the desired state, nothing was done.

This flag is not cosmetic: dependencies between steps are built on it (for example onchanges: — “restart the service only if the config changed”). A module that always returns changed = true breaks this mechanism and triggers needless restarts. More on idempotency and changed is in the Destiny section.

State semantics follow the resource naturally: for persistent resources it is present / absent, for processes and containers running / stopped / absent. There is no need to force present/absent on everything — pick states that reflect the nature of the resource.

Next to the binary is a static manifest.yaml. It describes the plugin so that soul-lint can validate Destiny without running the plugin:

kind: soul_module # plugin type
protocol_version: 1 # plugin protocol version (compat flag, not the module version)
namespace: acme # author's namespace
name: pgrole # module name
required_capabilities: [network_outbound] # what it needs from the host
side_effects: # which resources it touches
- { service: postgresql }
spec:
states:
present: # supported states and their parameters
input:
name: { type: string, required: true }
password: { type: string, secret: true }
absent:
input:
name: { type: string, required: true }
  • kind: soul_module — the plugin-type discriminator.
  • protocol_version — the version of the plugin protocol, a compat flag. It is not the version of your module (the module version is the git ref of the release). The host supports a fixed set of protocol versions; on a mismatch the launch is rejected. You only bump this number when the protocol format changes, not on every module release.
  • required_capabilities — what the plugin needs from the host (for example outbound network, Vault access, running as root). soul-lint statically checks that what is requested fits the host’s policy and fails Destiny before applying if it does not.
  • side_effects — every resource the module touches (services, files, packages, ports, and so on). This is both an audit trail and conflict detection: if two modules in the same run claim the same resource, the host flags it. If a module touches a resource at runtime that is not declared in side_effects, the step is marked as a policy violation.
  • spec.states — the supported states and the parameter schema for each. soul-lint uses it to check a Destiny step’s parameters.

The manifest and the module’s code must stay in sync — that is the author’s responsibility (a self-test helps: calling Apply with parameters outside the schema should return an argument-validation error).

  1. Generate the skeleton with soul-lint plugin-init:

    Terminal window
    soul-lint plugin-init acme/pgrole --out ./soul-mod-pgrole --author "Acme Ops"

    This gives a ready project tree with a manifest and a handler stub — all that is left is to write Apply.

  2. Implement Validate / Apply through the SDK interface and build the binary soul-mod-<name>. Because the plugin is a separate process that talks over gRPC, the implementation language imposes no runtime dependency on the managed host: a self-contained binary arrives on the host.

  3. Check the manifest locally:

    Terminal window
    soul-lint validate-manifest ./soul-mod-pgrole/manifest.yaml

Plugins are registered with Keeper (source + git ref); Keeper resolves them into binaries and keeps them in an artifact cache. Delivery to the host and caching work like this:

  • On the host, binaries and modules live in a local cache; the file name includes the SHA-256 of the content, so several versions can sit side by side and delivery happens only on an actual change.
  • In push mode Keeper sends the host all registered modules in bulk (without analyzing the specific Destiny) and compares each one’s SHA-256 against what is already cached: a match skips the copy, a change pulls it down. The soul binary itself is updated by the same SHA-256 comparison mechanism.
  • In pull mode the module is delivered by an ordinary Destiny operation, placed in the local cache, and verified by fingerprint.

When executing a step, the agent launches the cached binary as a child process, performs the handshake, and calls Apply over gRPC. More on the host cache and delivery is in the Soul component.