Skip to content

Commit fa4cfb6

Browse files
committed
Merge branch 'libvirt-image-sync'
2 parents 461f7fa + c73b728 commit fa4cfb6

16 files changed

Lines changed: 992 additions & 29 deletions

config.libvirt.example.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,22 @@ shrink_ticks = 3
1717
[backend]
1818
name = "libvirt-gpu"
1919
type = "libvirt"
20-
image_name = "husk-gpu-golden.qcow2" # golden qcow2 filename in each host's pool dir
2120
min_ready = 2 # global warm-pool size across all hosts
2221
max_total = 2 # one GPU host, one GPU → 1 (raise as the pool grows)
2322

23+
# Image source — pick ONE:
24+
# (a) image_ref: an OCI artifact built by build-images.yml and published to
25+
# ghcr. The controller `oras pull`s it once to a local cache and scp's it to
26+
# every host pool (named by content digest). Pin a tag or a digest. Editing
27+
# this while huskd runs hot-reloads: the new image is staged and idle slots
28+
# drain onto it (running jobs finish first), old goldens are GC'd.
29+
image_ref = "ghcr.io/acts-project/husk-gpu:v1"
30+
# image_cache_dir = "/var/cache/husk/images" # optional; default ~/.cache/husk/images
31+
#
32+
# (b) image_name: a qcow2 filename you placed in each host's pool dir yourself
33+
# (the manual path — no registry, no auto-sync/drain).
34+
# image_name = "husk-gpu-golden.qcow2"
35+
2436
# One entry per VM-host. GPU host: list gpu_pci_addresses (one slot per GPU).
2537
# CPU host: omit them and set max_slots (default 1). Setting both is rejected.
2638
[[backend.hosts]]

image-pipeline.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ How husk builds, distributes, and rolls out the golden VM images that back its
44
runner slots. Companion to `plan.md` (which deliberately deferred custom images
55
in "Phase 4"); this document un-defers that work with a concrete design.
66

7-
> **Status (2026-06-16):** design agreed, not yet built. The runner/podman stack
8-
> is currently installed at *cloud-init time* as a spike (`src/husk/cloudinit.py`)
9-
> and the only image-build artifact today is the manual, single-host
10-
> `scripts/build-golden-image.sh`. This document describes the target system.
7+
> **Status (2026-06-16):** Phase A (CI build → ghcr via ORAS) merged. **Phases B
8+
> + C are now built for the libvirt backend** (`src/husk/image_sync.py` +
9+
> `LibvirtBackend.sync_images`): the controller pulls the config-pinned
10+
> `image_ref` (via the pure-Python `oras` client — no CLI dep) to a local cache,
11+
> scp's it to each host pool by digest, stamps the
12+
> digest into slot metadata, and drains slots onto a new ref on config
13+
> hot-reload, GC'ing orphaned goldens. **OpenStack/Glance delivery is still
14+
> deferred** (OpenStack remains the CPU path; the GPU image ships only to libvirt
15+
> hosts). The runner/podman stack is still installed at cloud-init time for slots
16+
> that boot a stock base; a baked golden is the boot-speed optimization.
1117
1218
-----
1319

@@ -149,13 +155,18 @@ live per-slot overlay — overwriting it in place corrupts running slots. So:
149155

150156
## Phasing
151157

152-
- **Phase A — CI image build (this is next; see implementation plan).** Single
153-
spec, two variants, built in GitHub CI, pushed to ghcr via ORAS. Output:
154-
pullable `husk-base` + `husk-gpu` artifacts. *Does not touch huskd.*
155-
- **Phase B — delivery & sync.** `image_sync.py`, config ref change, oras pull
156-
on the controller, libvirt scp + Glance upload.
157-
- **Phase C — versioned rollout / drain.** Digest stamping + drain-on-change +
158-
GC.
158+
- **Phase A — CI image build. ✅ DONE.** Single spec, two variants, built in
159+
GitHub CI, pushed to ghcr via ORAS. Output: pullable `husk-base` + `husk-gpu`
160+
artifacts (`build-images.yml`). *Does not touch huskd.*
161+
- **Phase B — delivery & sync (libvirt). ✅ DONE.** `image_sync.py` (`oras
162+
resolve`+`pull` to a controller cache, content-addressed), `LibvirtBackend.
163+
sync_images` scp's the golden to each host pool by digest (idempotent, atomic),
164+
driven by the config `image_ref`. *Glance upload still deferred.*
165+
- **Phase C — versioned rollout / drain (libvirt). ✅ DONE.** Digest stamped into
166+
domain metadata; `Slot.image_stale` set when it diverges from the host's current
167+
image; the controller drains stale idle slots (rebuild adopts the new golden)
168+
and `_gc_goldens` removes unreferenced backing files. `image_ref` is
169+
hot-reloadable (per-host overrides remain restart-only).
159170

