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:
@@ -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.")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user