From 3680f07ee3757838cbe4cb990b6f989d90453970 Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 9 Jun 2026 18:53:04 +0200 Subject: [PATCH] feat(template): check-win-iso (massgrave resolver) + patch-win-iso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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%. --- docs/RUNBOOK-PhaseC.md | 16 ++++ index.html | 9 ++ src/ci_orchestrator/commands/template.py | 100 ++++++++++++++++++++++ src/ci_orchestrator/winimage/massgrave.py | 96 +++++++++++++++++++++ tests/python/test_commands_deploy_win.py | 53 ++++++++++++ tests/python/test_winimage.py | 35 ++++++++ 6 files changed, 309 insertions(+) create mode 100644 index.html create mode 100644 src/ci_orchestrator/winimage/massgrave.py diff --git a/docs/RUNBOOK-PhaseC.md b/docs/RUNBOOK-PhaseC.md index 59aea22..e53b152 100644 --- a/docs/RUNBOOK-PhaseC.md +++ b/docs/RUNBOOK-PhaseC.md @@ -77,6 +77,8 @@ sudo); `--uninstall` removes the block. The script defines two helpers: | `ci vmware --vmx ...` | Open a VMX in the VMware Workstation GUI | [§4](#4-manual-vm-lifecycle) | | `ci creds set --user ...` | Store guest credentials in the keyring | [§5](#5-credentials--template-provisioning) | | `ci template deploy-linux` | Build the Linux template VM | [§5](#5-credentials--template-provisioning) | +| `ci template check-win-iso` | Report the newest WS2025 ISO from massgrave + cache status | [§5](#5-credentials--template-provisioning) | +| `ci template patch-win-iso` | Inject autounattend + no-prompt boot into a Windows ISO | [§5](#5-credentials--template-provisioning) | | `ci template deploy-win` | Build the Windows Server 2025 template VM (download→patch→deploy) | [§5](#5-credentials--template-provisioning) | | `ci template prepare-linux` | Provision the Linux template over SSH | [§5](#5-credentials--template-provisioning) | | `ci template prepare-win` | Provision a Windows template over WinRM | [§5](#5-credentials--template-provisioning) | @@ -432,6 +434,20 @@ ci template deploy-win --iso-url 'https://example/win2025.iso' --iso-sha256 , then: +ci template patch-win-iso --in /var/lib/ci/iso/ # → .patched.iso +ci template deploy-win --win-iso /var/lib/ci/iso/.patched.iso +``` + +(`deploy-win` patches the ISO itself too, so `patch-win-iso` is optional — use it +to produce/inspect the bootable no-prompt ISO separately.) + Notes: - Provide **exactly one** of `--iso-url` / `--win-iso`. A downloaded ISO is cached by its URL file name and reused on re-runs. diff --git a/index.html b/index.html new file mode 100644 index 0000000..08ef321 --- /dev/null +++ b/index.html @@ -0,0 +1,9 @@ +it-it_windows_server_2025_updated_may_2026_x64_dvd_adcb5eb0.iso | ZeroFS

it-it_windows_server_2025_updated_may_2026_x64_dvd_adcb5eb0.iso

7.8 GB · 166 views · 105 downloads

Additional Info

uploaded
May 20, 2026, 5:26 a.m.
SHA-256
4c4b0c05283c8616a9daf587495c7807af3e1c1c418e595f76db654b9436a748

Share

note from uploader

No note available

Policy violation? Report this file.

loading

About Disk Image

ISO is an archive file format that is an exact sector-by-sector copy of an optical disc (CD, DVD, or Blu-ray), conforming to the ISO 9660 filesystem standard. ISO images contain the complete contents and structure of a disc, including the filesystem, boot sectors, and all files, making them ideal for distributing operating systems (Linux distributions, Windows installation media), software, and game backups. ISO files can be mounted as virtual drives on most operating systems without burning to physical media, or burned to a disc using disc burning software. The format is named after the ISO 9660 filesystem standard used on optical media.

Supported platforms

Windows macOS Linux
\ No newline at end of file diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index f163463..e5b7c0c 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -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 /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: .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 ``. + """ + 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}") diff --git a/src/ci_orchestrator/winimage/massgrave.py b/src/ci_orchestrator/winimage/massgrave.py new file mode 100644 index 0000000..1c276cb --- /dev/null +++ b/src/ci_orchestrator/winimage/massgrave.py @@ -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[^|]+?)\s*\|\s*(?P[^|]+?)\s*\|\s*\[(?P[^\]]+\.iso)\]\((?P[^)]+)\)" +) + + +@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 - `` 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) diff --git a/tests/python/test_commands_deploy_win.py b/tests/python/test_commands_deploy_win.py index fe099f0..02ccc6d 100644 --- a/tests/python/test_commands_deploy_win.py +++ b/tests/python/test_commands_deploy_win.py @@ -271,3 +271,56 @@ def test_deploy_win_flag_timeout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) assert result.exit_code != 0 assert "install_complete.flag not seen" in result.output + + +# ── check-win-iso / patch-win-iso ──────────────────────────────────────────── + +def test_check_win_iso_not_cached(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from ci_orchestrator.winimage import massgrave as mg + (tmp_path / "iso").mkdir(parents=True) + monkeypatch.setattr(tmpl_mod, "load_config", lambda: _make_config(tmp_path)) + info = mg.WinServerIso(build="26100.32860", filename="ws.iso", + url="https://zerofs.link/f/AAA/", lang="English", arch="x64") + monkeypatch.setattr(tmpl_mod.win_massgrave, "latest_ws2025", lambda **_k: info) + result = CliRunner().invoke(cli, ["template", "check-win-iso"]) + assert result.exit_code == 0, result.output + assert "26100.32860" in result.output and "cached: no" in result.output + + +def test_check_win_iso_cached(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from ci_orchestrator.winimage import massgrave as mg + (tmp_path / "iso").mkdir(parents=True) + (tmp_path / "iso" / "ws.iso").write_bytes(b"x") + monkeypatch.setattr(tmpl_mod, "load_config", lambda: _make_config(tmp_path)) + info = mg.WinServerIso(build="26100.32860", filename="ws.iso", + url="u", lang="English", arch="x64") + monkeypatch.setattr(tmpl_mod.win_massgrave, "latest_ws2025", lambda **_k: info) + result = CliRunner().invoke(cli, ["template", "check-win-iso"]) + assert result.exit_code == 0, result.output + assert "cached: yes" in result.output + + +def test_patch_win_iso_standalone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + src = tmp_path / "in.iso" + src.write_bytes(b"iso") + captured: dict[str, Any] = {} + + def fake_patch(base: Path, *, out_iso: Path, **_k: Any) -> Path: + captured["base"] = base + captured["out"] = out_iso + out_iso.write_bytes(b"patched") + return out_iso + + monkeypatch.setattr(tmpl_mod.win_iso_patch, "patch_iso", fake_patch) + result = CliRunner().invoke(cli, ["template", "patch-win-iso", "--in", str(src)]) + assert result.exit_code == 0, result.output + assert captured["base"] == src + assert str(captured["out"]).endswith("in.iso.patched.iso") + + +def test_patch_win_iso_missing_input(tmp_path: Path) -> None: + result = CliRunner().invoke( + cli, ["template", "patch-win-iso", "--in", str(tmp_path / "nope.iso")] + ) + assert result.exit_code != 0 + assert "input ISO not found" in result.output diff --git a/tests/python/test_winimage.py b/tests/python/test_winimage.py index ce701b2..aa01d63 100644 --- a/tests/python/test_winimage.py +++ b/tests/python/test_winimage.py @@ -11,6 +11,41 @@ import pytest from ci_orchestrator.winimage import autounattend as au from ci_orchestrator.winimage import iso_patch as ip +from ci_orchestrator.winimage import massgrave as mg + +# ── massgrave resolver ─────────────────────────────────────────────────────── + +_SAMPLE_MD = """\ +## Windows Server 2025 +Build - 26100.32860 (May 2026) +| English | x64 | [en-us_windows_server_2025_updated_may_2026_x64_dvd_abc.iso](https://zerofs.link/f/AAA/)| +| Italian | x64 | [it-it_windows_server_2025_updated_may_2026_x64_dvd_abc.iso](https://zerofs.link/f/BBB/)| +Build - 26100.1742 +| English | x64 | [X23-81958_26100.1742_en-us.iso](https://oemsoc.download.prss.microsoft.com/x.iso?t=z)| +## Windows Server 2022 +| English | x64 | [en-us_windows_server_2022.iso](https://zerofs.link/f/CCC/)| +""" + + +def test_resolve_ws2025_picks_newest_english() -> None: + i = mg.resolve_ws2025(_SAMPLE_MD, lang="English", arch="x64") + assert i.build == "26100.32860" + assert i.filename.startswith("en-us_windows_server_2025_updated") + assert i.url == "https://zerofs.link/f/AAA/" + + +def test_resolve_ws2025_italian() -> None: + assert mg.resolve_ws2025(_SAMPLE_MD, lang="Italian").url == "https://zerofs.link/f/BBB/" + + +def test_resolve_ws2025_not_found() -> None: + with pytest.raises(click.ClickException, match="No Windows Server 2025"): + mg.resolve_ws2025(_SAMPLE_MD, lang="Klingon") + + +def test_ws2025_section_missing() -> None: + with pytest.raises(click.ClickException, match="section not found"): + mg._ws2025_section("## Windows Server 2022\nnothing here") # ── autounattend ───────────────────────────────────────────────────────────