Skip to content

Commit dcc6d4e

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 dcc6d4e

3 files changed

Lines changed: 693 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)