scram

0.1.0

scram is the snapshot tool of UraniumOS. A frame is a read-only btrfs snapshot of the root and of /home. scram keeps the frames, builds updates in a copy of the system, writes the boot menu and schedules changes that the initramfs applies before the root is mounted. It is one binary from the uranium-tools package and runs as root. rod takes a frame before every install, remove and update, and the autoframe service takes one every hour.

scram COMMAND [ARGUMENT ...]

boot describes the boot side, storage the disk layout and backups copies to other machines. A frame is stored on the same disk as the system, so it is not a backup.

What a frame is

A frame is a directory in .scram on the top level of the btrfs filesystem. It holds a read-only snapshot of @ (the root) and one of @home, plus a few small files. A snapshot shares its blocks with the live subvolume, so a frame only costs the blocks that change after it was taken. Flashing to a frame replaces the live subvolumes with writable copies of the snapshots, so packages, /etc, services and home directories go back to that moment.

The top level of the filesystem (subvolume 5) is mounted on /run/scram/top by the initramfs. Without root, scram cannot read the frames, and geiger warns that the frames cannot be reached.

.scram/ID/@, .scram/ID/@homeThe read-only snapshots of a frame.
.scram/ID/noteThe text given to frame -m.
.scram/ID/boot/The kernel and initramfs of the profile when the frame was taken (vmlinuz, initrd) and id, the first 12 characters of the md5 sum of both. There is no boot/ if the profile had no kernel or if the initramfs took the frame.
.scram/ID/keepAn empty file. A frame with it is skipped by prune.
.scram/keep.listThe left-out directories, one per line as SUBVOLUME PATH, for example @ var/cache. apply adds to it.
.scram/try/@, .scram/try/stateThe copy made by try, and the id of the pre-update frame taken before it.
.scram/boot-actionOne line, commit, flash ID or flash ID @, for the initramfs to carry out at the next boot.
.scram/trial, .scram/trial-bootWritten by the initramfs when it commits an update. See Trial boots.
.scram/live/The writable copies that a frame boot runs from.
.scram/.new-@, .scram/scratch.PID/Temporary copies during a flash and during scratch or bisect.

Frame ids

An id is the local date and time, a dash and a label, like 20261102-165551-install. A second frame in the same second with the same label gets .2, .3 and so on. A frame without a label has only the time.

Wherever a command takes FRAME, you can give an id or a part of one. scram uses the newest frame whose label or id equals the argument. If there is none, it uses the newest frame whose id contains the argument. With no argument it uses the newest frame. A short name can match another label, so update finds pre-update. Give the full id when you are unsure.

Which frames exist

installThe installer takes it with the note "fresh install" right after copying the system. The manual install does the same by hand.
pre-install, pre-remove, pre-updaterod +i, rod +r and rod +u, before they change anything. Not with rod -n and not inside scram try.
pre-container, pre-container-removesilo, before it creates or removes a container.
autoscram auto. The autoframe service waits an hour after it starts, runs scram auto 48 and repeats.
pre-restore, pre-flash, pre-updaterestore, an offline flash and try.
pre-commit, pre-flash, pre-revertThe initramfs, before it applies a scheduled commit, a scheduled flash or a revert.
your labelscram frame LABEL.

scram only deletes frames labelled auto. Every other frame stays until you remove it by hand, see prune.

Commands

Take and look at frames

frame [-m NOTE] [LABEL]
Takes a frame and prints its id. It runs the pre-frame hooks, takes snapshots of @ and @home, copies the kernel and initramfs of the profile into the frame, rewrites the boot menu if /boot/limine.conf exists, and runs the post-frame hooks. -m needs a note after it. If only the kernel copy fails, the frame is still made, without a kernel copy. Errors in the menu rewrite are not shown, so run scram boot to see them.
roll
Lists the ids, oldest first. A kept frame has * after the id, and a pending update adds a last line.
log
Lists the ids with the number of files that changed since the frame before, and the notes. It compares every pair of neighbouring frames with rsync, which takes a while with many frames.
status
Prints the top level, the number of frames, what a frame covers, the prune rule and, when they apply, an update on trial, a pending update and the action waiting for the next boot. It ends with the left-out directories from keep.list. The prune line always says 48, whatever number you give prune.
diff [FRAME [FRAME]]
With one frame (default the newest), it lists what differs from the live subvolumes. With two frames, it lists what changed from the first to the second. Each line comes from rsync -i, with the subvolume in front: >f+++++++++ is a new file, >f.st...... is a file whose size or time changed, and *deleting is a file that is gone. The comparison uses size and time, not content, and it skips /var/log, /var/cache, /var/tmp, /tmp, /run, /proc, /sys, /dev and the left-out directories.
cat PATH [FRAME]
Writes the file as it was in the frame to standard output. A path under /home/ comes from @home, and everything else comes from @. If the file does not exist in the frame, the command fails.
blame PATH
Goes through all frames, oldest first, and prints the id and a version whenever the version changes: the first 8 characters of the md5 sum of a file, dir for a directory or absent. The first frame is always printed.
keep [FRAME]
Marks the frame as kept. Running it again removes the mark. With no argument it uses the newest frame.
# scram frame -m "fresh install" install
20261102-165551-install
# scram frame -m "before kernel update" kernel
20261102-165552-kernel
# scram roll
20261102-165551-install
20261102-165552-kernel
# scram log
20261102-165551-install
    fresh install
