docs,config: aggiorna AGENTS.md, config.example, HOST-SETUP.md, checklist faseA e fix vari orchestrator (workstation, build, job, wait, winrm, test)

This commit is contained in:
Simone
2026-05-17 00:14:04 +02:00
parent dc8449a0d7
commit e810747557
10 changed files with 135 additions and 52 deletions
+1 -1
View File
@@ -223,6 +223,6 @@ Configurazione in `PSScriptAnalyzerSettings.psd1`. Regole principali:
7. **`[ordered]@{}` non disponibile in PS 2** — non e' un problema qui (PS 5.1), ma ricordare che `[ordered]` e' PS 3+.
8. **`ForEach-Object -Parallel`** — disponibile solo in PS 7+; usare `foreach` classico o `Start-Job`.
9. **Snapshot template catturato con VM accesa** — produce file `*.vmem` / `*.vmsn` con memoria; `vmrun clone ... linked -snapshot=<name>` rifiuta con `Error: The virtual machine should not be powered on. It is already running.` anche se `vmrun list` mostra 0 VM. Catturare snapshot SOLO da stato fully powered-off (verificare assenza di `.vmem` nella dir template).
10. **`vmrun getGuestIPAddress` per check "VM running"** — richiede VMware Tools attivi nel guest; in headless Tools puo' impiegare 30-60s a rispondere e il check appare bloccato. Usare `vmrun list` e cercare il path del VMX nell'output.
10. **`vmrun getGuestIPAddress` per check "VM running"** — richiede VMware Tools attivi nel guest; in headless Tools puo' impiegare 30-60s a rispondere e il check appare bloccato. Usare `vmrun list` e cercare il path del VMX nell'output per `is_running`. Per recuperare l'IP usare `getGuestIPAddress -wait` (blocca internamente finché Tools registrano l'IP) e filtrare le righe che iniziano con `"Error:"` perché vmrun in modalità headless può tornare exit code 0 con `"Error: The VMware Tools are not running..."` su stdout invece di usare un exit code non-zero.
11. **Machine-id identico tra clone Linux** — le cloud image Ubuntu hanno un `/etc/machine-id` fisso. VMware DHCP assegna l'IP tramite DHCP client-id derivato da machine-id: se tutti i clone hanno lo stesso machine-id ottengono lo stesso IP e si bloccano per "IP collision". Fix nel template (prima di prendere lo snapshot): `sudo truncate -s 0 /etc/machine-id && sudo rm -f /var/lib/dbus/machine-id && sudo shutdown -h now`, poi ricreare lo snapshot `BaseClean-Linux` da stato powered-off.
12. **`& nativecmd 2>$null` NON sopprime stderr in PS 5.1 con `$ErrorActionPreference='Stop'`** — la stderr del processo nativo viene convertita in ErrorRecord nel pipeline di PowerShell; sotto `'Stop'` questo diventa un errore terminante PRIMA che `2>$null` possa scartarlo. Esempio: `ssh-keygen -R <ip> -f <file>` stampa "Host X not found in file" su stderr → eccezione terminante. Fix: usare `$ErrorActionPreference = 'SilentlyContinue'` attorno alla chiamata oppure `2>&1 | Out-Null`. Per i job CI su VM ephemere locali, usare direttamente `StrictHostKeyChecking=no UserKnownHostsFile=NUL` (default in `_Transport.psm1` con `KnownHostsFile=''`) — nessun `ssh-keygen` necessario.
+8 -4
View File
@@ -4,6 +4,14 @@
# Environment variables (CI_ROOT, CI_TEMPLATES, CI_BUILD_VMS, CI_ARTIFACTS,
# CI_KEYS, CI_VMRUN_PATH, CI_SSH_KEY_PATH, CI_GUEST_CRED_TARGET) override
# anything set here.
#
# IMPORTANT (TOML rule): top-level keys must appear BEFORE any [section] header.
# Keys placed after a [section] belong to that section, not to the root table.
# Top-level orchestrator settings.
vmrun_path = "C:/Program Files (x86)/VMware/VMware Workstation/vmrun.exe"
ssh_key_path = "F:/CI/keys/ci_linux"
guest_cred_target = "BuildVMGuest"
# --------------------------------------------------------------- Windows host
[paths]
@@ -24,7 +32,3 @@ keys = "F:/CI/keys"
# Phase C hook: backend selector. Only "workstation" is implemented in Phase A.
[backend]
type = "workstation"
vmrun_path = "C:/Program Files (x86)/VMware/VMware Workstation/vmrun.exe"
ssh_key_path = "F:/CI/keys/ci_linux"
guest_cred_target = "BuildVMGuest"
+19
View File
@@ -38,6 +38,7 @@ F:\CI\
├── keys\
│ ├── ci_linux # chiave privata SSH per Linux VMs (ed25519, no passphrase)
│ └── ci_linux.pub # chiave pubblica corrispondente
├── config.toml # configurazione Python orchestrator (ssh_key_path, ecc.)
├── act_runner\
│ ├── act_runner.exe # binario runner Gitea
│ ├── config.yaml # config runner (path, label, capacity)
@@ -139,6 +140,22 @@ Allo `Setup-Host.ps1` exit 0:
```
Poi **provisiona il template VM Linux** — vedi [LINUX-TEMPLATE-SETUP.md](LINUX-TEMPLATE-SETUP.md)
6. **Crea `F:\CI\config.toml`** — necessario affinché il Python orchestrator trovi la chiave SSH e le altre impostazioni:
```powershell
Copy-Item N:\Code\Workspace\Local-CI-CD-System\config.example.toml F:\CI\config.toml
```
Il file `config.example.toml` contiene già i path corretti per Windows (`F:\CI\...`). Modificare solo se si usa un `$CIRoot` diverso da `F:\CI`.
Valori chiave configurati:
| Chiave | Valore default | Scopo |
| -------------------- | ----------------------- | -------------------------------------------- |
| `ssh_key_path` | `F:/CI/keys/ci_linux` | Chiave privata per SSH verso Linux guest |
| `guest_cred_target` | `BuildVMGuest` | Target Windows Credential Manager (WinRM) |
| `backend.type` | `workstation` | Backend VMware (solo `workstation` in Phase A) |
Le variabili d'ambiente `CI_SSH_KEY_PATH`, `CI_VMRUN_PATH`, `CI_GUEST_CRED_TARGET` sovrascrivono i valori del TOML se presenti.
6. **Registra chiave SSH con Gitea**:
- Gitea UI → User Settings → SSH Keys → Add Key
- Chiave pubblica: `%USERPROFILE%\.ssh\id_rsa.pub`
@@ -162,6 +179,7 @@ Allo `Setup-Host.ps1` exit 0:
| Snapshot name (Windows) | `BaseClean` |
| Snapshot name (Linux) | `BaseClean-Linux` |
| SSH key (Linux VMs) | `F:\CI\keys\ci_linux` (privata), `F:\CI\keys\ci_linux.pub` |
| Orchestrator config TOML | `F:\CI\config.toml` (copia di `config.example.toml`) |
| Clone base dir | `F:\CI\BuildVMs\` |
| Artifact dir | `F:\CI\Artifacts\` |
| Log dir | `F:\CI\Logs\` (retention: 30 giorni) |
@@ -196,3 +214,4 @@ Task schedulati (vedi `scripts/Register-CIScheduledTasks.ps1` per il bootstrap):
| Service `act_runner` non parte | Check `F:\CI\act_runner\logs\stderr.log` |
| `New-StoredCredential` fail | Verifica modulo CredentialManager importato + scope corretto |
| Job CI fallisce su `Test-WSMan` | TrustedHosts host non include subnet VM, AllowUnencrypted=$false |
| `wait-ready` SSH: `No authentication methods available` | `F:\CI\config.toml` mancante o `ssh_key_path` non configurato. Crea il file (vedi step 6) oppure passa `--ssh-key-path F:\CI\keys\ci_linux` |
+50 -31
View File
@@ -16,8 +16,8 @@ copiabili. Stop a primo errore: ogni passo è un gate per il successivo.
Il codice nuovo va installato nel venv che act_runner usa
(`F:\CI\python\venv\`).
- [ ] Aprire PowerShell **come amministratore** sull'host CI.
- [ ] Posizionarsi nella copia git aggiornata del repo (pull del branch
- [x] Aprire PowerShell **come amministratore** sull'host CI.
- [x] Posizionarsi nella copia git aggiornata del repo (pull del branch
`feature/python-rewrite-and-linux-migration`):
```powershell
@@ -27,13 +27,13 @@ Il codice nuovo va installato nel venv che act_runner usa
git pull
```
- [ ] Installare il package in editable nel venv di produzione:
- [x] Installare il package in editable nel venv di produzione:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m pip install -e .
```
- [ ] Smoke check rapido che la CLI risponda:
- [x] Smoke check rapido che la CLI risponda:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator --help
@@ -50,19 +50,19 @@ L'act_runner gira come servizio SYSTEM e legge `runner/config.yaml`
all'avvio. La modifica di A4 (`PYTHONIOENCODING=utf-8`) ha effetto solo
dopo restart.
- [ ] Trovare il servizio:
- [x] Trovare il servizio:
```powershell
Get-Service | Where-Object { $_.Name -like '*act*runner*' }
```
- [ ] Restart (sostituisci `<NomeServizio>` con quello trovato):
- [x] Restart (sostituisci `<NomeServizio>` con quello trovato):
```powershell
Restart-Service -Name '<NomeServizio>'
```
- [ ] Verificare che sia partito:
- [x] Verificare che sia partito:
```powershell
Get-Service -Name '<NomeServizio>'
@@ -76,7 +76,7 @@ dopo restart.
PoC pendente da A1.
- [ ] Clonare manualmente un template Windows:
- [x] Clonare manualmente un template Windows:
```powershell
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
@@ -86,14 +86,14 @@ PoC pendente da A1.
linked -snapshot=BaseClean -cloneName=smoke-win
```
- [ ] Avviare la VM:
- [x] Avviare la VM:
```powershell
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
-T ws start 'F:\CI\BuildVMs\smoke-win\smoke-win.vmx' nogui
```
- [ ] Eseguire `wait-ready`:
- [x] Eseguire `wait-ready`:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator wait-ready `
@@ -103,7 +103,7 @@ PoC pendente da A1.
Atteso: exit code `0` entro 3 minuti.
- [ ] Cleanup VM di smoke:
- [x] Cleanup VM di smoke:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm remove `
@@ -116,7 +116,7 @@ PoC pendente da A1.
Stesso schema, template Linux. PoC pendente da A1.
- [ ] Clonare:
- [x] Clonare:
```powershell
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
@@ -126,7 +126,7 @@ Stesso schema, template Linux. PoC pendente da A1.
linked -snapshot=BaseClean-Linux -cloneName=smoke-linux
```
- [ ] Start + wait-ready:
- [x] Start + wait-ready:
```powershell
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
@@ -139,7 +139,11 @@ Stesso schema, template Linux. PoC pendente da A1.
Atteso: exit code `0`.
- [ ] Cleanup:
> **Prerequisito scoperto**: `F:\CI\config.toml` deve esistere con `ssh_key_path`
> impostato — altrimenti SSH fallisce con `No authentication methods available`.
> Creato durante questa validazione; vedi HOST-SETUP.md step 6.
- [x] Cleanup:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm remove `
@@ -152,29 +156,44 @@ Stesso schema, template Linux. PoC pendente da A1.
Valida A3+A4 insieme: clone → wait → build → collect → cleanup.
- [ ] Lanciare un job sul template Windows con uno script trivial:
- [x] Lanciare un job sul template Windows con uno script trivial:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator job `
--template 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
--snapshot BaseClean `
--name smoke-job-win `
--template-path 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
--snapshot-name BaseClean `
--job-id smoke-job-win `
--repo-url 'http://10.10.20.11:3100/Simone/your-repo.git' `
--branch main `
--build-command 'echo hello > artifact.txt' `
--artifact-source 'C:\ci\workspace\artifact.txt' `
--artifact-dest 'F:\CI\Artifacts\smoke-job-win'
--guest-artifact-source 'C:\ci\workspace\artifact.txt' `
--artifact-base-dir 'F:\CI\Artifacts\smoke-job-win'
```
Atteso: exit code `0`, file `F:\CI\Artifacts\smoke-job-win\artifact.txt` presente,
VM smoke-job-win **non** più esistente in `F:\CI\BuildVMs\` (cleanup garantito).
- [ ] Ripetere su Linux (cambiare `--template`, `--snapshot`,
`--build-command 'echo hello > artifact.txt'`,
`--artifact-source '/opt/ci/build/artifact.txt'`).
- [ ] Verificare assenza VM orfane:
- [x] Ripetere su Linux:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm cleanup --dry-run
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator job `
--template-path 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' `
--snapshot-name BaseClean-Linux `
--guest-os linux `
--job-id smoke-job-linux `
--repo-url 'http://10.10.20.11:3100/Simone/your-repo.git' `
--branch main `
--build-command 'echo hello > artifact.txt' `
--guest-artifact-source 'artifact.txt' `
--artifact-base-dir 'F:\CI\Artifacts\smoke-job-linux'
```
Atteso: exit code `0`, `F:\CI\Artifacts\smoke-job-linux\smoke-job-linux\artifact.txt` presente.
- [x] Verificare assenza VM orfane:
```powershell
& 'F:\CI\python\venv\Scripts\python.exe' -m ci_orchestrator vm cleanup --what-if
```
Atteso: nessun candidato.
@@ -260,11 +279,11 @@ Solo dopo che i passi 37 sono tutti `[x]` PASS.
| Passo | Descrizione | Stato |
| ----- | ----------------------------------------- | ----- |
| 1 | Aggiorna venv di produzione | [ ] |
| 2 | Restart act_runner | [ ] |
| 3 | Smoke `wait-ready` Windows | [ ] |
| 4 | Smoke `wait-ready` Linux | [ ] |
| 5 | Smoke `job` end-to-end (Win + Linux) | [ ] |
| 1 | Aggiorna venv di produzione | [x] |
| 2 | Restart act_runner | [x] |
| 3 | Smoke `wait-ready` Windows | [x] |
| 4 | Smoke `wait-ready` Linux | [x] |
| 5 | Smoke `job` end-to-end (Win + Linux) | [x] |
| 6 | Workflow Gitea (lint + self-test + build) | [ ] |
| 7 | Burn-in 4 job × 10 round | [ ] |
| 8 | Benchmark wall-clock | [ ] |
+7 -2
View File
@@ -121,11 +121,16 @@ class WorkstationVmrunBackend:
args: list[str] = [handle.identifier]
if timeout > 0:
args.extend(["-wait"])
result = self._run("getGuestIPAddress", *args, check=False, timeout=timeout or None)
try:
result = self._run("getGuestIPAddress", *args, check=False, timeout=timeout or None)
except subprocess.TimeoutExpired:
return None
if result.returncode != 0:
return None
ip = (result.stdout or "").strip()
return ip or None
if not ip or ip.lower().startswith("error:"):
return None
return ip
def list_snapshots(self, handle: VmHandle) -> list[str]:
result = self._run("listSnapshots", handle.identifier)
+6 -3
View File
@@ -144,6 +144,7 @@ def _linux_build(
elif clone_url:
click.echo(f"[build run] cloning {clone_url} into guest:{workdir}")
cmd = (
f"GIT_TERMINAL_PROMPT=0 "
f"git clone --depth 1 --branch {_sh_quote(clone_branch)} "
f"{'--recurse-submodules ' if clone_submodules else ''}"
f"{_sh_quote(clone_url)} {_sh_quote(workdir)}"
@@ -314,12 +315,14 @@ def _windows_build(
f"$src = {src_expr}; "
f"$zip = {_ps_quote(artifact_zip)}; "
"if (-not (Test-Path $src)) { "
" Write-Host \"[build run] artifact source not found: $src\"; "
" exit 2 } ; "
" throw \"[build run] artifact source not found: $src\" }; "
"$dir = Split-Path $zip -Parent; "
"if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }; "
"if (Test-Path $zip) { Remove-Item $zip -Force }; "
"Compress-Archive -Path (Join-Path $src '*') -DestinationPath $zip -Force"
"if (Test-Path $src -PathType Leaf) { "
" Compress-Archive -Path $src -DestinationPath $zip -Force "
"} else { "
" Compress-Archive -Path (Join-Path $src '*') -DestinationPath $zip -Force }"
)
t.run(pkg_ps)
click.echo(f"[build run] artifact written to {artifact_zip}")
+21 -6
View File
@@ -135,14 +135,14 @@ def _wait_running(
def _wait_for_ip(
backend: VmBackend, handle: VmHandle, deadline: float, poll: float
) -> str | None:
while _now() < deadline:
while (now := _now()) < deadline:
call_timeout = min(poll, deadline - now)
try:
ip = backend.get_ip(handle)
ip = backend.get_ip(handle, timeout=call_timeout)
except BackendError:
ip = None
if ip:
return ip
time.sleep(poll)
return None
@@ -328,7 +328,22 @@ def job(
"""
# Reserved for future host-side clone variant; the Python port always
# provisions sources via in-guest ``git clone``.
del submodules, use_git_clone, gitea_credential_target
del submodules, use_git_clone
# Inject Gitea credentials into HTTP(S) clone URL so git doesn't prompt.
authed_repo_url = repo_url
if repo_url and repo_url.startswith(("http://", "https://")):
from urllib.parse import urlparse, urlunparse
try:
gitea_cred = KeyringCredentialStore().get(gitea_credential_target)
parsed = urlparse(repo_url)
host = parsed.hostname or ""
port_part = f":{parsed.port}" if parsed.port else ""
netloc = f"{gitea_cred.username}:{gitea_cred.password}@{host}{port_part}"
authed_repo_url = urlunparse(parsed._replace(netloc=netloc))
except Exception:
pass # no credential stored — try unauthenticated (public repo)
config: Config = load_config()
if not template_path:
@@ -463,7 +478,7 @@ def job(
workdir="/opt/ci/build",
output_dir="/opt/ci/output",
host_source_dir=None,
clone_url=repo_url,
clone_url=authed_repo_url,
clone_branch=branch,
clone_commit=commit,
clone_submodules=False,
@@ -479,7 +494,7 @@ def job(
workdir="C:\\CI\\build",
artifact_zip="C:\\CI\\output\\artifacts.zip",
host_source_dir=None,
clone_url=repo_url,
clone_url=authed_repo_url,
clone_branch=branch,
clone_commit=commit,
clone_submodules=False,
+4 -3
View File
@@ -61,14 +61,14 @@ def _wait_running(
def _wait_for_ip(
backend: VmBackend, handle: VmHandle, deadline: float, poll: float
) -> str | None:
while time.monotonic() < deadline:
while (now := time.monotonic()) < deadline:
call_timeout = min(poll, deadline - now)
try:
ip = backend.get_ip(handle)
ip = backend.get_ip(handle, timeout=call_timeout)
except BackendError:
ip = None
if ip:
return ip
time.sleep(poll)
return None
@@ -153,6 +153,7 @@ def wait_ready(
sys.exit(_EXIT_TIMEOUT_RUNNING)
if ip_address is None:
click.echo("[wait-ready] waiting for guest IP (VMware Tools must be running)...")
ip = _wait_for_ip(backend, handle, deadline, poll_interval)
if not ip:
click.echo("[wait-ready] timeout waiting for guest IP.", err=True)
+6 -2
View File
@@ -116,11 +116,15 @@ class WinRmTransport:
stdout, stderr, returncode = client.execute_ps(script)
except Exception as exc:
raise TransportConnectError(f"WinRM execute_ps failed: {exc}") from exc
# pypsrp returns stderr as a list of PSRP error records in some versions;
# normalise to a string.
# pypsrp returns stderr as a PSDataStreams object (newer versions) or a
# list of error records (older versions); normalise to a plain string.
stderr_any: Any = stderr
if isinstance(stderr_any, str):
stderr_str = stderr_any
elif hasattr(stderr_any, "error"):
# PSDataStreams — extract .error records.
error_records = getattr(stderr_any, "error", None) or []
stderr_str = "\n".join(str(e) for e in error_records)
else:
try:
stderr_str = "\n".join(str(s) for s in stderr_any)
+13
View File
@@ -96,6 +96,19 @@ def test_get_ip_returns_none_on_failure(monkeypatch: pytest.MonkeyPatch, fake_vm
assert backend.get_ip(VmHandle("x.vmx")) is None
def test_get_ip_returns_none_when_tools_not_running(monkeypatch: pytest.MonkeyPatch, fake_vmrun: str) -> None:
"""vmrun exits 0 but writes 'Error: ...' to stdout — must return None, not the error string."""
monkeypatch.setattr(
subprocess,
"run",
lambda *a, **kw: _make_completed(
0, stdout="Error: The VMware Tools are not running in the virtual machine: x.vmx\n"
),
)
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)
assert backend.get_ip(VmHandle("x.vmx")) is None
def test_get_ip_returns_address(monkeypatch: pytest.MonkeyPatch, fake_vmrun: str) -> None:
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_completed(0, stdout="192.168.79.42\n"))
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)