Skip to content

Commit 855704d

Browse files
committed
feat(ci): add "GenericCloud: Test Image" workflow to validate GenericCloud .qcow2 images
Boots the qcow2 directly under QEMU/KVM on the runner with a cloud-init seed ISO (no cloud-API counterpart), runs the same release / arch / disk / dnf / package-list assertions as oci-test.yml and azure-test.yml, collects the package list, and posts a Mattermost summary. x86_64 runs on ubuntu-24.04; aarch64 uses the AlmaLinux org RunsOn arm64 metal pool with ubuntu-24.04-arm as the fork fallback. Per-arch QEMU differences (binary, machine, AAVMF firmware) are parameterized through a shared gencloud-test-steps composite action so both legs share one install / boot / test / cleanup path. Accepts both the build pipeline public S3 URL and the official repo.almalinux.org URL shape. For the gencloud_ext4 subtype the test adds a `findmnt -no FSTYPE /` assertion that the booted root FS is actually ext4 - something the cloud-API siblings cannot check since Azure / OCI only publish the XFS variant.
1 parent 7bc4d60 commit 855704d

4 files changed

Lines changed: 732 additions & 0 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
name: "GenericCloud image test steps"
2+
description: "Boots a GenericCloud .qcow2 under QEMU/KVM with a cloud-init seed and runs in-VM smoke tests over SSH"
3+
4+
inputs:
5+
image_url:
6+
description: "Public S3 URL to the GenericCloud .qcow2 image"
7+
required: true
8+
image_filename:
9+
description: "Image filename (last path segment of image_url)"
10+
required: true
11+
subtype:
12+
description: "Image subtype: gencloud (XFS root) or gencloud_ext4 (ext4 root)"
13+
required: true
14+
alma_arch:
15+
description: "Base image architecture: x86_64 or aarch64 (used by the QEMU case statement)"
16+
required: true
17+
alma_arch_full:
18+
description: "Full image architecture incl. microarch suffix (x86_64, x86_64_v2, or aarch64) - used for the in-VM rpm-arch assertion"
19+
required: true
20+
release_string:
21+
description: "Expected /etc/almalinux-release substring (e.g. 'AlmaLinux release 10.1')"
22+
required: true
23+
notify_mattermost:
24+
description: "Send notification to Mattermost (true/false)"
25+
required: true
26+
MATTERMOST_WEBHOOK_URL:
27+
description: "Mattermost incoming webhook URL"
28+
required: false
29+
default: ''
30+
MATTERMOST_CHANNEL:
31+
description: "Mattermost channel for notifications"
32+
required: false
33+
default: ''
34+
35+
runs:
36+
using: composite
37+
steps:
38+
- name: Install hypervisor packages
39+
shell: bash
40+
run: |
41+
# Install hypervisor packages
42+
sudo apt-get update
43+
case "${{ inputs.alma_arch }}" in
44+
x86_64)
45+
sudo apt-get install -y --no-install-recommends \
46+
qemu-system-x86 cloud-image-utils
47+
;;
48+
aarch64)
49+
sudo apt-get install -y --no-install-recommends \
50+
qemu-system-arm qemu-efi-aarch64 cloud-image-utils
51+
;;
52+
*)
53+
echo "[Error] Unsupported alma_arch: '${{ inputs.alma_arch }}'"
54+
exit 1
55+
;;
56+
esac
57+
58+
- name: Verify nested KVM is available
59+
shell: bash
60+
run: |
61+
# Verify nested KVM is available
62+
if [ ! -e /dev/kvm ]; then
63+
echo "[Error] /dev/kvm not present on this runner; nested KVM is required"
64+
exit 1
65+
fi
66+
ls -l /dev/kvm
67+
# The default GH-hosted runner user is in the kvm group already on
68+
# ubuntu-24.04; on RunsOn metal it varies. Loosen perms if /dev/kvm
69+
# is not writable for the current user.
70+
if [ ! -w /dev/kvm ]; then
71+
sudo chmod 666 /dev/kvm
72+
fi
73+
74+
- name: Generate ephemeral SSH keypair
75+
shell: bash
76+
run: |
77+
# Generate ephemeral SSH keypair
78+
mkdir -p ~/.ssh
79+
chmod 700 ~/.ssh
80+
ssh-keygen -t ed25519 -N '' -C "gencloud-test-${GITHUB_RUN_ID}" -f ~/.ssh/gencloud_test
81+
82+
- name: Download base image
83+
shell: bash
84+
env:
85+
IMAGE_URL: ${{ inputs.image_url }}
86+
run: |
87+
# Download base image
88+
curl -fL --retry 5 --retry-delay 5 -o base.qcow2 "${IMAGE_URL}"
89+
qemu-img info base.qcow2
90+
91+
- name: Create writable qcow2 overlay (100 GiB)
92+
shell: bash
93+
run: |
94+
# Create writable qcow2 overlay (100 GiB)
95+
# 100 GiB virtual size so cloud-init growpart yields a root FS
96+
# comfortably above the 98 GiB assertion below; the overlay stays
97+
# sparse, so this costs no real disk on the runner.
98+
qemu-img create -f qcow2 -F qcow2 -b base.qcow2 disk.qcow2 100G
99+
100+
- name: Build cloud-init seed.iso
101+
shell: bash
102+
run: |
103+
# Build cloud-init seed.iso
104+
cat > meta-data <<EOF
105+
instance-id: gencloud-test-${GITHUB_RUN_ID}
106+
local-hostname: gencloud-test
107+
EOF
108+
109+
PUB=$(cat ~/.ssh/gencloud_test.pub)
110+
cat > user-data <<EOF
111+
#cloud-config
112+
hostname: gencloud-test
113+
users:
114+
- default
115+
ssh_pwauth: false
116+
ssh_authorized_keys:
117+
- ${PUB}
118+
EOF
119+
120+
cloud-localds seed.iso user-data meta-data
121+
122+
- name: Boot QEMU/KVM guest (daemonized, hostfwd 2222 -> 22)
123+
shell: bash
124+
env:
125+
ALMA_ARCH: ${{ inputs.alma_arch }}
126+
run: |
127+
# Boot QEMU/KVM guest (daemonized, hostfwd 2222 -> 22)
128+
case "${ALMA_ARCH}" in
129+
x86_64)
130+
QEMU=qemu-system-x86_64
131+
MACHINE_ARGS=(-machine q35,accel=kvm -cpu host)
132+
FW_ARGS=()
133+
;;
134+
aarch64)
135+
QEMU=qemu-system-aarch64
136+
MACHINE_ARGS=(-machine virt,accel=kvm -cpu host)
137+
FW_ARGS=(-bios /usr/share/AAVMF/AAVMF_CODE.fd)
138+
;;
139+
esac
140+
141+
"${QEMU}" \
142+
-name gencloud-test \
143+
"${MACHINE_ARGS[@]}" \
144+
"${FW_ARGS[@]}" \
145+
-smp 2 -m 2048 \
146+
-drive file=disk.qcow2,if=virtio,format=qcow2,cache=writeback \
147+
-drive file=seed.iso,if=virtio,format=raw,readonly=on \
148+
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
149+
-device virtio-net-pci,netdev=net0 \
150+
-display none \
151+
-serial file:console.log \
152+
-pidfile qemu.pid \
153+
-daemonize
154+
155+
sleep 2
156+
echo "QEMU PID: $(cat qemu.pid)"
157+
158+
- name: Wait for SSH on 127.0.0.1:2222
159+
shell: bash
160+
run: |
161+
# Wait for SSH on 127.0.0.1:2222
162+
# QEMU's SLIRP hostfwd accepts the host-side TCP handshake before
163+
# sshd inside the guest is actually ready, so a plain `nc -z` gate
164+
# fires too early. Loop ssh-keyscan instead - it only succeeds
165+
# once the guest's sshd has produced a host-key banner, and
166+
# populates known_hosts in the same step.
167+
for i in $(seq 1 60); do
168+
if ssh-keyscan -p 2222 -T 5 127.0.0.1 > ~/.ssh/known_hosts.tmp 2>/dev/null \
169+
&& [ -s ~/.ssh/known_hosts.tmp ]; then
170+
mv ~/.ssh/known_hosts.tmp ~/.ssh/known_hosts
171+
echo "[Info] SSH ready after ${i} attempt(s)"
172+
break
173+
fi
174+
if [ "${i}" -eq 60 ]; then
175+
echo "[Error] SSH did not become reachable within 10 minutes"
176+
exit 1
177+
fi
178+
sleep 10
179+
done
180+
181+
- name: Run image tests
182+
shell: bash
183+
env:
184+
ALMA_ARCH: ${{ inputs.alma_arch }}
185+
ALMA_ARCH_FULL: ${{ inputs.alma_arch_full }}
186+
RELEASE_STRING: ${{ inputs.release_string }}
187+
IMAGE_FILENAME: ${{ inputs.image_filename }}
188+
SUBTYPE: ${{ inputs.subtype }}
189+
run: |
190+
# Run image tests
191+
SSH=(ssh -i ~/.ssh/gencloud_test -p 2222 \
192+
-o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \
193+
"almalinux@127.0.0.1")
194+
195+
echo "[Debug] AlmaLinux release:"
196+
ALMA_RELEASE=$("${SSH[@]}" "grep '${RELEASE_STRING}' /etc/almalinux-release")
197+
echo "${ALMA_RELEASE}"
198+
199+
# The package owning /etc/almalinux-release is 'almalinux-release'
200+
# on stable AlmaLinux but 'almalinux-kitten-release' on Kitten,
201+
# so query it dynamically rather than hard-coding the name (same
202+
# pattern as azure-test.yml).
203+
echo "[Debug] AlmaLinux release package:"
204+
RELEASE_PACKAGE=$("${SSH[@]}" "rpm -qf /etc/almalinux-release")
205+
echo "${RELEASE_PACKAGE}"
206+
207+
# On v2 microarch builds the release package reports %{ARCH} as
208+
# 'x86_64_v2' rather than 'x86_64', so the assertion grep uses
209+
# the full arch including the suffix.
210+
echo "[Debug] System architecture:"
211+
SYSTEM_ARCH=$("${SSH[@]}" "rpm -q --qf='%{ARCH}\n' ${RELEASE_PACKAGE} | grep '${ALMA_ARCH_FULL}'")
212+
echo "${SYSTEM_ARCH}"
213+
214+
echo "[Debug] Disk and filesystems:"
215+
"${SSH[@]}" "sudo lsblk"
216+
# Root-FS size threshold is 95 GiB on a 100 GiB overlay: the cloud
217+
# image's UEFI partition layout (1M BIOS-boot + 200M /boot/efi +
218+
# 1G /boot) eats ~1.2 GiB before the root partition, and ext4/xfs
219+
# metadata trims another ~1.5 GiB off the usable FS size, so the
220+
# observed ceiling on a fully-grown root is ~97 GiB. Anything
221+
# under ~10 GiB means cloud-init growpart didn't run.
222+
"${SSH[@]}" 'ROOT_SIZE_BYTES=$(df -B1 --output=size / | tail -n 1 | tr -d " "); MIN_SIZE_BYTES=$((95*1024*1024*1024)); [ "${ROOT_SIZE_BYTES}" -gt "${MIN_SIZE_BYTES}" ] || { echo "[Error] Root filesystem resize check failed: ${ROOT_SIZE_BYTES} bytes (expected > ${MIN_SIZE_BYTES} bytes)"; exit 1; }'
223+
224+
# The gencloud_ext4 variant must boot with an ext4 root - that's
225+
# the entire reason it's a separate subtype. Anything else (xfs
226+
# leak-through, mismount) means the build pipeline produced the
227+
# wrong image.
228+
if [ "${SUBTYPE}" = "gencloud_ext4" ]; then
229+
echo "[Debug] Root filesystem type (expecting ext4 for gencloud_ext4):"
230+
ROOT_FSTYPE=$("${SSH[@]}" "findmnt -no FSTYPE /")
231+
echo "${ROOT_FSTYPE}"
232+
if [ "${ROOT_FSTYPE}" != "ext4" ]; then
233+
echo "[Error] Root filesystem is '${ROOT_FSTYPE}', expected 'ext4' for the gencloud_ext4 variant"
234+
exit 1
235+
fi
236+
fi
237+
238+
echo "[Debug] Check for updates:"
239+
# dnf check-update returns 100 when updates are available - treat as success
240+
rc=0
241+
"${SSH[@]}" "sudo dnf check-update" || rc=$?
242+
if [ "${rc}" -ne 0 ] && [ "${rc}" -ne 100 ]; then
243+
echo "[Error] dnf check-update failed with exit code ${rc}"
244+
exit "${rc}"
245+
fi
246+
247+
PKG_FILE="${IMAGE_FILENAME}.txt"
248+
"${SSH[@]}" "rpm -qa --queryformat '%{NAME}\n' | sort > /tmp/${PKG_FILE}"
249+
scp -i ~/.ssh/gencloud_test -P 2222 -o StrictHostKeyChecking=accept-new \
250+
"almalinux@127.0.0.1:/tmp/${PKG_FILE}" "./${PKG_FILE}"
251+
252+
{
253+
echo "PKG_FILE=${PKG_FILE}"
254+
echo "ALMA_RELEASE=${ALMA_RELEASE}"
255+
echo "SYSTEM_ARCH=${SYSTEM_ARCH}"
256+
} >> "$GITHUB_ENV"
257+
258+
- name: Upload packages list artifact
259+
if: env.PKG_FILE != ''
260+
uses: actions/upload-artifact@v7
261+
with:
262+
name: ${{ env.PKG_FILE }}
263+
path: ./${{ env.PKG_FILE }}
264+
265+
- name: Show guest console log on failure
266+
if: failure()
267+
shell: bash
268+
run: |
269+
# Show guest console log on failure
270+
echo "===== guest console.log ====="
271+
cat console.log || true
272+
273+
- name: Job summary
274+
if: always()
275+
shell: bash
276+
env:
277+
IMAGE_FILENAME: ${{ inputs.image_filename }}
278+
IMAGE_URL: ${{ inputs.image_url }}
279+
SUBTYPE: ${{ inputs.subtype }}
280+
JOB_STATUS: ${{ job.status }}
281+
run: |
282+
# Job summary
283+
{
284+
echo "## GenericCloud Image Test"
285+
echo ""
286+
echo "- **Image**: [${IMAGE_FILENAME}](${IMAGE_URL})"
287+
if [ "${SUBTYPE}" = "gencloud_ext4" ]; then
288+
echo "- **Subtype**: \`gencloud_ext4\` (ext4 root)"
289+
fi
290+
if [ -n "${ALMA_RELEASE:-}" ]; then
291+
echo "- **AlmaLinux release**: \`${ALMA_RELEASE}\`"
292+
fi
293+
if [ -n "${SYSTEM_ARCH:-}" ]; then
294+
echo "- **System architecture**: \`${SYSTEM_ARCH}\`"
295+
fi
296+
if [ "${JOB_STATUS}" = "success" ]; then
297+
echo "- **Test**: passed ✅"
298+
else
299+
echo "- **Test**: failed ❌"
300+
fi
301+
} >> "$GITHUB_STEP_SUMMARY"
302+
303+
- name: Shut down guest
304+
if: always()
305+
shell: bash
306+
run: |
307+
# Shut down guest
308+
if [ -f qemu.pid ]; then
309+
PID=$(cat qemu.pid)
310+
if kill -0 "${PID}" 2>/dev/null; then
311+
kill "${PID}" 2>/dev/null || true
312+
for _ in $(seq 1 15); do
313+
kill -0 "${PID}" 2>/dev/null || break
314+
sleep 1
315+
done
316+
kill -0 "${PID}" 2>/dev/null && kill -9 "${PID}" || true
317+
fi
318+
fi
319+
320+
- name: Send notification to Mattermost
321+
uses: mattermost/action-mattermost-notify@master
322+
if: always() && inputs.notify_mattermost == 'true' && inputs.MATTERMOST_WEBHOOK_URL != ''
323+
with:
324+
MATTERMOST_WEBHOOK_URL: ${{ inputs.MATTERMOST_WEBHOOK_URL }}
325+
MATTERMOST_CHANNEL: ${{ inputs.MATTERMOST_CHANNEL }}
326+
MATTERMOST_USERNAME: ${{ github.triggering_actor }}
327+
TEXT: |
328+
:almalinux: **${{ inputs.image_filename }}**, GenericCloud image test, by the GitHub [Action](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
329+
330+
**Image**: [${{ inputs.image_filename }}](${{ inputs.image_url }})
331+
${{ inputs.subtype == 'gencloud_ext4' && '**Subtype**: `gencloud_ext4` (ext4 root)' || '' }}
332+
${{ env.ALMA_RELEASE && format('**AlmaLinux release**: `{0}`', env.ALMA_RELEASE) || '' }}
333+
${{ env.SYSTEM_ARCH && format('**System architecture**: `{0}`', env.SYSTEM_ARCH) || '' }}
334+
**Test**: ${{ job.status == 'success' && 'passed ✅' || 'failed ❌' }}

0 commit comments

Comments
 (0)