20261102-165552-kernel  2 files changed
    before kernel update
# scram diff install
@: >f..t...... usr/version
@home: >f.st...... alice/.config/theme
# scram status
top:      /run/scram/top
frames:   2 (0 auto, 0 kept)
covers:   @ and @home, each with its own kernel and initramfs for the boot menu
prune:    auto frames beyond the newest 48 are deleted, all other frames stay
excluded:
  @home alice/Downloads
# scram frame same
20261102-165629-same
# scram frame same
20261102-165629-same.2
# scram keep install
20261102-165551-install: kept
# scram blame /etc/hostname
20261102-165551-install  absent
20261102-165552-hostname  25687c92
20261102-165553-rename  e00492ec

Go back

restore PATH [FRAME]
Replaces one file or directory with its copy from the frame. It takes a pre-restore frame first, then removes PATH and copies the saved version back. A directory comes back as it was, so files that were created in it later are gone. scram tells you which frame it used and how to undo the restore. It refuses if PATH is a left-out directory, and it fails if the frame does not contain PATH.
flash [--boot] [--root] [FRAME]
Replaces the live subvolumes with copies of the frame. Put the options before FRAME. Without --boot it runs now, but while the system runs from @ it refuses and asks you to run it offline. It takes a pre-flash frame, then replaces @ and after it @home: a writable snapshot of the frame is made, left-out subvolumes are moved into it, the old subvolume is deleted and the copy takes its place. With --root only @ is replaced and the home directories stay as they are. With --boot scram writes the action to boot-action, says that the flash is scheduled for the next boot, and the initramfs does the work. Use --boot on a running system.
cancel
Deletes boot-action. It prints nothing, and it does not touch a pending try copy or an update on trial.
scratch [-f FRAME] [COMMAND]
Makes a writable snapshot of @, or of the frame with -f, mounts /dev, /proc and /sys in it and runs COMMAND in a chroot. Without COMMAND it opens bash -l with SCRAM_SCRATCH=1 set, which changes the prompt. The copy is deleted afterwards. The copy holds @ only, not /home. Left-out directories are bind-mounted from the live system, so writes to them are real.
bisect GOOD BAD COMMAND
Takes the frames from GOOD to BAD and runs COMMAND in a copy of the frame in the middle, as scratch does, with its output discarded. Status 0 means good. The search halves the range until two neighbouring frames are left.
# scram restore /home/alice/.config/theme install
restored /home/alice/.config/theme from 20261102-165551-install (undo: scram restore /home/alice/.config/theme 20261102-165552-pre-restore)
# scram flash --boot install
flash to 20261102-165551-install scheduled for the next boot
# scram scratch -f hostname 'cat /etc/hostname'
uranium
# scram bisect step-a step-e 'grep -qx 1 /etc/mode'
20261102-165625-step-c: good
20261102-165626-step-d: bad
first bad frame: 20261102-165626-step-d
# scram flash install
flashed to 20261102-165551-install (undo: scram flash 20261102-165552-pre-flash)

On a running system, the last command refuses and asks you to run it offline.

Booting a frame from the boot menu leaves @ and @home unmounted, because the system runs from copies in .scram/live. An offline scram flash FRAME can run from there, see troubleshooting.

Update in a copy

An update is built in a copy of @ while the system keeps running, and the copy replaces the root at the next boot. rod +u does this when scram works: it takes a pre-update frame, syncs the package index and runs scram try 'ROD_INNER=1 rod +u'. See rod.

