From bf89893dbec4ca203b66a38dd35c49ffcd8d3a96 Mon Sep 17 00:00:00 2001 From: Mike Riddle Date: Mon, 2 Nov 2026 16:12:56 -0500 Subject: [PATCH] Add the setup-vagrant-libvirt action Install Vagrant, the vagrant-libvirt provider and libvirt on an Ubuntu GitHub Actions runner for Beaker acceptance tests, without failing the job when Launchpad is down. Ubuntu dropped vagrant and vagrant-libvirt after 23.10; ppa:evgeni/vagrant carries vagrant 2.3.7, the last MPL-licensed release, and a matching vagrant-libvirt. The action tries, in order: 1. The Actions cache: the PPA's packages, restored from the newest key for the runner's Ubuntu series and architecture, installed with no request to Launchpad. 2. The PPA, with each step retried three times, 30s and then 60s apart. Only the PPA's own index refresh uses --error-on=any, so a flaky third-party source on the runner image doesn't fail the job. 3. HashiCorp's apt repository, pinned to vagrant-version, with `vagrant plugin install vagrant-libvirt` pinned to vagrant-libvirt-version, and a warning that this is BSL-licensed Vagrant. On a series the PPA doesn't publish for (ppa-series), the action goes straight here with a notice. A pull request can only restore caches saved from its own ref, its base branch or the default branch, so `cache-only: 'true'` saves the packages from a default-branch workflow instead of installing. It caches every package the PPA would provide, unpinned, under a key derived from their file names: a new PPA build gets a new key, and restoring an existing key keeps it from being evicted. Downloads go through apt-get download, which checks them against the PPA's signed index. CI lints the action and runs it on real runners: cache-only on ubuntu-24.04 and ubuntu-latest, and installs from the cache, the PPA and HashiCorp, with Launchpad reachable and blocked. See simp/puppetsync#122. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 111 ++++++++++++++++++++++++ CHANGELOG.md | 15 ++++ README.md | 90 +++++++++++++++++++- action.yml | 126 +++++++++++++++++++++++++++ renovate.json | 7 ++ setup-vagrant-libvirt.bash | 169 +++++++++++++++++++++++++++++++++++++ 6 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 action.yml create mode 100644 renovate.json create mode 100755 setup-vagrant-libvirt.bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..964153a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,111 @@ +# Lint the action, and run it on real runners in each mode +--- +name: Test + +'on': + pull_request: + types: [opened, reopened, synchronize] + push: + branches: [main] + +jobs: + lint: + name: 'Lint' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Run actionlint + run: | + bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color + - name: Run shellcheck + run: shellcheck setup-vagrant-libvirt.bash + + # cache-only first, so the install jobs that follow can restore its packages + cache: + name: 'Cache-only (${{ matrix.runner }})' + strategy: + matrix: + runner: [ubuntu-24.04, ubuntu-latest] + fail-fast: false + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - id: vagrant + uses: ./ + with: + cache-only: 'true' + - name: Check the result + env: + SOURCE: ${{ steps.vagrant.outputs.source }} + run: | + echo "source: ${SOURCE}" + . /etc/os-release + if [ "$VERSION_CODENAME" = 'noble' ]; then + [[ "$SOURCE" =~ ^(cached|saved)$ ]] + else + [ "$SOURCE" = 'none' ] + fi + ! command -v vagrant + + # `noble` is the source expected where the runner is on noble, which the + # PPA publishes for; on any other series it is always hashicorp + install: + name: 'Install (${{ matrix.runner }}, cache ${{ matrix.cache }}, Launchpad ${{ matrix.launchpad }})' + needs: cache + strategy: + matrix: + include: + - {runner: ubuntu-24.04, cache: 'true', launchpad: reachable, noble: cache} + - {runner: ubuntu-24.04, cache: 'true', launchpad: blocked, noble: cache} + - {runner: ubuntu-24.04, cache: 'false', launchpad: reachable, noble: ppa} + - {runner: ubuntu-24.04, cache: 'false', launchpad: blocked, noble: hashicorp} + - {runner: ubuntu-latest, cache: 'true', launchpad: reachable, noble: cache} + fail-fast: false + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - name: Block Launchpad + if: matrix.launchpad == 'blocked' + run: | + for host in ppa.launchpadcontent.net ppa.launchpad.net api.launchpad.net launchpad.net keyserver.ubuntu.com; do + echo "0.0.0.0 ${host}" | sudo tee -a /etc/hosts + done + - id: vagrant + uses: ./ + with: + cache: ${{ matrix.cache }} + - name: Check the result + env: + SOURCE: ${{ steps.vagrant.outputs.source }} + NOBLE: ${{ matrix.noble }} + run: | + . /etc/os-release + expected=hashicorp + if [ "$VERSION_CODENAME" = 'noble' ]; then + expected=$NOBLE + fi + echo "source: ${SOURCE} (expected ${expected})" + [ "$SOURCE" = "$expected" ] + vagrant --version + vagrant plugin list | grep vagrant-libvirt + virsh --connect qemu:///system list --all + + # The one check for branch protection to require: GitHub only blocks a + # merge on checks listed by name, and this job fails unless every job it + # needs succeeded. Add any new job in this workflow to `needs:`. + all-checks: + name: 'All checks' + if: always() + needs: + - lint + - cache + - install + runs-on: ubuntu-latest + steps: + - name: 'Fail unless every needed job succeeded' + env: + NEEDS: ${{ toJSON(needs) }} + run: | + jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$NEEDS" + jq -e 'all(.[]; .result == "success")' <<< "$NEEDS" > /dev/null diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..126e1c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0] + +### Added + +- Install Vagrant, vagrant-libvirt and libvirt from the Actions cache, + `ppa:evgeni/vagrant`, or HashiCorp's apt repository, in that order. +- `cache-only` mode, to save the PPA's packages from the default branch. + +[1.0.0]: https://github.com/simp/github-action-setup-vagrant-libvirt/releases/tag/v1.0.0 diff --git a/README.md b/README.md index e12ab1e..988ad70 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,90 @@ # github-action-setup-vagrant-libvirt -Install Vagrant and vagrant-libvirt on Ubuntu GitHub Actions runners, surviving Launchpad PPA outages + +Install Vagrant, the vagrant-libvirt provider and libvirt on an Ubuntu +GitHub Actions runner, for Beaker acceptance tests with +`BEAKER_HYPERVISOR=vagrant_libvirt`. + +Ubuntu stopped shipping `vagrant` and `vagrant-libvirt` after 23.10. +[`ppa:evgeni/vagrant`](https://launchpad.net/~evgeni/+archive/ubuntu/vagrant) +carries matching builds of both, with Vagrant 2.3.7, the last MPL-licensed +release. Installing from the PPA alone fails the job whenever Launchpad is +down, so this action tries, in order: + +1. **The Actions cache**: the PPA's packages, saved by a `cache-only` run on + the default branch. No request goes to Launchpad. Their dependencies come + from the Ubuntu archive. +2. **The PPA**: `add-apt-repository`, then a refresh of the PPA's index alone + and the install. Each step is tried three times, 30s and then 60s apart. +3. **HashiCorp**: `vagrant` from , pinned + to `vagrant-version`, and `vagrant plugin install vagrant-libvirt`, pinned + to `vagrant-libvirt-version` and built against `libvirt-dev`. This is + BSL-licensed Vagrant, so the job gets a warning. + +On an Ubuntu series the PPA doesn't publish for (`ppa-series`), the action +goes straight to HashiCorp with a notice. + +## Usage + +In the acceptance job: + +```yaml + - name: Setup libvirt for Vagrant + uses: simp/github-action-setup-vagrant-libvirt@v1.0.0 + - name: beaker + env: + BEAKER_HYPERVISOR: 'vagrant_libvirt' + VAGRANT_DEFAULT_PROVIDER: 'libvirt' + run: bundle exec rake beaker:suites[default,almalinux9] +``` + +GitHub only lets a pull request restore caches saved from its own ref, its +base branch or the default branch, so a cache saved by one pull request is +no use to the next. Save the packages from the default branch with a +separate workflow, using a runner on a series the PPA publishes for: + +```yaml +name: Vagrant package cache + +'on': + push: + branches: [main] + schedule: + - cron: '17 6 * * 1,4' + workflow_dispatch: {} + +jobs: + cache: + runs-on: ubuntu-24.04 + steps: + - uses: simp/github-action-setup-vagrant-libvirt@v1.0.0 + with: + cache-only: 'true' +``` + +A `cache-only` run looks up the packages the PPA currently provides and +derives the cache key from their file names. If that key is already cached, +it restores it, which also keeps GitHub from evicting it after seven days +without use. Otherwise it downloads the packages and saves them. A new PPA +build gets a new key, and installs restore the newest key for their Ubuntu +series and architecture. The run fails if the PPA can't be reached after +the retries. + +## Inputs + +| Input | Default | Description | +|---|---|---| +| `cache-only` | `'false'` | `'true'` saves the PPA's packages to the Actions cache instead of installing anything | +| `cache` | `'true'` | `'false'` ignores the Actions cache when installing | +| `ppa-series` | `'noble'` | Space-separated Ubuntu codenames the PPA publishes for | +| `vagrant-version` | `'2.4.9-1'` | HashiCorp's apt version of `vagrant`, for the fallback | +| `vagrant-libvirt-version` | `'0.12.2'` | The `vagrant-libvirt` plugin version for the fallback | + +## Outputs + +| Output | Description | +|---|---| +| `source` | Install: `cache`, `ppa` or `hashicorp`. Cache-only: `cached` (already cached), `saved`, or `none` (the PPA doesn't publish for this series) | + +## License + +[Apache-2.0](LICENSE) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..48442fc --- /dev/null +++ b/action.yml @@ -0,0 +1,126 @@ +--- +name: 'Setup Vagrant with libvirt' +description: >- + Install Vagrant, vagrant-libvirt and libvirt on an Ubuntu runner from + ppa:evgeni/vagrant, the Actions cache, or HashiCorp's apt repository, + so a Launchpad outage doesn't fail the job. +branding: + icon: 'box' + color: 'blue' + +inputs: + cache-only: + description: >- + 'true' to save the PPA's packages to the Actions cache instead of + installing anything. Run it from the default branch (push, schedule) + so pull requests can restore the cache. + required: false + default: 'false' + cache: + description: "'false' to ignore the Actions cache when installing" + required: false + default: 'true' + ppa-series: + description: >- + Space-separated Ubuntu codenames that ppa:evgeni/vagrant publishes + for. On any other series the action goes straight to HashiCorp. + required: false + default: 'noble' + vagrant-version: + description: "HashiCorp's apt version of vagrant, used when the PPA can't be" + required: false + default: '2.4.9-1' + vagrant-libvirt-version: + description: 'The vagrant-libvirt plugin version installed with HashiCorp vagrant' + required: false + default: '0.12.2' + +outputs: + source: + description: >- + Where Vagrant came from: 'cache', 'ppa' or 'hashicorp' (install), or + 'cached', 'saved' or 'none' (cache-only) + value: ${{ steps.install.outputs.source || steps.result.outputs.source }} + +runs: + using: composite + steps: + - name: Check the Ubuntu series + id: series + shell: bash + env: + PPA_SERIES: ${{ inputs.ppa-series }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" series' + + # Install: restore the newest cached package set, if any + - name: Restore the cached PPA packages + if: >- + inputs.cache-only != 'true' && inputs.cache != 'false' && + steps.series.outputs.ppa == 'true' + uses: actions/cache/restore@v6 + with: + path: ${{ steps.series.outputs.dir }} + key: ${{ steps.series.outputs.prefix }} + restore-keys: ${{ steps.series.outputs.prefix }} + + - name: Install Vagrant and libvirt + id: install + if: inputs.cache-only != 'true' + shell: bash + env: + DEBS: ${{ steps.series.outputs.dir }} + PPA: ${{ steps.series.outputs.ppa }} + CODENAME: ${{ steps.series.outputs.codename }} + VAGRANT_VERSION: ${{ inputs.vagrant-version }} + VAGRANT_LIBVIRT_VERSION: ${{ inputs.vagrant-libvirt-version }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" install' + + # Cache-only: name the PPA's current packages, then save them once + - name: Resolve the PPA packages + id: resolve + if: inputs.cache-only == 'true' && steps.series.outputs.ppa == 'true' + shell: bash + env: + PREFIX: ${{ steps.series.outputs.prefix }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" resolve' + + # A restore of an existing key also keeps it from being evicted + - name: Check the cache + id: cached + if: steps.resolve.outcome == 'success' + uses: actions/cache/restore@v6 + with: + path: ${{ steps.series.outputs.dir }} + key: ${{ steps.resolve.outputs.key }} + + - name: Download the PPA packages + if: steps.resolve.outcome == 'success' && steps.cached.outputs.cache-hit != 'true' + shell: bash + env: + DEBS: ${{ steps.series.outputs.dir }} + run: '"$GITHUB_ACTION_PATH/setup-vagrant-libvirt.bash" download' + + - name: Save the PPA packages + if: steps.resolve.outcome == 'success' && steps.cached.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ${{ steps.series.outputs.dir }} + key: ${{ steps.resolve.outputs.key }} + + - name: Report the cache result + id: result + if: inputs.cache-only == 'true' + shell: bash + env: + PPA: ${{ steps.series.outputs.ppa }} + CODENAME: ${{ steps.series.outputs.codename }} + HIT: ${{ steps.cached.outputs.cache-hit }} + run: | + if [ "$PPA" != 'true' ]; then + echo "::notice::ppa:evgeni/vagrant has no packages for Ubuntu ${CODENAME}; nothing to cache" + echo 'source=none' >> "$GITHUB_OUTPUT" + elif [ "$HIT" = 'true' ]; then + echo 'source=cached' >> "$GITHUB_OUTPUT" + else + echo 'source=saved' >> "$GITHUB_OUTPUT" + fi diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..25c6603 --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "github>simp/renovate-config:ruby.json" + ] +} diff --git a/setup-vagrant-libvirt.bash b/setup-vagrant-libvirt.bash new file mode 100755 index 0000000..13a8808 --- /dev/null +++ b/setup-vagrant-libvirt.bash @@ -0,0 +1,169 @@ +#!/bin/bash +# +# The steps of action.yml. Usage: setup-vagrant-libvirt.bash COMMAND +# +# series report the Ubuntu codename, whether the PPA publishes for it, +# and the cache directory and key prefix +# install install Vagrant, vagrant-libvirt and libvirt from the cached +# PPA packages, else the PPA, else HashiCorp and rubygems.org +# resolve add the PPA and name the packages it would install, with a +# cache key derived from their file names +# download download the resolved packages into the cache directory +# (run in the same job as resolve, which adds the PPA) +# +# ppa:evgeni/vagrant has vagrant 2.3.7, the last MPL-licensed release, and +# a matching vagrant-libvirt. Ubuntu dropped both after 23.10. + +set -euo pipefail + +PPA_NAME='ppa:evgeni/vagrant' +PPA_URL='https://ppa.launchpadcontent.net/evgeni/vagrant/' +PPA_LIST="${RUNNER_TEMP:-/tmp}/vagrant-ppa-debs.list" +LIBVIRT_PACKAGES=(libvirt-daemon-system libvirt-daemon qemu-system-x86 qemu-utils dnsmasq) + +# Up to three attempts, 30s and then 60s apart +retry() { + local delay + for delay in 30 60 0; do + "$@" && return 0 + if [ "$delay" -eq 0 ]; then + return 1 + fi + echo "::notice::Retrying in ${delay}s: $*" + sleep "$delay" + done +} + +ppa_sources() { + compgen -G '/etc/apt/sources.list.d/evgeni-ubuntu-vagrant-*' || true +} + +# Refresh the PPA's index alone, failing on any error. A plain +# `apt-get update` only warns when a source is unreachable. +update_ppa() { + local sources + sources=$(ppa_sources) + [ -n "$sources" ] || return 1 + sudo apt-get update --error-on=any \ + -o Dir::Etc::sourcelist="$sources" \ + -o Dir::Etc::sourceparts=- \ + -o APT::Get::List-Cleanup=0 +} + +add_ppa() { + retry sudo add-apt-repository -y -n "$PPA_NAME" && retry update_ppa +} + +# Removing the PPA with add-apt-repository can itself need Launchpad +remove_ppa() { + sudo rm -f /etc/apt/sources.list.d/evgeni-ubuntu-vagrant-* +} + +series() { + local ppa=false arch s + # shellcheck source=/dev/null + . /etc/os-release + arch=$(dpkg --print-architecture) + for s in ${PPA_SERIES:-}; do + if [ "$s" = "$VERSION_CODENAME" ]; then + ppa=true + fi + done + { + echo "codename=${VERSION_CODENAME}" + echo "ppa=${ppa}" + echo "dir=${RUNNER_TEMP}/vagrant-ppa-debs" + echo "prefix=vagrant-ppa-debs-${VERSION_CODENAME}-${arch}-" + } >> "$GITHUB_OUTPUT" +} + +from_cache() { + if ! compgen -G "${DEBS}/*.deb" > /dev/null; then + return 1 + fi + echo "Installing the cached PPA packages: $(cd "$DEBS" && echo *.deb)" + if retry sudo apt-get update && + retry sudo apt-get install -y --no-install-recommends "$DEBS"/*.deb "${LIBVIRT_PACKAGES[@]}"; then + return 0 + fi + echo '::warning::Installing the cached PPA packages failed; trying the PPA' + return 1 +} + +from_ppa() { + if [ "${PPA:-}" != 'true' ]; then + return 1 + fi + retry sudo apt-get update || true + if add_ppa && retry sudo apt-get install -y --no-install-recommends vagrant vagrant-libvirt "${LIBVIRT_PACKAGES[@]}"; then + return 0 + fi + remove_ppa + return 1 +} + +from_hashicorp() { + local fallback="BSL-licensed Vagrant ${VAGRANT_VERSION} from HashiCorp and vagrant-libvirt ${VAGRANT_LIBVIRT_VERSION} from rubygems.org" + if [ "${PPA:-}" = 'true' ]; then + echo "::warning::ppa:evgeni/vagrant is unavailable, so this job uses ${fallback}" + else + echo "::notice::ppa:evgeni/vagrant has no packages for Ubuntu ${CODENAME}, so this job uses ${fallback}" + fi + retry curl -fsSL -o "${RUNNER_TEMP}/hashicorp.asc" https://apt.releases.hashicorp.com/gpg + sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg "${RUNNER_TEMP}/hashicorp.asc" + echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com ${CODENAME} main" | + sudo tee /etc/apt/sources.list.d/hashicorp.list + retry sudo apt-get update + retry sudo apt-get install -y --no-install-recommends "vagrant=${VAGRANT_VERSION}" \ + libvirt-dev build-essential pkg-config "${LIBVIRT_PACKAGES[@]}" + retry vagrant plugin install vagrant-libvirt --plugin-version "$VAGRANT_LIBVIRT_VERSION" +} + +install() { + local source + if from_cache; then + source=cache + elif from_ppa; then + source=ppa + else + from_hashicorp + source=hashicorp + fi + sudo chmod 666 /var/run/libvirt/libvirt-sock + echo "Installed $(vagrant --version) (source: ${source})" + echo "source=${source}" >> "$GITHUB_OUTPUT" +} + +# The PPA's packages that `apt-get install vagrant vagrant-libvirt` would +# fetch, as apt's "'URL' FILE SIZE HASH" lines. Archive packages are left +# out: the install step gets those from the archive either way. +resolve() { + local key + retry sudo apt-get update || true + add_ppa + apt-get install --print-uris -qq vagrant vagrant-libvirt | grep -F "'${PPA_URL}" > "$PPA_LIST" || true + if [ ! -s "$PPA_LIST" ]; then + echo '::error::ppa:evgeni/vagrant provides none of the packages for vagrant and vagrant-libvirt' + return 1 + fi + cat "$PPA_LIST" + key="${PREFIX}$(awk '{ print $2 }' "$PPA_LIST" | sort | sha256sum | cut -c1-16)" + echo "key=${key}" >> "$GITHUB_OUTPUT" +} + +# apt-get download checks each file against the PPA's signed index +download() { + local packages + mapfile -t packages < <(awk '{ split($2, file, "_"); print file[1] }' "$PPA_LIST") + mkdir -p "$DEBS" + (cd "$DEBS" && retry apt-get download "${packages[@]}") + ls -l "$DEBS" +} + +case "${1:-}" in + series | install | resolve | download) "$1" ;; + *) + echo "Usage: $0 series|install|resolve|download" >&2 + exit 2 + ;; +esac