Skip to main content

max / alloy

Measure what a hotfix is allowed to do, instead of assuming it The hotfix channel (GO d866e125) rests on one assumption: that a package layered today is still there, and still correct, after the base moves tomorrow. This harness measures that assumption. It is not a test suite and nothing runs it automatically; it exists to be re-run when rpm-ostree or bootc moves, because every result it produces is a behaviour of those two. The assumption does not hold, and the README records the run. bootc upgrade refuses outright on a layered deployment rather than keeping or discarding the layer, so it and rpm-ostree layering are mutually exclusive. A component that is already a base package cannot be replaced: override replace goes inactive and writes no commit, in both of its forms. A component that is a loose file owned by no package, which is what Containerfile:2067 and :2072 produce, cannot be layered over at all; it dies in checkout. What does work is layering a package the base does not carry, and that survives a base move and self-heals when the base catches up at the same version. It wedges the machine when the base moves ahead of it, which is the normal life of a hotfix and the reason this was worth measuring first. A minimal fedora-bootc base rather than Alloy, so a run costs a 200 MB build rather than a 5 GB one. Nothing measured here depends on what else the image carries.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 21:15 UTC
Signed with PGP, not checked
Commit: 5f4bce78b10e207a92a12f9e6d31c4682738b1a5
Parent: 4496f54
12 files changed, +580 insertions, -0 deletions
M .gitignore +6
@@ -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"