try [--check COMMAND] COMMAND
Takes a pre-update frame, snapshots @ into .scram/try/@, mounts /dev, /proc, /sys and the left-out directories in the copy, and runs COMMAND in a chroot. If it works and a check was given, the check runs the same way. If either fails, the copy is deleted and scram says that the update failed and the system is untouched. If both work, scram says that the update is pending and how to commit it.
commit [--boot] [--force]
Makes the tried copy the root. It first compares the live @ with the pre-update frame, as diff does. If anything differs, it lists the differences and stops, unless you give --force, which goes on and loses those changes, because the copy replaces @ whole. Home directories are not touched. Without --boot, the swap happens now, and only while @ is not mounted. With --boot, scram writes commit to boot-action and tells you that the commit is scheduled for the next boot and that it reverts unless you confirm it or the system stays healthy. The comparison happens when you run the command, not at the reboot, so changes made in between are dropped without a warning. If there is no copy, scram says that nothing is pending.
discard
Deletes the copy in .scram/try. It prints nothing.
confirm
Deletes trial and trial-boot. It prints nothing and always succeeds.
verify
Does nothing when no update is on trial. Otherwise it waits SCRAM_TRIAL_WAIT seconds (300), then runs geiger up to three times, waiting SCRAM_TRIAL_RETRY seconds (30) after each failure. A pass confirms the update. After three failures, scram reboots to revert the update, unless SCRAM_NOREBOOT is set. The trial service runs it at every boot.
# scram try 'echo broken > /usr/version; exit 1'
scram: update failed (1), system untouched
# scram try 'echo 3.0 > /usr/version'
update ok, pending: commit with scram commit --boot
# scram roll | tail -2
20261102-165552-pre-update
(update pending, frame 20261102-165552-pre-update)
# scram try true
scram: update pending: commit or discard
# scram commit --boot
commit scheduled for the next boot, it reverts unless you run scram confirm or the system stays healthy

Trial boots

A commit scheduled with --boot is applied by the initramfs before the root is mounted. It takes a pre-commit frame, swaps the tried copy in and writes trial (the id of pre-commit) and trial-boot. The system then boots into the new root on trial.

The update stays on trial until scram confirm removes the two files, by hand or through scram verify after geiger passes. If the next normal boot finds both files it takes a pre-revert frame and flashes @ back to pre-commit, and home directories keep their state. Booting a frame from the menu is not a normal boot and leaves the files alone. A system that hangs or panics in the new root is reverted by the next power cycle that boots normally. The pre-revert frame holds the system from before the revert, so the revert can be undone with scram flash.

geiger decides if the new root is healthy, and verify waits five minutes before it asks. geiger fails on a service that is not running, a machine with a network device and no IPv4 address, or a root filesystem more than 95% full. An update booted without a network link is therefore reverted. Messages and file formats are on boot.

A commit without --boot, done offline, has no trial and no revert. The way back is the pre-update frame.

Copy frames

send [-p PARENT] [FRAME]
Writes the frame to standard output as a tar archive. It holds id, @.send and @home.send, which are the output of btrfs send for each snapshot. With -p, each stream holds only what changed since the same snapshot in PARENT. The note, the keep mark, the kernel copy and the left-out directories are not in it. Both streams are written to $TMPDIR (default /tmp, a tmpfs on UraniumOS) before they are packed, so the whole frame has to fit there.
receive
Reads an archive from standard input the same way, creates the frame with the id from the archive, and runs btrfs receive for each stream. An incremental stream needs its parent frame on the receiving machine.
# scram send install > full.tar
# scram send -p install kernel > step.tar
# scram send install | scram receive
scram: frame 20261102-165551-install exists

On the machine that receives, scram receive < full.tar prints the id of the new frame.

See backups for using this to copy a frame to another machine.

Set up

apply
Reads the left-out directories from the configuration and makes each one a subvolume of its own. An existing directory is renamed to PATH.tf-old, a subvolume is created, the contents are copied back, and the old directory is removed. A missing directory is created, and a path that is already a subvolume is only recorded. Every new subvolume is announced and added to .scram/keep.list. scram never turns a subvolume back into a directory. If a step fails, scram stops with an error, and if the copy fails the data is still in PATH.tf-old. Run it while nothing uses the directory, because programs that have files open in it keep the old copy.
boot [N]
Copies the kernel and initramfs of the profile to the boot partition, copies limine to EFI/BOOT/BOOTX64.EFI, and writes limine.conf with the current system, the newest N frames (default all) and a rescue shell. It prints the path of limine.conf, which is described in boot. A full partition is reported by the failing file operation, but not during frame or a rod operation.
doctor
Checks the layout and prints a line per check: ok, warn or FAIL.
prune [N]
Keeps the newest N frames labelled auto (default 48) that have no keep mark and deletes the older ones, with both snapshots, boot/ and note. Kept frames do not count toward N. An argument that is not a number means 48. scram never deletes frames with other labels, so the pre-install and pre-update frames of rod pile up.
auto [N]
Takes a frame labelled auto, then runs prune N. It prints nothing. The autoframe service runs scram auto 48.

