Vendored config recipes¶
Punix ships a fixed set of config generators: nginx, Prometheus, Litestream, PostgreSQL, mail, WireGuard, plus dataFiles for anything in a tree format. When your daemon isn't on that list and its config isn't YAML/JSON either, you have two options that don't involve forking Punix:
- Write the file yourself as a literal
configFilesentry. Works today, costs you everything above it: no derivation from the fleet, no validation, no attribution. - Vendor a recipe: a small Python file that renders the config from typed data. You then get for your daemon what nginx gets: typed input, validated values, fleet-derivation, provenance, atomic rollback.
A recipe is trusted code
A config recipe runs inside the deploy process, at the effect boundary, and the files it writes land on the target as root. Punix contains what it can: it confines the paths, forces the mode on anything carrying a secret, scans its output for leaked credentials, and refuses to run a file that doesn't match its pin. It does not sandbox it. A recipe you vendor is a recipe you have read. Treat pinning it the way you'd treat adding a dependency, rather than installing a plugin.
The shape of a recipe¶
A file exporting render, and optionally a SCHEMA describing the data it consumes:
# recipes/memcached.py
from __future__ import annotations
from punix.deploy.schema import Field, RecipeSchema
from punix.deploy.stack import ConfigFile, recipe_record
REF = "thirdparty.memcached"
SCHEMA = RecipeSchema(
ref=REF,
fields=(
Field("memoryMb", kind="int", min=1, max=1_048_576),
Field("port", kind="int", min=1, max=65535),
Field("listen", kind="str", encoder="ip_or_cidr"),
Field("backends", kind="strlist", encoder="stream_upstream"),
Field("authToken", kind="secret", required=False, default=None),
),
)
def render(stack):
rec = recipe_record(stack, REF)
if rec is None: # the surface isn't used in this stack
return ()
return (
ConfigFile(path="/etc/memcached/proxy.lua", content=_lua(rec)),
ConfigFile(path="/etc/memcached.conf", content=_flags(rec)),
)
Three rules the interface enforces:
- You name encoders, you never define them.
encoder="ip_or_cidr"resolves to a predicate in Punix's single audit home. An unknown name is refused when the schema is registered, not at deploy time. Astrorstrlistfield must name one: an unvalidated string is the injection class. - You return data; the kernel performs every effect. Your
renderis a pure function. It cannot write, chmod, or run anything. - You declare which file carries a secret; the kernel sets the mode. Pass
secret_bearing=Trueand Punix forces0600, refuses anything looser, and keeps the bytes out of the rollback manifest. You cannot get the mode wrong, because you don't choose it.
A secret field deliberately takes no encoder: a credential is arbitrary bytes, and an allowlist over it would reject valid passwords. Guard your grammar's separators at render time instead (as the built-in mail recipe does for the : in a dovecot passwd-file).
Pinning it¶
Copy the file into your own tree and record its digest in a manifest:
cd infra/
mkdir -p recipes && cp ~/src/memcached-recipe.py recipes/memcached.py
shasum -a 256 recipes/memcached.py
# infra/recipes.pin.toml
[recipes."thirdparty.memcached"]
path = "recipes/memcached.py" # relative to this manifest
sha256 = "9f2c…" # a content digest — never a branch, tag, or URL
Then pass it:
loaded 1 pinned recipe(s) from recipes.pin.toml: thirdparty.memcached
deployed: stack Cache → gen-001 (2 config file(s), 0 store path(s) pinned)
Nothing third-party runs without --recipe-pins. A manifest sitting next to your PCL is inert; loading is an explicit act, on the command that needs it (service deploy, fleet apply, and check).
Selecting it from PCL¶
A config module names the recipe with configRecipe and supplies its fields; the stack lists it in configModules:
module CacheConfig {
configRecipe = "thirdparty.memcached"
memoryMb = 512
port = 11211
listen = "127.0.0.1"
backends = ["10.0.0.1:11211", "10.0.0.2:11211"]
}
module Cache {
backend = "systemd"
storeModules = ["Memcached"]
services = ["MemcachedSvc"]
configModules = ["CacheConfig"]
}
Because the loader runs before your PCL is type-checked, a mistake here is a located compile error, caught before deploy:
$ punix check cache.pcl --recipe-pins recipes.pin.toml
cache.pcl:3: error: [E1] field 'memoryMb' of recipe 'thirdparty.memcached' expects int, got bool
It is configRecipe, not recipe
recipe is how Punix recognises a package module. A config module using it would be sent to the builder.
What changing a recipe does¶
Editing a vendored file without updating its digest blocks the deploy:
error: refusing to load recipe 'thirdparty.memcached': recipes/memcached.py does not match its pin
pinned: 9f2c…
actual: 41ba…
A recipe changing is not an error to silence — review the diff, then update
`sha256` in recipes.pin.toml to re-pin it (R1).
The mechanism is working as designed: a recipe emits config that runs as root, so a change to one is a change you should have read. Review the diff, paste the new digest, commit: the re-pin lands in your git history as evidence that someone looked.
What you get back¶
Everything a built-in gets:
- Typed, validated input. Wrong type, unknown field, missing required field, a value failing its encoder: all refused, most with a source location.
- Path confinement and collision checks. Your output goes through the same kernel write boundary as
std.*: see path confinement. - Secret handling. A
secretfield arrives as a reference and is resolved below the seam; it never enters a hash, the store, orgen-NNN.json. - A leak scan. Because your recipe isn't first-party, Punix additionally scans its output for a resolved secret appearing in a file you didn't declare secret-bearing, and refuses the deploy if it finds one. (Built-in generators are exempt: they route secrets to
0600files by construction, and scanning them produces false positives.) - Provenance. Each file records which recipe produced it and a hash of that recipe's code:
$ punix service why Cache /etc/memcached.conf
/etc/memcached.conf: produced by recipe thirdparty.memcached @ 3e73…
- Atomic deploy and rollback, identical to every other generation.
Limits¶
- One file per recipe. A vendored recipe imports the standard library and
punix.*; a multi-file recipe would need each file pinned, which isn't wired. std.*is reserved. A vendored recipe claiming a built-in's name is refused, never allowed to shadow it. Use your own prefix.- No sandbox. See the warning at the top. An unreviewed-recipe ecosystem needs isolation Punix does not yet have, which is why there is no install-by-URL and no auto-discovery.
- No collection sync yet. Recipes are vendored file by file; distributing a whole git-backed, rev-pinned collection is designed but unbuilt.
- Supervisor backends are not vendorable. A backend's renderer is an ordinary recipe, but its activator actually starts services; an effect, so it stays host code.
Related¶
- Composing a stack: including
dataFiles, which needs no recipe at all when your format is YAML/JSON. - Config-file path confinement.
- Secrets at deploy.
- Generations and rollback: where the recipe attribution is recorded.
A runnable example¶
examples/tooling-config/ is this page end to end: a recipe outside the Punix tree, digest-pinned, selected from PCL, rendering .editorconfig for a set of repos. Its run.sh exits 0 only if the file renders and all four refusals fire: tampered recipe, missing --recipe-pins, out-of-range input, and provenance reporting the recipe's code hash.