max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+120 insertions,
-3 deletions
| @@ -69,6 +69,47 @@ | |||
| 69 | 69 | Recovery is `rpm-ostree uninstall` and then upgrade, after which `bootc | |
| 70 | 70 | upgrade` works again, but a user has no way to know that is what happened. | |
| 71 | 71 | ||
| 72 | + | ## The wedge is avoidable, 2026-08-14 | |
| 73 | + | ||
| 74 | + | Measured after the above, on the same versions. The rule is that **our layers | |
| 75 | + | are disposable**: never carried across an upgrade, dropped before it, and | |
| 76 | + | re-applied afterwards only if the base still lacks the fix. If no layer and no | |
| 77 | + | base copy are ever in the same transaction, the depsolve conflict cannot arise. | |
| 78 | + | ||
| 79 | + | It holds. From a machine on `n1` holding a layered `alloy-demo-0.0.2`, against | |
| 80 | + | `n4` (base carrying 0.0.3), `case.sh n4 --drain`: | |
| 81 | + | ||
| 82 | + | Removed: alloy-demo-0.0.2-1.fc43.x86_64 | |
| 83 | + | Upgraded: alloy-demo 0.0.2-1.fc43 -> 0.0.3-1.fc43 | |
| 84 | + | ||
| 85 | + | base-mark: n4 binary: alloy-demo 0.0.3 no layers | |
| 86 | + | ||
| 87 | + | **One reboot, not two.** The uninstall and the upgrade each stage a deployment | |
| 88 | + | and the second composes onto the first, so both land in one boot. The rollback | |
| 89 | + | deployment still holds the layered state, which is what a rollback is for. | |
| 90 | + | ||
| 91 | + | **`bootc upgrade` consents again.** With no local modifications left, the | |
| 92 | + | machine is back on the bootc path rather than stranded on `rpm-ostree upgrade` | |
| 93 | + | forever, which was the other cost the first run turned up. | |
| 94 | + | ||
| 95 | + | **The sharp edge, and it cost a run to find.** A request is recorded under | |
| 96 | + | whatever string was typed to install it, so a package layered by full NEVRA | |
| 97 | + | cannot be removed by its bare name: | |
| 98 | + | ||
| 99 | + | # rpm-ostree uninstall alloy-demo | |
| 100 | + | error: Package/capability 'alloy-demo' is not currently requested | |
| 101 | + | ||
| 102 | + | with `alloy-demo-0.0.2-1.fc43.x86_64` sitting in `requested-packages` the whole | |
| 103 | + | time. That reads as "there was nothing to do" rather than as a failure, so a | |
| 104 | + | drain written against the package name silently does not drain and the machine | |
| 105 | + | wedges exactly as if the rule had never been applied. Anything implementing | |
| 106 | + | this has to read `requested-packages` and uninstall the exact strings it finds; | |
| 107 | + | `readstate.py --requested <prefix>` is that lookup, and `--drain` uses it. | |
| 108 | + | ||
| 109 | + | Not `rpm-ostree reset`, though bootc's own error message suggests it and it | |
| 110 | + | would work here. Reset drops every layered package including ones the user | |
| 111 | + | layered themselves, and those are not ours to remove. | |
| 112 | + | ||
| 72 | 113 | ## Use | |
| 73 | 114 | ||
| 74 | 115 | Needs `qemu-system-x86_64` with KVM, OVMF, podman, and about 25 GB free. No | |
| @@ -83,10 +124,13 @@ | |||
| 83 | 124 | ./sshx 'systemctl reboot' | |
| 84 | 125 | ./case.sh n3 # move the base, reboot, read the state back | |
| 85 | 126 | ./case.sh n2 --bootc # the refusal, on purpose | |
| 127 | + | ./case.sh n4 --drain # the wedge, with our layers dropped first | |
| 86 | 128 | ./serve.sh down # and stop the servers | |
| 87 | 129 | ||
| 88 | 130 | The full sequence that produced the table is `n1`, layer the hotfix, then | |
| 89 | - | `case.sh n2`, `case.sh n3`, `case.sh n4`. | |
| 131 | + | `case.sh n2`, `case.sh n3`, `case.sh n4`. The drain result is `n1`, layer the | |
| 132 | + | hotfix, then `case.sh n4 --drain` — reinstall first, because it is the same | |
| 133 | + | starting state as `n4` and a disk carried over from another case is not it. | |
| 90 | 134 | ||
| 91 | 135 | Everything a run writes goes to `state/`, which is gitignored. Delete it to | |
| 92 | 136 | start clean. |
| @@ -5,6 +5,20 @@ | |||
| 5 | 5 | # | |
| 6 | 6 | # case.sh n2 upgrade with rpm-ostree, reboot, read the state back | |
| 7 | 7 | # case.sh n2 --bootc use bootc upgrade instead, which is a separate answer | |
| 8 | + | # case.sh n4 --drain drop our layers first, then upgrade | |
| 9 | + | # | |
| 10 | + | # --drain is the candidate answer to the wedge the first run measured: a base | |
| 11 | + | # that moves ahead of a layered hotfix stops the machine updating, forever, | |
| 12 | + | # and that is the normal life of a hotfix rather than an edge case. The rule | |
| 13 | + | # it tests is that our own layers are disposable — never carried across an | |
| 14 | + | # upgrade, dropped before it and re-applied after if the base still needs | |
| 15 | + | # them. If that holds, the depsolve conflict cannot arise at all, because | |
| 16 | + | # there is never a layer and a base copy in the same transaction. | |
| 17 | + | # | |
| 18 | + | # Chained into one transaction on purpose. rpm-ostree stages a deployment per | |
| 19 | + | # command, and what matters is whether a user pays one reboot or two, so the | |
| 20 | + | # uninstall and the upgrade go in a single invocation and the deployment count | |
| 21 | + | # is part of the result. | |
| 8 | 22 | # | |
| 9 | 23 | # Retagging is how the base moves. The installed system points at :latest in | |
| 10 | 24 | # the host registry, so pushing a different variant to that tag is the whole | |
| @@ -22,13 +36,43 @@ | |||
| 22 | 36 | . "$HERE/../privilege.sh" | |
| 23 | 37 | ||
| 24 | 38 | MARK="${1:-}" | |
| 25 | - | [ -n "$MARK" ] || die "usage: case.sh <mark> [--bootc]" | |
| 39 | + | [ -n "$MARK" ] || die "usage: case.sh <mark> [--bootc|--drain]" | |
| 26 | 40 | VERB="rpm-ostree upgrade" | |
| 27 | - | [ "${2:-}" = "--bootc" ] && VERB="bootc upgrade" | |
| 41 | + | DRAIN=no | |
| 42 | + | case "${2:-}" in | |
| 43 | + | --bootc) VERB="bootc upgrade" ;; | |
| 44 | + | --drain) DRAIN=yes ;; | |
| 45 | + | "") ;; | |
| 46 | + | *) die "unknown option ${2}" ;; | |
| 47 | + | esac | |
| 28 | 48 | ||
| 29 | 49 | privc podman push --tls-verify=false "$IMAGE:$MARK" \ | |
| 30 | 50 | "127.0.0.1:$REGISTRY_PORT/alloy-layertest:latest" >/dev/null 2>&1 | |
| 31 | 51 | ||
| 52 | + | # Drop our own layers before the upgrade rather than after, and by the exact | |
| 53 | + | # string the request was recorded under. A bare package name does not match a | |
| 54 | + | # request made by NEVRA, and rpm-ostree says so in a way that reads as "there | |
| 55 | + | # was nothing to do" rather than as a failure — which is how a drain silently | |
| 56 | + | # does not drain and the machine wedges anyway. | |
| 57 | + | # | |
| 58 | + | # Enumerated rather than `rpm-ostree reset`, which would work here and is | |
| 59 | + | # wrong as a model: reset drops every layered package including ones the user | |
| 60 | + | # layered themselves, and those are not ours to remove. Production has to be | |
| 61 | + | # this precise, so the harness is too. | |
| 62 | + | if [ "$DRAIN" = yes ]; then | |
| 63 | + | say "=== drain: dropping our layers before the upgrade ===" | |
| 64 | + | ours=$("$HERE/sshx" 'rpm-ostree status --json' \ | |
| 65 | + | | python3 "$HERE/readstate.py" --requested alloy-demo) | |
| 66 | + | if [ -z "$ours" ]; then | |
| 67 | + | say "nothing of ours is layered" | |
| 68 | + | else | |
| 69 | + | for package in $ours; do | |
| 70 | + | say "uninstalling $package" | |
| 71 | + | "$HERE/sshx" "rpm-ostree uninstall $package" 2>&1 | tail -3 | |
| 72 | + | done | |
| 73 | + | fi | |
| 74 | + | fi | |
| 75 | + | ||
| 32 | 76 | say "=== $VERB onto $MARK ===" | |
| 33 | 77 | # Not fatal. A refusal is a result, and the whole point of --bootc is to see | |
| 34 | 78 | # one, so the script has to survive the command it is measuring. | |
| @@ -42,3 +86,12 @@ | |||
| 42 | 86 | echo -n "binary: "; alloy-demo 2>&1 || echo "(absent)"; \ | |
| 43 | 87 | echo -n "rpm -q: "; rpm -q alloy-demo 2>&1' || die "guest did not come back" | |
| 44 | 88 | "$HERE/sshx" 'rpm-ostree status --json' | python3 "$HERE/readstate.py" | |
| 89 | + | ||
| 90 | + | # Whether the machine can still take the next one. A wedge does not show up in | |
| 91 | + | # the state above — it reports a perfectly ordinary deployment — and only | |
| 92 | + | # announces itself the next time somebody tries to update. Both verbs, because | |
| 93 | + | # the whole point of dropping the layer is to hand `bootc upgrade` back a | |
| 94 | + | # deployment it will consent to work on. | |
| 95 | + | say "--- can it still update? ---" | |
| 96 | + | "$HERE/sshx" 'rpm-ostree upgrade --check 2>&1 | tail -3' || true | |
| 97 | + | "$HERE/sshx" 'bootc upgrade --check 2>&1 | tail -3' || true |
| @@ -8,6 +8,15 @@ | |||
| 8 | 8 | # gets mistaken for an applied hotfix. | |
| 9 | 9 | # | |
| 10 | 10 | # ./sshx 'rpm-ostree status --json' | python3 readstate.py | |
| 11 | + | # | |
| 12 | + | # With --requested PREFIX it prints nothing but the requested-packages of the | |
| 13 | + | # booted deployment whose name starts with PREFIX, one per line, for a caller | |
| 14 | + | # that means to uninstall them. The exact strings matter: a request is recorded | |
| 15 | + | # under whatever was typed to install it, so a package installed by full NEVRA | |
| 16 | + | # cannot be removed by its bare name — `rpm-ostree uninstall alloy-demo` on a | |
| 17 | + | # machine holding `alloy-demo-0.0.2-1.fc43.x86_64` answers "not currently | |
| 18 | + | # requested" and leaves it in place. Measured 2026-08-14, and it is the whole | |
| 19 | + | # reason this mode exists rather than the caller writing a package name. | |
| 11 | 20 | ||
| 12 | 21 | import json | |
| 13 | 22 | import sys | |
| @@ -20,6 +29,17 @@ | |||
| 20 | 29 | ) | |
| 21 | 30 | ||
| 22 | 31 | data = json.load(sys.stdin) | |
| 32 | + | ||
| 33 | + | if len(sys.argv) > 2 and sys.argv[1] == "--requested": | |
| 34 | + | prefix = sys.argv[2] | |
| 35 | + | for deployment in data["deployments"]: | |
| 36 | + | if not deployment.get("booted"): | |
| 37 | + | continue | |
| 38 | + | for package in deployment.get("requested-packages", []): | |
| 39 | + | if package.startswith(prefix): | |
| 40 | + | print(package) | |
| 41 | + | sys.exit(0) | |
| 42 | + | ||
| 23 | 43 | for index, deployment in enumerate(data["deployments"]): | |
| 24 | 44 | print("--- deployment %d booted=%s staged=%s" % ( | |
| 25 | 45 | index, deployment.get("booted"), deployment.get("staged"))) |