Skip to content

Latest commit

 

History

History
188 lines (151 loc) · 9.69 KB

File metadata and controls

188 lines (151 loc) · 9.69 KB

Using the tool

However you launch it, the guided menu is the same.

Creating an image

Create Image scans your running system first. It reads the image you are on and any packages you have layered, so it already knows the base — it never asks you to choose one. That matters: Universal Blue images are not rebase-compatible with each other, so an image built on the wrong base is one you cannot switch to.

It works whether or not you have layered anything. With nothing layered it simply starts from your current base, and you add what you want from there — packages, COPR repositories, systemd services, base-package removals.

The main menu lists every base image the tool supports, so you can see up front whether your system is one of them.

If the scan cannot run, the tool says so and offers to let you pick a base image by hand instead. That happens with a bare podman run, which has no access to your host's state — the aib wrapper and distrobox both hand it in, so neither hits this. See container limitations.

Make the package readable before you switch

The first successful build publishes the image to GHCR as a private package. That is GitHub's default for a newly published package, and it is a separate setting from the repository's own visibility — a public repository does not publish public packages. So a green build is not yet a switchable image: sudo bootc switch on a machine with no registry credentials cannot read it.

Make it readable once, from the package's own page:

  1. Open https://github.com/<your-user>/<your-repo>/pkgs/container/<your-repo>
  2. Package settings -> Change visibility -> Public

The tool checks this for you. After a successful build, View build status tries the same anonymous pull your machine would make and says so if it cannot read the image — and stops saying it once the package is public.

Keeping the package private is a fine choice, but then the machine needs GHCR pull credentials for root before the switch works. See bootc's registry documentation.

What else to expect

  • The tool creates a public GitHub repo under your account, and GitHub Actions builds the image after creation. Scheduled rebuilds also run daily on GitHub.
  • The main menu can show recent GitHub Actions build status for a configured repo.
  • Repos can be test-built locally with Podman before you push, whichever build method they use — from a source checkout (see container limitations). A BlueBuild repo is rendered with the same BlueBuild CLI GitHub Actions uses, pulled from its installer image, and then built with Podman. The build renders this tool's current template plus your repo's cosign.pub; edits you made by hand to other files in your clone (for example under files/system/) are not part of the local test, while in CI they are.
  • The update menu can rotate the repo's cosign signing key and update cosign.pub.
  • The disk-image workflow (build-disk.yml) and the local just build-qcow2 both run bootc-image-builder by digest rather than by tag, for the same reason the Homebrew layer below is: it produces every qcow2 and installer ISO your repository publishes. The pinned digest moves when this tool is updated, and an update re-pins a repository it created earlier.

Migrating layered packages from your current system

If you use the scan flow to carry layered packages from your current system into the new image, first follow Trusting The Signing Key in the generated repo's README. Then run these in the same session before rebooting:

sudo rpm-ostree reset
sudo bootc switch --enforce-container-sigpolicy ghcr.io/<your-user>/<your-repo>:latest
systemctl reboot

--enforce-container-sigpolicy verifies the switch against that repository's signing key and keeps verification enabled for every later bootc upgrade. Dropping the flag disables signature verification for both the switch and those upgrades.

That clears the old layered package state from the current deployment before you switch to the image-based version of those changes. You do not need to reboot between rpm-ostree reset and bootc switch.

rpm-ostree reset with no category flags clears every layered package, override and initramfs customization on the deployment, not only the ones the image reproduces. The scan says so up front: it can carry packages requested from a repository and base-package removals, and it stops to ask before continuing when it finds anything else — a locally installed RPM, a package replaced by a local build, a regenerated initramfs. Those are pinned to files on your machine, so no generated image reproduces them. Run rpm-ostree status before the reset if you want the full list.

Homebrew on Fedora Atomic images

This is about adding Homebrew to the image you build — not about installing this tool with Homebrew, which is covered in Installing.

Universal Blue images ship with Homebrew (brew) already integrated. Fedora Atomic images do not.