To remove another frame, do what prune does as root with the real id.

# cd /run/scram/top/.scram/ID
# btrfs subvolume delete @ @home
# rm -rf boot note keep
# cd .. && rmdir ID
# scram doctor
ok    subvolume @
ok    subvolume @home
ok    excluded @home/alice/Downloads is a subvolume
ok    8 frames, newest 20261102-165631-auto
warn  no boot menu, run: scram boot
ok    95% free
# echo $?
0

The checks:

FAILA subvolume (@ or @home) is missing, or a left-out directory is not a subvolume. In the second case, run scram apply.
warnNo frames exist yet; an update is waiting and has to be committed or discarded; an action is scheduled for the next boot; there is no boot menu, so run scram boot; the kernel in the profile differs from the one on the boot partition, so run scram boot; or less than 10% of the filesystem is free.
okThe subvolumes, the left-out directories, the number of frames and the newest one, whether a boot menu is present, and the free space.

Leaving directories out

A directory with big data you can get again, or data a rollback must not touch, is made a seperate subvolume. A snapshot does not contain a nested subvolume, so frames do not hold the directory and a flash leaves it alone. A copy made by try does not contain it either.

scram reads /etc/scram, then the files in /etc/scram.d/ in name order, then ~/.config/scram of the user who runs the command. Each line is one path, and text after # is ignored. Only the first word of a line counts, so a path with a space is cut short. A line that starts with - leaves the path out, a line that starts with + puts it back, and the last line for a path wins. A leading ~ means $HOME. Other lines are ignored.

A + line cancels a - line only before apply has run. It does not remove a subvolume that exists and does not edit keep.list. The system ships /etc/scram.d/00-uranium.conf:

# index and caches
-/rod/nixpkgs
-/var/cache
-/var/tmp

Example that leaves out a web root:

# echo '-/srv/www' > /etc/scram.d/10-www.conf
# scram apply
excluded /srv/www
# scram status | grep -A4 ^excluded
excluded:
  @ rod/nixpkgs
  @ var/cache
  @ var/tmp
  @ srv/www

A path under /home/ belongs to @home and is stored without the /home/ part, so -~/Downloads for alice becomes @home alice/Downloads. All other paths belong to @.

A flash and a commit move each left-out subvolume into the new copy. They stop with an error if another nested subvolume is still in the old one. List such subvolumes with - and run apply. If a frame that was taken before apply still holds files at the path, the move fails.

Hooks

Executable files in /etc/scram.hooks/pre-frame.d/ run before a frame is taken, in name order, with the label as the only argument. Files in post-frame.d/ run afterwards with the new id. Hooks run for every frame scram takes, including those of rod and the autoframe service, and a hook that hangs stops them. They do not run for frames the initramfs takes. A hook can stop a database or flush files so the snapshot is consistent. Without one, a snapshot holds what a power cut would leave.

Environment

SCRAM_TOPWhere the top level is mounted. Default /run/scram/top.
SCRAM_FLASHUse this directory as .scram and do not mount the top level. The boot menu is not rewritten after a frame.
SCRAM_LIVEWhere the live system is mounted. Default /.
SCRAM_SUBSThe subvolumes a frame covers. Default @ @home.
SCRAM_CONF, SCRAM_USER_CONFThe configuration file and the user file. The defaults are /etc/scram and $HOME/.config/scram. The directory is SCRAM_CONF followed by .d.
SCRAM_HOOKSThe hook directory. Default /etc/scram.hooks.
SCRAM_BOOTThe boot partition mount point. Default /boot.
SCRAM_PROFILEThe profile that holds the kernel, initramfs and limine. Default /rod/profile.
SCRAM_LABELThe root filesystem label used in root=LABEL=. Default uranium.
SCRAM_CMDLINEFile with extra kernel arguments. Default /etc/cmdline.
SCRAM_KERNELSHow many kernels besides the current one the boot partition keeps. Default 3.
SCRAM_TRIAL_WAIT, SCRAM_TRIAL_RETRY, SCRAM_NOREBOOTUsed by verify: seconds before the first check (300), seconds between checks (30) and a switch that stops the reboot.

SCRAM_TOP and SCRAM_LIVE make scram work on a mounted copy of a disk.

Limits