feat(template): check-win-iso (massgrave resolver) + patch-win-iso

Add `ci template check-win-iso`: parse the massgrave windows-server-links
markdown source on GitHub for the newest Windows Server 2025 build, file
name and link, and report whether it is already in <CI_ROOT>/iso/. The
download itself stays manual — the massgrave link is behind a Cloudflare
Turnstile file host, not scriptable.

Add `ci template patch-win-iso --in ISO [--out ISO]`: standalone equivalent
of deploy-win Step 4 — inject autounattend.xml + post-install.ps1 and a
no-prompt boot image into a manually downloaded ISO, producing a
ready-to-deploy `.patched.iso`.

winimage/massgrave.py does the parse (no auto-download). RUNBOOK §5
documents the check → download → patch → deploy workflow. Coverage 93.6%.
This commit is contained in:
2026-06-09 18:53:04 +02:00
parent c257c931c3
commit 3680f07ee3
6 changed files with 309 additions and 0 deletions
+100
View File
@@ -83,6 +83,7 @@ from ci_orchestrator.transport.errors import TransportError
from ci_orchestrator.transport.winrm import WinRmResult, WinRmTransport
from ci_orchestrator.winimage import autounattend as win_autounattend
from ci_orchestrator.winimage import iso_patch as win_iso_patch
from ci_orchestrator.winimage import massgrave as win_massgrave
@click.group()
@@ -2358,3 +2359,102 @@ def template_deploy_win(
click.echo(f" VMX: {vmx_path}")
click.echo(f" Snapshot: {snapshot_name}")
click.echo(f" Next: ci template prepare-win --vmx {vmx_path}")
# ═══════════════════════════════════════════════════════════════════════════
# template check-win-iso — resolve the latest WS2025 ISO from massgrave
# ═══════════════════════════════════════════════════════════════════════════
@template.command("check-win-iso")
@click.option("--lang", default="English", show_default=True, help="massgrave language column.")
@click.option("--arch", default="x64", show_default=True, help="Architecture column.")
def template_check_win_iso(lang: str, arch: str) -> None:
"""Report the newest Windows Server 2025 ISO from the massgrave source.
The download itself is not automatable (the massgrave link is behind a
Cloudflare-Turnstile file host), so this only resolves the latest build,
file name and URL and tells you whether it is already in <CI_ROOT>/iso/.
Download it once in a browser, then run ``template patch-win-iso`` /
``deploy-win --win-iso``.
"""
config = load_config()
iso_dir = config.paths.root / "iso"
info = win_massgrave.latest_ws2025(lang=lang, arch=arch)
cached = (iso_dir / info.filename).is_file()
click.echo(f"Latest WS2025 ({lang}/{arch}):")
click.echo(f" build: {info.build}")
click.echo(f" filename: {info.filename}")
click.echo(f" url: {info.url}")
click.echo(f" cached: {'yes — ' + str(iso_dir / info.filename) if cached else 'no'}")
if not cached:
click.echo(
"\nDownload it in a browser (the link is Cloudflare-gated), save it to\n"
f" {iso_dir / info.filename}\n"
"then: ci template patch-win-iso --in "
f"{iso_dir / info.filename}"
)
# ═══════════════════════════════════════════════════════════════════════════
# template patch-win-iso — standalone no-prompt + autounattend ISO patch
# ═══════════════════════════════════════════════════════════════════════════
@template.command("patch-win-iso")
@click.option("--in", "in_iso_str", required=True, metavar="PATH", help="Windows install ISO to patch.")
@click.option("--out", "out_iso_str", default=None, metavar="PATH",
help="Output ISO (default: <in>.patched.iso).")
@click.option("--image-index", default=2, show_default=True)
@click.option("--product-key", default="TVRH6-WHNXV-R9WG3-9XRFY-MY832", show_default=True)
@click.option("--admin-user", default="Administrator", show_default=True)
@click.option("--admin-password", default="WinBuild!", show_default=True)
@click.option("--computer-name", default="WINBUILD-2025", show_default=True)
@click.option("--locale-os", default="en-US", show_default=True)
@click.option("--locale-user", default="it-IT", show_default=True)
@click.option("--keyboard", default="0410:00000410", show_default=True)
@click.option("--timezone", default="W. Europe Standard Time", show_default=True)
def template_patch_win_iso(
in_iso_str: str,
out_iso_str: str | None,
image_index: int,
product_key: str,
admin_user: str,
admin_password: str,
computer_name: str,
locale_os: str,
locale_user: str,
keyboard: str,
timezone: str,
) -> None:
"""Patch a Windows ISO: inject autounattend.xml + post-install.ps1 + no-prompt boot.
Standalone equivalent of deploy-win Step 4 — produce a ready-to-deploy ISO
from a manually downloaded one, then ``deploy-win --win-iso <out>``.
"""
in_iso = Path(in_iso_str)
if not in_iso.is_file():
raise click.ClickException(f"input ISO not found: {in_iso}")
out_iso = (
Path(out_iso_str)
if out_iso_str
else in_iso.with_suffix(in_iso.suffix + ".patched.iso")
)
try:
win_autounattend.validate_computer_name(computer_name)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
params = win_autounattend.WinUnattendParams(
admin_user=admin_user, admin_password=admin_password,
computer_name=computer_name, product_key=product_key,
image_index=image_index, locale_os=locale_os, locale_user=locale_user,
keyboard=keyboard, timezone=timezone,
)
win_iso_patch.patch_iso(
in_iso,
autounattend_xml=win_autounattend.render_autounattend(params),
post_install_ps1=win_autounattend.render_post_install(params),
out_iso=out_iso,
)
click.echo(f"\nPatched ISO: {out_iso}")
click.echo(f"Deploy with: ci template deploy-win --win-iso {out_iso}")
+96
View File
@@ -0,0 +1,96 @@
"""Resolve the latest Windows Server 2025 ISO from the massgrave.dev source.
The rendered massgrave page links through a Cloudflare-Turnstile-gated file host
(``zerofs.link``) — not scriptable — so we only *resolve* (parse the source
markdown on GitHub for the newest build + file name + link). The operator
downloads it once in a browser, then feeds it to ``template patch-win-iso`` /
``deploy-win --win-iso``.
"""
from __future__ import annotations
import re
import urllib.request
from dataclasses import dataclass
import click
RAW_MD_URL = (
"https://raw.githubusercontent.com/massgravel/massgrave.dev/main/docs/"
"windows-server-links.md"
)
_USER_AGENT = "ci-orchestrator/1.0 (+deploy-win)"
_WS2025_HEADING = "## Windows Server 2025"
_BUILD_RE = re.compile(r"Build\s*-\s*([0-9][0-9.]+)")
# Table row: | English | x64 | [filename.iso](url)|
_ROW_RE = re.compile(
r"\|\s*(?P<lang>[^|]+?)\s*\|\s*(?P<arch>[^|]+?)\s*\|\s*\[(?P<file>[^\]]+\.iso)\]\((?P<url>[^)]+)\)"
)
@dataclass(frozen=True)
class WinServerIso:
"""A resolved Windows Server ISO entry from the massgrave source."""
build: str
filename: str
url: str
lang: str
arch: str
def fetch_links_md(*, url: str = RAW_MD_URL, timeout: float = 30.0) -> str:
"""Download the massgrave windows-server-links markdown source."""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp:
text: str = resp.read().decode("utf-8")
return text
def _ws2025_section(md: str) -> str:
"""Return the markdown between the WS2025 heading and the next ``## `` heading."""
start = md.find(_WS2025_HEADING)
if start < 0:
raise click.ClickException("Windows Server 2025 section not found in massgrave source.")
rest = md[start + len(_WS2025_HEADING):]
nxt = rest.find("\n## ")
return rest if nxt < 0 else rest[:nxt]
def resolve_ws2025(
md: str, *, lang: str = "English", arch: str = "x64"
) -> WinServerIso:
"""Resolve the newest WS2025 ISO entry for ``lang``/``arch``.
The newest build is the first ``Build - <n>`` table in the section (the
"updated" one); the OEM tables below it are older RTM media.
"""
section = _ws2025_section(md)
build = ""
for line in section.splitlines():
m = _BUILD_RE.search(line)
if m:
build = m.group(1)
row = _ROW_RE.search(line)
if (
row
and build
and row.group("lang").strip().lower() == lang.lower()
and row.group("arch").strip().lower() == arch.lower()
):
return WinServerIso(
build=build,
filename=row.group("file").strip(),
url=row.group("url").strip(),
lang=lang,
arch=arch,
)
raise click.ClickException(
f"No Windows Server 2025 {lang}/{arch} entry found in massgrave source."
)
def latest_ws2025(*, lang: str = "English", arch: str = "x64", timeout: float = 30.0) -> WinServerIso:
"""Fetch the source and resolve the newest WS2025 ISO entry."""
return resolve_ws2025(fetch_links_md(timeout=timeout), lang=lang, arch=arch)