max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+207 insertions,
-6 deletions
| @@ -13,6 +13,14 @@ | |||
| 13 | 13 | # This is what Alloy does today (Containerfile:2067 and :2072). | |
| 14 | 14 | # none the image does not carry the component at all, so a hotfix is a | |
| 15 | 15 | # plain layered package rather than a replacement. | |
| 16 | + | # carry the image carries the component's RPM as a FILE, in a file:// repo | |
| 17 | + | # under /usr/share, and does not install it. The component is still | |
| 18 | + | # absent from @System, so it is layered exactly as in `none`, but the | |
| 19 | + | # package it layers from travels with the image and needs no network. | |
| 20 | + | # This is the shape Alloy takes: it makes an offline install work, and | |
| 21 | + | # because @System never holds the component there is no version of it | |
| 22 | + | # for a layer to conflict with, which is the wedge that `rpm` and the | |
| 23 | + | # n4 case run into. | |
| 16 | 24 | # | |
| 17 | 25 | # The build context is state/, which holds the RPMs and the ssh key that | |
| 18 | 26 | # build.sh puts there. Nothing in the context is tracked. | |
| @@ -26,17 +34,26 @@ | |||
| 26 | 34 | COPY registries.conf /etc/containers/registries.conf.d/010-alloy-demo.conf | |
| 27 | 35 | ||
| 28 | 36 | COPY rpms /tmp/rpms | |
| 37 | + | # Per-version repos, each holding one RPM and its metadata. `carry` takes the | |
| 38 | + | # one matching DEMO_VERSION, so moving the base from one carry variant to | |
| 39 | + | # another is what "the image shipped a newer component" looks like. | |
| 40 | + | COPY repos /tmp/repos | |
| 29 | 41 | RUN case "$SHAPE" in \ | |
| 30 | 42 | rpm) \ | |
| 31 | 43 | dnf -y install "/tmp/rpms/alloy-demo-${DEMO_VERSION}-1.fc43.x86_64.rpm" ;; \ | |
| 32 | 44 | loose) \ | |
| 33 | 45 | printf '#!/bin/sh\necho "alloy-demo %s"\n' "$DEMO_VERSION" > /usr/bin/alloy-demo \ | |
| 34 | 46 | && chmod 0755 /usr/bin/alloy-demo ;; \ | |
| 47 | + | carry) \ | |
| 48 | + | mkdir -p /usr/share/alloy-demo \ | |
| 49 | + | && cp -r "/tmp/repos/$DEMO_VERSION" /usr/share/alloy-demo/rpm \ | |
| 50 | + | && printf '[alloy-demo-local]\nname=carried on the medium\nbaseurl=file:///usr/share/alloy-demo/rpm\nenabled=1\ngpgcheck=0\n' \ | |
| 51 | + | > /etc/yum.repos.d/alloy-demo-local.repo ;; \ | |
| 35 | 52 | none) \ | |
| 36 | 53 | : ;; \ | |
| 37 | 54 | *) echo "unknown SHAPE: $SHAPE" >&2; exit 1 ;; \ | |
| 38 | 55 | esac \ | |
| 39 | - | && rm -rf /tmp/rpms \ | |
| 56 | + | && rm -rf /tmp/rpms /tmp/repos \ | |
| 40 | 57 | && dnf clean all | |
| 41 | 58 | ||
| 42 | 59 | # Which base is running, readable from the guest without trusting a tag. The |
| @@ -110,13 +110,66 @@ | |||
| 110 | 110 | would work here. Reset drops every layered package including ones the user | |
| 111 | 111 | layered themselves, and those are not ours to remove. | |
| 112 | 112 | ||
| 113 | + | ## The carry shape, measured 2026-08-14, and it is the one Alloy takes | |
| 114 | + | ||
| 115 | + | The image carries our RPMs **as files**, in a `file://` repo under | |
| 116 | + | `/usr/share/alloy/rpm`, and does not install them. The component is still | |
| 117 | + | absent from `@System`, so it layers exactly as in the `none` cases, but the | |
| 118 | + | package it layers from travels inside the image. Variants `c1` (carries 0.0.2) | |
| 119 | + | and `c2` (carries 0.0.3). | |
| 120 | + | ||
| 121 | + | Nothing has to be copied anywhere at install time. The ISO is the image, so the | |
| 122 | + | ISO carries the repo; the installed machine is the image, so it carries the | |
| 123 | + | repo too. That is the whole delivery mechanism. | |
| 124 | + | ||
| 125 | + | **Layering works with no network.** With the network repo `enabled=0`, from a | |
| 126 | + | `c1` machine: | |
| 127 | + | ||
| 128 | + | # rpm-ostree install -y --idempotent alloy-demo | |
| 129 | + | Added: alloy-demo-0.0.2-1.fc43.x86_64 | |
| 130 | + | ||
| 131 | + | Requested under the **bare name**, because that is what was typed. Worth | |
| 132 | + | noticing next to the NEVRA trap recorded below: install by name and the drain | |
| 133 | + | can uninstall by name. | |
| 134 | + | ||
| 135 | + | **A base move does not wedge it.** `c1` to `c2`, base carrying 0.0.3 against a | |
| 136 | + | layer at 0.0.2, no drain, plain `rpm-ostree upgrade`: it succeeds. This is the | |
| 137 | + | whole reason to prefer this shape over shipping the component as a base RPM. | |
| 138 | + | `@System` never holds the component, so the conflict that permanently wedges | |
| 139 | + | the `n4` case has nothing to arise between. | |
| 140 | + | ||
| 141 | + | **But the layer does not follow the image.** After that upgrade the machine is | |
| 142 | + | on base `c2` and still running `alloy-demo 0.0.2`, while the base beside it | |
| 143 | + | carries 0.0.3. rpm-ostree does not re-resolve a satisfied request just because | |
| 144 | + | a repo behind it moved. Shipping a new console inside a new image therefore | |
| 145 | + | reaches nobody on its own, which is the opposite of what it looks like. | |
| 146 | + | ||
| 147 | + | **What advances it is a drop and reinstall**, in one boot: | |
| 148 | + | ||
| 149 | + | # rpm-ostree uninstall alloy-demo && rpm-ostree install -y alloy-demo | |
| 150 | + | Upgraded: alloy-demo 0.0.2-1.fc43 -> 0.0.3-1.fc43 | |
| 151 | + | ||
| 152 | + | So the drain rule survives, for a better reason than the one it was invented | |
| 153 | + | for. Here it is not an escape from a wedge, it is the only thing that makes a | |
| 154 | + | component track the image it shipped with. `alloy update` owns it. | |
| 155 | + | ||
| 156 | + | **`bootc upgrade` refuses permanently, not transiently.** An Alloy machine | |
| 157 | + | always carries a layer, so it always has "local rpm-ostree modifications". | |
| 158 | + | `alloy update` owns `rpm-ostree upgrade` for the life of the machine, and the | |
| 159 | + | earlier note about moving users off `bootc upgrade` is a permanent condition | |
| 160 | + | rather than a step in a migration. | |
| 161 | + | ||
| 113 | 162 | ## How an installed machine gets the components, 2026-08-14 | |
| 114 | 163 | ||
| 115 | - | Decided by Max: **they travel on the ISO**, not over the network. The installer | |
| 116 | - | copies our repo from the medium onto the target, and the machine layers from | |
| 117 | - | `file:///var/lib/alloy/rpm`. That is the only shape that survives an offline | |
| 118 | - | install, and it keeps the no-phone-home position intact for a machine whose | |
| 119 | - | owner has not consented to anything yet. | |
| 164 | + | Decided by Max: **they travel on the ISO**, not over the network. That is the | |
| 165 | + | only shape that survives an offline install, and it keeps the no-phone-home | |
| 166 | + | position intact for a machine whose owner has not consented to anything yet. | |
| 167 | + | ||
| 168 | + | The realization is the carry shape above, which is cheaper than the copy-off- | |
| 169 | + | the-medium version first sketched: the repo rides inside the image, so the ISO | |
| 170 | + | carries it because the ISO is the image, and the installed machine has it for | |
| 171 | + | the same reason. No copy step in the installer, and no `/var` state to go | |
| 172 | + | missing. | |
| 120 | 173 | ||
| 121 | 174 | **Layering happens at first boot, not during the install.** `installtime.sh` | |
| 122 | 175 | measured the alternative and it is unproven: `rpm-ostree install` is a D-Bus |
| @@ -37,6 +37,17 @@ | |||
| 37 | 37 | cp /state/rpmbuild/RPMS/*/*.rpm /state/repo/ | |
| 38 | 38 | cp /state/rpmbuild/RPMS/*/*.rpm /state/rpms/ | |
| 39 | 39 | createrepo_c /state/repo >/dev/null | |
| 40 | + | ||
| 41 | + | # And one repo per version, for the `carry` shape. A carried repo holding | |
| 42 | + | # every version would let a machine resolve a version its image never | |
| 43 | + | # shipped, so moving the base from one carry variant to the next would | |
| 44 | + | # prove nothing. | |
| 45 | + | rm -rf /state/repos && mkdir -p /state/repos | |
| 46 | + | for v in 0.0.1 0.0.2 0.0.3; do | |
| 47 | + | mkdir -p "/state/repos/$v" | |
| 48 | + | cp "/state/rpmbuild/RPMS"/*/alloy-demo-$v-*.rpm "/state/repos/$v/" | |
| 49 | + | createrepo_c "/state/repos/$v" >/dev/null | |
| 50 | + | done | |
| 40 | 51 | ' >/dev/null | |
| 41 | 52 | ||
| 42 | 53 | # ------------------------------------------------------- the build context |
| @@ -26,6 +26,11 @@ | |||
| 26 | 26 | # r1 and l1 are not part of that sequence. They exist to reproduce the two dead | |
| 27 | 27 | # ends: a component that is a base RPM cannot be replaced, and a component that | |
| 28 | 28 | # is an unowned file cannot be layered over. | |
| 29 | + | # | |
| 30 | + | # c1 and c2 are the shape Alloy actually takes: the image carries the RPM as a | |
| 31 | + | # file in a file:// repo and does not install it. c1 carries 0.0.2 and c2 | |
| 32 | + | # carries 0.0.3, so c1 -> c2 is "the image shipped a newer component" and the | |
| 33 | + | # question is whether a machine's layer follows it without the n4 conflict. | |
| 29 | 34 | VARIANTS=( | |
| 30 | 35 | "n1:none:" | |
| 31 | 36 | "n2:none:" | |
| @@ -33,6 +38,8 @@ | |||
| 33 | 38 | "n4:rpm:0.0.3" | |
| 34 | 39 | "r1:rpm:0.0.1" | |
| 35 | 40 | "l1:loose:0.0.1" | |
| 41 | + | "c1:carry:0.0.2" | |
| 42 | + | "c2:carry:0.0.3" | |
| 36 | 43 | ) | |
| 37 | 44 | ||
| 38 | 45 | die() { printf 'error: %s\n' "$*" >&2; exit 1; } |
| @@ -36,6 +36,19 @@ | |||
| 36 | 36 | # but a line that read `enable` on a unit missing its condition would be the | |
| 37 | 37 | # other direction, which is why the condition lives in the unit and not here. | |
| 38 | 38 | enable alloy-installer-ssh.service | |
| 39 | + | # Lays the console and shop down as layered packages on a freshly installed | |
| 40 | + | # machine, from the repo the installer copied off the medium. They cannot ship | |
| 41 | + | # inside the image: a component the base carries can never be replaced | |
| 42 | + | # client-side, so an image carrying them is an image whose console can never be | |
| 43 | + | # fixed. See alloy-layer-components.service and build/layertest. | |
| 44 | + | # | |
| 45 | + | # Enabled on every install, and gated in the unit rather than here. Both of its | |
| 46 | + | # conditions have to hold — the repo present, and the console absent — so on a | |
| 47 | + | # machine that already has a console it is a no-op, and on one installed from | |
| 48 | + | # an older medium it is not its business. Reading "already ran" off the | |
| 49 | + | # filesystem rather than a stamp is what makes it answer correctly after a | |
| 50 | + | # rollback or a deliberate uninstall. | |
| 51 | + | enable alloy-layer-components.service | |
| 39 | 52 | # alloy-debug-shell@ is deliberately not listed here. It is a template, and | |
| 40 | 53 | # `systemctl preset-all` cannot instantiate one from a preset line: a line | |
| 41 | 54 | # naming `alloy-debug-shell@tty9.service` matches no unit file and is ignored |
| @@ -1,0 +1,100 @@ | |||
| 1 | + | # Lay down Alloy's own components on a machine that has just been installed. | |
| 2 | + | # | |
| 3 | + | # The console and shop cannot ship inside the image. A component the base | |
| 4 | + | # carries cannot be replaced client-side — `rpm-ostree install` refuses to | |
| 5 | + | # depsolve against it, and `override replace` records a request that never | |
| 6 | + | # activates — so an image that carried them would be an image whose console | |
| 7 | + | # could never be fixed without rebuilding an ISO and writing a drive. They are | |
| 8 | + | # layered packages instead, and stay layered. Measured in build/layertest. | |
| 9 | + | # | |
| 10 | + | # The repo they come from rides inside the image, at /usr/share/alloy/rpm, as | |
| 11 | + | # RPM files that are never installed at build time. So this needs no network, | |
| 12 | + | # and it needs nothing copied off the medium either: the ISO carries the repo | |
| 13 | + | # because the ISO is the image, and this machine has it for the same reason. | |
| 14 | + | # Deliberate twice over: an offline install has to produce a working machine, | |
| 15 | + | # and a machine whose owner has not consented to anything yet must not reach | |
| 16 | + | # out. See docs/STACK.md, "Updates" and "Hotfixes". | |
| 17 | + | # | |
| 18 | + | # Carrying the RPMs is not the same as carrying the component, and the | |
| 19 | + | # difference is the whole design. What cannot be replaced client-side is an | |
| 20 | + | # installed package or a file at the path being layered over. An uninstalled | |
| 21 | + | # .rpm sitting in /usr/share is neither: @System does not hold the component, | |
| 22 | + | # /usr/bin/alloy does not exist, and the layer goes down cleanly over both. | |
| 23 | + | # Measured, `c1`/`c2` in build/layertest. | |
| 24 | + | # | |
| 25 | + | # ## Why this reboots, and why that is not a regression | |
| 26 | + | # | |
| 27 | + | # /usr is a read-only ostree tree, so a layered package arrives as a new | |
| 28 | + | # deployment and a new deployment needs a boot. There is no version of this | |
| 29 | + | # that ends with the console usable in the session it ran from. | |
| 30 | + | # | |
| 31 | + | # Layering during the install instead would avoid it, and was measured rather | |
| 32 | + | # than assumed: `rpm-ostree install --sysroot` against an unbooted target is a | |
| 33 | + | # D-Bus client with no daemon it can reach, and did not work. Not proven | |
| 34 | + | # impossible, and build/layertest/installtime.sh records exactly where to pick | |
| 35 | + | # that up. Until then, one reboot. | |
| 36 | + | # | |
| 37 | + | # ## Ordering | |
| 38 | + | # | |
| 39 | + | # Before greetd, because the session wrapper it launches calls `alloy theme | |
| 40 | + | # apply` and the greeter is the first thing a person sees. A boot that reached | |
| 41 | + | # the greeter first would show a login that leads to a session that cannot | |
| 42 | + | # start, which is a worse failure than a visible one-time setup step. | |
| 43 | + | # | |
| 44 | + | # After network-online is deliberately NOT set. Nothing here uses the network, | |
| 45 | + | # and ordering after something that may never arrive is how a machine with no | |
| 46 | + | # link sits at a blank console for ninety seconds. | |
| 47 | + | ||
| 48 | + | [Unit] | |
| 49 | + | Description=Install Alloy's own components from the installer medium | |
| 50 | + | Documentation=https://makenot.work/git/max/alloy | |
| 51 | + | # Default dependencies deliberately left ON. The early-boot idiom | |
| 52 | + | # (DefaultDependencies=no, Conflicts=shutdown.target) is wrong here: rpm-ostree | |
| 53 | + | # is a D-Bus client and rpm-ostreed is D-Bus activated, so a unit that ordered | |
| 54 | + | # itself before the bus would fail on a machine where everything is fine. | |
| 55 | + | After=dbus.service | |
| 56 | + | Wants=dbus.service | |
| 57 | + | Before=greetd.service getty.target | |
| 58 | + | ||
| 59 | + | # The gate, and it is two conditions rather than a stamp file on purpose. | |
| 60 | + | # | |
| 61 | + | # The repo has to be there: an image built before this existed, or one somebody | |
| 62 | + | # stripped, has nothing to install from and this unit is not its business. | |
| 63 | + | ConditionPathExists=/usr/share/alloy/rpm/repodata/repomd.xml | |
| 64 | + | # And the console has to be absent. This is the "already ran" test, and reading | |
| 65 | + | # it off the filesystem rather than off a stamp means it answers correctly | |
| 66 | + | # after a rollback, after a user uninstalls the layer deliberately, and on a | |
| 67 | + | # machine somebody imaged from another one. A stamp file would say "done" in | |
| 68 | + | # all three cases and leave the machine without a console. | |
| 69 | + | ConditionPathExists=!/usr/bin/alloy | |
| 70 | + | ||
| 71 | + | [Service] | |
| 72 | + | Type=oneshot | |
| 73 | + | RemainAfterExit=yes | |
| 74 | + | # By bare name, not by NEVRA, and it matters later rather than here. rpm-ostree | |
| 75 | + | # records a request under the string it was given, so a package layered by full | |
| 76 | + | # name-version-release cannot afterwards be removed by its bare name — it | |
| 77 | + | # answers "not currently requested" and leaves the package in place, which | |
| 78 | + | # reads as nothing-to-do rather than as a failure. `alloy update` has to drop | |
| 79 | + | # and re-add these on every upgrade (it is the only thing that makes a layered | |
| 80 | + | # component follow the image it shipped with), so installing them by name here | |
| 81 | + | # is what keeps that safe. | |
| 82 | + | # | |
| 83 | + | # -y because rpm-ostree prompts on a tty it does not have, and a unit blocked | |
| 84 | + | # forever on a prompt nobody can see is indistinguishable from a hang. | |
| 85 | + | # --idempotent so a re-run after a partial failure is not itself an error. | |
| 86 | + | ExecStart=/usr/bin/rpm-ostree install --idempotent -y alloy shop | |
| 87 | + | ExecStart=/usr/bin/systemctl reboot | |
| 88 | + | ||
| 89 | + | # A failure here leaves a machine with no console, which the user cannot fix | |
| 90 | + | # from the session they cannot start. Failing loudly is the only honest option: | |
| 91 | + | # the journal names the unit, and the next boot retries because the conditions | |
| 92 | + | # above are both still true. | |
| 93 | + | # | |
| 94 | + | # No Restart=, though. A unit whose second ExecStart is `reboot` and that | |
| 95 | + | # restarts on failure is a boot loop, and a boot loop on the one machine class | |
| 96 | + | # this project claims to support is worse than a machine that boots to a | |
| 97 | + | # console and says what went wrong. | |
| 98 | + | ||
| 99 | + | [Install] | |
| 100 | + | WantedBy=multi-user.target |