160171
Each phase is independently useful: A produces artifacts you can place by hand
161172
(exactly the manual step today, just reproducible); B automates delivery against

images/build.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ ARGS=(
110110
# enabled — cloud-init starts it each cycle after laying the JIT config.
111111
--run-command 'systemctl --global enable podman.socket'
112112
--run-command 'mkdir -p /var/lib/systemd/linger && touch /var/lib/systemd/linger/runner'
113+
114+
# SELinux: the CI build host is non-SELinux Ubuntu, which can't reliably relabel
115+
# an EL guest offline — virt-customize's --selinux-relabel (below) only defers by
116+
# touching /.autorelabel, and a first boot under *enforcing* then deadlocks:
117+
# nothing is labeled, so nothing — including the autorelabel service itself —
118+
# can exec, and every unit dies with status=127 (a wedged VM, no serial output).
119+
# Boot permissive so exec is never blocked; the deferred autorelabel still runs
120+
# and completes. Safe for husk: runners are ephemeral + firewall-isolated, and
121+
# cloud-init already sets containers.conf label=false, so guest SELinux is not
122+
# load-bearing here.
123+
--run-command "sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config"
113124
)
114125

115126
# --------------------------------------------------------------- gpu additions

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ dependencies = [
1414
[project.optional-dependencies]
1515
dev = ["pytest", "ruff>=0.15"]
1616
# libvirt backend — kept optional so the OpenStack/CI path needs no libvirt headers.
17-
libvirt = ["libvirt-python>=10"]
17+
# `oras` is the pure-Python OCI client used to pull golden images (no oras CLI dep).
18+
libvirt = ["libvirt-python>=10", "oras>=0.2"]
1819

1920
[project.scripts]
2021
huskd = "husk.cli:huskd"

src/husk/config.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,22 @@ class HostConfig:
6666
gpu_pci_addresses: tuple[str, ...] = ()
6767
max_slots: int | None = None # CPU host capacity; None → 1 (and GPU forbids it)
6868
image_name: str | None = None # per-host override of the backend golden image
69+
image_ref: str | None = None # per-host override of the backend OCI image ref
6970

7071

7172
@dataclass(frozen=True)
7273
class BackendConfig:
7374
name: str
7475
type: str
75-
image_name: str # OpenStack image name OR libvirt golden qcow2 (shared field)
7676
min_ready: int
7777
max_total: int
78+
# Image source. OpenStack uses `image_name` (a Glance image name). libvirt
79+
# uses `image_ref` (an OCI artifact ref, e.g. ghcr.io/org/husk-gpu:v1 — synced
80+
# to each host by the controller) when set, else `image_name` as a literal
81+
# qcow2 filename already present in the host pool (the manual/local path).
82+
image_name: str = ""
83+
image_ref: str = "" # libvirt: OCI ref pulled+staged by the controller
84+
image_cache_dir: str = "" # controller-local oras pull cache ("" → default)
7885
# OpenStack-only (optional / unused for the libvirt backend)
7986
cloud: str = ""
8087
flavor_name: str = ""
@@ -153,11 +160,14 @@ class _Host(BaseModel):
153160
gpu_pci_addresses: list[str] = []
154161
max_slots: int | None = None
155162
image_name: str | None = None
163+
image_ref: str | None = None
156164

157165
class _Backend(BaseModel):
158166
name: str
159167
type: str = "openstack"
160-
image_name: str
168+
image_name: str = ""
169+
image_ref: str = ""
170+
image_cache_dir: str = ""
161171
min_ready: int = 1
162172
max_total: int = 2
163173
# OpenStack-only (optional for the libvirt backend)
@@ -244,6 +254,8 @@ def settings_customise_sources(
244254
type=s.backend.type,
245255
cloud=s.backend.cloud,
246256
image_name=s.backend.image_name,
257+
image_ref=s.backend.image_ref,
258+
image_cache_dir=s.backend.image_cache_dir,
247259
flavor_name=s.backend.flavor_name,
248260
network_name=s.backend.network_name,
249261
keypair=s.backend.keypair,
@@ -262,6 +274,7 @@ def settings_customise_sources(
262274
gpu_pci_addresses=tuple(h.gpu_pci_addresses),
263275
max_slots=h.max_slots,
264276
image_name=h.image_name,
277+
image_ref=h.image_ref,
265278
)
266279
for h in s.backend.hosts
267280
),

src/husk/controller.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@
3737
# the backend/github/server objects and needs a restart — apply_reloaded_config
3838
# warns about and ignores changes to those rather than half-applying them.
3939
_HOT_RELOAD: dict[str, tuple[str, ...]] = {
40-
"backend": ("min_ready", "max_total"),
40+
# image_ref is hot: a new ref is picked up by sync_images on the next tick,
41+
# which stages the new golden and drains slots onto it (see image-pipeline.md
42+
# Phase C). Per-host image_ref overrides are still restart-only.
43+
"backend": ("min_ready", "max_total", "image_ref"),
4144
"controller": ("shrink_ticks",),
4245
"timeouts": (
4346
"poll_interval_sec",
@@ -189,10 +192,31 @@ def apply_reloaded_config(self, new: Config) -> None:
189192
self.cfg = dataclasses.replace(self.cfg, **replacements)
190193
log.info("config reload applied: %s", "; ".join(changes))
191194

195+
def _sync_images(self) -> None:
196+
"""Hand the backend the current image config so it can stage the golden
197+
image to every host (and GC orphans). Optional: backends with no image
198+
delivery (OpenStack/fake) don't implement it. A failure here must not kill
199+
the tick — we log and proceed with whatever image the hosts already hold."""
200+
fn = getattr(self.backend, "sync_images", None)
201+
if fn is None:
202+
return
203+
try:
204+
fn(self.cfg.backend)
205+
except Exception:
206+
log.warning(
207+
"image sync failed; continuing with current host image",
208+
exc_info=True,
209+
)
210+
192211
# ----------------------------------------------------------------- tick
193212
def tick(self) -> ControllerState | None:
194213
now = self._clock()
195214

215+
# 0. IMAGE SYNC — ensure each host holds the configured golden image
216+
# before any create/rebuild this tick (a no-op once synced; on a ref
217+
# change it stages the new image and slots drain onto it below).
218+
self._sync_images()
219+
196220
# 1. FAIL-SAFE SNAPSHOT — a raise aborts the whole tick (no mutations).
197221
try:
198222
slots = self.backend.list_slots()
@@ -287,12 +311,20 @@ def tick(self) -> ControllerState | None:
287311
log.warning("slot %s busy past max_job_duration; stopping", s.id)
288312
self._safe(lambda: self.backend.stop_slot(s), f"stop {s.id}")
289313
elif state is SlotState.IDLE:
290-
if (
291-
runner
292-
and self._state_age(s.id, now) > self.cfg.timeouts.idle_timeout_sec
293-
):
314+
stale = s.image_stale
315+
idle_timed_out = (
316+
self._state_age(s.id, now) > self.cfg.timeouts.idle_timeout_sec
317+
)
318+
if runner and (stale or idle_timed_out):
319+
# Deregister the (idle, no-job) runner so GitHub stops
320+
# dispatching to it; the slot then recycles — rebuilding onto
321+
# the current golden, which clears a stale image. Same drain
322+
# path as the idle-timeout reap, just also triggered by a
323+
# config image_ref change.
294324
log.info(
295-
"slot %s idle past idle_timeout; deregistering runner", s.id
325+
"slot %s idle; deregistering runner (%s)",
326+
s.id,
327+
"stale image" if stale else "idle_timeout",
296328
)
297329
self._safe(
298330
lambda: self.github.delete_runner(runner.id), "delete_runner"

0 commit comments

Comments
 (0)