Skip to content

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:

  1. Write the file yourself as a literal configFiles entry. Works today, costs you everything above it: no derivation from the fleet, no validation, no attribution.
  2. 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. A str or strlist field must name one: an unvalidated string is the injection class.
  • You return data; the kernel performs every effect. Your render is 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=True and Punix forces 0600, 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:

punix service deploy Cache --file cache.pcl --recipe-pins recipes.pin.toml
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 secret field arrives as a reference and is resolved below the seam; it never enters a hash, the store, or gen-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 0600 files 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.

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.