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