max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
12 files changed,
+580 insertions,
-0 deletions
| @@ -54,3 +54,9 @@ | |||
| 54 | 54 | ||
| 55 | 55 | # vmtest scratch: qcow2 targets, firmware vars, vTPM state, screendumps. | |
| 56 | 56 | /build/vmtest/state/ | |
| 57 | + | ||
| 58 | + | # layertest scratch: RPMs, the generated repo, the target disk, firmware vars. | |
| 59 | + | /build/layertest/state/ | |
| 60 | + | ||
| 61 | + | # Byte-compiled python from the vmtest and layertest helpers. | |
| 62 | + | __pycache__/ |
| @@ -1,0 +1,53 @@ | |||
| 1 | + | # The image under test. Not Alloy: a minimal bootc image carrying one | |
| 2 | + | # stand-in component, so a run costs a 200 MB build rather than a 5 GB one. | |
| 3 | + | # What is being measured is rpm-ostree and bootc behaviour, which does not | |
| 4 | + | # depend on what else the image holds. | |
| 5 | + | # | |
| 6 | + | # SHAPE is the whole point. The three values are the three ways a first-party | |
| 7 | + | # component can reach an image, and they behave differently enough that the | |
| 8 | + | # answer to "can we hotfix this" changes with them: | |
| 9 | + | # | |
| 10 | + | # rpm the component is an RPM installed at build time, so it is a base | |
| 11 | + | # package. This is what an RPM channel would want. | |
| 12 | + | # loose the component is a file copied into /usr/bin owned by no package. | |
| 13 | + | # This is what Alloy does today (Containerfile:2067 and :2072). | |
| 14 | + | # none the image does not carry the component at all, so a hotfix is a | |
| 15 | + | # plain layered package rather than a replacement. | |
| 16 | + | # | |
| 17 | + | # The build context is state/, which holds the RPMs and the ssh key that | |
| 18 | + | # build.sh puts there. Nothing in the context is tracked. | |
| 19 | + | FROM quay.io/fedora/fedora-bootc:43 | |
| 20 | + | ||
| 21 | + | ARG SHAPE=none | |
| 22 | + | ARG DEMO_VERSION=0.0.1 | |
| 23 | + | ARG BASE_MARK=n1 | |
| 24 | + | ||
| 25 | + | COPY alloy-demo.repo /etc/yum.repos.d/alloy-demo.repo | |
| 26 | + | COPY registries.conf /etc/containers/registries.conf.d/010-alloy-demo.conf | |
| 27 | + | ||
| 28 | + | COPY rpms /tmp/rpms | |
| 29 | + | RUN case "$SHAPE" in \ | |
| 30 | + | rpm) \ | |
| 31 | + | dnf -y install "/tmp/rpms/alloy-demo-${DEMO_VERSION}-1.fc43.x86_64.rpm" ;; \ | |
| 32 | + | loose) \ | |
| 33 | + | printf '#!/bin/sh\necho "alloy-demo %s"\n' "$DEMO_VERSION" > /usr/bin/alloy-demo \ | |
| 34 | + | && chmod 0755 /usr/bin/alloy-demo ;; \ | |
| 35 | + | none) \ | |
| 36 | + | : ;; \ | |
| 37 | + | *) echo "unknown SHAPE: $SHAPE" >&2; exit 1 ;; \ | |
| 38 | + | esac \ | |
| 39 | + | && rm -rf /tmp/rpms \ | |
| 40 | + | && dnf clean all | |
| 41 | + | ||
| 42 | + | # Which base is running, readable from the guest without trusting a tag. The | |
| 43 | + | # registry tag is rewritten on every case, so the tag says nothing about what | |
| 44 | + | # actually booted. | |
| 45 | + | RUN echo "$BASE_MARK" > /usr/share/base-mark | |
| 46 | + | ||
| 47 | + | # Key-only root ssh, because the harness drives the guest from outside. The | |
| 48 | + | # authorized keys file lives under /usr rather than in /root: on an ostree | |
| 49 | + | # system /root is /var/roothome, which is not part of the image. | |
| 50 | + | COPY authorized_keys /usr/share/alloy-demo/authorized_keys | |
| 51 | + | RUN printf 'PermitRootLogin prohibit-password\nAuthorizedKeysFile /usr/share/alloy-demo/authorized_keys\n' \ | |
| 52 | + | > /etc/ssh/sshd_config.d/10-alloy-demo.conf \ | |
| 53 | + | && systemctl enable sshd |
| @@ -1,0 +1,119 @@ | |||
| 1 | + | # layertest — what a hotfix is allowed to do to an installed machine | |
| 2 | + | ||
| 3 | + | Alloy is distributed as a builder rather than as an image, so there is no way | |
| 4 | + | to hand a running machine a fix short of "rebuild the ISO and write a drive". | |
| 5 | + | The proposed answer is a signed RPM repo carrying only our own components, | |
| 6 | + | layered with `rpm-ostree` (GoingsOn task `d866e125`). That answer rests on one | |
| 7 | + | assumption: that a package layered today is still there, and still correct, | |
| 8 | + | after the base moves tomorrow. | |
| 9 | + | ||
| 10 | + | This harness measures that assumption instead of trusting it. It is not a test | |
| 11 | + | suite and nothing runs it automatically. It exists to be re-run when rpm-ostree | |
| 12 | + | or bootc moves, because every result below is a behaviour of those two and not | |
| 13 | + | of anything in this repo. | |
| 14 | + | ||
| 15 | + | ## What it found, 2026-08-14 | |
| 16 | + | ||
| 17 | + | Measured on rpm-ostree 2026.1, bootc 1.16.3, libostree 2026.2, against a | |
| 18 | + | `quay.io/fedora/fedora-bootc:43` base. | |
| 19 | + | ||
| 20 | + | **`bootc upgrade` refuses to run on a layered deployment.** It does not discard | |
| 21 | + | the layer and does not keep it. It errors and does nothing: | |
| 22 | + | ||
| 23 | + | error: Upgrading: Deployment contains local rpm-ostree modifications; | |
| 24 | + | cannot upgrade via bootc. You can run `rpm-ostree reset` to undo the | |
| 25 | + | modifications. | |
| 26 | + | ||
| 27 | + | The two mechanisms are mutually exclusive. `rpm-ostree upgrade` is the verb | |
| 28 | + | that works on a layered system, and it pulls the same container image, so | |
| 29 | + | anything shipping a hotfix channel has to move machines off `bootc upgrade`. | |
| 30 | + | ||
| 31 | + | **A component already in the base cannot be replaced from a repo.** With the | |
| 32 | + | component installed as a base RPM at 0.0.1 and 0.0.2 in the repo: | |
| 33 | + | ||
| 34 | + | - `rpm-ostree install` fails to depsolve, `cannot install both ... from | |
| 35 | + | @System`. | |
| 36 | + | - `rpm-ostree override replace` reports `Inactive base replacements` and writes | |
| 37 | + | no new commit, in both of its forms. They differ only in what they leave | |
| 38 | + | behind, and neither difference helps: given a local RPM or a URL, the request | |
| 39 | + | is recorded in `requested-base-local-replacements` and survives a reboot as a | |
| 40 | + | silent no-op, so the machine reports a hotfix it is not running. Given | |
| 41 | + | `--experimental --from repo=`, nothing is recorded at all once the machine | |
| 42 | + | reboots, so the request disappears without ever having done anything. | |
| 43 | + | - `override remove` on a base package does commit, but pairing it with | |
| 44 | + | `--install` fails the same depsolve. | |
| 45 | + | ||
| 46 | + | Reproduce with variant `r1`. | |
| 47 | + | ||
| 48 | + | **A component that is an unowned file cannot be layered over at all.** This is | |
| 49 | + | the shape Alloy actually ships: `Containerfile:2067` and `:2072` copy the | |
| 50 | + | console and shop into `/usr/bin`, owned by no package. Layering an RPM that | |
| 51 | + | carries the same path dies in checkout rather than in depsolve: | |
| 52 | + | ||
| 53 | + | error: Checkout alloy-demo-0.0.2: Hardlinking ... to alloy-demo: File exists | |
| 54 | + | ||
| 55 | + | Reproduce with variant `l1`. | |
| 56 | + | ||
| 57 | + | **The shape that does work, and where it breaks.** If the base does not carry | |
| 58 | + | the component at all, the hotfix is a plain layered package and applies | |
| 59 | + | cleanly. Across `rpm-ostree upgrade`: | |
| 60 | + | ||
| 61 | + | | the base moves to | what happens to the layer | | |
| 62 | + | |---|---| | |
| 63 | + | | a base still without the component (`n2`) | survives, re-applies, clean | | |
| 64 | + | | a base carrying it at the same version (`n3`) | goes `Inactive requests: already provided by`, drops out of `packages`, stays in `requested-packages`. Self-healing | | |
| 65 | + | | a base carrying a **newer** version (`n4`) | **the machine stops updating**, `cannot install both 0.0.2 from alloy-demo and 0.0.3 from @System`, and every later upgrade fails too | | |
| 66 | + | ||
| 67 | + | That last row is the normal life of a hotfix: ship it out of band, fold it into | |
| 68 | + | the next image at a higher version, and every machine that took it wedges. | |
| 69 | + | Recovery is `rpm-ostree uninstall` and then upgrade, after which `bootc | |
| 70 | + | upgrade` works again, but a user has no way to know that is what happened. | |
| 71 | + | ||
| 72 | + | ## Use | |
| 73 | + | ||
| 74 | + | Needs `qemu-system-x86_64` with KVM, OVMF, podman, and about 25 GB free. No | |
| 75 | + | swtpm and no OVMF secure-boot variables, unlike vmtest: nothing here installs | |
| 76 | + | to an encrypted disk. | |
| 77 | + | ||
| 78 | + | ./build.sh # three RPMs, the repo, and six base images | |
| 79 | + | ./serve.sh up # RPM repo on 8080, OCI registry on 5000 | |
| 80 | + | ./install-disk.sh n1 # install a variant to state/disk.raw | |
| 81 | + | ./vm.sh & # boot it, ssh lands on 2223 | |
| 82 | + | ./sshx 'rpm-ostree install alloy-demo-0.0.2-1.fc43.x86_64' | |
| 83 | + | ./sshx 'systemctl reboot' | |
| 84 | + | ./case.sh n3 # move the base, reboot, read the state back | |
| 85 | + | ./case.sh n2 --bootc # the refusal, on purpose | |
| 86 | + | ./serve.sh down # and stop the servers | |
| 87 | + | ||
| 88 | + | The full sequence that produced the table is `n1`, layer the hotfix, then | |
| 89 | + | `case.sh n2`, `case.sh n3`, `case.sh n4`. | |
| 90 | + | ||
| 91 | + | Everything a run writes goes to `state/`, which is gitignored. Delete it to | |
| 92 | + | start clean. | |
| 93 | + | ||
| 94 | + | ## Things worth knowing before changing this | |
| 95 | + | ||
| 96 | + | **Read the JSON, not the human output.** A request that never activated prints | |
| 97 | + | much like one that did. `readstate.py` exists to show the `packages` versus | |
| 98 | + | `requested-packages` split, which is the only reliable way to tell an applied | |
| 99 | + | hotfix from a no-op. It is the same split `crates/alloy/src/pkg.rs:1099-1160` | |
| 100 | + | already parses, which is what the console would report to a user. | |
| 101 | + | ||
| 102 | + | **Reinstall between shapes, and let `install-disk.sh` delete the firmware | |
| 103 | + | variables.** Keeping `OVMF_VARS.fd` across a reinstall boots the new disk | |
| 104 | + | against the old boot entries and lands at a `grub>` prompt. | |
| 105 | + | ||
| 106 | + | **`--target-imgref` at install time is what makes upgrades possible.** Without | |
| 107 | + | it the installed system points at a `localhost/` reference that resolves to | |
| 108 | + | nothing inside the guest. `build-iso.sh --update-target` does the same job for | |
| 109 | + | the ISO. | |
| 110 | + | ||
| 111 | + | **The base image is deliberately not Alloy.** A run costs a 200 MB build rather | |
| 112 | + | than a 5 GB one, and everything measured here is rpm-ostree and bootc | |
| 113 | + | behaviour, which does not depend on what else the image carries. If a result | |
| 114 | + | ever looks like it might turn on Alloy's own content, that is the point to | |
| 115 | + | rebuild this against `localhost/alloy:local` rather than to argue about it. | |
| 116 | + | ||
| 117 | + | **The registry is insecure and the repo is unsigned.** Signing is its own | |
| 118 | + | subtask. Adding it here would add ways for a run to fail that have nothing to | |
| 119 | + | do with what is being measured. |
| @@ -1,0 +1,32 @@ | |||
| 1 | + | %global debug_package %{nil} | |
| 2 | + | ||
| 3 | + | Name: alloy-demo | |
| 4 | + | Version: %{demo_version} | |
| 5 | + | Release: 1%{?dist} | |
| 6 | + | Summary: Stand-in for a first-party Alloy component | |
| 7 | + | License: MIT | |
| 8 | + | URL: https://makenot.work/git/max/alloy | |
| 9 | + | ||
| 10 | + | %description | |
| 11 | + | A single executable that prints its own version. It stands in for the alloy | |
| 12 | + | console or for shop in an experiment about what rpm-ostree and bootc will let | |
| 13 | + | a hotfix do. It carries no Alloy code, and it is never published anywhere. | |
| 14 | + | ||
| 15 | + | Three versions get built from this one spec. 0.0.1 is what a base image ships, | |
| 16 | + | 0.0.2 is the hotfix, and 0.0.3 is the version a later base image carries once | |
| 17 | + | the fix has been folded back in. That last one is the case that matters: it is | |
| 18 | + | where a machine holding the hotfix stops being able to update. | |
| 19 | + | ||
| 20 | + | %install | |
| 21 | + | mkdir -p %{buildroot}%{_bindir} | |
| 22 | + | cat > %{buildroot}%{_bindir}/alloy-demo <<'EOF' | |
| 23 | + | #!/bin/sh | |
| 24 | + | echo "alloy-demo VERSION_PLACEHOLDER" | |
| 25 | + | EOF | |
| 26 | + | sed -i "s/VERSION_PLACEHOLDER/%{version}/" %{buildroot}%{_bindir}/alloy-demo | |
| 27 | + | chmod 0755 %{buildroot}%{_bindir}/alloy-demo | |
| 28 | + | ||
| 29 | + | %files | |
| 30 | + | %{_bindir}/alloy-demo | |
| 31 | + | ||
| 32 | + | %changelog |
| @@ -1,0 +1,84 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # build.sh — build the three stand-in RPMs, the repo that serves them, and | |
| 4 | + | # every base image variant. | |
| 5 | + | # | |
| 6 | + | # Everything lands in state/, which is gitignored. Deleting that directory is | |
| 7 | + | # how to start clean; nothing here reads anything it did not put there. | |
| 8 | + | # | |
| 9 | + | # Rootful podman, because bootc install has to find the image in the same | |
| 10 | + | # container store it runs out of, and a rootless-to-rootful copy of a 2 GB | |
| 11 | + | # image is a slower way to arrive at the same place. That matches | |
| 12 | + | # build-image.sh, which is rootful for the same reason. | |
| 13 | + | set -euo pipefail | |
| 14 | + | ||
| 15 | + | # shellcheck source=build/layertest/common.sh | |
| 16 | + | . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" | |
| 17 | + | ||
| 18 | + | # shellcheck source=build/privilege.sh | |
| 19 | + | . "$HERE/../privilege.sh" | |
| 20 | + | ||
| 21 | + | mkdir -p "$STATE/rpms" | |
| 22 | + | ||
| 23 | + | # ---------------------------------------------------------------- the RPMs | |
| 24 | + | # Built in a container rather than on the host: rpmbuild and createrepo_c are | |
| 25 | + | # not on a dev box by default, and the packages have to be fc43 to install into | |
| 26 | + | # an fc43 base without an %{dist} mismatch nobody wants to debug. | |
| 27 | + | say "building alloy-demo 0.0.1, 0.0.2 and 0.0.3" | |
| 28 | + | podman run --rm -v "$HERE:/spec:ro,z" -v "$STATE:/state:z" -w /state \ | |
| 29 | + | registry.fedoraproject.org/fedora:43 bash -c ' | |
| 30 | + | set -e | |
| 31 | + | dnf -y install rpm-build createrepo_c >/dev/null 2>&1 | |
| 32 | + | for v in 0.0.1 0.0.2 0.0.3; do | |
| 33 | + | rpmbuild --define "_topdir /state/rpmbuild" --define "demo_version $v" \ | |
| 34 | + | -bb /spec/alloy-demo.spec >/dev/null | |
| 35 | + | done | |
| 36 | + | rm -rf /state/repo && mkdir -p /state/repo | |
| 37 | + | cp /state/rpmbuild/RPMS/*/*.rpm /state/repo/ | |
| 38 | + | cp /state/rpmbuild/RPMS/*/*.rpm /state/rpms/ | |
| 39 | + | createrepo_c /state/repo >/dev/null | |
| 40 | + | ' >/dev/null | |
| 41 | + | ||
| 42 | + | # ------------------------------------------------------- the build context | |
| 43 | + | # Written rather than tracked, because both files name a port that lives in | |
| 44 | + | # common.sh, and two copies of a port number is one too many. | |
| 45 | + | # | |
| 46 | + | # Unsigned and insecure on purpose. Signing is its own subtask, and mixing it | |
| 47 | + | # in here would add a way for a run to fail that has nothing to do with what is | |
| 48 | + | # being measured. | |
| 49 | + | cat > "$STATE/alloy-demo.repo" <<EOF | |
| 50 | + | [alloy-demo] | |
| 51 | + | name=alloy-demo hotfix channel (experiment) | |
| 52 | + | baseurl=http://$HOST_FROM_GUEST:$REPO_PORT/ | |
| 53 | + | enabled=1 | |
| 54 | + | gpgcheck=0 | |
| 55 | + | EOF | |
| 56 | + | ||
| 57 | + | cat > "$STATE/registries.conf" <<EOF | |
| 58 | + | [[registry]] | |
| 59 | + | location = "$HOST_FROM_GUEST:$REGISTRY_PORT" | |
| 60 | + | insecure = true | |
| 61 | + | EOF | |
| 62 | + | ||
| 63 | + | # The guest is driven over ssh, so the key that drives it has to be in the | |
| 64 | + | # image. Copied at build time rather than committed. | |
| 65 | + | [ -f "$HOME/.ssh/id_ed25519.pub" ] || die "no ~/.ssh/id_ed25519.pub to authorize" | |
| 66 | + | cp "$HOME/.ssh/id_ed25519.pub" "$STATE/authorized_keys" | |
| 67 | + | ||
| 68 | + | # ------------------------------------------------------------- the images | |
| 69 | + | for variant in "${VARIANTS[@]}"; do | |
| 70 | + | mark="${variant%%:*}" | |
| 71 | + | rest="${variant#*:}" | |
| 72 | + | shape="${rest%%:*}" | |
| 73 | + | version="${rest#*:}" | |
| 74 | + | say "building $IMAGE:$mark (shape=$shape version=${version:-none})" | |
| 75 | + | privc podman build \ | |
| 76 | + | -f "$HERE/Containerfile" \ | |
| 77 | + | --build-arg "SHAPE=$shape" \ | |
| 78 | + | --build-arg "DEMO_VERSION=${version:-0.0.1}" \ | |
| 79 | + | --build-arg "BASE_MARK=$mark" \ | |
| 80 | + | -t "$IMAGE:$mark" \ | |
| 81 | + | "$STATE" >/dev/null | |
| 82 | + | done | |
| 83 | + | ||
| 84 | + | say "built: $(printf '%s ' "${VARIANTS[@]%%:*}")" |
| @@ -1,0 +1,44 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # case.sh — move the guest's base to a variant and report what became of the | |
| 4 | + | # layered package. | |
| 5 | + | # | |
| 6 | + | # case.sh n2 upgrade with rpm-ostree, reboot, read the state back | |
| 7 | + | # case.sh n2 --bootc use bootc upgrade instead, which is a separate answer | |
| 8 | + | # | |
| 9 | + | # Retagging is how the base moves. The installed system points at :latest in | |
| 10 | + | # the host registry, so pushing a different variant to that tag is the whole | |
| 11 | + | # mechanism; nothing in the guest has to be reconfigured between cases. | |
| 12 | + | # | |
| 13 | + | # The reboot is not optional. rpm-ostree stages a deployment and the running | |
| 14 | + | # system keeps running the old one, so reading `alloy-demo` before the reboot | |
| 15 | + | # reports the previous answer and looks like a failure to apply. | |
| 16 | + | set -euo pipefail | |
| 17 | + | ||
| 18 | + | # shellcheck source=build/layertest/common.sh | |
| 19 | + | . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" | |
| 20 | + | ||
| 21 | + | # shellcheck source=build/privilege.sh | |
| 22 | + | . "$HERE/../privilege.sh" | |
| 23 | + | ||
| 24 | + | MARK="${1:-}" | |
| 25 | + | [ -n "$MARK" ] || die "usage: case.sh <mark> [--bootc]" | |
| 26 | + | VERB="rpm-ostree upgrade" | |
| 27 | + | [ "${2:-}" = "--bootc" ] && VERB="bootc upgrade" | |
| 28 | + | ||
| 29 | + | privc podman push --tls-verify=false "$IMAGE:$MARK" \ | |
| 30 | + | "127.0.0.1:$REGISTRY_PORT/alloy-layertest:latest" >/dev/null 2>&1 | |
| 31 | + | ||
| 32 | + | say "=== $VERB onto $MARK ===" | |
| 33 | + | # Not fatal. A refusal is a result, and the whole point of --bootc is to see | |
| 34 | + | # one, so the script has to survive the command it is measuring. | |
| 35 | + | "$HERE/sshx" "$VERB" 2>&1 | tail -6 || true | |
| 36 | + | ||
| 37 | + | "$HERE/sshx" 'systemctl reboot' >/dev/null 2>&1 || true | |
| 38 | + | sleep 45 | |
| 39 | + | ||
| 40 | + | say "--- booted ---" | |
| 41 | + | "$HERE/sshx" 'echo -n "base-mark: "; cat /usr/share/base-mark; \ | |
| 42 | + | echo -n "binary: "; alloy-demo 2>&1 || echo "(absent)"; \ | |
| 43 | + | echo -n "rpm -q: "; rpm -q alloy-demo 2>&1' || die "guest did not come back" | |
| 44 | + | "$HERE/sshx" 'rpm-ostree status --json' | python3 "$HERE/readstate.py" |
| @@ -1,0 +1,39 @@ | |||
| 1 | + | # Shared settings for the layertest scripts. Sourced, not run. | |
| 2 | + | # | |
| 3 | + | # The ports differ from anything else on a dev box on purpose: vmtest already | |
| 4 | + | # forwards ssh to 2222, and running both harnesses at once should not need a | |
| 5 | + | # conversation about which one owns a port. | |
| 6 | + | ||
| 7 | + | HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 8 | + | STATE="${LAYERTEST_STATE:-$HERE/state}" | |
| 9 | + | ||
| 10 | + | IMAGE=localhost/alloy-layertest | |
| 11 | + | REGISTRY_PORT=5000 | |
| 12 | + | REPO_PORT=8080 | |
| 13 | + | SSH_PORT=2223 | |
| 14 | + | ||
| 15 | + | # Under qemu user networking the host is 10.0.2.2 from inside the guest. Both | |
| 16 | + | # the RPM repo and the registry are reached through it. | |
| 17 | + | HOST_FROM_GUEST=10.0.2.2 | |
| 18 | + | ||
| 19 | + | # The variants, as mark:shape:version. | |
| 20 | + | # | |
| 21 | + | # n1 through n4 are the upgrade sequence, and they are the run that answers the | |
| 22 | + | # question. n1 and n2 do not carry the component, so the hotfix is a layered | |
| 23 | + | # package; n3 carries it at the hotfix version (the base has caught up) and n4 | |
| 24 | + | # carries it one version further on (the base has moved ahead). | |
| 25 | + | # | |
| 26 | + | # r1 and l1 are not part of that sequence. They exist to reproduce the two dead | |
| 27 | + | # ends: a component that is a base RPM cannot be replaced, and a component that | |
| 28 | + | # is an unowned file cannot be layered over. | |
| 29 | + | VARIANTS=( | |
| 30 | + | "n1:none:" | |
| 31 | + | "n2:none:" | |
| 32 | + | "n3:rpm:0.0.2" | |
| 33 | + | "n4:rpm:0.0.3" | |
| 34 | + | "r1:rpm:0.0.1" | |
| 35 | + | "l1:loose:0.0.1" | |
| 36 | + | ) | |
| 37 | + | ||
| 38 | + | die() { printf 'error: %s\n' "$*" >&2; exit 1; } | |
| 39 | + | say() { printf '%s\n' "$*"; } |
| @@ -1,0 +1,53 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # install-disk.sh — install a variant to state/disk.raw with bootc, ready to | |
| 4 | + | # boot with vm.sh. | |
| 5 | + | # | |
| 6 | + | # install-disk.sh n1 | |
| 7 | + | # | |
| 8 | + | # Destroys whatever was on the disk. That is the point: every case starts from | |
| 9 | + | # a freshly installed machine, and reusing a disk across shapes is how a run | |
| 10 | + | # ends up measuring the last run. | |
| 11 | + | # | |
| 12 | + | # --target-imgref is what makes the rest work. Without it the installed system | |
| 13 | + | # would point at localhost/alloy-layertest, which resolves to nothing inside | |
| 14 | + | # the guest, and no upgrade could ever be fetched. Pointing it at the host's | |
| 15 | + | # registry is the same trick build-iso.sh --update-target plays for the ISO. | |
| 16 | + | # | |
| 17 | + | # No TPM, no encryption, unlike vmtest. This harness measures what rpm-ostree | |
| 18 | + | # and bootc do to a layered package, and the encrypted path only adds ways for | |
| 19 | + | # a run to fail for unrelated reasons. | |
| 20 | + | set -euo pipefail | |
| 21 | + | ||
| 22 | + | # shellcheck source=build/layertest/common.sh | |
| 23 | + | . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" | |
| 24 | + | ||
| 25 | + | # shellcheck source=build/privilege.sh | |
| 26 | + | . "$HERE/../privilege.sh" | |
| 27 | + | ||
| 28 | + | MARK="${1:-}" | |
| 29 | + | [ -n "$MARK" ] || die "usage: install-disk.sh <mark> (n1 n2 n3 n4 r1 l1)" | |
| 30 | + | privc podman image exists "$IMAGE:$MARK" || die "no image $IMAGE:$MARK; run build.sh" | |
| 31 | + | ||
| 32 | + | # The firmware variables go with the disk. Leaving them behind boots the old | |
| 33 | + | # disk's boot entries against a new install and lands at a grub prompt, which | |
| 34 | + | # is the same lesson vmtest's README records. | |
| 35 | + | rm -f "$STATE/disk.raw" "$STATE/OVMF_VARS.fd" | |
| 36 | + | truncate -s 20G "$STATE/disk.raw" | |
| 37 | + | ||
| 38 | + | say "installing $MARK to state/disk.raw" | |
| 39 | + | privc podman run --rm --privileged --pid=host \ | |
| 40 | + | --security-opt label=type:unconfined_t \ | |
| 41 | + | -v /var/lib/containers:/var/lib/containers \ | |
| 42 | + | -v /dev:/dev \ | |
| 43 | + | -v "$STATE:/output" \ | |
| 44 | + | "$IMAGE:$MARK" \ | |
| 45 | + | bootc install to-disk --via-loopback --generic-image --filesystem ext4 \ | |
| 46 | + | --target-imgref "$HOST_FROM_GUEST:$REGISTRY_PORT/alloy-layertest:latest" \ | |
| 47 | + | /output/disk.raw | |
| 48 | + | ||
| 49 | + | # The installed system will pull its upgrades from :latest, so :latest has to | |
| 50 | + | # be the variant just installed or the first upgrade would be a surprise. | |
| 51 | + | privc podman push --tls-verify=false "$IMAGE:$MARK" \ | |
| 52 | + | "127.0.0.1:$REGISTRY_PORT/alloy-layertest:latest" >/dev/null 2>&1 | |
| 53 | + | say "installed $MARK, and pushed it as :latest" |
| @@ -1,0 +1,28 @@ | |||
| 1 | + | # Read `rpm-ostree status --json` down to the fields that answer the question. | |
| 2 | + | # | |
| 3 | + | # The distinction that matters is packages versus requested-packages, and | |
| 4 | + | # base-local-replacements versus requested-base-local-replacements. A request | |
| 5 | + | # that never becomes active is recorded in the second of each pair and absent | |
| 6 | + | # from the first, and it survives a reboot looking exactly like a request that | |
| 7 | + | # worked. Reading the human `rpm-ostree status` output instead is how a no-op | |
| 8 | + | # gets mistaken for an applied hotfix. | |
| 9 | + | # | |
| 10 | + | # ./sshx 'rpm-ostree status --json' | python3 readstate.py | |
| 11 | + | ||
| 12 | + | import json | |
| 13 | + | import sys | |
| 14 | + | ||
| 15 | + | KEYS = ( | |
| 16 | + | "base-local-replacements", "requested-base-local-replacements", | |
| 17 | + | "packages", "requested-packages", | |
| 18 | + | "base-removals", "requested-base-removals", | |
| 19 | + | "origin", "version", | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | data = json.load(sys.stdin) | |
| 23 | + | for index, deployment in enumerate(data["deployments"]): | |
| 24 | + | print("--- deployment %d booted=%s staged=%s" % ( | |
| 25 | + | index, deployment.get("booted"), deployment.get("staged"))) | |
| 26 | + | for key in KEYS: | |
| 27 | + | if deployment.get(key): | |
| 28 | + | print(" %s = %s" % (key, deployment[key])) |
| @@ -1,0 +1,77 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # serve.sh — bring the RPM repo and the OCI registry up, or take them down. | |
| 4 | + | # | |
| 5 | + | # Both have to be reachable from inside the guest, which means bound on the | |
| 6 | + | # host rather than on loopback only: qemu user networking reaches the host as | |
| 7 | + | # 10.0.2.2, and a server bound to 127.0.0.1 is not there. | |
| 8 | + | # | |
| 9 | + | # serve.sh up start both, idempotent | |
| 10 | + | # serve.sh down stop both | |
| 11 | + | # serve.sh status say what is listening | |
| 12 | + | set -euo pipefail | |
| 13 | + | ||
| 14 | + | # shellcheck source=build/layertest/common.sh | |
| 15 | + | . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" | |
| 16 | + | ||
| 17 | + | # shellcheck source=build/privilege.sh | |
| 18 | + | . "$HERE/../privilege.sh" | |
| 19 | + | ||
| 20 | + | PIDFILE="$STATE/repo-server.pid" | |
| 21 | + | ||
| 22 | + | repo_up() { | |
| 23 | + | [ -d "$STATE/repo" ] || die "no state/repo; run build.sh first" | |
| 24 | + | if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then | |
| 25 | + | say "repo already serving on $REPO_PORT"; return | |
| 26 | + | fi | |
| 27 | + | # `setsid --fork`, and no `&` anywhere. The server has to stop being this | |
| 28 | + | # script's descendant, and it has to stop holding this script's stdout. | |
| 29 | + | # | |
| 30 | + | # Both of those bite. A plain `cmd &` leaves the server a child that bash | |
| 31 | + | # waits for at exit, and wrapping it in `( ... & )` only moves the problem to | |
| 32 | + | # the subshell, which then holds the write end of any pipe `serve.sh up` was | |
| 33 | + | # called through. Either way `./serve.sh up | tail` never returns and an && | |
| 34 | + | # chain after it never runs. nohup does not help: it redirects, it does not | |
| 35 | + | # detach. | |
| 36 | + | setsid --fork python3 -m http.server "$REPO_PORT" \ | |
| 37 | + | --bind 0.0.0.0 --directory "$STATE/repo" \ | |
| 38 | + | < /dev/null > "$STATE/repo-server.log" 2>&1 | |
| 39 | + | pgrep -f "http.server $REPO_PORT" > "$PIDFILE" || true | |
| 40 | + | say "repo serving on $REPO_PORT" | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | repo_down() { | |
| 44 | + | if [ -f "$PIDFILE" ]; then | |
| 45 | + | kill "$(cat "$PIDFILE")" 2>/dev/null || true | |
| 46 | + | rm -f "$PIDFILE" | |
| 47 | + | fi | |
| 48 | + | # The pidfile is not the whole story: a run killed part-way leaves a server | |
| 49 | + | # with no pidfile, and the next `up` would then bind-fail for a reason that | |
| 50 | + | # reads as unrelated. | |
| 51 | + | pkill -f "http.server $REPO_PORT" 2>/dev/null || true | |
| 52 | + | say "repo stopped" | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | registry_up() { | |
| 56 | + | if privc podman container exists alloy-layertest-registry 2>/dev/null; then | |
| 57 | + | privc podman start alloy-layertest-registry >/dev/null 2>&1 || true | |
| 58 | + | else | |
| 59 | + | privc podman run -d --name alloy-layertest-registry \ | |
| 60 | + | -p "$REGISTRY_PORT:5000" docker.io/library/registry:2 >/dev/null | |
| 61 | + | fi | |
| 62 | + | say "registry serving on $REGISTRY_PORT" | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | registry_down() { | |
| 66 | + | privc podman rm -f alloy-layertest-registry >/dev/null 2>&1 || true | |
| 67 | + | say "registry stopped" | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | case "${1:-up}" in | |
| 71 | + | up) repo_up; registry_up ;; | |
| 72 | + | down) repo_down; registry_down ;; | |
| 73 | + | status) | |
| 74 | + | ss -ltn 2>/dev/null | grep -E ":$REPO_PORT|:$REGISTRY_PORT" || say "neither port is listening" | |
| 75 | + | ;; | |
| 76 | + | *) die "usage: serve.sh [up|down|status]" ;; | |
| 77 | + | esac |
| @@ -1,0 +1,18 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # sshx — run a command in the guest. | |
| 4 | + | # | |
| 5 | + | # The host key changes every time a disk is reinstalled, which is every case, | |
| 6 | + | # so known-hosts checking is turned off rather than fought with. This reaches | |
| 7 | + | # a throwaway VM on loopback and nothing else. | |
| 8 | + | set -euo pipefail | |
| 9 | + | ||
| 10 | + | # shellcheck source=build/layertest/common.sh | |
| 11 | + | . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" | |
| 12 | + | ||
| 13 | + | exec ssh \ | |
| 14 | + | -o StrictHostKeyChecking=no \ | |
| 15 | + | -o UserKnownHostsFile=/dev/null \ | |
| 16 | + | -o LogLevel=ERROR \ | |
| 17 | + | -o ConnectTimeout=5 \ | |
| 18 | + | -p "$SSH_PORT" root@127.0.0.1 "$@" |
| @@ -1,0 +1,27 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # vm.sh — boot state/disk.raw headless, with ssh forwarded to $SSH_PORT. | |
| 4 | + | # | |
| 5 | + | # No qemu monitor socket. The obvious place for one is state/, and under a | |
| 6 | + | # session scratch path that exceeds the 108-byte limit a unix socket path has, | |
| 7 | + | # which fails with a message about socket paths that reads as unrelated to | |
| 8 | + | # anything being done. ssh is the control channel here, so the monitor was | |
| 9 | + | # never needed; vmtest keeps one because it drives a ratatui installer through | |
| 10 | + | # screendumps and has nothing else to talk to. | |
| 11 | + | set -euo pipefail | |
| 12 | + | ||
| 13 | + | # shellcheck source=build/layertest/common.sh | |
| 14 | + | . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" | |
| 15 | + | ||
| 16 | + | [ -f "$STATE/disk.raw" ] || die "no state/disk.raw; run install-disk.sh first" | |
| 17 | + | [ -f "$STATE/OVMF_VARS.fd" ] || cp /usr/share/OVMF/OVMF_VARS_4M.fd "$STATE/OVMF_VARS.fd" | |
| 18 | + | ||
| 19 | + | exec qemu-system-x86_64 \ | |
| 20 | + | -enable-kvm -machine q35 -cpu host -m 4096 -smp 4 \ | |
| 21 | + | -drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \ | |
| 22 | + | -drive "if=pflash,format=raw,unit=1,file=$STATE/OVMF_VARS.fd" \ | |
| 23 | + | -drive "file=$STATE/disk.raw,if=virtio,format=raw" \ | |
| 24 | + | -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22" \ | |
| 25 | + | -device virtio-net-pci,netdev=n0 \ | |
| 26 | + | -display none \ | |
| 27 | + | -serial "file:$STATE/serial.log" |