When you choose a Fedora Atomic base image (Silverblue, Kinoite, etc.), the tool offers to include Homebrew using the Universal Blue brew OCI layer (ghcr.io/ublue-os/brew). The generated Containerfile names it by digest rather than by tag, because its whole /system_files tree is copied into / of an image your workflow then signs — a tag that moved upstream would change what that signature covers without changing anything in your repository. The pinned digest moves when this tool is updated, after the new payload has been reviewed. This adds:

  • The Homebrew installation and /etc/profile.d/brew-path.sh, the one shell integration fragment the generated image keeps
  • brew-setup.service for first-boot initialization
  • brew-update.timer and brew-upgrade.timer for automatic maintenance

This option is skipped automatically for Universal Blue base images since they already include Homebrew. You can also toggle it later through the update menu.

Why the generated Containerfile deletes three of the layer's own files

The brew layer's /system_files carries three login-shell fragments alongside the units and the tarball — /etc/profile.d/brew.sh, /etc/profile.d/brew-bash-completion.sh and /usr/share/fish/vendor_conf.d/ublue-brew.fish. All three execute code out of /home/linuxbrew/.linuxbrew, and brew-setup.service ends with chown -R 1000:1000 /home/linuxbrew, so on a booted machine that prefix is owned by the desktop user. /etc/profile.d and the fish vendor directory are read by every login shell, root's included (su -, sudo -i, a console or SSH root login) — so as shipped, whoever owns the prefix chooses what root executes, with no password and no sudo record.

The generated Containerfile therefore removes the three, and writes /etc/profile.d/brew-path.sh in their place. That fragment only extends PATH, and only when the prefix is owned by the account whose shell it is (test -O), so brew still works for the desktop user and no login shell runs anything out of a user-writable prefix. The build then greps the login-shell directories and fails if anything else still mentions brew: the layer is an image this tool does not build, pulled by a mutable tag, so a later digest can add a fourth fragment without anything here changing.

Two consequences are worth knowing:

  • brew's bash completion is not installed. The way the layer provided it was to source files out of the prefix itself, which is the vulnerability.
  • fish does not read /etc/profile.d, so fish users get no automatic PATH entry. Add one in your own files/system_files overlay if you want it, guarded the same way.

Why the generated Containerfile adds a drop-in for brew-setup.service

The layer's brew-setup.service unpacks the 154 MB Homebrew tarball on first boot, as root, staging it through /tmp/homebrew:

ExecStart=/usr/bin/mkdir -p /tmp/homebrew
ExecStart=/usr/bin/tar --zstd -xf /usr/share/homebrew.tar.zst -C /tmp/homebrew
ExecStart=/usr/bin/cp -R -n /tmp/homebrew/home/linuxbrew/.linuxbrew /home/linuxbrew
ExecStart=/usr/bin/chown -R 1000:1000 /home/linuxbrew

The unit ships no PrivateTmp=, and /tmp on a booted system is a world-writable tmpfs. mkdir -p exits 0 on an existing symlink rather than replacing it, so an account that creates /tmp/homebrew first has root extract through its symlink, has whatever else it left there copied into /home/linuxbrew, and has the result handed to UID 1000 by the chown. The -n protects nothing on the run that matters — the unit's own ConditionPathExists=!/home/linuxbrew/.linuxbrew guarantees the destination does not exist yet.

The generated Containerfile therefore writes /usr/lib/systemd/system/brew-setup.service.d/10-private-tmp.conf, carrying a single PrivateTmp=yes. The unit gets its own /tmp and /var/tmp for the whole invocation, shared by every ExecStart= of that invocation and by nothing else on the system, so the staging path stops being a name another account can claim. /home/linuxbrew is outside both, so the payload still lands where it should.

A drop-in rather than a unit that overrides ExecStart=: a copy of the layer's command chain would drift silently the next time the layer moves. The same build step therefore fails if the layer stops shipping brew-setup.service, or if none of that unit's ExecStart= lines stage under /tmp or /var/tmp — the only two directories PrivateTmp= covers. As with the fragment sweep above, the layer is pulled by a mutable tag, so a payload that moved has to stop the build for review rather than ship beside a drop-in that no longer protects anything.