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