Skip to content

feat: libvirt/KVM infrastructure backend as an alternative to DigitalOcean - #23

Merged
richardcase merged 9 commits into
mainfrom
qemu_support
Oct 3, 2026
Merged

richardcase merged 9 commits into
mainfrom
qemu_support

Conversation

@richardcase

Copy link
Copy Markdown
Member

What

Adds a second infrastructure backend so both e2e suites can run against local KVM virtual machines (system libvirt) instead of DigitalOcean droplets. Select it with INFRA_BACKEND=libvirt. DigitalOcean stays the default.

The aim is cheap, frequent runs on a bare-metal self-hosted GitHub runner, and a local dev loop on a workstation with /dev/kvm. A full brigade run takes about 4 minutes locally.

How

  • Backend seam. New infra/types.py holds neutral Node/Infra types (Droplet is renamed Node, .droplets becomes .nodes). infra/backend.py dispatches on INFRA_BACKEND and owns the provision / collect-logs / teardown lifecycle that was duplicated in the two conftests.
  • libvirt provisioner (infra/libvirt.py), driven by virsh and virt-install, no new Python dependencies:
    • a per-run NAT network on the first free 10.210.N.0/24, with a fixed DHCP lease per VM;
    • qcow2 overlays on a pinned, SHA256-verified Ubuntu 22.04 cloud image, cached in a dedicated lm-acceptance storage pool;
    • the existing cloud_init.yaml.j2 delivered as a NoCloud seed, plus a libvirt-only block enabling root SSH;
    • a serial console log per VM, saved to artifacts/<run_id>/;
    • teardown by name, and make clean-libvirt to sweep leftovers (base images are kept).
  • Parent interface is now per node: eth1 on DO as before; on single-NIC libvirt hosts the host script uses the default-route interface. The unused DISK template variable is removed.
  • Config. INFRA_BACKEND and LIBVIRT_* knobs (see .env.example). DO_API_TOKEN is only required for the DigitalOcean backend.
  • CI. New e2e-libvirt.yml, workflow_dispatch only, on [self-hosted, linux, x64, kvm], with a suite input (brigade / battery / both) and its own concurrency group.
  • Docs. docs/libvirt-backend.md covers prerequisites for Ubuntu and Arch, nested virtualization, and the host-firewall caveat below.

Things worth knowing

  • With ufw active and libvirt on its nftables firewall backend, the VMs get no DHCP lease. Provisioning detects the missing lease within two minutes and says to set firewall_backend = "iptables". Docker's FORWARD DROP policy can similarly break VM egress, so keep Docker off the runner.
  • A failed or interrupted libvirt provision cleans up after itself (after saving console logs) unless KEEP_INFRA_ON_FAILURE is set.
  • A base image is only trusted once its upload has completed, so an interrupted upload is replaced on the next run.
  • On libvirt, RUN_ID must be letters, digits, . and -, and must not start with base.

Testing

  • Offline: 94 tests pass (tests/test_libvirt.py drives the provisioner through a stateful fake of virsh/virt-install), with and without INFRA_BACKEND=libvirt in the environment. make lint is clean.
  • Brigade e2e on libvirt (Arch workstation, libvirt 12.7, flintlock v0.15.2, brigade v0.2.0): passed three times, most recently 99 passed in 3m49s, with nothing left behind afterwards.
  • Battery e2e on libvirt: provisioning, bootstrap and poolmgrd came up; the run was stopped after the first test (xfail). The pool VM reached CREATED and then failed battery's guest-agent readiness check, which is the gap already described in docs/battery-known-gaps.md.
  • Not run: the DigitalOcean suite. The rendered DO host script and cloud-init are unchanged apart from the removed DISK= line, and a DO regression run is planned after merge.
  • Not run: the e2e-libvirt workflow itself, since the self-hosted runner does not exist yet.

