winvm 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
winvm/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """winvm - build and run slim, unattended Windows VMs on QEMU/KVM from YAML image files."""
2
+ __version__ = "1.2.0"
winvm/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
winvm/balloon.py ADDED
@@ -0,0 +1,132 @@
1
+ """Dynamic memory: keep a guest's RAM near what it uses, between `ram: {min, max}`.
2
+
3
+ The VM gets `max`; a small helper (this module, one detached process per instance, on its
4
+ own QMP socket) reads the guest's memory stats from the virtio-balloon driver and moves the
5
+ balloon: it grows the guest at once when free memory runs short and gives memory back to
6
+ the host only after it has been free for a while, in steps. A pagefile in the guest absorbs
7
+ spikes faster than the balloon can deflate (~240 MB/s). If the stats stop or the helper is
8
+ told to quit, it hands the guest its full `max` back.
9
+
10
+ python -m winvm.balloon NAME (started by `winvm up`; logs to <run dir>/balloon.log)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import signal
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from .config import Config
22
+ from .util import Chan, Fatal, meminfo, mib
23
+
24
+ POLL = 2 # s between looks at the guest (its stats update this often)
25
+ HOLD = 30 # s memory must stay free before the guest gives it back
26
+ STEP = 512 # MiB given back per step
27
+ STALE = 20 # s without fresh stats: give everything back
28
+
29
+
30
+ def ram_range(run: dict) -> tuple[int, int] | None:
31
+ """`ram: {min, max}` -> (min, max) in MiB; None for a fixed `ram: 4G`."""
32
+ ram = run.get("ram")
33
+ if not isinstance(ram, dict):
34
+ return None
35
+ lo, hi = mib(ram.get("min") or ram.get("max")), mib(ram.get("max") or ram.get("min"))
36
+ return (min(lo, hi), max(lo, hi))
37
+
38
+
39
+ def headroom(used: int) -> int:
40
+ """Free memory the guest should have: 1 GiB, or a quarter of what it uses."""
41
+ return max(1024, used // 4)
42
+
43
+
44
+ class Helper:
45
+ def __init__(self, run_dir: Path, lo: int, hi: int, reserve: int):
46
+ self.dir, self.lo, self.hi, self.reserve = run_dir, lo, hi, reserve
47
+ self.qmp = Chan(run_dir / "qmp-balloon.sock", qmp=True)
48
+ self.path = "/machine/peripheral/balloon0"
49
+ self.qmp("qom-set", {"path": self.path, "property": "guest-stats-polling-interval", "value": POLL})
50
+ self.free_since: float | None = None
51
+
52
+ def actual(self) -> int:
53
+ return self.qmp("query-balloon")["actual"] >> 20
54
+
55
+ def set(self, target: int, why: str) -> None:
56
+ self.qmp("balloon", {"value": target << 20})
57
+ log(f"{why}: {target} MiB")
58
+
59
+ def step(self) -> dict:
60
+ st = self.qmp("qom-get", {"path": self.path, "property": "guest-stats"})
61
+ actual = self.actual()
62
+ if time.time() - st.get("last-update", 0) > STALE: # guest's balloon service silent
63
+ if actual < self.hi:
64
+ self.set(self.hi, "no memory stats from the guest - giving it all back")
65
+ return {"actual": actual, "state": "no stats"}
66
+ avail = st["stats"]["stat-available-memory"] >> 20
67
+ used = max(0, actual - avail)
68
+ room = headroom(used)
69
+ pressed = meminfo("MemAvailable") < 2 * self.reserve # host short: give back sooner
70
+ if pressed:
71
+ room = max(512, used // 8)
72
+ want = min(self.hi, max(self.lo, used + room))
73
+ if avail < room * 3 // 4 and actual < self.hi: # short: grow at once, a bit more
74
+ self.set(min(self.hi, used + room * 5 // 4), f"guest needs memory ({avail} MiB free)")
75
+ self.free_since = None
76
+ elif want < actual - STEP // 2: # spare: after HOLD, give it back
77
+ self.free_since = self.free_since or time.time() # step by step (one per poll)
78
+ if time.time() - self.free_since >= (5 if pressed else HOLD):
79
+ self.set(max(want, actual - STEP), "giving memory back" + (" (host is short)" if pressed else ""))
80
+ else:
81
+ self.free_since = None
82
+ return {"actual": actual, "used": used, "free": avail, "state": "auto"}
83
+
84
+ def run(self) -> None:
85
+ status = self.dir / "balloon.json"
86
+ while True:
87
+ state = self.step()
88
+ tmp = status.with_suffix(".tmp")
89
+ tmp.write_text(json.dumps({**state, "min": self.lo, "max": self.hi, "time": int(time.time())}) + "\n")
90
+ tmp.replace(status)
91
+ time.sleep(POLL)
92
+
93
+
94
+ def log(msg: str) -> None:
95
+ print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True)
96
+
97
+
98
+ def main(name: str) -> int:
99
+ cfg = Config() # same settings as the winvm that started us (env)
100
+ from .vm import Instance
101
+ vm = Instance(cfg, name)
102
+ rng = ram_range(vm.settings)
103
+ if not rng:
104
+ return 0
105
+ helper = None
106
+
107
+ def quit_(*_):
108
+ if helper: # don't leave the guest small without anyone to grow it
109
+ try:
110
+ helper.set(helper.hi, "stopping - guest gets all its memory back")
111
+ except (Fatal, OSError):
112
+ pass
113
+ sys.exit(0)
114
+
115
+ signal.signal(signal.SIGTERM, quit_)
116
+ signal.signal(signal.SIGINT, quit_)
117
+ (vm.dir / "balloon.pid").write_text(f"{os.getpid()}\n")
118
+ try:
119
+ helper = Helper(vm.dir, *rng, mib(cfg.s.get("mem_reserve") or 0))
120
+ log(f"auto memory for {name}: {rng[0]}-{rng[1]} MiB")
121
+ helper.run()
122
+ except (Fatal, OSError, ValueError, KeyError) as e: # QEMU gone (socket closed) or no stats
123
+ if vm.alive and helper:
124
+ quit_()
125
+ log(f"stopped: {e}")
126
+ finally:
127
+ (vm.dir / "balloon.pid").unlink(missing_ok=True)
128
+ return 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main(sys.argv[1]))
winvm/build.py ADDED
@@ -0,0 +1,406 @@
1
+ """Building images.
2
+
3
+ ISO images (offline, unattended), from Microsoft's own, unmodified ISO:
4
+ 1. a small payload CD: autounattend.xml, winslim/ (pe.cmd + lists + unattend.xml),
5
+ the first-boot payload and the image's `setup` steps (written in pure Python)
6
+ 2. a VM without network boots the Windows ISO (Enter is pressed for "Press any key
7
+ to boot from CD"), with the payload and virtio-win CDs. Setup finds autounattend.xml
8
+ and runs start.cmd -> pe.cmd: apply Windows, debloat it offline (data/guest/lists),
9
+ inject drivers, reboot. Windows' own setup then runs the payload: guest tools,
10
+ OpenSSH, your setup steps, cleanup; one more boot, power off
11
+ 3. the disk becomes a compressed read-only layer
12
+ With `slim: true` the debloated image is captured once onto a disk kept in
13
+ cache/slim; later builds apply it directly and skip the debloat.
14
+
15
+ Images `from` another image: boot a scratch VM on the image below (with network), run
16
+ the steps over SSH, shut it down and keep its changes as a compressed layer.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import fcntl
21
+ import json
22
+ import os
23
+ import re
24
+ import shutil
25
+ import socket
26
+ import subprocess as sp
27
+ import threading
28
+ import time
29
+ from pathlib import Path
30
+ from xml.sax.saxutils import escape
31
+
32
+ from . import __version__, guest, host
33
+ from .config import GUEST, Config
34
+ from .media import check_edition, write_iso
35
+ from .store import Store, label
36
+ from .util import Fatal, die, dos, log, run, warn
37
+ from .vm import Instance, running
38
+
39
+ # virtio driver folders injected into Windows (w11/amd64 flavour)
40
+ VIRTIO_DRIVERS = "vioscsi viostor NetKVM Balloon vioserial viorng vioinput pvpanic fwcfg viogpudo".split()
41
+ CAPTURE_GB = 30 # size of the slim-capture disk; pe.cmd recognizes it by this
42
+ WORK = r"C:\winvm-build" # work folder of `from` builds: downloads, copies, cwd of run steps
43
+ BOM = b"\xef\xbb\xbf" # Windows PowerShell reads .ps1 files without it as ANSI, not UTF-8
44
+
45
+
46
+ def build(cfg: Config, names: list[str], force=False, gui=False) -> None:
47
+ """Build whatever `names` need, bottom-up; up-to-date images are skipped. Asks once
48
+ for the downloads that takes (cfg.yes: don't ask)."""
49
+ store = Store(cfg)
50
+ order: list[str] = []
51
+ for n in names:
52
+ order += [x for x in cfg.chain(n) if x not in order]
53
+
54
+ def pending(n: str) -> bool:
55
+ try:
56
+ return force and n in names or not store.current(n)
57
+ except Fatal: # e.g. no ISO yet: must be built
58
+ return True
59
+
60
+ todo = [n for n in order if pending(n)]
61
+ if not todo:
62
+ return
63
+ host.require(cfg, gui) # the host can run it at all?
64
+ # (before downloading gigabytes)
65
+ items = host.downloads_for(cfg, todo)
66
+ host.consent(items, cfg.yes)
67
+ host.fetch_all(cfg, items)
68
+ cfg.forget() # an ISO may have arrived
69
+ for n in order:
70
+ cur = store.current(n)
71
+ if cur and not (force and n in names):
72
+ continue
73
+ if cur and (users := store.users(cur)):
74
+ die(f"{label(cur)} is in use by {', '.join(users)} - rm/reset those to rebuild it")
75
+ t0 = time.time()
76
+ (build_overlay if "from" in cfg.image(n) else build_iso)(cfg, n, gui)
77
+ log(f"built {label(store.require(n))} in {(time.time() - t0) / 60:.0f} min")
78
+
79
+
80
+ def tools(cfg: Config) -> dict[str, Path]:
81
+ """The pinned virtio-win ISO and OpenSSH MSI (downloaded by build() already)."""
82
+ return {k: host.fetch(p["url"], p["dest"], p["sha256"], p.get("size"))
83
+ for k in ("virtio", "openssh") for p in [host.pinned(cfg, k)]}
84
+
85
+
86
+ def fetch_step(cfg: Config, st: dict) -> Path:
87
+ d = st["download"]
88
+ return host.fetch(d["url"], cfg.cache / "downloads" / d["sha256"][:16] / d["name"], d["sha256"])
89
+
90
+
91
+ # ------------------------------------------------------------------ ISO images
92
+
93
+ def build_iso(cfg: Config, name: str, gui=False) -> None:
94
+ img, h, store = cfg.image(name), cfg.hash(name), Store(cfg)
95
+ check_names(cfg, name)
96
+ iso = cfg.iso(name)
97
+ check_edition(iso, img.get("edition"), img.get("index"))
98
+ inst = Instance(cfg, f"_install-{name}", builder=True)
99
+ inst.require_stopped()
100
+ shutil.rmtree(inst.home, ignore_errors=True)
101
+ inst.home.mkdir(parents=True)
102
+ t = tools(cfg)
103
+ disks = []
104
+ if img.get("slim"):
105
+ slim = slim_image(cfg, name, inst, t)
106
+ log(f"applying the cached slim image {slim.name}")
107
+ disks = [(slim, True)]
108
+ payload = make_payload(cfg, name, inst.home, t, "install", preserviced=bool(disks))
109
+
110
+ run(host.qemu_img(cfg), "create", "-q", "-f", "qcow2", inst.disk, img["disk_size"])
111
+ log(f"installing Windows for image '{name}' (unattended, offline)")
112
+ serial, _ = run_installer(inst, [iso, payload, t["virtio"]], disks, gui)
113
+ if "WINSLIM-FAIL" in serial:
114
+ die(f"install failed - see {inst.dir}/serial.log (also C:\\Windows\\Setup on {inst.disk})")
115
+ if "WINSLIM-DONE" not in serial:
116
+ die(f"install did not finish; disk kept at {inst.disk}. If it stopped in WinPE (nothing after "
117
+ f"`installing`), WINVM_DEBUG=1 winvm build {name} keeps the error on screen "
118
+ f"(winvm -i _install-{name} screenshot)")
119
+ log(f"writing image layer {name}@{h[:12]}")
120
+ store.add(name, h, inst.disk, None, user=img["user"], password=img["password"],
121
+ source=str(cfg.sources[name]))
122
+ shutil.rmtree(inst.home)
123
+ shutil.rmtree(inst.dir, ignore_errors=True)
124
+
125
+
126
+ def check_names(cfg: Config, name: str) -> None:
127
+ """Values Windows Setup would stop at with an error dialog (the build would wait forever)."""
128
+ img = cfg.image(name)
129
+ host_name, user = str(img["hostname"]), str(img["user"])
130
+ if not re.fullmatch(r"[A-Za-z0-9-]{1,15}", host_name) or host_name.isdigit():
131
+ die(f"image '{name}': hostname '{host_name}' - Windows takes 1-15 letters, digits and -, "
132
+ f"not only digits")
133
+ if not re.fullmatch(r"[A-Za-z0-9._-]{1,20}", user) or user.endswith("."):
134
+ die(f"image '{name}': user '{user}' - winvm takes 1-20 letters, digits and . _ -, not ending in .")
135
+
136
+
137
+ def slim_image(cfg: Config, name: str, inst: Instance, t: dict) -> Path:
138
+ """A disk holding the captured, debloated image (winslim-image\\install.wim) for this
139
+ ISO/edition/debloat set, made once and kept in cache/slim."""
140
+ disk = cfg.cache / "slim" / f"{cfg.slim_key(name)[:16]}.qcow2"
141
+ disk.parent.mkdir(parents=True, exist_ok=True)
142
+ with open(disk.with_suffix(".lock"), "w") as lock:
143
+ fcntl.flock(lock, fcntl.LOCK_EX) # data dirs share it: one maker at a time
144
+ return disk if disk.exists() else make_slim(cfg, name, inst, t, disk)
145
+
146
+
147
+ def make_slim(cfg: Config, name: str, inst: Instance, t: dict, disk: Path) -> Path:
148
+ if running(cfg):
149
+ warn("other VMs are running; the capture VM needs its RAM too")
150
+ out, target = disk.with_suffix(".part"), inst.home / "capture.qcow2"
151
+ for f in (out, target):
152
+ run(host.qemu_img(cfg), "create", "-q", "-f", "qcow2", f, f"{CAPTURE_GB if f == out else 64}G")
153
+ payload = make_payload(cfg, name, inst.home, t, "capture")
154
+ log("slim image: apply + offline debloat + capture (once; the install follows)")
155
+ cap = Instance(cfg, inst.name, builder=True)
156
+ cap.disk = target
157
+ _, reason = run_installer(cap, [cfg.iso(name), payload, t["virtio"]], [(out, False)], no_reboot=True)
158
+ if reason != "guest-reset": # pe.cmd reboots on success
159
+ out.unlink(missing_ok=True)
160
+ die(f"capture failed (VM ended with {reason or 'no event'}); "
161
+ f"WINVM_DEBUG=1 winvm build {name} keeps the error on screen")
162
+ out.replace(disk)
163
+ target.unlink()
164
+ payload.unlink()
165
+ return disk
166
+
167
+
168
+ def list_file(name: str) -> str:
169
+ """A data/guest/lists/*.txt file without comments and blank lines."""
170
+ lines = (ln.rstrip() for ln in (GUEST / "lists" / f"{name}.txt").read_text().splitlines())
171
+ return "".join(f"{ln}\n" for ln in lines if ln.strip() and not ln.lstrip().startswith("#"))
172
+
173
+
174
+ def ps_bool(v) -> str:
175
+ return "$true" if v else "$false"
176
+
177
+
178
+ def make_payload(cfg: Config, name: str, work: Path, t: dict, mode: str, preserviced=False) -> Path:
179
+ """The payload CD: autounattend.xml + winslim/ (pe.cmd, lists, config, unattend.xml,
180
+ first-boot payload, setup steps)."""
181
+ img = cfg.image(name)
182
+ debloat = img.get("debloat") or {}
183
+ tree = work / "payload"
184
+ shutil.rmtree(tree, ignore_errors=True)
185
+ ws, pl = tree / "winslim", tree / "winslim/payload"
186
+ pl.mkdir(parents=True)
187
+ (tree / "autounattend.xml").write_bytes(dos((GUEST / "autounattend.xml").read_text()))
188
+ for f in ("pe.cmd", "start.cmd"):
189
+ (ws / f).write_bytes(dos((GUEST / f).read_text()))
190
+ for f in ("appx-keep", "capabilities-remove", "features-remove", "features-keep-payload",
191
+ "services-disable", "offline-delete"):
192
+ (ws / f"{f}.txt").write_bytes(dos(list_file(f)))
193
+ (ws / "offline-reg.txt").write_bytes(dos((GUEST / "lists/offline-reg.txt").read_text()))
194
+ knobs = {"WIN_EDITION": img.get("edition") or "",
195
+ "WIN_INDEX": 1 if preserviced else img.get("index") or "",
196
+ "WIN_STRIP_PAYLOADS": int(bool(debloat.get("strip_payloads"))),
197
+ "WIN_COMPACT": int(bool(debloat.get("compact_os"))),
198
+ "WINSLIM_MODE": mode, "WIN_PRESERVICED": int(preserviced),
199
+ "VIRTIO_DRIVERS": " ".join(VIRTIO_DRIVERS), "CAPTURE_GB": CAPTURE_GB,
200
+ "WINSLIM_DEBUG": int(os.environ.get("WINVM_DEBUG") == "1"),
201
+ "WINSLIM_BUILD": f"winvm-{__version__}-{name}-{time.strftime('%Y%m%d')}"}
202
+ (ws / "config.cmd").write_bytes(dos("".join(f'set "{k}={v}"\n' for k, v in knobs.items())))
203
+
204
+ xml = {"WIN_USER": img["user"], "WIN_PASSWORD": img["password"], "WIN_HOSTNAME": img["hostname"],
205
+ "WIN_TIMEZONE": img["timezone"], "WIN_LOCALE": img["locale"], "WIN_KEYBOARD": img["keyboard"]}
206
+ unattend = (GUEST / "unattend.xml.in").read_text()
207
+ for k, v in xml.items():
208
+ unattend = unattend.replace(f"@{k}@", escape(str(v), {'"': "&quot;", "'": "&apos;"}))
209
+ (ws / "unattend.xml").write_bytes(dos(unattend))
210
+
211
+ for f in ("SetupComplete.cmd", "setupcomplete.ps1", "firstlogon.ps1", "final.ps1", "common.ps1"):
212
+ (pl / f).write_bytes(dos((GUEST / "payload" / f).read_text()))
213
+ for f in ("appx-keep", "services-disable"):
214
+ (pl / f"{f}.txt").write_bytes(dos(list_file(f)))
215
+ (pl / "settings.ps1").write_bytes(BOM + dos(
216
+ f"$WinUser = {guest.quote(img['user'])}\n$WinCompact = {ps_bool(debloat.get('compact_os'))}\n"
217
+ f"$RemoveWebView = {ps_bool(debloat.get('remove_webview'))}\n"))
218
+ shutil.copy(t["openssh"], pl / "OpenSSH.msi") # virtio guest tools: pe.cmd takes them off the CD
219
+ (pl / "authorized_keys").write_bytes(dos(cfg.authorized_keys()))
220
+ if mode == "install": # a capture stops before the payload
221
+ stage_setup(cfg, name, pl / "extra")
222
+ out = write_iso(tree, work / f"payload-{mode}.iso", "WINVM")
223
+ shutil.rmtree(tree)
224
+ return out
225
+
226
+
227
+ def stage_setup(cfg: Config, name: str, extra: Path) -> None:
228
+ """`setup` steps -> payload/extra: setup.ps1, which setupcomplete.ps1 runs as SYSTEM after
229
+ the debloat (a failing step fails the build), and the files the steps use in extra/files,
230
+ where the steps run."""
231
+ steps = cfg.steps(name)
232
+ if not steps:
233
+ return
234
+ files = extra / "files"
235
+ files.mkdir(parents=True)
236
+ ps = ["$ErrorActionPreference = 'Stop'", "Set-Location -LiteralPath (Join-Path $PSScriptRoot files)"]
237
+ for i, st in enumerate(steps, 1):
238
+ if "run" not in st:
239
+ src = fetch_step(cfg, st) if "download" in st else Path(st["copy"][0])
240
+ dst = files / (st["download"]["name"] if "download" in st else src.name)
241
+ if dst.exists():
242
+ die(f"image '{name}' setup step {i}: a file named {dst.name} is already staged")
243
+ (shutil.copytree if src.is_dir() else shutil.copy)(src, dst)
244
+ else:
245
+ ps += [f"# step {i}", "$global:LASTEXITCODE = 0", st["run"],
246
+ f"if ($LASTEXITCODE) {{ throw \"setup step {i} exited with $LASTEXITCODE\" }}"]
247
+ (extra / "setup.ps1").write_bytes(BOM + dos("\n".join(ps)))
248
+ log(f"setup: {len(steps)} step(s) staged")
249
+
250
+
251
+ def run_installer(inst: Instance, cdroms: list[Path], disks=(), gui=False, no_reboot=False) -> tuple[str, str]:
252
+ """Boot an offline installer VM, press Enter for the CD's "Press any key", stream its
253
+ [SC]/[FL]/[WIN] progress lines from the serial log until QEMU exits. Returns the whole
254
+ log and why the VM stopped (QMP SHUTDOWN reason, e.g. guest-shutdown / guest-reset)."""
255
+ shutil.copyfile(host.firmware(inst.cfg)[1], inst.vars)
256
+ inst.claim(inst.ram)
257
+ serial = inst.dir / "serial.log"
258
+ serial.write_bytes(b"")
259
+ if not gui and inst.state.get("vnc"):
260
+ log(f"watch it: vncviewer 127.0.0.1:{inst.state['vnc']} (serial log: {serial})")
261
+ t0 = time.time()
262
+ argv = [*inst.qemu_argv(install=True, gui=gui, cdroms=cdroms, disks=disks), "-pidfile", str(inst.pidfile)]
263
+ qemu = sp.Popen(argv + (["-no-reboot"] if no_reboot else []))
264
+ reason: list[str] = []
265
+ watcher = threading.Thread(target=watch_qmp, args=(inst.dir / "qmp-build.sock", qemu, reason), daemon=True)
266
+ watcher.start()
267
+ buf = b""
268
+ try:
269
+ with serial.open("rb") as f:
270
+ while True:
271
+ done = qemu.poll() is not None # read once more after QEMU exits
272
+ buf += f.read()
273
+ *lines, buf = buf.split(b"\n")
274
+ for ln in lines:
275
+ if ln[:4] in (b"[SC]", b"[FL]", b"[WIN"):
276
+ print(ln.decode(errors="replace").rstrip("\r"), flush=True)
277
+ if done:
278
+ break
279
+ time.sleep(0.5)
280
+ finally:
281
+ if qemu.poll() is None:
282
+ qemu.terminate()
283
+ qemu.wait()
284
+ inst.pidfile.unlink(missing_ok=True)
285
+ watcher.join(timeout=10) # it may not have read SHUTDOWN yet
286
+ log(f"installer VM finished after {(time.time() - t0) / 60:.0f} min")
287
+ return serial.read_text(errors="replace"), (reason[-1] if reason else "")
288
+
289
+
290
+ def watch_qmp(sock: Path, qemu: sp.Popen, reason: list[str]) -> None:
291
+ """The installer VM's own QMP socket: press Enter for ~12 s (Microsoft's ISO asks to
292
+ "Press any key to boot from CD"), then record SHUTDOWN reasons until QEMU exits."""
293
+ for _ in range(300): # QEMU may take a while to start
294
+ if qemu.poll() is not None:
295
+ return
296
+ try:
297
+ s = socket.socket(socket.AF_UNIX)
298
+ s.connect(str(sock))
299
+ break
300
+ except OSError:
301
+ s.close()
302
+ time.sleep(0.1)
303
+ else:
304
+ warn("could not reach the installer VM's QMP socket - press Enter in its window yourself")
305
+ return
306
+ f = s.makefile("rw", encoding="utf-8")
307
+
308
+ def send(cmd: str, args: dict | None = None) -> None:
309
+ f.write(json.dumps({"execute": cmd, **({"arguments": args} if args else {})}) + "\n")
310
+ f.flush()
311
+
312
+ try:
313
+ f.readline()
314
+ send("qmp_capabilities")
315
+ end = time.time() + 12
316
+ while time.time() < end:
317
+ send("send-key", {"keys": [{"type": "qcode", "data": "ret"}]})
318
+ time.sleep(0.5)
319
+ for line in f:
320
+ msg = json.loads(line)
321
+ if msg.get("event") == "SHUTDOWN":
322
+ reason.append(msg.get("data", {}).get("reason", ""))
323
+ except (OSError, ValueError):
324
+ pass
325
+
326
+
327
+ # ------------------------------------------------------------------ images built on images
328
+
329
+ def build_overlay(cfg: Config, name: str, gui=False) -> None:
330
+ img, h, store = cfg.image(name), cfg.hash(name), Store(cfg)
331
+ below = store.require(img["from"])
332
+ steps = cfg.steps(name)
333
+ for st in steps: # fetch first: fail before booting
334
+ if "download" in st:
335
+ fetch_step(cfg, st)
336
+ inst = Instance(cfg, f"_build-{name}", builder=True)
337
+ inst.require_stopped()
338
+ shutil.rmtree(inst.home, ignore_errors=True)
339
+ inst.home.mkdir(parents=True)
340
+ inst.create(name, below)
341
+ try:
342
+ log(f"building image '{name}' on {label(below)}: {len(steps)} step(s)")
343
+ inst.start(gui=gui)
344
+ call(inst, f"New-Item -ItemType Directory -Force {guest.quote(WORK)} | Out-Null")
345
+ for i, st in enumerate(steps, 1):
346
+ kind, arg = next(iter(st.items()))
347
+ log(f"step {i}/{len(steps)}: {describe(st)}")
348
+ if kind == "run":
349
+ rc = guest.pipe(inst.ssh, guest.script(arg, cwd=WORK, strict=True), stdin=sp.DEVNULL)
350
+ if rc:
351
+ die(f"image '{name}' step {i} failed (exit {rc})")
352
+ elif kind == "download":
353
+ scp(inst, fetch_step(cfg, st), f"{WORK}\\{arg['name']}")
354
+ elif kind == "copy":
355
+ src = Path(arg[0])
356
+ dst = arg[1] if len(arg) > 1 else f"{WORK}\\{src.name}"
357
+ # the parent must exist for scp (a drive root like C:\ always does)
358
+ call(inst, f"$p = Split-Path {guest.quote(dst)}; if ($p -and -not (Test-Path -LiteralPath $p)) "
359
+ "{ New-Item -ItemType Directory -Force -Path $p | Out-Null }")
360
+ scp(inst, src, dst)
361
+ elif kind == "restart":
362
+ stop_clean(inst)
363
+ inst.start(gui=gui)
364
+ # leave nothing behind, and hand freed blocks back so the layer stays small
365
+ call(inst, f"Remove-Item -LiteralPath {guest.quote(WORK)} -Recurse -Force; "
366
+ "Optimize-Volume -DriveLetter C -ReTrim -ErrorAction SilentlyContinue | Out-Null")
367
+ stop_clean(inst)
368
+ log(f"writing image layer {name}@{h[:12]}")
369
+ meta = store.meta(below)
370
+ store.add(name, h, inst.disk, below, user=meta.get("user"), password=meta.get("password"),
371
+ source=str(cfg.sources[name]))
372
+ finally:
373
+ if inst.alive:
374
+ inst.kill()
375
+ shutil.rmtree(inst.home, ignore_errors=True)
376
+ shutil.rmtree(inst.dir, ignore_errors=True)
377
+
378
+
379
+ def stop_clean(inst: Instance) -> None:
380
+ """Shut the build VM down; a disk it had to be killed on is not worth keeping."""
381
+ if not inst.stop():
382
+ die(f"{inst.name} did not shut down by itself - build aborted")
383
+
384
+
385
+ def describe(st: dict) -> str:
386
+ """One log line for a step."""
387
+ kind, arg = next(iter(st.items()))
388
+ if kind == "run":
389
+ lines = arg.strip().splitlines()
390
+ return "run " + lines[0][:90] + (" ..." if len(lines) > 1 or len(lines[0]) > 90 else "")
391
+ if kind == "download":
392
+ return f"download {arg['name']}"
393
+ if kind == "copy":
394
+ return f"copy {Path(arg[0]).name}" + (f" -> {arg[1]}" if len(arg) > 1 else "")
395
+ return kind
396
+
397
+
398
+ def call(inst: Instance, ps: str) -> None:
399
+ """winvm's own guest commands during a build; fail loudly."""
400
+ r = guest.capture(inst.ssh, "$ErrorActionPreference = 'Stop'\n" + ps)
401
+ if r.returncode:
402
+ die(f"guest command failed: {ps[:100]}\n{r.stdout.strip()[:500]}")
403
+
404
+
405
+ def scp(inst: Instance, src: Path, dst: str) -> None:
406
+ run("scp", "-F", inst.dir / "ssh_config", "-q", "-r", src, f"{inst.name}:{dst.replace(chr(92), '/')}")