pkg-be-plugin: auto-create ZFS boot environments before pkg transactions
Date: Thu, 14 May 2026 05:30:20 UTC
Hello,
I've written a pkg(8) plugin that automatically creates a ZFS boot
environment before each install, upgrade, and deinstall transaction. If
a transaction leaves the system in a broken state, the pre-transaction
BE is there to boot into.
The plugin is called pkg-be-plugin and installs as be.so. It uses
libbe(3) directly — no exec of bectl(8) or zfs(8).
*Behaviour*
On each covered transaction, the plugin calls |libbe_init()| and
|be_create()| to snapshot the current BE under a timestamped name
(default prefix: |pre-pkg|, e.g. |pre-pkg-20260514-091532|). After
creation, it prunes older auto-created BEs to keep the count at or below
a configurable limit, with a minimum-age guard so recent rollback points
aren't destroyed even when over the limit.
All activity is logged to syslog(3) at LOG_NOTICE for normal operations
and LOG_WARNING/LOG_ERR for failures, so admins can grep
|/var/log/messages| to find BE names for rollback after a bad transaction.
*Configuration* (via |/usr/local/etc/pkg/be.conf|, UCL format)
* |BE_PLUGIN_ENABLED| — master switch (default: true)
* |BE_PLUGIN_KEEP| — maximum BEs to retain (default: 5)
* |BE_PLUGIN_NAME_PREFIX| — name prefix (default: |pre-pkg|)
* |BE_PLUGIN_MIN_AGE| — minimum age before pruning (default: 7d;
protects recent rollback points from being destroyed when count
exceeds KEEP)
* |BE_PLUGIN_STRICT| — abort transaction on BE creation failure
(default: false)
* |BE_PLUGIN_SKIP_TRANSACTIONS| — comma-separated list of transaction
types to skip (|install|, |upgrade|, |deinstall|)
*Non-ZFS systems*
|libbe_init()| fails on UFS roots and in jails without ZFS access. In
non-strict mode (the default) this is logged as a warning and the
transaction proceeds normally. Strict mode causes a fail-closed abort,
which may be appropriate for ZFS-only fleets.
*Testing*
Tested on FreeBSD 15.0-RELEASE-p5 with the install/upgrade/deinstall
transaction types, including multi-package transactions, the prune path
(over-KEEP and under-min-age scenarios), and strict-mode behaviour. Unit
tests cover the config parser and prune sort/filter logic.
*Source*
https://github.com/usenix17/pkg-be-plugin
<https://github.com/usenix17/pkg-be-plugin>
Feedback welcome. Specific things I'd appreciate eyes on:
1. *pkg plugin API usage* — particularly the hook lifecycle (init →
multiple hooks → shutdown) and whether
|PKG_PLUGIN_HOOK_PRE_{INSTALL,UPGRADE,DEINSTALL}| are the right
hooks for this purpose, or whether there's a less-surprising place
to do BE creation.
2. *libbe nvlist property access* — the |creation| property is stored
as a string of decimal Unix epoch seconds rather than a uint64. I
worked this out via integration testing; if this is documented
somewhere I missed, pointers welcome.
3. *Prune semantics* — currently the count can drift above |KEEP| if
all candidate BEs are under |MIN_AGE|. Trade-off chosen for the
homelab-friendly "never destroy a recent rollback" property. If list
consensus prefers strict-count enforcement, the policy is a one-line
change.
Sasha Karcz