pkg-be-plugin: auto-create ZFS boot environments before pkg transactions

From: Sasha Karcz <sasha_at_starnix.net>
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