When the left4me bundle was first integrated, ovh.left4me's node
file carried ~40 lines of left4me-related metadata (git_url,
secret_key, full nginx vhost, monitoring, backups, nftables
rules). The maintainer pushed back: per-node metadata should be
only what genuinely varies per host. Refactor brought it down to
{'domain': 'left4.me'} with everything else in bundle defaults
or in a reactor deriving from the domain.
Add the rule to bundles/AGENTS.md from the bundle-author angle
(use defaults / vault-keyed-on-node for secrets, cite left4me
and postgresql for the established pattern). Add the reviewer's
form to nodes/AGENTS.md Pitfalls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.5 KiB
bundles/
Before you start
Read docs/agents/conventions.md first
— it covers vault calls, demagify, the repo.libs.<x> helpers, and the
files agents must not modify. Skipping it leads to subtly broken bundles
(vault calls in the wrong place, dict-in-set TypeError because of
unhashable nesting, etc.).
For bundlewrap-language reference (item types, dep keywords,
metadata_reactor, defaults, item-file template syntax) see the fork's
AGENTS.md
and its docs/content/guide/item_file_templates.md.
What's here
103 bundles. Each is a directory bundles/<name>/ containing some of:
bundles/<name>/
├── items.py # the items this bundle creates (files, services, packages, …)
├── metadata.py # `defaults` + `@metadata_reactor` functions
├── files/ # static or templated file payloads referenced from items.py
└── README.md # one doc per bundle, for humans and agents (see "Per-bundle README" below)
Conventions
- Bundle names are lowercase, hyphen-separated:
backup-server,bind-acme,dm-crypt. No underscores in new bundle names — seeconventions.md#naming-conventions. items.pyis plain Python; it producesfiles = {...},pkg_apt = {...},svc_systemd = {...}, etc. dicts at module scope. Cross-item dependencies useneeds/triggers/triggered_by— see the fork'sAGENTS.mdfor the full keyword cheat sheet.metadata.pyusesdefaults = {...}for static seed values and@metadata_reactor.provides(...)for derived values. Reactors are pure functions of(metadata,)— no side effects, no I/O.- Helpers go in
libs/when they're useful to more than one bundle. Don't duplicate logic across bundles. - Custom item types (e.g.
download:) live initems/, not per-bundle. - Bundles own application-wide knowledge; nodes carry only the few
per-host knobs the bundle actually needs. When designing a bundle,
identify the per-node knobs (e.g. domain, uplink interface, a
vault-id suffix) and put everything else in
defaults, or in a reactor that derives from those knobs. Per-node random secrets belong indefaultsviarepo.vault.random_bytes_as_base64_for(...)keyed on the node — not in the node file. Seebundles/left4me/metadata.py:10(secret_keyderived in defaults) andbundles/postgresql/metadata.py:4(vault-derivedpassword_forat module scope).
How to add a new bundle
-
mkdir bundles/<name>/(lowercase, hyphenated). -
Write
items.pyand (if anything is configurable)metadata.py. Userepo.libs.hashable.hashable(...)when you need to nest a dict or set inside a metadata set; raw dicts/sets aren't hashable. -
Drop static payloads into
bundles/<name>/files/. For Mako-templated files, declare'content_type': 'mako'on thefile:item — see the fork's item-file-templates guide. -
Wire to nodes. Either add an entry to the relevant
groups/<axis>/<x>.py(preferred for shared bundles) or to the node'sbundleslist directly (nodes/AGENTS.md). -
Verify, in this order:
bw test— repo-wide parse + cross-cutting hooks. Loads every bundle, but reactors don't fire for nodes that haven't opted into the bundle yet — bugs in new reactors stay hidden here.- Attach the bundle to a node (via the node's
bundleslist, or a group it belongs to). Until you do, the next steps don't actually exercise the bundle. bw test <node>— exercises every reactor and item-graph edge for that node. This is where most new-bundle bugs surface.bw items <node> --blame— confirm items materialise with the right paths, authored by the expected bundle.bw metadata <node> -k <a/b>— spot-check derived metadata.bw hash <node>— preview vs current host state.
See
docs/agents/commands.md#bundle-validation-workflowfor the rationale. -
Add a
bundles/<name>/README.md. See "Per-bundle README" below for what to cover.
How to remove a bundle
git grep '<name>'innodes/,groups/, and otherbundles/to find references.- Remove those references.
rm -rf bundles/<name>/.bw testandbw nodesto confirm clean.
Pitfalls
metadata.pyis evaluated at load time for every node, every invocation ofbw. Heavy work or I/O slows the whole repo. Keep reactors pure and fast; pre-compute inlibs/if you must.- Static files vs templates.
bundles/<x>/files/<f>is static unless the matchingfile:item declarescontent_type='mako'(or a templating extension triggers it). To check, read the matchingfile:entry initems.py. - Reactors writing across namespaces. Some bundles' reactors write
into other bundles' metadata namespaces (e.g.
nextcloudwrites intoapt.packages,archive.paths). When you change such a bundle, every consumer's metadata changes too. The bundle'sREADME.mdoften calls these out — but the authoritative source ismetadata.pyitself; grep'<other-bundle>':in the reactors when in doubt. bw hashdoesn't accept selectors. Usebw hash <node>per literal name; see the fork's runbook.
Per-bundle README
Each bundle has (or should have) a README.md. One doc per bundle,
written for humans and agents both. There's no fixed structure —
match the bundle's actual surface, write what helps a future reader
(or future you) avoid trial-and-error.
The existing READMEs vary in quality and shape. For orientation, look at the bigger ones, not the two-line ones:
bundles/flask/README.md— title + one-sentence purpose, a metadata example as a Python dict, then the contract the consuming git repo has to satisfy + a logging pitfall. The closest thing to a "balanced doc" in tree.bundles/dm-crypt/README.md— same shape, shorter: purpose + metadata example + one sentence on effect.bundles/apt/README.md— relevant upstream URLs at the top, then a Python metadata example with rich inline comments (type / optionality / where keys come from).bundles/nextcloud/README.md— operational scratchpad: iPhone-import recipe, preview-generator commands, reset queries. Captures muscle-memory the maintainer would otherwise re-learn each time.
Useful things to include, when relevant:
- A sentence or two on what the bundle does and when you'd attach it.
- A metadata example as a Python dict literal, with
#comments on each key (type, required vs default, units, where it comes from). This is the cleanest way to communicate the schema and matches howmetadata.pyactually looks. - Anything non-obvious about wiring it up — required keys without defaults, group-membership expectations, manual one-time steps.
- Cross-namespace metadata writes, when this bundle's reactors populate another bundle's namespace. Easy to miss, cheap to flag.
- Gotchas, debug recipes, failure modes you've actually hit.
What to skip:
- An exhaustive item list —
items.pyis shorter and more accurate. - Anything that would just rot — version numbers, "TODO" lists, change notes. Use git history.
If a single paragraph is enough to say what's worth saying, write a single paragraph. Verbosity isn't a goal.
Convention going forward is leave-as-you-go: any time you materially edit a bundle, top up its README (or write one if it's missing). Don't burn a session bulk-reformatting the existing ones — uneven quality is part of what we accept in exchange for not blocking other work.
See also
docs/agents/conventions.md— repo idioms (vault, demagify, naming, do-not-touch list).docs/agents/commands.md— repo-specific command deltas.items/AGENTS.md— custom item types (download:); when to write a new one vs usefile:.libs/AGENTS.md— shared helpers.- Fork's
AGENTS.md— bundlewrap-language reference + safety envelope.