feat(template): add --store-credential to prepare-win + full docstring

- --store-credential: after snapshot, writes build-user credentials into
  the system keyring using the two-entry scheme (service:meta + service)
  that KeyringCredentialStore.get() reads. Works on Windows (Credential
  Manager) and Linux (SecretService / file backend).
- --credential-target: keyring target name, default BuildVMGuest (matches
  config.guest_cred_target).
- Rewrote module docstring: full prose documentation of both `template
  backup` and `template prepare-win` including all 12 provisioning steps.
- 2 new tests covering --store-credential with custom and default targets.
- mypy --strict clean; coverage 90.03%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 01:30:57 +02:00
parent 9e84d89823
commit 84746d2232
2 changed files with 162 additions and 9 deletions
+76 -9
View File
@@ -1,14 +1,56 @@
"""``template`` sub-commands. """``template`` sub-commands — CI template VM management.
Ports ``scripts/Backup-CITemplate.ps1`` to Python. Commands
--------
template backup
Ports ``scripts/Backup-CITemplate.ps1`` to Python. Creates timestamped
7z archives of VMware template directories and prunes old backups beyond
``--keep-count``. Stops ``act-runner.service`` before copying and
restarts it afterwards; pass ``--skip-runner-stop`` to bypass this.
* ``template backup`` — create timestamped copies of VMware template template prepare-win
directories; prune old backups beyond ``--keep-count``. Provisions a Windows template VM end-to-end via pypsrp (replaces the
``Prepare-WinBuild*.ps1`` PowerShell scripts that required PSWSMan /
New-PSSession — unreliable on Linux hosts).
The command stops ``act-runner.service`` before copying and restarts it Steps performed by ``template prepare-win``
afterwards (via ``systemctl``), so it must run as root or with appropriate ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
sudo privileges. Pass ``--skip-runner-stop`` to bypass this when the runner 1. Resolves ``Install-CIToolchain-<vmx-stem>.ps1`` from
is already offline. ``<repo-root>/template/`` (or ``--toolchain-script``).
2. Optionally locates ``template/guest-setup/windows/ci-static-ip.ps1``
for the ip_pool feature.
3. Starts the VM via vmrun (skipped if already running).
4. Auto-detects guest IP via VMware Tools (skipped if ``--ip``
supplied).
5. Waits for TCP/5986 then probes WinRM session readiness.
6. Uploads and executes the toolchain script inside the guest. Handles
the Windows Update reboot-required exit code (3010) in a loop
(max 10 iterations).
7. Installs the ``CI-StaticIp`` scheduled task (``SYSTEM``, At Startup)
so each linked clone applies a pre-assigned static IP from
``guestinfo.ip-assignment`` before WinRM starts.
8. Runs post-setup remote validation (11 guest-state checks via a
single ``ConvertTo-Json`` call).
9. Final reboot → re-affirms autologin registry keys → graceful
``Stop-Computer``.
10. Waits for the VM to power off (120 s timeout).
11. Creates the ``--snapshot`` (default ``BaseClean``); optionally
deletes and recreates if a snapshot with that name already exists.
12. If ``--store-credential`` is set, writes build-user credentials into
the system keyring (Windows → Credential Manager; Linux →
SecretService / file backend) using the two-entry scheme read by
:class:`~ci_orchestrator.credentials.KeyringCredentialStore`.
Run from the repository root so toolchain and guest-setup scripts are
auto-discovered under ``template/``.
Usage example::
python -m ci_orchestrator template prepare-win \\
--vmx /var/lib/ci/templates/WinBuild2025/WinBuild2025.vmx \\
--admin-password AdminPass1! \\
--build-password CIBuildPass1! \\
--store-credential
""" """
from __future__ import annotations from __future__ import annotations
@@ -282,6 +324,10 @@ def _wait_winrm(transport: WinRmTransport, timeout: int) -> None:
@click.option("--auth", default="basic", show_default=True, @click.option("--auth", default="basic", show_default=True,
type=click.Choice(["basic", "ntlm", "negotiate"]), type=click.Choice(["basic", "ntlm", "negotiate"]),
help="WinRM authentication protocol.") help="WinRM authentication protocol.")
@click.option("--store-credential", is_flag=True,
help="Store build-user credentials in the system keyring after provisioning.")
@click.option("--credential-target", default="BuildVMGuest", show_default=True,
help="Keyring target name for the stored credential.")
def template_prepare_win( def template_prepare_win(
vmx: str, vmx: str,
guest_ip: str, guest_ip: str,
@@ -296,6 +342,8 @@ def template_prepare_win(
skip_cleanup: bool, skip_cleanup: bool,
winrm_timeout: int, winrm_timeout: int,
auth: str, auth: str,
store_credential: bool,
credential_target: str,
) -> None: ) -> None:
"""Provision a Windows template VM via pypsrp (Linux-host compatible). """Provision a Windows template VM via pypsrp (Linux-host compatible).
@@ -593,4 +641,23 @@ Register-ScheduledTask -TaskName 'CI-StaticIp' `
[vmrun_exe, "-T", "ws", "snapshot", str(vmx_path), snapshot], [vmrun_exe, "-T", "ws", "snapshot", str(vmx_path), snapshot],
check=True, check=True,
) )
click.echo(f"[prepare-win] Snapshot '{snapshot}' created. Provisioning complete.") click.echo(f"[prepare-win] Snapshot '{snapshot}' created.")
# Store credentials in system keyring (Windows Credential Manager on Windows,
# SecretService / file backend on Linux) using the two-entry scheme so
# KeyringCredentialStore.get() can retrieve them without knowing the username.
if store_credential:
try:
import keyring
except ImportError as exc:
raise click.ClickException(
"keyring is not installed; run `pip install keyring`."
) from exc
keyring.set_password(f"{credential_target}:meta", "username", build_username)
keyring.set_password(credential_target, build_username, build_password)
click.echo(
f"[prepare-win] Credentials stored in keyring "
f"(target='{credential_target}', user='{build_username}')."
)
click.echo("[prepare-win] Provisioning complete.")
+86
View File
@@ -915,3 +915,89 @@ def test_prepare_win_vm_shutdown_timeout(
) )
assert result.exit_code != 0 assert result.exit_code != 0
assert "power off" in result.output assert "power off" in result.output
def test_prepare_win_store_credential(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--store-credential calls keyring.set_password with the two-entry scheme."""
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
monkeypatch.chdir(tmp_path)
backend = _make_backend([True, False])
transport = _make_transport()
_patch_prepare(monkeypatch, backend, transport)
stored: list[tuple[str, str, str]] = []
fake_keyring = types.SimpleNamespace(
set_password=lambda svc, user, pw: stored.append((svc, user, pw))
)
monkeypatch.setattr(tmpl_mod, "keyring", fake_keyring, raising=False)
# Patch the import inside the function so it returns our fake module.
import builtins
real_import = builtins.__import__
def _fake_import(name: str, *args: object, **kwargs: object) -> object:
if name == "keyring":
return fake_keyring
return real_import(name, *args, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(builtins, "__import__", _fake_import)
result = CliRunner().invoke(
cli,
[
"template", "prepare-win",
"--vmx", str(vmx),
"--ip", "192.168.1.100",
"--admin-password", "adminpass",
"--build-password", "buildpass",
"--store-credential",
"--credential-target", "TestTarget",
],
)
assert result.exit_code == 0, result.output
assert any(s[0] == "TestTarget:meta" for s in stored), "username meta-entry not stored"
assert any(s[0] == "TestTarget" for s in stored), "password entry not stored"
assert "Credentials stored" in result.output
def test_prepare_win_store_credential_default_target(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Default credential target is BuildVMGuest."""
vmx = _setup_prepare_env(tmp_path, with_static_ip=False)
monkeypatch.chdir(tmp_path)
backend = _make_backend([True, False])
transport = _make_transport()
_patch_prepare(monkeypatch, backend, transport)
stored: list[tuple[str, str, str]] = []
import builtins
real_import = builtins.__import__
def _fake_import(name: str, *args: object, **kwargs: object) -> object:
if name == "keyring":
return types.SimpleNamespace(
set_password=lambda svc, user, pw: stored.append((svc, user, pw))
)
return real_import(name, *args, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(builtins, "__import__", _fake_import)
result = CliRunner().invoke(
cli,
[
"template", "prepare-win",
"--vmx", str(vmx),
"--ip", "192.168.1.100",
"--admin-password", "adminpass",
"--build-password", "buildpass",
"--store-credential",
],
)
assert result.exit_code == 0, result.output
assert any(s[0] == "BuildVMGuest:meta" for s in stored)
assert any(s[0] == "BuildVMGuest" for s in stored)