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:
@@ -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 <HAS
|
||||
ci template deploy-win --gui --win-iso /var/lib/ci/iso/win2025.iso # local ISO, watch install
|
||||
```
|
||||
|
||||
**Finding / preparing an ISO from massgrave.** The massgrave "updated" link is
|
||||
behind a Cloudflare-Turnstile file host, so the download itself can't be
|
||||
scripted — but you can resolve the newest build and pre-patch a manual download:
|
||||
|
||||
```bash
|
||||
ci template check-win-iso # newest WS2025 build + URL + cache status
|
||||
# → download that URL in a browser into /var/lib/ci/iso/<filename>, then:
|
||||
ci template patch-win-iso --in /var/lib/ci/iso/<filename> # → <filename>.patched.iso
|
||||
ci template deploy-win --win-iso /var/lib/ci/iso/<filename>.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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user