Skip to content

Commit 6bd4dd6

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 6bd4dd6

4 files changed

Lines changed: 725 additions & 0 deletions

File tree

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
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+
RELEASE_PACKAGE="almalinux-release"
195+
196+
echo "[Debug] AlmaLinux release:"
197+
ALMA_RELEASE=$("${SSH[@]}" "grep '${RELEASE_STRING}' /etc/almalinux-release")
198+
echo "${ALMA_RELEASE}"
199+
200+
# On v2 microarch builds, almalinux-release reports %{ARCH} as
201+
# 'x86_64_v2' rather than 'x86_64', so the assertion has to grep
202+
# for the full arch including the suffix.
203+
echo "[Debug] System architecture:"
204+
SYSTEM_ARCH=$("${SSH[@]}" "rpm -q --qf='%{ARCH}\n' ${RELEASE_PACKAGE} | grep '${ALMA_ARCH_FULL}'")
205+
echo "${SYSTEM_ARCH}"
206+
207+
echo "[Debug] Disk and filesystems:"
208+
"${SSH[@]}" "sudo lsblk"
209+
# Root-FS size threshold is 95 GiB on a 100 GiB overlay: the cloud
210+
# image's UEFI partition layout (1M BIOS-boot + 200M /boot/efi +
211+
# 1G /boot) eats ~1.2 GiB before the root partition, and ext4/xfs
212+
# metadata trims another ~1.5 GiB off the usable FS size, so the
213+
# observed ceiling on a fully-grown root is ~97 GiB. Anything
214+
# under ~10 GiB means cloud-init growpart didn't run.
215+
"${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; }'
216+
217+
# The gencloud_ext4 variant must boot with an ext4 root - that's
218+
# the entire reason it's a separate subtype. Anything else (xfs
219+
# leak-through, mismount) means the build pipeline produced the
220+
# wrong image.
221+
if [ "${SUBTYPE}" = "gencloud_ext4" ]; then
222+
echo "[Debug] Root filesystem type (expecting ext4 for gencloud_ext4):"
223+
ROOT_FSTYPE=$("${SSH[@]}" "findmnt -no FSTYPE /")
224+
echo "${ROOT_FSTYPE}"
225+
if [ "${ROOT_FSTYPE}" != "ext4" ]; then
226+
echo "[Error] Root filesystem is '${ROOT_FSTYPE}', expected 'ext4' for the gencloud_ext4 variant"
227+
exit 1
228+
fi
229+
fi
230+
231+
echo "[Debug] Check for updates:"
232+
# dnf check-update returns 100 when updates are available - treat as success
233+
rc=0
234+
"${SSH[@]}" "sudo dnf check-update" || rc=$?
235+
if [ "${rc}" -ne 0 ] && [ "${rc}" -ne 100 ]; then
236+
echo "[Error] dnf check-update failed with exit code ${rc}"
237+
exit "${rc}"
238+
fi
239+
240+
PKG_FILE="${IMAGE_FILENAME}.txt"
241+
"${SSH[@]}" "rpm -qa --queryformat '%{NAME}\n' | sort > /tmp/${PKG_FILE}"
242+
scp -i ~/.ssh/gencloud_test -P 2222 -o StrictHostKeyChecking=accept-new \
243+
"almalinux@127.0.0.1:/tmp/${PKG_FILE}" "./${PKG_FILE}"
244+
245+
{
246+
echo "PKG_FILE=${PKG_FILE}"
247+
echo "ALMA_RELEASE=${ALMA_RELEASE}"
248+
echo "SYSTEM_ARCH=${SYSTEM_ARCH}"
249+
} >> "$GITHUB_ENV"
250+
251+
- name: Upload packages list artifact
252+
if: env.PKG_FILE != ''
253+
uses: actions/upload-artifact@v7
254+
with:
255+
name: ${{ env.PKG_FILE }}
256+
path: ./${{ env.PKG_FILE }}
257+
258+
- name: Show guest console log on failure
259+
if: failure()
260+
shell: bash
261+
run: |
262+
# Show guest console log on failure
263+
echo "===== guest console.log ====="
264+
cat console.log || true
265+
266+
- name: Job summary
267+
if: always()
268+
shell: bash
269+
env:
270+
IMAGE_FILENAME: ${{ inputs.image_filename }}
271+
IMAGE_URL: ${{ inputs.image_url }}
272+
SUBTYPE: ${{ inputs.subtype }}
273+
JOB_STATUS: ${{ job.status }}
274+
run: |
275+
# Job summary
276+
{
277+
echo "## GenericCloud Image Test"
278+
echo ""
279+
echo "- **Image**: [${IMAGE_FILENAME}](${IMAGE_URL})"
280+
if [ "${SUBTYPE}" = "gencloud_ext4" ]; then
281+
echo "- **Subtype**: \`gencloud_ext4\` (ext4 root)"
282+
fi
283+
if [ -n "${ALMA_RELEASE:-}" ]; then
284+
echo "- **AlmaLinux release**: \`${ALMA_RELEASE}\`"
285+
fi
286+
if [ -n "${SYSTEM_ARCH:-}" ]; then
287+
echo "- **System architecture**: \`${SYSTEM_ARCH}\`"
288+
fi
289+
if [ "${JOB_STATUS}" = "success" ]; then
290+
echo "- **Test**: passed ✅"
291+
else
292+
echo "- **Test**: failed ❌"
293+
fi
294+
} >> "$GITHUB_STEP_SUMMARY"
295+
296+
- name: Shut down guest
297+
if: always()
298+
shell: bash
299+
run: |
300+
# Shut down guest
301+
if [ -f qemu.pid ]; then
302+
PID=$(cat qemu.pid)
303+
if kill -0 "${PID}" 2>/dev/null; then
304+
kill "${PID}" 2>/dev/null || true
305+
for _ in $(seq 1 15); do
306+
kill -0 "${PID}" 2>/dev/null || break
307+
sleep 1
308+
done
309+
kill -0 "${PID}" 2>/dev/null && kill -9 "${PID}" || true
310+
fi
311+
fi
312+
313+
- name: Send notification to Mattermost
314+
uses: mattermost/action-mattermost-notify@master
315+
if: always() && inputs.notify_mattermost == 'true' && inputs.MATTERMOST_WEBHOOK_URL != ''
316+
with:
317+
MATTERMOST_WEBHOOK_URL: ${{ inputs.MATTERMOST_WEBHOOK_URL }}
318+
MATTERMOST_CHANNEL: ${{ inputs.MATTERMOST_CHANNEL }}
319+
MATTERMOST_USERNAME: ${{ github.triggering_actor }}
320+
TEXT: |
321+
:almalinux: **${{ inputs.image_filename }}**, GenericCloud image test, by the GitHub [Action](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
322+
323+
**Image**: [${{ inputs.image_filename }}](${{ inputs.image_url }})
324+
${{ inputs.subtype == 'gencloud_ext4' && '**Subtype**: `gencloud_ext4` (ext4 root)' || '' }}
325+
${{ env.ALMA_RELEASE && format('**AlmaLinux release**: `{0}`', env.ALMA_RELEASE) || '' }}
326+
${{ env.SYSTEM_ARCH && format('**System architecture**: `{0}`', env.SYSTEM_ARCH) || '' }}
327+
**Test**: ${{ job.status == 'success' && 'passed ✅' || 'failed ❌' }}

0 commit comments

Comments
 (0)