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 +2 -0
- winvm/__main__.py +5 -0
- winvm/balloon.py +132 -0
- winvm/build.py +406 -0
- winvm/cli.py +813 -0
- winvm/config.py +387 -0
- winvm/data/guest/autounattend.xml +17 -0
- winvm/data/guest/lists/appx-keep.txt +10 -0
- winvm/data/guest/lists/capabilities-remove.txt +23 -0
- winvm/data/guest/lists/features-keep-payload.txt +7 -0
- winvm/data/guest/lists/features-remove.txt +10 -0
- winvm/data/guest/lists/offline-delete.txt +10 -0
- winvm/data/guest/lists/offline-reg.txt +136 -0
- winvm/data/guest/lists/services-disable.txt +51 -0
- winvm/data/guest/payload/SetupComplete.cmd +4 -0
- winvm/data/guest/payload/common.ps1 +16 -0
- winvm/data/guest/payload/final.ps1 +16 -0
- winvm/data/guest/payload/firstlogon.ps1 +60 -0
- winvm/data/guest/payload/setupcomplete.ps1 +117 -0
- winvm/data/guest/pe.cmd +342 -0
- winvm/data/guest/start.cmd +20 -0
- winvm/data/guest/unattend.xml.in +65 -0
- winvm/data/images/base.yaml +37 -0
- winvm/data/winvm.yaml +56 -0
- winvm/guest.py +107 -0
- winvm/host.py +328 -0
- winvm/media.py +129 -0
- winvm/store.py +112 -0
- winvm/util.py +184 -0
- winvm/vm.py +583 -0
- winvm-1.2.0.dist-info/METADATA +410 -0
- winvm-1.2.0.dist-info/RECORD +36 -0
- winvm-1.2.0.dist-info/WHEEL +5 -0
- winvm-1.2.0.dist-info/entry_points.txt +2 -0
- winvm-1.2.0.dist-info/licenses/LICENSE +21 -0
- winvm-1.2.0.dist-info/top_level.txt +1 -0
winvm/__init__.py
ADDED
winvm/__main__.py
ADDED
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), {'"': """, "'": "'"}))
|
|
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), '/')}")
|