bundlewrap/bundles/AGENTS.md
CroneKorkN 59788f315a
agents/bundles: reactors must read metadata or be defaults
The left4me bundle's first cut had two reactors that returned
static dicts without calling metadata.get(...): systemd_services
(enable/run flags) and nftables_output (two static rule strings).
Both passed bw test (no consumer yet). Once attached to
ovh.left4me, bw raised "did not request any metadata, you might
want to use defaults instead". Fix was to fold both into defaults.

Document the pitfall, with the verbatim error wording and the
note that this applies to cross-namespace contributions too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:31 +02:00

9 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 — see conventions.md#naming-conventions.
  • items.py is plain Python; it produces files = {...}, pkg_apt = {...}, svc_systemd = {...}, etc. dicts at module scope. Cross-item dependencies use needs / triggers / triggered_by — see the fork's AGENTS.md for the full keyword cheat sheet.
  • metadata.py uses defaults = {...} 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 in items/, 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 in defaults via repo.vault.random_bytes_as_base64_for(...) keyed on the node — not in the node file. See bundles/left4me/metadata.py:10 (secret_key derived in defaults) and bundles/postgresql/metadata.py:4 (vault-derived password_for at module scope).

How to add a new bundle

  1. mkdir bundles/<name>/ (lowercase, hyphenated).

  2. Write items.py and (if anything is configurable) metadata.py. Use repo.libs.hashable.hashable(...) when you need to nest a dict or set inside a metadata set; raw dicts/sets aren't hashable.

  3. Drop static payloads into bundles/<name>/files/. For Mako-templated files, declare 'content_type': 'mako' on the file: item — see the fork's item-file-templates guide.

  4. Wire to nodes. Either add an entry to the relevant groups/<axis>/<x>.py (preferred for shared bundles) or to the node's bundles list directly (nodes/AGENTS.md).

  5. 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 bundles list, 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-workflow for the rationale.

  6. Add a bundles/<name>/README.md. See "Per-bundle README" below for what to cover.

How to remove a bundle

  1. git grep '<name>' in nodes/, groups/, and other bundles/ to find references.
  2. Remove those references.
  3. rm -rf bundles/<name>/.
  4. bw test and bw nodes to confirm clean.

Pitfalls

  • metadata.py is evaluated at load time for every node, every invocation of bw. Heavy work or I/O slows the whole repo. Keep reactors pure and fast; pre-compute in libs/ if you must.
  • Static files vs templates. bundles/<x>/files/<f> is static unless the matching file: item declares content_type='mako' (or a templating extension triggers it). To check, read the matching file: entry in items.py.
  • Reactors writing across namespaces. Some bundles' reactors write into other bundles' metadata namespaces (e.g. nextcloud writes into apt.packages, archive.paths). When you change such a bundle, every consumer's metadata changes too. The bundle's README.md often calls these out — but the authoritative source is metadata.py itself; grep '<other-bundle>': in the reactors when in doubt.
  • bw hash doesn't accept selectors. Use bw 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 raises ValueError: <reactor> on <node> did not request any metadata, you might want to use defaults instead once a node consumes the bundle. Fix: fold the contribution into defaults. The rule applies even when the reactor writes into another bundle's namespace — a static contribution to e.g. nftables/output belongs in defaults, where bw merges it with other bundles' contributions.

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 how metadata.py actually 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.py is 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