Caught during the left4me-integration nginx 80.conf move: the agent declared a redundant 'source': '80.conf' on a file: item whose destination already ended in 80.conf. The maintainer flagged it as noise. Document the rule: only declare source when the basename differs from the destination (e.g. .mako template to a non-suffixed destination). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 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. file:sourcedefaults to the destination basename. For a destination of/etc/foo/bar.confwith nosourcekey, bw looks forbundles/<bundle>/files/bar.conf. Only declaresourceexplicitly when the basename you want differs (e.g. shipping a Mako template namedbar.conf.makoto a destination of/etc/foo/bar.conf).- 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.- Reactors must read metadata. If a reactor body returns a static
dict without calling
metadata.get(...), bw raisesValueError: <reactor> on <node> did not request any metadata, you might want to use defaults insteadonce a node consumes the bundle. Fix: fold the contribution intodefaults. The rule applies even when the reactor writes into another bundle's namespace — a static contribution to e.g.nftables/outputbelongs indefaults, where bw merges it with other bundles' contributions. triggers↔triggered: Trueinvariant. Any item listed in another'striggerslist must declaretriggered: True. bw enforces this atbw testtime: "…triggered by …, but missing 'triggered' attribute". Corollary: an action can't be both in an upstreamtriggerslist AND self-healing every apply — pick one.- Triggered actions don't recover from partial failure. When an
upstream item's apply succeeds but its triggered downstream action
fails, subsequent applies can't recover via the trigger chain —
upstream is "already in desired state" and never re-triggers. For
actions that must self-heal (pip installs, chowns, migrations),
drop
triggered: Trueand gate the command withunless: <fast-check>.unlessis a shell command on the target host whose exit status decides whether the main command runs (exit 0 = skip); it's checked at fire time, aftertriggered:filtering.
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.