Copilot AI balanced review requested due to automatic review settings October 3, 2026 13:06
@richardcase
richardcase merged commit 28cb881 into main Oct 3, 2026
1 check passed
@richardcase
richardcase deleted the qemu_support branch October 3, 2026 13:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Inactive-pool cleanup, YAML-safe SSH-key rendering, and subnet validation need correction.

Review effort: Balanced
Findings: 3 Medium severity

Open (3)
What changed in this PR

Adds a backend-neutral infrastructure layer and local libvirt/KVM provisioning while retaining DigitalOcean as the default.

Changes:

  • Introduces libvirt provisioning, cleanup, diagnostics, configuration, and CI.
  • Refactors shared infrastructure concepts from droplets to nodes.
  • Adds offline tests and operator documentation.
File Description
.env.example Documents backend and libvirt settings.
.github/​workflows/​e2e-libvirt.yml Adds self-hosted libvirt E2E workflow.
AGENTS.md Documents libvirt development commands.
Makefile Adds clean-libvirt.
README.md Introduces local KVM usage.
docs/​libvirt-backend.md Documents setup, operation, and cleanup.
pyproject.toml Generalizes the E2E marker.
liquidmetal_at/​config.py Adds backend selection and libvirt configuration.
liquidmetal_at/​logs.py Uses backend-neutral nodes.
liquidmetal_at/​infra/​types.py Defines shared infrastructure types.
liquidmetal_at/​infra/​backend.py Centralizes backend lifecycle dispatch.
liquidmetal_at/​infra/​libvirt.py Implements libvirt provisioning and teardown.
liquidmetal_at/​infra/​do.py Adapts DigitalOcean to shared types.
liquidmetal_at/​bootstrap/​cloudinit.py Enables libvirt root SSH configuration.
liquidmetal_at/​bootstrap/​host.py Bootstraps backend-neutral nodes.
liquidmetal_at/​bootstrap/​brigade_cluster.py Uses shared infrastructure types.
liquidmetal_at/​bootstrap/​templates/​cloud_init.yaml.j2 Adds libvirt root SSH settings.
liquidmetal_at/​bootstrap/​templates/​provision_host.sh.j2 Supports automatic parent-interface selection.
tests/​conftest.py Uses the shared backend lifecycle.
tests/​battery/​conftest.py Migrates battery fixtures to shared lifecycle.
tests/​battery/​test_claim_release.py Uses node-based host counts.
tests/​battery/​test_events.py Uses node-based host counts.
tests/​battery/​test_lease_expiry.py Uses node-based host counts.
tests/​battery/​test_pool_lifecycle.py Uses node-based host counts.
tests/​test_backend.py Tests backend dispatch and lifecycle.
tests/​test_cleanup.py Updates template rendering inputs.
tests/​test_config.py Tests libvirt configuration.
tests/​test_guest_agent_vsock.py Migrates host access to nodes.
tests/​test_infra_types.py Tests shared types and rendering.
tests/​test_libvirt.py Exercises libvirt behavior offline.
tests/​test_placement.py Migrates placement checks to nodes.
tests/​test_quorum.py Migrates quorum checks to nodes.
tests/​test_ssh_reachability.py Migrates SSH checks to nodes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

users:
- name: root
ssh_authorized_keys:
- {{ root_ssh_key }}
Comment thread liquidmetal_at/config.py
Comment on lines +234 to +238
if not _SUBNET_PREFIX.match(subnet_prefix):
raise ConfigError(
f"LIBVIRT_SUBNET_PREFIX={subnet_prefix!r} invalid; give the first two "
"octets, e.g. 10.210"
)
Comment on lines +433 to +437
if pool in _virsh(uri, run, "pool-list", "--name").split():
_virsh(uri, run, "pool-refresh", pool) # console logs are created by QEMU, not virsh
for vol in _volumes(uri, pool, run):
if match(vol) and not vol.startswith(BASE_PREFIX):
remove("vol-delete", "--pool", pool, vol)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants