diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index 5d42da5..38dca15 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -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 - directories; prune old backups beyond ``--keep-count``. +template prepare-win + 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 -afterwards (via ``systemctl``), so it must run as root or with appropriate -sudo privileges. Pass ``--skip-runner-stop`` to bypass this when the runner -is already offline. + Steps performed by ``template prepare-win`` + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 1. Resolves ``Install-CIToolchain-.ps1`` from + ``/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 @@ -282,6 +324,10 @@ def _wait_winrm(transport: WinRmTransport, timeout: int) -> None: @click.option("--auth", default="basic", show_default=True, type=click.Choice(["basic", "ntlm", "negotiate"]), 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( vmx: str, guest_ip: str, @@ -296,6 +342,8 @@ def template_prepare_win( skip_cleanup: bool, winrm_timeout: int, auth: str, + store_credential: bool, + credential_target: str, ) -> None: """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], 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.") diff --git a/tests/python/test_commands_template.py b/tests/python/test_commands_template.py index 5973308..4b0e77e 100644 --- a/tests/python/test_commands_template.py +++ b/tests/python/test_commands_template.py @@ -915,3 +915,89 @@ def test_prepare_win_vm_shutdown_timeout( ) assert result.exit_code != 0 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)