wimcast 0.1.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.
- wimcast/__init__.py +3 -0
- wimcast/__main__.py +6 -0
- wimcast/bootfiles.py +151 -0
- wimcast/build.py +453 -0
- wimcast/cache.py +172 -0
- wimcast/cli.py +192 -0
- wimcast/config.py +293 -0
- wimcast/doctor.py +160 -0
- wimcast/drivers.py +98 -0
- wimcast/inf.py +101 -0
- wimcast/iso.py +258 -0
- wimcast/manifest.py +169 -0
- wimcast/model.py +53 -0
- wimcast/probe.py +128 -0
- wimcast/publish.py +72 -0
- wimcast/py.typed +0 -0
- wimcast/run.py +104 -0
- wimcast/sched.py +102 -0
- wimcast/wim.py +159 -0
- wimcast/winpe.py +111 -0
- wimcast-0.1.0.dist-info/METADATA +229 -0
- wimcast-0.1.0.dist-info/RECORD +26 -0
- wimcast-0.1.0.dist-info/WHEEL +5 -0
- wimcast-0.1.0.dist-info/entry_points.txt +2 -0
- wimcast-0.1.0.dist-info/licenses/LICENSE +21 -0
- wimcast-0.1.0.dist-info/top_level.txt +1 -0
wimcast/__init__.py
ADDED
wimcast/__main__.py
ADDED
wimcast/bootfiles.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Boot payload: pinned wimboot and the ISO's boot files (plan 5.2 stage 8).
|
|
2
|
+
|
|
3
|
+
wimboot is downloaded from the GitHub release matching the pinned version
|
|
4
|
+
(config [wimboot] version, default 2.9.0, see README "Why wimboot is
|
|
5
|
+
pinned"). bootmgr, bootmgr.efi, boot\\boot.sdi and boot\\bcd come from the
|
|
6
|
+
source ISO, preserving the exact bits the image was built from.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from wimcast.cache import Cache
|
|
21
|
+
|
|
22
|
+
from wimcast.iso import IsoReader, open_iso
|
|
23
|
+
|
|
24
|
+
WIMBOOT_BASE_URL = "https://github.com/ipxe/wimboot/releases/download/v{version}/wimboot"
|
|
25
|
+
WIMBOOT_SHA256 = {
|
|
26
|
+
"2.9.0": "5f067ccdc4d084d5bf77b6c853bd0f8402dfc2b4cd1b103d358993ae97fae8e3",
|
|
27
|
+
}
|
|
28
|
+
_WIMBOOT_CHUNK = 1024 * 1024
|
|
29
|
+
|
|
30
|
+
# ISO members copied to the final layout, (iso member, relative dest)
|
|
31
|
+
BOOT_MEMBERS: tuple[tuple[str, str], ...] = (
|
|
32
|
+
("bootmgr", "bootmgr"),
|
|
33
|
+
("bootmgr.efi", "bootmgr.efi"),
|
|
34
|
+
("boot/boot.sdi", "boot/boot.sdi"),
|
|
35
|
+
("boot/bcd", "boot/bcd"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BootfilesError(RuntimeError):
|
|
40
|
+
"""A boot-file fetch or extraction failure."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.message = message
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sha256_file(path: Path) -> str:
|
|
48
|
+
digest = hashlib.sha256()
|
|
49
|
+
with path.open("rb") as fd:
|
|
50
|
+
for chunk in iter(lambda: fd.read(_WIMBOOT_CHUNK), b""):
|
|
51
|
+
digest.update(chunk)
|
|
52
|
+
return digest.hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Bootfiles:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
cache: Cache | None = None,
|
|
59
|
+
sevenzip: str | None = None,
|
|
60
|
+
sha256: str | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._cache = cache
|
|
63
|
+
self._sevenzip = sevenzip
|
|
64
|
+
self._sha256 = sha256.lower() if sha256 else None
|
|
65
|
+
|
|
66
|
+
def _expected_sha256(self, version: str) -> str:
|
|
67
|
+
if self._sha256 is not None:
|
|
68
|
+
return self._sha256
|
|
69
|
+
digest = WIMBOOT_SHA256.get(version)
|
|
70
|
+
if digest is None:
|
|
71
|
+
raise BootfilesError(
|
|
72
|
+
f"no pinned SHA-256 for wimboot v{version}; "
|
|
73
|
+
"set [wimboot] sha256 to the digest of the release asset"
|
|
74
|
+
)
|
|
75
|
+
return digest
|
|
76
|
+
|
|
77
|
+
async def wimboot(self, version: str, dest: Path) -> Path:
|
|
78
|
+
"""Pinned wimboot binary, cached; downloads on miss."""
|
|
79
|
+
version = version.lstrip("v")
|
|
80
|
+
expected = self._expected_sha256(version)
|
|
81
|
+
key = f"wimboot:v{version}:{expected}"
|
|
82
|
+
if self._cache is not None and self._cache.materialize(key, dest):
|
|
83
|
+
return dest
|
|
84
|
+
url = WIMBOOT_BASE_URL.format(version=version)
|
|
85
|
+
data = await asyncio.to_thread(self._download, url)
|
|
86
|
+
if not data:
|
|
87
|
+
raise BootfilesError(f"wimboot v{version} downloaded empty payload")
|
|
88
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
89
|
+
if sha != expected:
|
|
90
|
+
raise BootfilesError(
|
|
91
|
+
f"wimboot v{version} checksum mismatch: expected {expected}, got {sha}"
|
|
92
|
+
)
|
|
93
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".wimboot-", suffix=".tmp", dir=dest.parent)
|
|
95
|
+
tmp = Path(tmp_name)
|
|
96
|
+
try:
|
|
97
|
+
with os.fdopen(fd, "wb") as out:
|
|
98
|
+
out.write(data)
|
|
99
|
+
out.flush()
|
|
100
|
+
os.fsync(out.fileno())
|
|
101
|
+
if self._cache is not None:
|
|
102
|
+
blob = self._cache.put(key, tmp, sha, len(data))
|
|
103
|
+
if self._cache.enabled:
|
|
104
|
+
if not self._cache.materialize(key, dest):
|
|
105
|
+
raise BootfilesError(f"cache lost wimboot entry {key}")
|
|
106
|
+
else:
|
|
107
|
+
blob.replace(dest)
|
|
108
|
+
else:
|
|
109
|
+
tmp.replace(dest)
|
|
110
|
+
finally:
|
|
111
|
+
tmp.unlink(missing_ok=True)
|
|
112
|
+
return dest
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _download(url: str) -> bytes:
|
|
116
|
+
try:
|
|
117
|
+
with urllib.request.urlopen(url, timeout=60) as response:
|
|
118
|
+
return bytes(response.read())
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
raise BootfilesError(f"cannot download {url}: {exc}") from exc
|
|
121
|
+
|
|
122
|
+
async def iso_boot_files(self, iso_path: Path, dest_dir: Path) -> None:
|
|
123
|
+
"""Copy the boot payload members from the ISO into dest_dir."""
|
|
124
|
+
await asyncio.to_thread(self._iso_boot_files_sync, iso_path, dest_dir)
|
|
125
|
+
|
|
126
|
+
def _iso_boot_files_sync(self, iso_path: Path, dest_dir: Path) -> None:
|
|
127
|
+
reader = open_iso(iso_path, self._sevenzip)
|
|
128
|
+
try:
|
|
129
|
+
self._copy_members(reader, dest_dir)
|
|
130
|
+
finally:
|
|
131
|
+
reader.close()
|
|
132
|
+
|
|
133
|
+
def _copy_members(self, reader: IsoReader, dest_dir: Path) -> None:
|
|
134
|
+
for member, rel in BOOT_MEMBERS:
|
|
135
|
+
if not reader.exists(member):
|
|
136
|
+
raise BootfilesError(f"boot member not found in ISO: {member}")
|
|
137
|
+
self._copy_member(reader, member, dest_dir / rel)
|
|
138
|
+
|
|
139
|
+
def _copy_member(self, reader: IsoReader, member: str, dest: Path) -> None:
|
|
140
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".boot-", suffix=".tmp", dir=dest.parent)
|
|
142
|
+
tmp = Path(tmp_name)
|
|
143
|
+
try:
|
|
144
|
+
with os.fdopen(fd, "wb") as out:
|
|
145
|
+
for chunk in reader.stream_file(member):
|
|
146
|
+
out.write(chunk)
|
|
147
|
+
out.flush()
|
|
148
|
+
os.fsync(out.fileno())
|
|
149
|
+
tmp.replace(dest)
|
|
150
|
+
finally:
|
|
151
|
+
tmp.unlink(missing_ok=True)
|
wimcast/build.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Build orchestrator (plan 5.2).
|
|
2
|
+
|
|
3
|
+
One source leaf = one ISO = one finalized/<arch>/<name>/ tree. Stage
|
|
4
|
+
ordering, caching and verification:
|
|
5
|
+
|
|
6
|
+
probe -> hash -> extract -> info -> filter -> export -> winpe -> bootfiles
|
|
7
|
+
-> publish -> manifest -> verify
|
|
8
|
+
|
|
9
|
+
Every expensive stage is keyed in the content-addressed store; a hit skips
|
|
10
|
+
the work entirely and only republishes the files.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import hashlib
|
|
17
|
+
import logging
|
|
18
|
+
import shutil
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from wimcast.cache import Cache
|
|
27
|
+
from wimcast.config import Config
|
|
28
|
+
|
|
29
|
+
from wimcast import __version__
|
|
30
|
+
from wimcast.bootfiles import Bootfiles
|
|
31
|
+
from wimcast.cache import stage_key
|
|
32
|
+
from wimcast.drivers import Drivers
|
|
33
|
+
from wimcast.iso import open_iso
|
|
34
|
+
from wimcast.manifest import (
|
|
35
|
+
BuildMeta,
|
|
36
|
+
edition_filenames,
|
|
37
|
+
write_build_json,
|
|
38
|
+
write_images_ini,
|
|
39
|
+
)
|
|
40
|
+
from wimcast.model import Artifact, Source, WimImage
|
|
41
|
+
from wimcast.probe import BOOT_MEMBER, ISO_MEMBERS, SourceError, discover
|
|
42
|
+
from wimcast.publish import prune_tree, publish_file, publish_tree
|
|
43
|
+
from wimcast.run import Runner, ToolError
|
|
44
|
+
from wimcast.wim import WimError, Wimlib, WimOperations, parse_wimlib_version
|
|
45
|
+
from wimcast.winpe import build_winpe
|
|
46
|
+
|
|
47
|
+
log = logging.getLogger("wimcast.build")
|
|
48
|
+
|
|
49
|
+
_LAYOUT_VERSION = "v1"
|
|
50
|
+
_MIN_WIMLIB = (1, 13, 0)
|
|
51
|
+
_CHUNK = 1024 * 1024
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BuildError(RuntimeError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Stage:
|
|
60
|
+
name: str
|
|
61
|
+
duration_s: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class SourceResult:
|
|
66
|
+
arch: str
|
|
67
|
+
name: str
|
|
68
|
+
images: tuple[WimImage, ...]
|
|
69
|
+
exported: tuple[tuple[int, str, str], ...]
|
|
70
|
+
artifacts: tuple[Artifact, ...]
|
|
71
|
+
stages: tuple[Stage, ...]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class BuildSummary:
|
|
76
|
+
sources: list[SourceResult] = field(default_factory=list)
|
|
77
|
+
durations_s: float = 0.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def config_hash(config: Config) -> str:
|
|
81
|
+
return hashlib.sha256(repr(config).encode("utf-8")).hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tree_hash(directory: Path) -> str:
|
|
85
|
+
"""Deterministic hash of a directory tree (relative paths + contents)."""
|
|
86
|
+
digest = hashlib.sha256()
|
|
87
|
+
for path in sorted(directory.rglob("*")):
|
|
88
|
+
if path.is_file():
|
|
89
|
+
rel = path.relative_to(directory).as_posix().encode("utf-8")
|
|
90
|
+
digest.update(rel)
|
|
91
|
+
with path.open("rb") as fd:
|
|
92
|
+
for chunk in iter(lambda: fd.read(_CHUNK), b""):
|
|
93
|
+
digest.update(chunk)
|
|
94
|
+
return digest.hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def file_sha256(path: Path) -> str:
|
|
98
|
+
digest = hashlib.sha256()
|
|
99
|
+
with path.open("rb") as fd:
|
|
100
|
+
for chunk in iter(lambda: fd.read(_CHUNK), b""):
|
|
101
|
+
digest.update(chunk)
|
|
102
|
+
return digest.hexdigest()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class IsoHashMemo:
|
|
106
|
+
"""In-process memo of ISO hashes, keyed on (path, size, mtime)."""
|
|
107
|
+
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self._entries: dict[tuple[str, int, int], str] = {}
|
|
110
|
+
|
|
111
|
+
def get(self, path: Path) -> str:
|
|
112
|
+
stat = path.stat()
|
|
113
|
+
key = (str(path), stat.st_size, stat.st_mtime_ns)
|
|
114
|
+
cached = self._entries.get(key)
|
|
115
|
+
if cached is not None:
|
|
116
|
+
return cached
|
|
117
|
+
sha = file_sha256(path)
|
|
118
|
+
self._entries[key] = sha
|
|
119
|
+
return sha
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Builder:
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
config: Config,
|
|
126
|
+
runner: Runner,
|
|
127
|
+
cache: Cache | None = None,
|
|
128
|
+
wimlib: WimOperations | None = None,
|
|
129
|
+
workdir_base: Path | None = None,
|
|
130
|
+
finalized_root: Path = Path("finalized"),
|
|
131
|
+
) -> None:
|
|
132
|
+
from wimcast.cache import Cache
|
|
133
|
+
|
|
134
|
+
config.validate()
|
|
135
|
+
self._config = config
|
|
136
|
+
self._runner = runner
|
|
137
|
+
self._cache = cache if cache is not None else Cache(config.cache.directory)
|
|
138
|
+
self._wimlib = wimlib or Wimlib(runner, config.tools.wimlib_imagex)
|
|
139
|
+
self._drivers = Drivers(config)
|
|
140
|
+
self._memo = IsoHashMemo()
|
|
141
|
+
self._workdir_base = workdir_base
|
|
142
|
+
self._finalized_root = finalized_root
|
|
143
|
+
self._wimlib_version: str | None = None
|
|
144
|
+
|
|
145
|
+
async def _tool_version(self) -> str:
|
|
146
|
+
if self._wimlib_version is None:
|
|
147
|
+
exe = self._config.tools.wimlib_imagex or "wimlib-imagex"
|
|
148
|
+
try:
|
|
149
|
+
result = await self._runner.run(exe, "--version")
|
|
150
|
+
except ToolError as exc:
|
|
151
|
+
raise BuildError(f"wimlib-imagex not usable: {exc}") from exc
|
|
152
|
+
version = parse_wimlib_version(result.stdout)
|
|
153
|
+
if version is None:
|
|
154
|
+
raise BuildError(f"cannot parse wimlib-imagex version from {result.stdout!r}")
|
|
155
|
+
if version < _MIN_WIMLIB:
|
|
156
|
+
raise BuildError(
|
|
157
|
+
"wimlib-imagex >= 1.13 required, found "
|
|
158
|
+
f"{'.'.join(str(part) for part in version)}"
|
|
159
|
+
)
|
|
160
|
+
self._wimlib_version = f"wimlib-imagex {'.'.join(str(part) for part in version)}"
|
|
161
|
+
return self._wimlib_version
|
|
162
|
+
|
|
163
|
+
async def build(self, path: Path | None = None) -> BuildSummary:
|
|
164
|
+
path = path or Path("sources")
|
|
165
|
+
try:
|
|
166
|
+
sources = discover(path)
|
|
167
|
+
except SourceError as exc:
|
|
168
|
+
raise BuildError(str(exc)) from exc
|
|
169
|
+
if not sources:
|
|
170
|
+
raise BuildError(f"no sources found under {path}")
|
|
171
|
+
summary = BuildSummary()
|
|
172
|
+
for source in sources:
|
|
173
|
+
started = time.monotonic()
|
|
174
|
+
result = await self.build_source(source)
|
|
175
|
+
summary.sources.append(result)
|
|
176
|
+
summary.durations_s += time.monotonic() - started
|
|
177
|
+
return summary
|
|
178
|
+
|
|
179
|
+
async def build_source(self, source: Source) -> SourceResult:
|
|
180
|
+
stages: list[Stage] = []
|
|
181
|
+
workdir = self._new_workdir(source)
|
|
182
|
+
try:
|
|
183
|
+
cfg_hash = config_hash(self._config)
|
|
184
|
+
tool_version = await self._tool_version()
|
|
185
|
+
|
|
186
|
+
iso_sha = await asyncio.to_thread(self._memo.get, source.iso_path)
|
|
187
|
+
|
|
188
|
+
started = time.monotonic()
|
|
189
|
+
install_wim, boot_wim, backend = await asyncio.to_thread(
|
|
190
|
+
self._extract, source.iso_path, workdir
|
|
191
|
+
)
|
|
192
|
+
stages.append(Stage("extract", time.monotonic() - started))
|
|
193
|
+
install_sha = await asyncio.to_thread(file_sha256, install_wim)
|
|
194
|
+
|
|
195
|
+
started = time.monotonic()
|
|
196
|
+
images = await self._wimlib.info(install_wim)
|
|
197
|
+
stages.append(Stage("info", time.monotonic() - started))
|
|
198
|
+
|
|
199
|
+
selected = self._select_editions(images)
|
|
200
|
+
if not selected:
|
|
201
|
+
raise BuildError(
|
|
202
|
+
f"no edition matched [editions] allowed={self._config.editions.allowed!r}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
started = time.monotonic()
|
|
206
|
+
exported = await self._export(
|
|
207
|
+
selected, install_wim, install_sha, cfg_hash, tool_version, workdir
|
|
208
|
+
)
|
|
209
|
+
stages.append(Stage("export", time.monotonic() - started))
|
|
210
|
+
|
|
211
|
+
started = time.monotonic()
|
|
212
|
+
boot_wim_final = await self._build_winpe(
|
|
213
|
+
source, boot_wim, cfg_hash, tool_version, workdir
|
|
214
|
+
)
|
|
215
|
+
stages.append(Stage("winpe", time.monotonic() - started))
|
|
216
|
+
|
|
217
|
+
# Emit driver reports (boot.wim injected, install.wim deferred).
|
|
218
|
+
self._drivers.report_for(source)
|
|
219
|
+
|
|
220
|
+
started = time.monotonic()
|
|
221
|
+
bootfiles = Bootfiles(
|
|
222
|
+
self._cache,
|
|
223
|
+
sevenzip=self._config.tools.sevenzip,
|
|
224
|
+
sha256=self._config.wimboot.sha256,
|
|
225
|
+
)
|
|
226
|
+
boot_dir = workdir / "boot"
|
|
227
|
+
await bootfiles.wimboot(self._config.wimboot.version, boot_dir / "wimboot")
|
|
228
|
+
await bootfiles.iso_boot_files(source.iso_path, boot_dir)
|
|
229
|
+
stages.append(Stage("bootfiles", time.monotonic() - started))
|
|
230
|
+
|
|
231
|
+
started = time.monotonic()
|
|
232
|
+
published = await asyncio.to_thread(
|
|
233
|
+
self._publish,
|
|
234
|
+
source,
|
|
235
|
+
workdir,
|
|
236
|
+
exported,
|
|
237
|
+
iso_sha,
|
|
238
|
+
tool_version,
|
|
239
|
+
backend,
|
|
240
|
+
boot_wim_final,
|
|
241
|
+
)
|
|
242
|
+
stages.append(Stage("publish", time.monotonic() - started))
|
|
243
|
+
|
|
244
|
+
started = time.monotonic()
|
|
245
|
+
await self._verify(source, exported)
|
|
246
|
+
stages.append(Stage("verify", time.monotonic() - started))
|
|
247
|
+
|
|
248
|
+
started = time.monotonic()
|
|
249
|
+
artifacts = await asyncio.to_thread(self._published_artifacts, source)
|
|
250
|
+
dest = self._finalized_root / source.arch / source.name
|
|
251
|
+
meta = BuildMeta(
|
|
252
|
+
layout_version=_LAYOUT_VERSION,
|
|
253
|
+
wimcast_version=__version__,
|
|
254
|
+
tool_wimlib_imagex=tool_version,
|
|
255
|
+
iso_backend=backend,
|
|
256
|
+
iso=source.iso_path.name,
|
|
257
|
+
iso_sha256=iso_sha,
|
|
258
|
+
arch=source.arch,
|
|
259
|
+
name=source.name,
|
|
260
|
+
edition_images=tuple(exported),
|
|
261
|
+
artifacts=artifacts,
|
|
262
|
+
start_offset_s=0.0,
|
|
263
|
+
end_offset_s=sum(stage.duration_s for stage in stages),
|
|
264
|
+
)
|
|
265
|
+
await asyncio.to_thread(write_build_json, dest / "build.json", meta)
|
|
266
|
+
expected = published | {dest / "build.json"}
|
|
267
|
+
await asyncio.to_thread(prune_tree, dest, expected)
|
|
268
|
+
stages.append(Stage("manifest", time.monotonic() - started))
|
|
269
|
+
|
|
270
|
+
return SourceResult(
|
|
271
|
+
arch=source.arch,
|
|
272
|
+
name=source.name,
|
|
273
|
+
images=images,
|
|
274
|
+
exported=tuple(exported),
|
|
275
|
+
artifacts=artifacts,
|
|
276
|
+
stages=tuple(stages),
|
|
277
|
+
)
|
|
278
|
+
finally:
|
|
279
|
+
if workdir != self._workdir_base:
|
|
280
|
+
try:
|
|
281
|
+
shutil.rmtree(workdir)
|
|
282
|
+
except OSError as exc:
|
|
283
|
+
log.warning("cannot remove workdir %s: %s", workdir, exc)
|
|
284
|
+
|
|
285
|
+
def _new_workdir(self, source: Source) -> Path:
|
|
286
|
+
if self._workdir_base is not None:
|
|
287
|
+
workdir = self._workdir_base / source.arch / source.name
|
|
288
|
+
else:
|
|
289
|
+
workdir = Path(tempfile.mkdtemp(prefix="wimcast-"))
|
|
290
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
return workdir
|
|
292
|
+
|
|
293
|
+
def _extract(self, iso_path: Path, workdir: Path) -> tuple[Path, Path | None, str]:
|
|
294
|
+
"""Extract install.wim (or install.esd) and boot.wim from the ISO."""
|
|
295
|
+
reader = open_iso(iso_path, self._config.tools.sevenzip)
|
|
296
|
+
dest_dir = workdir / "iso"
|
|
297
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
try:
|
|
299
|
+
member = next((m for m in ISO_MEMBERS if reader.exists(m)), None)
|
|
300
|
+
if member is None:
|
|
301
|
+
raise BuildError(f"neither {' nor '.join(ISO_MEMBERS)} found in {iso_path}")
|
|
302
|
+
install_dest = dest_dir / "install.wim"
|
|
303
|
+
reader.extract_file(member, install_dest)
|
|
304
|
+
boot_dest: Path | None = None
|
|
305
|
+
if reader.exists(BOOT_MEMBER):
|
|
306
|
+
boot_dest = dest_dir / "boot.wim"
|
|
307
|
+
reader.extract_file(BOOT_MEMBER, boot_dest)
|
|
308
|
+
return install_dest, boot_dest, reader.backend
|
|
309
|
+
finally:
|
|
310
|
+
reader.close()
|
|
311
|
+
|
|
312
|
+
def _select_editions(self, images: tuple[WimImage, ...]) -> tuple[WimImage, ...]:
|
|
313
|
+
allowed = self._config.editions.allowed
|
|
314
|
+
if not allowed:
|
|
315
|
+
return images
|
|
316
|
+
return tuple(
|
|
317
|
+
image for image in images if any(part.lower() in image.name.lower() for part in allowed)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
async def _export(
|
|
321
|
+
self,
|
|
322
|
+
images: tuple[WimImage, ...],
|
|
323
|
+
install_wim: Path,
|
|
324
|
+
install_sha: str,
|
|
325
|
+
cfg_hash: str,
|
|
326
|
+
tool_version: str,
|
|
327
|
+
workdir: Path,
|
|
328
|
+
) -> list[tuple[int, str, str]]:
|
|
329
|
+
exported: list[tuple[int, str, str]] = []
|
|
330
|
+
sources_dir = workdir / "sources"
|
|
331
|
+
plan = edition_filenames(images, self._config.output.filename_scheme)
|
|
332
|
+
for image, filename in plan:
|
|
333
|
+
key = stage_key("export", install_sha, image.index, filename, cfg_hash, tool_version)
|
|
334
|
+
dest = sources_dir / filename
|
|
335
|
+
if dest.parent != sources_dir:
|
|
336
|
+
raise BuildError(f"unsafe export filename: {filename!r}")
|
|
337
|
+
if not self._cache.materialize(key, dest):
|
|
338
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
await self._wimlib.export(install_wim, image.index, dest, compress="LZX")
|
|
340
|
+
sha = await asyncio.to_thread(file_sha256, dest)
|
|
341
|
+
blob = self._cache.put(key, dest, sha, dest.stat().st_size)
|
|
342
|
+
if self._cache.enabled:
|
|
343
|
+
self._cache.materialize(key, dest)
|
|
344
|
+
else:
|
|
345
|
+
dest = blob
|
|
346
|
+
exported.append((image.index, image.name, filename))
|
|
347
|
+
return exported
|
|
348
|
+
|
|
349
|
+
async def _build_winpe(
|
|
350
|
+
self,
|
|
351
|
+
source: Source,
|
|
352
|
+
boot_wim: Path | None,
|
|
353
|
+
cfg_hash: str,
|
|
354
|
+
tool_version: str,
|
|
355
|
+
workdir: Path,
|
|
356
|
+
) -> Path | None:
|
|
357
|
+
config = self._config
|
|
358
|
+
if config.winpe.source == "file":
|
|
359
|
+
if config.winpe.path is None or config.winpe.sha256 is None:
|
|
360
|
+
raise BuildError('[winpe] source = "file" requires path and sha256')
|
|
361
|
+
base = Path(config.winpe.path)
|
|
362
|
+
if not base.is_file():
|
|
363
|
+
raise BuildError(f"[winpe] path does not exist: {base}")
|
|
364
|
+
if await asyncio.to_thread(file_sha256, base) != config.winpe.sha256.lower():
|
|
365
|
+
raise BuildError(f"[winpe] sha256 mismatch for {base}")
|
|
366
|
+
base_index = 1
|
|
367
|
+
else:
|
|
368
|
+
if boot_wim is None:
|
|
369
|
+
raise BuildError(f"{source.iso_path}: sources/boot.wim missing from ISO")
|
|
370
|
+
base = boot_wim
|
|
371
|
+
base_index = config.winpe.iso_index
|
|
372
|
+
base_sha = await asyncio.to_thread(file_sha256, base)
|
|
373
|
+
overlay = Path(config.winpe.overlay_dir) if config.winpe.overlay_dir else None
|
|
374
|
+
overlay_hash = (
|
|
375
|
+
await asyncio.to_thread(tree_hash, overlay)
|
|
376
|
+
if overlay is not None and overlay.is_dir()
|
|
377
|
+
else ""
|
|
378
|
+
)
|
|
379
|
+
drivers_dir = self._bootpe_drivers_dir(source)
|
|
380
|
+
drivers_hash = (
|
|
381
|
+
await asyncio.to_thread(tree_hash, drivers_dir) if drivers_dir is not None else ""
|
|
382
|
+
)
|
|
383
|
+
key = stage_key(
|
|
384
|
+
"winpe", base_sha, base_index, overlay_hash, drivers_hash, cfg_hash, tool_version
|
|
385
|
+
)
|
|
386
|
+
out = workdir / "boot.wim"
|
|
387
|
+
if not self._cache.materialize(key, out):
|
|
388
|
+
await build_winpe(
|
|
389
|
+
self._wimlib,
|
|
390
|
+
base,
|
|
391
|
+
base_index,
|
|
392
|
+
workdir,
|
|
393
|
+
overlay_dir=overlay,
|
|
394
|
+
drivers_dir=drivers_dir,
|
|
395
|
+
)
|
|
396
|
+
sha = await asyncio.to_thread(file_sha256, out)
|
|
397
|
+
self._cache.put(key, out, sha, out.stat().st_size)
|
|
398
|
+
if self._cache.enabled:
|
|
399
|
+
self._cache.materialize(key, out)
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
def _bootpe_drivers_dir(self, source: Source) -> Path | None:
|
|
403
|
+
drivers_root = self._config.winpe.drivers_dir
|
|
404
|
+
if drivers_root is None:
|
|
405
|
+
return None
|
|
406
|
+
arch_root = Path(drivers_root) / source.arch
|
|
407
|
+
return arch_root if arch_root.is_dir() else None
|
|
408
|
+
|
|
409
|
+
def _publish(
|
|
410
|
+
self,
|
|
411
|
+
source: Source,
|
|
412
|
+
workdir: Path,
|
|
413
|
+
exported: list[tuple[int, str, str]],
|
|
414
|
+
iso_sha: str,
|
|
415
|
+
tool_version: str,
|
|
416
|
+
backend: str,
|
|
417
|
+
boot_wim: Path | None,
|
|
418
|
+
) -> set[Path]:
|
|
419
|
+
dest = self._finalized_root / source.arch / source.name
|
|
420
|
+
published = publish_tree(workdir / "boot", dest)
|
|
421
|
+
sources_dest = dest / "sources"
|
|
422
|
+
for _, _, filename in exported:
|
|
423
|
+
published.add(publish_file(workdir / "sources" / filename, sources_dest / filename))
|
|
424
|
+
if boot_wim is not None and boot_wim.is_file():
|
|
425
|
+
published.add(publish_file(boot_wim, sources_dest / "boot.wim"))
|
|
426
|
+
if source.drivers_dir is not None:
|
|
427
|
+
published |= publish_tree(source.drivers_dir, sources_dest / "drivers")
|
|
428
|
+
write_images_ini(
|
|
429
|
+
sources_dest / "images.ini",
|
|
430
|
+
tuple((name, filename) for _, name, filename in exported),
|
|
431
|
+
)
|
|
432
|
+
published.add(sources_dest / "images.ini")
|
|
433
|
+
return published
|
|
434
|
+
|
|
435
|
+
def _published_artifacts(self, source: Source) -> tuple[Artifact, ...]:
|
|
436
|
+
base = self._finalized_root / source.arch / source.name
|
|
437
|
+
artifacts: list[Artifact] = []
|
|
438
|
+
for path in sorted(base.rglob("*.wim")):
|
|
439
|
+
artifacts.append(
|
|
440
|
+
Artifact(path=str(path), sha256=file_sha256(path), size=path.stat().st_size)
|
|
441
|
+
)
|
|
442
|
+
return tuple(artifacts)
|
|
443
|
+
|
|
444
|
+
async def _verify(self, source: Source, exported: list[tuple[int, str, str]]) -> None:
|
|
445
|
+
base = self._finalized_root / source.arch / source.name
|
|
446
|
+
for wim in sorted(base.rglob("*.wim")):
|
|
447
|
+
try:
|
|
448
|
+
await self._wimlib.verify(wim)
|
|
449
|
+
except WimError as exc:
|
|
450
|
+
raise BuildError(f"verify failed for {wim}: {exc}") from exc
|
|
451
|
+
for artifact in self._published_artifacts(source):
|
|
452
|
+
if artifact.sha256 != await asyncio.to_thread(file_sha256, Path(artifact.path)):
|
|
453
|
+
raise BuildError(f"hash mismatch after publish: {artifact.path}")
|