Bootable OS profiles for Flipper One, implemented as btrfs subvolumes plus overlayfs uppers over a read-only base. This is the working tool behind the proposal in flipperone-linux-build-scripts#80 — option (a) from that RFC, made concrete.
A profile is literally "an overlay on top of the base system":
mount -t overlay -o lowerdir=@base/<ver>,upperdir=...,workdir=....
apt/dpkg run unmodified inside it; clone and reset are btrfs snapshots, so
they cost nothing; base updates are whole new read-only subvolumes flipped
into place atomically.
One btrfs partition (the "pool"), top level mounted at /run/flipper/pool
on a booted device:
@base/
1.0/ read-only Debian rootfs (btrfs subvolume)
2.0/ next version, received via update
current -> 2.0 symlink, flipped atomically with rename(2)
@profiles/
<id>/
upper/ btrfs subvolume holding BOTH overlay dirs:
root/ overlayfs upperdir — ALL profile state
work/ overlayfs workdir
snapshots/<tag> read-only snapshots of upper (undo stack)
profile.json metadata: id, pinned base, origin, created
base pinned base version, one line (plain text so the
initramfs script reads it with `cat`)
@data shared user files, bind-mounted into every profile
Why upper/root + upper/work inside one subvolume: overlayfs renames
files between workdir and upperdir, and rename(2) across btrfs subvolumes
fails with EXDEV. Keeping both in the same subvolume also means one
snapshot captures everything a profile owns.
Profiles pin the base version they were created against. Adopting a new
base flips @base/current for future profiles; existing ones keep booting
their pinned base until explicitly re-based. Old bases stay until GC'd, so
base rollback is "flip the symlink back".
flipper-profile [--root DIR] <command>
--root defaults to /run/flipper/pool (env FLIPPER_PROFILE_ROOT).
| command | what it does |
|---|---|
init <dev|dir> |
create @base @profiles @data subvolumes (mounts/unmounts the device if given one) |
base-adopt <version> <stream|dir> |
install a base from a btrfs send stream (file or -) or a rootfs directory, mark it read-only, atomically flip @base/current |
create <id> [--from <id>] |
new empty profile, or instant clone via snapshot |
list |
profiles with pinned base, origin, exclusive disk usage (btrfs qgroups if quota is enabled, du fallback), snapshot count |
mount <id> <mnt> |
assemble the profile overlay against its pinned base at <mnt> |
snapshot [<id>] [--tag T] [--auto] |
read-only snapshot of the upper; --auto is the apt-hook mode: resolves the booted profile from /proc/cmdline, prunes to the last 5 auto-snapshots, and no-ops silently on non-profile boots |
undo [<id>] |
restore the most recent snapshot and pop it off the stack (repeat to step further back) |
reset <id> |
empty the upper — profile reverts to pristine base (snapshots kept) |
delete <id> |
remove the profile and its snapshots |
set-default <id> [--extlinux PATH] |
make the profile the default boot entry (see below) |
Everything is python3 stdlib + btrfs-progs; run as root.
set-default rewrites extlinux.conf (path via --extlinux or env
FLIPPER_EXTLINUX, default /boot/extlinux/extlinux.conf): it points
default at the profile's label, creating the label by cloning the first
existing entry and appending flipper.profile=<id> flipper.base=<ver> to
its append line. This is the same file FlipCTL already rewrites for
target switching in #68, so the two compose — one label per profile,
FlipCTL flips default.
At boot, kernel cmdline carries:
root=<btrfs partition> rootflags=subvol=/ flipper.profile=<id> [flipper.base=<ver>]
initramfs/ ships the initramfs-tools pieces:
hooks/flipper-profile→/etc/initramfs-tools/hooks/— pulls the btrfs + overlay modules (and thebtrfsbinary, for rescue shells) into the initramfs.scripts/local-bottom/flipper-profile→/etc/initramfs-tools/scripts/local-bottom/— after the root device is mounted, moves the pool to/run/flipper/pool, resolves the base (bootarg > profile pin >@base/current), mounts the overlay onto${rootmnt}, bind-mounts@data, and falls back to booting the plain pool root if anything is missing (no profile bootarg = clean pass-through for non-profile setups).
initramfs-tools moves /run into the real root before switch_root, so
the pool is already at /run/flipper/pool when the CLI runs in the booted
system — no fstab entry needed.
Then update-initramfs -u.
apt/90flipper-profile → /etc/apt/apt.conf.d/:
DPkg::Pre-Invoke { "/usr/sbin/flipper-profile snapshot --auto"; };
Every dpkg transaction gets a pre-snapshot of the booted profile's upper;
flipper-profile undo rolls the last one back. On systems not booted
through a profile the hook is a silent no-op, so it never breaks apt.
tests/e2e.sh (run with sudo) builds a 2 GB loopback btrfs image and
exercises the full lifecycle: init → base-adopt from a stub rootfs →
create/mount/write with upper-vs-base isolation checks → clone independence
→ snapshot/undo roundtrip → reset → second base adoption with atomic flip
and pin verification → base-adopt from a real btrfs send stream →
set-default rewriting a #68-style extlinux.conf → list/delete.
38 assertions, all green on btrfs-progs 6.x / kernel 7.x.
- No MCU handoff. The pre-Linux profile picker (MCU exposing the
selection over I2C,
boot.scrreading it) from the RFC is not here;set-defaultcovers the FlipCTL-driven flow from #68 only. - No GC policy. Old bases and pruned profiles accumulate until deleted by hand; auto-snapshots are capped at 5 per profile but base versions are never reaped automatically.
- No incremental base streams.
base-adoptclears the received subvolume's read-only flag to rename it into place, which dropsreceived_uuid— sobtrfs send -pparent chaining doesn't work yet. Full streams only. - No
flipper-profile check. The upper-vs-new-base conffile diff the RFC mentions for major base jumps is future work. - No per-profile
/var/machine-idpolicy — currently whatever the overlay produces (i.e. isolated per profile once written).
MIT — see LICENSE.