Suil SDK
Modules get one supported import surface, suil.sdk: the models, three
interfaces with their protocols, the libs and the errors. A module is built on
them, and everything outside suil.sdk is private to suil. That is what makes
the later split into separate repositories possible. The runner and the
modules move onto it in 04-migrate-sdk.md.
Layout
suil/sdk/
__init__.py the import surface and SDK_VERSION
models/ Context, Config, Secret, Encrypted, Tagged, Lookup
protocols.py Deploys, Expects, Checks, DeclaresFiles, Collects
module.py Module
facter.py Facter and the bootstrap that runs it on the node
errors.py Error and its subclasses
libs/<kind>/ one function per file, by kind
The SDK is models, interfaces, protocols and libs, and nothing else. How a module works inside is its own business: suil is a deployer and does not decide what a module writes or where. The order of its operations, its rendering and its restart conditions belong to the module.
Interfaces
A module implements up to three interfaces, one subclass per file:
Config in code/config.py, Module in code/main.py and Facter in
facts/collector.py. A module with no code is a meta module, as base is,
and implements none.
Config
Config is a pydantic v2 model with
extra='forbid', so a key the model does not know fails validation in any
data layer. get_config fills suil, a Context with node, role,
family, release and ssh_user, and validates the rest from the merged
data.
Module
Module is generic over its Config. Module(config) is built once per node
with the validated config. Its methods:
deploy(facts, force)- queue the operations that close the diff; mandatory.expected()- the state the facts must match; mandatory.check(facts)- problems judged from the facts; empty by default.files()-{path: content | None}for every file the module puts on the node,Nonefor one that must go; empty by default.
The constructor attaches every function of libs/module/ to the instance
under its own name, so a module calls self.diff_configs(...) or
self.file_needs_write(...) without importing them. The module writes its
files itself and gates each write with file_needs_write. Their digests stay
in expected().
load_class imports code/main.py and returns its one Module subclass, or
None for a meta module. It refuses a file with none or several, a class that
does not override deploy or expected, and a method whose signature differs
from its protocol's.
Facter
Facter is the only code that reads the node, so it runs there on the
standard library alone. Its config is the public config without suil. It
carries read(path), run(argv) and digest(value), which returns the same
sha256: form a Secret has in the public view.
check_facter checks facts/collector.py statically with ast, without
importing it: standard library imports and from suil.sdk import Facter
only, exactly one Facter subclass, and collect() with the protocol's
signature. collect_facts uploads suil/sdk/facter.py, the collector and the
public config, then runs the bootstrap with sudo. The bootstrap stands in for
suil.sdk, builds the one Facter with the config and prints the JSON of
collect(). collect_facts adds the digests of files() under files.
Protocols
Every method of an interface is a typing.Protocol, and load_class compares
signatures against them:
@runtime_checkable
class Deploys(Protocol):
def deploy(self, facts: dict, force: bool) -> None: ...
@runtime_checkable
class Expects(Protocol):
def expected(self) -> dict: ...
@runtime_checkable
class Checks(Protocol):
def check(self, facts: dict) -> list[str]: ...
@runtime_checkable
class DeclaresFiles(Protocol):
def files(self) -> dict[str, str | bytes | None]: ...
@runtime_checkable
class Collects(Protocol):
def collect(self) -> dict: ...
Config has no protocol: its contract is the pydantic model. Module is
Deploys and Expects, with Checks and DeclaresFiles optional. Facter
is Collects, checked from the source because it is never imported on the
control machine.
Secrets
Encrypted is an !ENC[...] value as the data loader reads it: its string is
the ciphertext, and reveal() decrypts it with SUIL_AGE_KEY. get_config
reveals every Encrypted of the node into a Secret, a str that holds the
plaintext, and hands that to the model.
A secret field is typed Secret. A field of its own turns any string into a
Secret, and in a union such as dict[str, Secret | str] a value stays a
Secret only when it arrived as one. The public view is the dump with
context={'public': True}, which writes every Secret as
sha256:<hex of the plaintext>; get_public and strip_secrets build it.
Under strict=True a Secret field takes only a Secret instance.
A plain str field would turn a Secret back into a plain string and leak
it into the public view. get_config refuses that: when a revealed plaintext
survives in the public view, it raises ModuleConfigError with the dotted
path of the field.
Libs and errors
suil.sdk.libs holds one function per file, one directory per kind:
config, host, inventory, merge, module, node, pyinfra, role,
run, string and yaml. A name does not repeat its location:
libs.module.diff_configs, with no module_ prefix. The kinds are exported
as modules, because collect exists in both node and role.
suil.sdk.errors holds Error, which logs itself when it is constructed,
and DataError, ModuleError, ModuleConfigError, ModuleOrderError,
ModuleValidateError, SecretError, NodeProbeError and DeploymentError.
Raise one and never log it as well.
Examples
A module's config with a secret field:
from suil.sdk import Config, Secret
class Config(Config):
password: Secret
env: dict[str, Secret | str] = {}
A module that gates its one file on the facts:
from io import StringIO
from pyinfra.operations import files
from suil.sdk import Module
PATH = '/etc/demo.conf'
class Demo(Module):
def deploy(self, facts: dict, force: bool) -> None:
content = self.files()[PATH]
if self.file_needs_write(facts, PATH, content, force):
files.put(name=f'write {PATH}', src=StringIO(content), dest=PATH)
def expected(self) -> dict:
return {'files': {PATH: self.file_digest(self.files()[PATH])}}
def files(self) -> dict[str, str | bytes | None]:
return {PATH: f'node {self.config.suil.node}\n'}
A collector:
from suil.sdk import Facter
class Demo(Facter):
def collect(self) -> dict:
return {'hostname': self.read('/etc/hostname')}
Acceptance
- A module whose class misses a mandatory method, or has a method with the wrong signature, is refused at load with the module, the file and the expected signature.
- A collector that imports more than the standard library and
Facteris refused before it is uploaded. - The public view carries the same digests as the one built by value, and
an
!ENCvalue in a field not typedSecretfails the config.