quality-loop 0.2.2__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quality_loop-0.2.2/quality_loop.egg-info → quality_loop-0.4.0}/PKG-INFO +3 -1
- {quality_loop-0.2.2 → quality_loop-0.4.0}/README.md +2 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/__init__.py +1 -1
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/adapter.py +16 -0
- quality_loop-0.4.0/quality_loop/guest/__init__.py +580 -0
- quality_loop-0.4.0/quality_loop/guest/boot/__init__.py +7 -0
- quality_loop-0.4.0/quality_loop/guest/boot/launch.py +35 -0
- quality_loop-0.4.0/quality_loop/guest/boot/screen_agent.py +54 -0
- quality_loop-0.4.0/quality_loop/guest/screens.py +138 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/runner.py +7 -3
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/screens/__init__.py +35 -5
- {quality_loop-0.2.2 → quality_loop-0.4.0/quality_loop.egg-info}/PKG-INFO +3 -1
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop.egg-info/SOURCES.txt +6 -0
- quality_loop-0.4.0/tests/test_guest.py +215 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/tests/test_screens.py +43 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/LICENSE +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/pyproject.toml +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/OPERATOR.md +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/__main__.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/audit.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/cli.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/compare.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/config.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/drive/__init__.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/drive/_mac.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/drive/_windows.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/drive/keys.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/drive/terminal.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/install.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/mcp.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/operator.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/pages.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/processes.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/progress.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/report.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/room.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/scenario.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/screens/_mac.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/screens/_windows.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/session.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/skill/SKILL.md +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/skill/references/filing.md +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/skill/references/setup.md +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/standin.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop/transcript.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop.egg-info/dependency_links.txt +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop.egg-info/entry_points.txt +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop.egg-info/requires.txt +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/quality_loop.egg-info/top_level.txt +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/setup.cfg +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/tests/test_core.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/tests/test_drive.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/tests/test_loop.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/tests/test_mcp.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/tests/test_operator.py +0 -0
- {quality_loop-0.2.2 → quality_loop-0.4.0}/tests/test_scenario_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quality-loop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Measure what a product teaches - its skill, tools or manual - with isolated agent runs of real tasks, reported by an operator agent
|
|
5
5
|
Author: craftware
|
|
6
6
|
License-Expression: MIT
|
|
@@ -110,6 +110,8 @@ under a policy the person sets. See [Running the loop](https://github.com/crftwr
|
|
|
110
110
|
launch command and a redirected home up to evidence tools of its own.
|
|
111
111
|
- [Reports](https://github.com/crftwr/quality-loop/blob/main/docs/reports.md): problems, severities, kinds and ids, the
|
|
112
112
|
reproduction record, and `FINDINGS.md` / `findings.json`.
|
|
113
|
+
- [Running the app in a VM](https://github.com/crftwr/quality-loop/blob/main/docs/guests.md): one image per OS,
|
|
114
|
+
the guest over SSH, a clean machine every run, screens in the guest.
|
|
113
115
|
- [Isolation and permissions](https://github.com/crftwr/quality-loop/blob/main/docs/isolation.md): what keeps the room, the
|
|
114
116
|
operator and the conductor in bounds, why each rule exists, and the known limits.
|
|
115
117
|
- [Development](https://github.com/crftwr/quality-loop/blob/main/docs/development.md): tests, live tests, releasing.
|
|
@@ -79,6 +79,8 @@ under a policy the person sets. See [Running the loop](https://github.com/crftwr
|
|
|
79
79
|
launch command and a redirected home up to evidence tools of its own.
|
|
80
80
|
- [Reports](https://github.com/crftwr/quality-loop/blob/main/docs/reports.md): problems, severities, kinds and ids, the
|
|
81
81
|
reproduction record, and `FINDINGS.md` / `findings.json`.
|
|
82
|
+
- [Running the app in a VM](https://github.com/crftwr/quality-loop/blob/main/docs/guests.md): one image per OS,
|
|
83
|
+
the guest over SSH, a clean machine every run, screens in the guest.
|
|
82
84
|
- [Isolation and permissions](https://github.com/crftwr/quality-loop/blob/main/docs/isolation.md): what keeps the room, the
|
|
83
85
|
operator and the conductor in bounds, why each rule exists, and the known limits.
|
|
84
86
|
- [Development](https://github.com/crftwr/quality-loop/blob/main/docs/development.md): tests, live tests, releasing.
|
|
@@ -103,6 +103,22 @@ class Adapter:
|
|
|
103
103
|
"""
|
|
104
104
|
return [(case, "") for case in cases]
|
|
105
105
|
|
|
106
|
+
def screen_backend(self) -> dict | None:
|
|
107
|
+
"""Where the declared screens are put up, when not on this desktop.
|
|
108
|
+
|
|
109
|
+
``{"path": file, "settings": {...}}``: a Python file with
|
|
110
|
+
``open_page``, ``open_terminal`` and ``close`` as in
|
|
111
|
+
`quality_loop.screens`, and what its ``configure(settings)`` is given.
|
|
112
|
+
Asked once per room, after the product is up, so settings may name
|
|
113
|
+
where it runs this time. None, the default, is this OS's own backend.
|
|
114
|
+
"""
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def platform(self) -> str:
|
|
118
|
+
"""The OS the room's app runs on, as ``sys.platform`` names it - what
|
|
119
|
+
the operator is told. Not this machine's when the product runs in a VM."""
|
|
120
|
+
return sys.platform
|
|
121
|
+
|
|
106
122
|
def alive(self) -> bool:
|
|
107
123
|
"""Whether the product is still answering, asked on the heartbeat."""
|
|
108
124
|
return True
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""A product's app in a VM, off the host's keyboard and screen (keyhac#271, xefm#750).
|
|
2
|
+
|
|
3
|
+
Everything Claude does stays on the host: the room, the operator, the
|
|
4
|
+
evidence, the rooms directory. Only the product's app moves into a guest, and
|
|
5
|
+
the run reaches it the way it reaches a local one:
|
|
6
|
+
|
|
7
|
+
- **A folder is shared.** The host writes into its host side (the stage, the
|
|
8
|
+
run's home, results); the guest reads the same files under its guest side.
|
|
9
|
+
:meth:`Guest.path` turns one into the other.
|
|
10
|
+
- **The guest is an SSH host alias.** Its address, user and key live in
|
|
11
|
+
``~/.ssh/config``, never here. A port the app opens in the guest is
|
|
12
|
+
forwarded to the host (:meth:`Guest.forward`), and one the host serves is
|
|
13
|
+
forwarded into the guest (:meth:`Guest.reverse`).
|
|
14
|
+
- **The guest's runtime is its own image's.** The image carries a Python
|
|
15
|
+
runtime and nothing that is under test (xefm#750, "Decisions on the shared
|
|
16
|
+
test image"): the product's working tree and what else its makers build are
|
|
17
|
+
staged every run, and third-party packages are installed into a fresh venv
|
|
18
|
+
in the guest every run (:meth:`Guest.venv`), with pip's download cache on
|
|
19
|
+
the host side of the share so a revert does not lose the downloads.
|
|
20
|
+
- **What has to be on the desktop runs in the desktop session**, where a
|
|
21
|
+
window is seen and can take focus - not in the SSH session, which has
|
|
22
|
+
neither. Windows starts it from a scheduled task run as the logged-in user
|
|
23
|
+
(:meth:`Guest.run_in_session`).
|
|
24
|
+
|
|
25
|
+
The VM should be clean when a run starts: a Tart clone of a base image, or a
|
|
26
|
+
VMware snapshot reverted to. When the per-machine settings name the VM for
|
|
27
|
+
the alias, :meth:`Guest.machine` does that itself.
|
|
28
|
+
|
|
29
|
+
Per-machine settings live outside every repository - they name this
|
|
30
|
+
machine's VMs, paths and Keychain items, never a secret itself -
|
|
31
|
+
in ``~/.config/quality-loop/guests.toml`` (``QUALITY_LOOP_GUESTS`` moves it):
|
|
32
|
+
|
|
33
|
+
[qa-mac]
|
|
34
|
+
share = "~/.cache/qa-guest=/Volumes/My Shared Files/qa"
|
|
35
|
+
python = "/Library/Frameworks/Python.framework/Versions/3.14/bin/python3"
|
|
36
|
+
tart = "<base VM>" # cloned per run, deleted after
|
|
37
|
+
visible = true
|
|
38
|
+
|
|
39
|
+
[qa-win]
|
|
40
|
+
share = "~/mnt/qa-win=C:\\\\qa"
|
|
41
|
+
python = "C:\\\\Python314\\\\python.exe"
|
|
42
|
+
vmx = "~/Virtual Machines.localized/<VM>.vmwarevm/<VM>.vmx"
|
|
43
|
+
snapshot = "<snapshot>" # reverted to before the run
|
|
44
|
+
vm_password = "keychain:<service>" # the VM's encryption password
|
|
45
|
+
smb = "//<user>@<guest>/<share>" # remounted on the share's host side
|
|
46
|
+
smb_password = "keychain:<service>"
|
|
47
|
+
|
|
48
|
+
Without a VM named, the run uses the guest as it is, already running.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import contextlib
|
|
54
|
+
import hashlib
|
|
55
|
+
import json
|
|
56
|
+
import ntpath
|
|
57
|
+
import os
|
|
58
|
+
import pathlib
|
|
59
|
+
import posixpath
|
|
60
|
+
import shutil
|
|
61
|
+
import socket
|
|
62
|
+
import subprocess
|
|
63
|
+
import tempfile
|
|
64
|
+
import time
|
|
65
|
+
import tomllib
|
|
66
|
+
import urllib.parse
|
|
67
|
+
|
|
68
|
+
#: Per-machine settings, by SSH alias. QUALITY_LOOP_GUESTS moves it.
|
|
69
|
+
GUESTS = pathlib.Path("~/.config/quality-loop/guests.toml")
|
|
70
|
+
|
|
71
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class GuestError(Exception):
|
|
75
|
+
"""The guest could not be reached, started or used."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def settings_for(alias: str, path: pathlib.Path | str | None = None) -> dict:
|
|
79
|
+
"""The table for ``alias`` in the per-machine settings, or ``{}``."""
|
|
80
|
+
where = pathlib.Path(path or os.environ.get("QUALITY_LOOP_GUESTS", GUESTS)).expanduser()
|
|
81
|
+
try:
|
|
82
|
+
with where.open("rb") as f:
|
|
83
|
+
return tomllib.load(f).get(alias, {})
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
return {}
|
|
86
|
+
except tomllib.TOMLDecodeError as error:
|
|
87
|
+
raise GuestError(f"{where}: {error}") from None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def secret(value: str) -> str:
|
|
91
|
+
"""``keychain:<service>`` read from the login Keychain; anything else as
|
|
92
|
+
it is. Read when a run needs it, never written anywhere."""
|
|
93
|
+
if not value.startswith("keychain:"):
|
|
94
|
+
return value
|
|
95
|
+
service = value.removeprefix("keychain:")
|
|
96
|
+
said = subprocess.run(["security", "find-generic-password", "-s", service, "-w"],
|
|
97
|
+
capture_output=True, text=True)
|
|
98
|
+
if said.returncode != 0:
|
|
99
|
+
raise GuestError(f"no Keychain item {service!r}: add it with "
|
|
100
|
+
f"security add-generic-password -a <account> -s {service} -w")
|
|
101
|
+
return said.stdout.rstrip("\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sh_quote(word: str) -> str:
|
|
105
|
+
return "'" + word.replace("'", "'\\''") + "'"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def ps_quote(word: str) -> str:
|
|
109
|
+
"""A PowerShell single-quoted literal."""
|
|
110
|
+
return "'" + word.replace("'", "''") + "'"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Guest:
|
|
114
|
+
"""One VM, reached as an SSH host alias, sharing a folder with the host."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, alias: str, host_share: pathlib.Path, guest_share: str,
|
|
117
|
+
settings: dict | None = None):
|
|
118
|
+
self.alias = alias
|
|
119
|
+
self.host_share = pathlib.Path(host_share).resolve()
|
|
120
|
+
self.guest_share = guest_share
|
|
121
|
+
self.settings = settings or {}
|
|
122
|
+
self._windows: bool | None = None
|
|
123
|
+
#: Set while a run's clone is up: its address, which the alias's
|
|
124
|
+
#: HostName does not know.
|
|
125
|
+
self.hostname: str | None = None
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_settings(cls, alias: str, settings: dict, share: str | None = None) -> "Guest":
|
|
129
|
+
"""A guest from its settings table; ``share`` (``host=guest``)
|
|
130
|
+
overrides the table's."""
|
|
131
|
+
share = share or settings.get("share", "")
|
|
132
|
+
host, _, guest = share.partition("=")
|
|
133
|
+
if not host or not guest:
|
|
134
|
+
raise GuestError(f"the guest {alias!r} needs share = <host dir>=<guest dir>: "
|
|
135
|
+
"a folder shared with the VM, as each side sees it")
|
|
136
|
+
host_share = pathlib.Path(host).expanduser()
|
|
137
|
+
if not host_share.is_dir():
|
|
138
|
+
raise GuestError(f"the share's host side, {host_share}, is not a directory")
|
|
139
|
+
return cls(alias, host_share, guest, settings)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_environment(cls, variable: str = "QUALITY_LOOP_GUEST",
|
|
143
|
+
share_variable: str | None = None,
|
|
144
|
+
settings_path: pathlib.Path | str | None = None) -> "Guest | None":
|
|
145
|
+
"""The guest ``variable`` names, or None when it names none. A product
|
|
146
|
+
passes its own variable names (``KEYHAC_QA_GUEST``)."""
|
|
147
|
+
alias = os.environ.get(variable)
|
|
148
|
+
if not alias:
|
|
149
|
+
return None
|
|
150
|
+
share = os.environ.get(share_variable) if share_variable else None
|
|
151
|
+
return cls.from_settings(alias, settings_for(alias, settings_path), share)
|
|
152
|
+
|
|
153
|
+
def to_settings(self) -> dict:
|
|
154
|
+
"""What :mod:`quality_loop.guest.screens` needs to reach this guest
|
|
155
|
+
from another process: JSON, no secrets."""
|
|
156
|
+
return {"alias": self.alias, "hostname": self.hostname,
|
|
157
|
+
"host_share": str(self.host_share), "guest_share": self.guest_share,
|
|
158
|
+
"windows": self.windows, "python": self.python}
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_screen_settings(cls, settings: dict) -> "Guest":
|
|
162
|
+
guest = cls(settings["alias"], pathlib.Path(settings["host_share"]),
|
|
163
|
+
settings["guest_share"], {"python": settings.get("python")})
|
|
164
|
+
guest._windows = settings["windows"]
|
|
165
|
+
guest.hostname = settings.get("hostname")
|
|
166
|
+
return guest
|
|
167
|
+
|
|
168
|
+
# --- talking to it ---------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def ssh_options(self) -> list[str]:
|
|
171
|
+
if self.hostname is None:
|
|
172
|
+
return []
|
|
173
|
+
# A clone answers with its base's host key at a new address each time.
|
|
174
|
+
return ["-o", f"HostName={self.hostname}", "-o", f"HostKeyAlias={self.alias}",
|
|
175
|
+
"-o", "StrictHostKeyChecking=accept-new"]
|
|
176
|
+
|
|
177
|
+
def ssh(self, command: str, timeout: float = 60) -> subprocess.CompletedProcess:
|
|
178
|
+
return subprocess.run(["ssh", "-o", "BatchMode=yes", *self.ssh_options(),
|
|
179
|
+
self.alias, command],
|
|
180
|
+
capture_output=True, text=True, timeout=timeout,
|
|
181
|
+
stdin=subprocess.DEVNULL)
|
|
182
|
+
|
|
183
|
+
def reachable(self, patience: float) -> bool:
|
|
184
|
+
"""Whether SSH gets in within ``patience`` seconds."""
|
|
185
|
+
deadline = time.monotonic() + patience
|
|
186
|
+
while time.monotonic() < deadline:
|
|
187
|
+
try:
|
|
188
|
+
if self.ssh("echo up", timeout=15).stdout.strip() == "up":
|
|
189
|
+
return True
|
|
190
|
+
except subprocess.TimeoutExpired:
|
|
191
|
+
pass
|
|
192
|
+
time.sleep(3)
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def machine(self):
|
|
196
|
+
"""A context manager for the run: the VM up from a clean state, and
|
|
197
|
+
put away afterwards, when the settings name one."""
|
|
198
|
+
if "tart" in self.settings:
|
|
199
|
+
return TartMachine(self)
|
|
200
|
+
if "vmx" in self.settings:
|
|
201
|
+
return VMwareMachine(self)
|
|
202
|
+
return contextlib.nullcontext()
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def windows(self) -> bool:
|
|
206
|
+
"""Whether the guest is Windows - asked once. A Windows guest's shell
|
|
207
|
+
is PowerShell, where `uname` is no command."""
|
|
208
|
+
if self._windows is None:
|
|
209
|
+
said = self.ssh("uname -s")
|
|
210
|
+
if said.returncode == 255:
|
|
211
|
+
raise GuestError(f"cannot reach the VM as {self.alias!r}:\n{said.stderr}")
|
|
212
|
+
self._windows = said.stdout.strip() != "Darwin"
|
|
213
|
+
return self._windows
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def platform(self) -> str:
|
|
217
|
+
"""``sys.platform`` as the guest's own Python would say it."""
|
|
218
|
+
return "win32" if self.windows else "darwin"
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def python(self) -> str:
|
|
222
|
+
"""The image's interpreter, as the guest names it (``python`` in the
|
|
223
|
+
settings)."""
|
|
224
|
+
python = self.settings.get("python")
|
|
225
|
+
if not python:
|
|
226
|
+
raise GuestError(f"the guest {self.alias!r} has no python = in its settings: "
|
|
227
|
+
"the image's interpreter, as the guest names it")
|
|
228
|
+
return python
|
|
229
|
+
|
|
230
|
+
def path(self, host_path: pathlib.Path | str) -> str:
|
|
231
|
+
"""A path under the host side of the share, as the guest sees it."""
|
|
232
|
+
parts = pathlib.Path(host_path).resolve().relative_to(self.host_share).parts
|
|
233
|
+
join = ntpath.join if self.windows else posixpath.join
|
|
234
|
+
return join(self.guest_share, *parts)
|
|
235
|
+
|
|
236
|
+
def mkdtemp(self, prefix: str) -> pathlib.Path:
|
|
237
|
+
"""A new directory on the share (its host side)."""
|
|
238
|
+
return pathlib.Path(tempfile.mkdtemp(prefix=prefix, dir=self.host_share)).resolve()
|
|
239
|
+
|
|
240
|
+
def kill(self, pid: int) -> None:
|
|
241
|
+
if self.windows:
|
|
242
|
+
self.ssh(f"Stop-Process -Id {int(pid)} -Force -ErrorAction SilentlyContinue")
|
|
243
|
+
else:
|
|
244
|
+
self.ssh(f"kill -INT {int(pid)}; sleep 3; kill -9 {int(pid)} 2>/dev/null; true")
|
|
245
|
+
|
|
246
|
+
def forward(self, port: int) -> subprocess.Popen:
|
|
247
|
+
"""The guest's 127.0.0.1:port as the host's, until terminated."""
|
|
248
|
+
return self._tunnel("-L", port)
|
|
249
|
+
|
|
250
|
+
def reverse(self, port: int) -> subprocess.Popen:
|
|
251
|
+
"""The host's 127.0.0.1:port as the guest's, until terminated."""
|
|
252
|
+
return self._tunnel("-R", port, start_new_session=True)
|
|
253
|
+
|
|
254
|
+
def _tunnel(self, direction: str, port: int, **popen) -> subprocess.Popen:
|
|
255
|
+
return subprocess.Popen(
|
|
256
|
+
["ssh", "-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
|
|
257
|
+
*self.ssh_options(), direction, f"{port}:127.0.0.1:{port}", self.alias],
|
|
258
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
259
|
+
**popen)
|
|
260
|
+
|
|
261
|
+
# --- what runs in it ---------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def stage(self, prefix: str = "ql-stage-") -> pathlib.Path:
|
|
264
|
+
"""A new stage on the share, carrying what runs in the guest on
|
|
265
|
+
quality-loop's behalf: its screen backends and the boot scripts
|
|
266
|
+
(``<stage>/ql``). The product adds its own files beside them."""
|
|
267
|
+
stage = self.mkdtemp(prefix)
|
|
268
|
+
ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
|
|
269
|
+
package = HERE.parent
|
|
270
|
+
ql = stage / "ql" / "quality_loop"
|
|
271
|
+
ql.mkdir(parents=True)
|
|
272
|
+
# quality_loop/__init__.py and screens/ need nothing else; guest/ is
|
|
273
|
+
# copied for its boot scripts.
|
|
274
|
+
shutil.copy2(package / "__init__.py", ql / "__init__.py")
|
|
275
|
+
shutil.copytree(package / "screens", ql / "screens", ignore=ignore)
|
|
276
|
+
shutil.copytree(HERE, ql / "guest", ignore=ignore)
|
|
277
|
+
return stage
|
|
278
|
+
|
|
279
|
+
def boot_script(self, stage: pathlib.Path, name: str) -> pathlib.Path:
|
|
280
|
+
"""``name`` among the boot scripts, as staged."""
|
|
281
|
+
return stage / "ql" / "quality_loop" / "guest" / "boot" / name
|
|
282
|
+
|
|
283
|
+
def venv(self, stage: pathlib.Path, requirements: list[str], *,
|
|
284
|
+
timeout: float = 900) -> str:
|
|
285
|
+
"""A fresh venv in the guest with ``requirements`` installed, from the
|
|
286
|
+
image's Python; returns its interpreter as the guest names it.
|
|
287
|
+
|
|
288
|
+
Made on the guest's own disk, which the revert after the run throws
|
|
289
|
+
away, so every run gets what the working tree asks for now. pip's
|
|
290
|
+
download cache is on the share (``<host share>/.pip-cache``), so the
|
|
291
|
+
next run downloads only what changed. Nothing that is under test goes
|
|
292
|
+
in here: the product stages its own code and its makers' packages."""
|
|
293
|
+
lines = "\n".join(requirements) + "\n"
|
|
294
|
+
digest = hashlib.sha256(lines.encode()).hexdigest()[:12]
|
|
295
|
+
(stage / "requirements.txt").write_text(lines, encoding="utf-8")
|
|
296
|
+
cache = self.host_share / ".pip-cache"
|
|
297
|
+
cache.mkdir(exist_ok=True)
|
|
298
|
+
if self.windows:
|
|
299
|
+
where = rf"$env:LOCALAPPDATA\ql-venv-{digest}"
|
|
300
|
+
python = rf"{where}\Scripts\python.exe"
|
|
301
|
+
command = (f"$ErrorActionPreference = 'Stop'; "
|
|
302
|
+
f"& {ps_quote(self.python)} -m venv \"{where}\"; "
|
|
303
|
+
f"if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}; "
|
|
304
|
+
f"& \"{python}\" -m pip install --disable-pip-version-check -q "
|
|
305
|
+
f"--cache-dir {ps_quote(self.path(cache))} "
|
|
306
|
+
f"-r {ps_quote(self.path(stage / 'requirements.txt'))}; "
|
|
307
|
+
f"if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}; "
|
|
308
|
+
f"Write-Output \"{python}\"")
|
|
309
|
+
else:
|
|
310
|
+
where = f"$HOME/.ql-venv-{digest}"
|
|
311
|
+
python = f"{where}/bin/python"
|
|
312
|
+
command = (f"set -e; {sh_quote(self.python)} -m venv \"{where}\"; "
|
|
313
|
+
f"\"{python}\" -m pip install --disable-pip-version-check -q "
|
|
314
|
+
f"--cache-dir {sh_quote(self.path(cache))} "
|
|
315
|
+
f"-r {sh_quote(self.path(stage / 'requirements.txt'))}; "
|
|
316
|
+
f"echo \"{python}\"")
|
|
317
|
+
said = self.ssh(command, timeout=timeout)
|
|
318
|
+
if said.returncode != 0:
|
|
319
|
+
raise GuestError(f"could not install the run's packages in the VM:\n"
|
|
320
|
+
f"{said.stdout[-1500:]}{said.stderr[-1500:]}")
|
|
321
|
+
return said.stdout.strip().splitlines()[-1].strip()
|
|
322
|
+
|
|
323
|
+
def run_in_session(self, stage: pathlib.Path, task: str, python: str,
|
|
324
|
+
script: pathlib.Path, *arguments: str) -> None:
|
|
325
|
+
"""Windows: start ``script`` (on the stage) with ``python`` - a
|
|
326
|
+
``pythonw.exe``, which opens no console window on that desktop - in
|
|
327
|
+
the logged-in user's desktop session, and return.
|
|
328
|
+
|
|
329
|
+
A scheduled task, run interactively, is how a process gets into that
|
|
330
|
+
session from SSH, where a window it opens is seen and EnumWindows
|
|
331
|
+
finds it. The task is XML, not schtasks /tr: the program and its
|
|
332
|
+
arguments stay apart, so nothing has to survive PowerShell's quoting
|
|
333
|
+
of a native command line - and the ScheduledTasks cmdlets need CIM,
|
|
334
|
+
which refuses a standard user over SSH. The task is left defined;
|
|
335
|
+
:meth:`forget_task` removes it."""
|
|
336
|
+
if not self.windows:
|
|
337
|
+
raise GuestError("run_in_session is for a Windows guest; on macOS, start "
|
|
338
|
+
"the app with `open`, which reaches the desktop session")
|
|
339
|
+
definition = stage / f"{task}.xml"
|
|
340
|
+
definition.write_text(task_xml(
|
|
341
|
+
python, " ".join(f'"{a}"' for a in ("-s", self.path(script), *arguments))),
|
|
342
|
+
encoding="utf-16")
|
|
343
|
+
quoted = ps_quote(task)
|
|
344
|
+
said = self.ssh(f"schtasks /create /tn {quoted} /xml "
|
|
345
|
+
f"{ps_quote(self.path(definition))} /f; schtasks /run /tn {quoted}")
|
|
346
|
+
if "SUCCESS: Attempted to run" not in said.stdout:
|
|
347
|
+
raise GuestError(f"could not run {script.name} in the VM's desktop session:\n"
|
|
348
|
+
f"{said.stdout}{said.stderr}")
|
|
349
|
+
|
|
350
|
+
def launch(self, stage: pathlib.Path, task: str, python: str, *,
|
|
351
|
+
script: str, log: pathlib.Path, environ: dict | None = None,
|
|
352
|
+
path: list[str] | None = None, cwd: str | None = None,
|
|
353
|
+
argv: list[str] | None = None) -> None:
|
|
354
|
+
"""Windows: run ``script`` (a guest path) as ``__main__`` in the
|
|
355
|
+
desktop session, through ``boot/launch.py``: ``environ`` set, ``path``
|
|
356
|
+
put in front of ``sys.path``, ``cwd`` entered, stdout and stderr into
|
|
357
|
+
``log`` (on the share), ``argv`` as its arguments. ``python`` should be
|
|
358
|
+
a ``pythonw.exe``: a console window would open on the desktop the room
|
|
359
|
+
looks at."""
|
|
360
|
+
settings = {"log": self.path(log), "script": script,
|
|
361
|
+
"environ": dict(environ or {}), "path": list(path or []),
|
|
362
|
+
"cwd": cwd, "argv": list(argv or [])}
|
|
363
|
+
launch = stage / f"{task}.json"
|
|
364
|
+
launch.write_text(json.dumps(settings), encoding="utf-8")
|
|
365
|
+
self.run_in_session(stage, task, python, self.boot_script(stage, "launch.py"),
|
|
366
|
+
self.path(launch))
|
|
367
|
+
|
|
368
|
+
def forget_task(self, task: str) -> None:
|
|
369
|
+
if self.windows:
|
|
370
|
+
self.ssh(f"schtasks /delete /tn {ps_quote(task)} /f")
|
|
371
|
+
|
|
372
|
+
def windowless(self, python: str) -> str:
|
|
373
|
+
"""``python``'s ``pythonw.exe`` beside it, on Windows."""
|
|
374
|
+
if self.windows and python.lower().endswith("python.exe"):
|
|
375
|
+
return python[:-len("python.exe")] + "pythonw.exe"
|
|
376
|
+
return python
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def task_xml(command: str, arguments: str) -> str:
|
|
380
|
+
"""A scheduled task that runs ``command`` in the creating user's desktop
|
|
381
|
+
session (InteractiveToken), with no time limit."""
|
|
382
|
+
from xml.sax.saxutils import escape
|
|
383
|
+
return f"""<?xml version="1.0" encoding="UTF-16"?>
|
|
384
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
385
|
+
<Principals>
|
|
386
|
+
<Principal id="Author">
|
|
387
|
+
<LogonType>InteractiveToken</LogonType>
|
|
388
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
389
|
+
</Principal>
|
|
390
|
+
</Principals>
|
|
391
|
+
<Settings>
|
|
392
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
393
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
394
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
395
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
396
|
+
<Enabled>true</Enabled>
|
|
397
|
+
</Settings>
|
|
398
|
+
<Actions Context="Author">
|
|
399
|
+
<Exec>
|
|
400
|
+
<Command>{escape(command)}</Command>
|
|
401
|
+
<Arguments>{escape(arguments)}</Arguments>
|
|
402
|
+
</Exec>
|
|
403
|
+
</Actions>
|
|
404
|
+
</Task>
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def listening(port: int) -> bool:
|
|
409
|
+
"""Whether something answers on the host's 127.0.0.1:port."""
|
|
410
|
+
with socket.socket() as probe:
|
|
411
|
+
probe.settimeout(1.0)
|
|
412
|
+
return probe.connect_ex(("127.0.0.1", port)) == 0
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def end_tunnels(stage: pathlib.Path) -> None:
|
|
416
|
+
"""The reverse tunnels :mod:`quality_loop.guest.screens` started for the
|
|
417
|
+
screens' pages: it records each one's pid on the stage, and the operator
|
|
418
|
+
that started them is gone by the run's end."""
|
|
419
|
+
for pid_file in (stage / "tunnels").glob("*.pid"):
|
|
420
|
+
try:
|
|
421
|
+
os.kill(int(pid_file.read_text()), 15)
|
|
422
|
+
except (OSError, ValueError):
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# --- the machines -----------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
class TartMachine:
|
|
429
|
+
"""A Tart VM cloned from the base for this run, and deleted after it.
|
|
430
|
+
|
|
431
|
+
Its share is ``tart run --dir``, tagged with the last part of the share's
|
|
432
|
+
guest side (``/Volumes/My Shared Files/qa`` -> ``qa``). Shown in a window
|
|
433
|
+
when ``visible = true``; watch it, but do not type into it.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def __init__(self, guest: Guest, patience: float = 240.0):
|
|
437
|
+
self.guest, self.patience = guest, patience
|
|
438
|
+
self.base = guest.settings["tart"]
|
|
439
|
+
self.name = f"{self.base}-run-{os.getpid()}"
|
|
440
|
+
self.process = None
|
|
441
|
+
|
|
442
|
+
def _tart(self, *args, check=True) -> subprocess.CompletedProcess:
|
|
443
|
+
said = subprocess.run(["tart", *args], capture_output=True, text=True)
|
|
444
|
+
if check and said.returncode != 0:
|
|
445
|
+
raise GuestError(f"tart {' '.join(args)}: {said.stderr.strip()}")
|
|
446
|
+
return said
|
|
447
|
+
|
|
448
|
+
def __enter__(self):
|
|
449
|
+
self._tart("clone", self.base, self.name)
|
|
450
|
+
tag = posixpath.basename(self.guest.guest_share.rstrip("/"))
|
|
451
|
+
args = ["tart", "run", f"--dir={tag}:{self.guest.host_share}", self.name]
|
|
452
|
+
if not self.guest.settings.get("visible"):
|
|
453
|
+
args.insert(2, "--no-graphics")
|
|
454
|
+
self.process = subprocess.Popen(args, stdin=subprocess.DEVNULL,
|
|
455
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
456
|
+
deadline = time.monotonic() + self.patience
|
|
457
|
+
while time.monotonic() < deadline:
|
|
458
|
+
said = self._tart("ip", self.name, check=False)
|
|
459
|
+
if said.returncode == 0 and said.stdout.strip():
|
|
460
|
+
self.guest.hostname = said.stdout.strip()
|
|
461
|
+
break
|
|
462
|
+
time.sleep(2)
|
|
463
|
+
if self.guest.hostname is None or not self.guest.reachable(
|
|
464
|
+
max(10.0, deadline - time.monotonic())):
|
|
465
|
+
self.__exit__()
|
|
466
|
+
raise GuestError(f"the clone {self.name} of {self.base} did not come up")
|
|
467
|
+
print(f"vm: {self.name}, a clone of {self.base}, at {self.guest.hostname}")
|
|
468
|
+
return self
|
|
469
|
+
|
|
470
|
+
def __exit__(self, *exc):
|
|
471
|
+
self._tart("stop", self.name, check=False)
|
|
472
|
+
if self.process is not None:
|
|
473
|
+
try:
|
|
474
|
+
self.process.wait(30)
|
|
475
|
+
except subprocess.TimeoutExpired:
|
|
476
|
+
self.process.kill()
|
|
477
|
+
self._tart("delete", self.name, check=False)
|
|
478
|
+
self.guest.hostname = None
|
|
479
|
+
print(f"vm: {self.name} deleted")
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class VMwareMachine:
|
|
484
|
+
"""A VMware Fusion VM reverted to a snapshot before the run.
|
|
485
|
+
|
|
486
|
+
A snapshot taken while the VM was running comes back running, signed in
|
|
487
|
+
and with SSH up, after `vmrun start`. The SMB share that was mounted on
|
|
488
|
+
the host does not survive the revert, so it is mounted again. The VM is
|
|
489
|
+
shut down afterwards, its share unmounted first: the next run reverts it
|
|
490
|
+
anyway, and a VM `vmrun` left running headless is one the Fusion app
|
|
491
|
+
cannot open. ``visible`` has no say here: `vmrun` can only resume it
|
|
492
|
+
without a window. ``vm_password`` is the VM's encryption password, which
|
|
493
|
+
`vmrun` needs for a VM with a vTPM.
|
|
494
|
+
|
|
495
|
+
Learnt on Fusion 26 with a vTPM-encrypted Windows 11 VM: `vmrun` cannot
|
|
496
|
+
linked-clone it ("Cannot read the virtual machine configuration file"),
|
|
497
|
+
nor snapshot it while it runs ("Authentication for encrypted virtual
|
|
498
|
+
machine failed") - take a running-state snapshot in the Fusion app, after
|
|
499
|
+
`vmrun suspend` (a headless VM does not show there).
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
VMRUN = "/Applications/VMware Fusion.app/Contents/Library/vmrun"
|
|
503
|
+
|
|
504
|
+
def __init__(self, guest: Guest, patience: float = 180.0):
|
|
505
|
+
self.guest, self.patience = guest, patience
|
|
506
|
+
settings = guest.settings
|
|
507
|
+
self.vmx = str(pathlib.Path(settings["vmx"]).expanduser())
|
|
508
|
+
self.snapshot = settings["snapshot"]
|
|
509
|
+
self.password = settings.get("vm_password")
|
|
510
|
+
|
|
511
|
+
def _vmrun(self, *args, check: bool = True) -> bool:
|
|
512
|
+
command = [self.VMRUN, "-T", "fusion"]
|
|
513
|
+
if self.password:
|
|
514
|
+
command += ["-vp", secret(self.password)]
|
|
515
|
+
# Into a file, not a pipe: `vmrun start` hands its stdout on to the
|
|
516
|
+
# vmware-vmx it starts, so a pipe would not close until the VM stops.
|
|
517
|
+
with tempfile.TemporaryFile("w+") as output:
|
|
518
|
+
said = subprocess.run(command + list(args), stdin=subprocess.DEVNULL,
|
|
519
|
+
stdout=output, stderr=subprocess.STDOUT, text=True)
|
|
520
|
+
output.seek(0)
|
|
521
|
+
if said.returncode != 0 and check:
|
|
522
|
+
raise GuestError(f"vmrun {args[0]}: {output.read().strip()}")
|
|
523
|
+
return said.returncode == 0
|
|
524
|
+
|
|
525
|
+
def __enter__(self):
|
|
526
|
+
self._vmrun("revertToSnapshot", self.vmx, self.snapshot)
|
|
527
|
+
# nogui: start with no argument means gui, which Fusion refuses for a
|
|
528
|
+
# VM a running-state snapshot left suspended ("The operation is not
|
|
529
|
+
# supported"). Opening Fusion afterwards shows the running VM.
|
|
530
|
+
self._vmrun("start", self.vmx, "nogui")
|
|
531
|
+
if not self.guest.reachable(self.patience):
|
|
532
|
+
raise GuestError(f"{self.guest.alias} did not answer over SSH after the revert "
|
|
533
|
+
f"to {self.snapshot!r}")
|
|
534
|
+
self._mount()
|
|
535
|
+
self._wait_for_the_clock()
|
|
536
|
+
print(f"vm: {self.guest.alias} reverted to {self.snapshot!r}")
|
|
537
|
+
return self
|
|
538
|
+
|
|
539
|
+
def _mount(self) -> None:
|
|
540
|
+
url = self.guest.settings.get("smb")
|
|
541
|
+
if not url:
|
|
542
|
+
return
|
|
543
|
+
mount = str(self.guest.host_share)
|
|
544
|
+
subprocess.run(["umount", mount], capture_output=True)
|
|
545
|
+
password = self.guest.settings.get("smb_password")
|
|
546
|
+
if password:
|
|
547
|
+
user, _, where = url.removeprefix("//").partition("@")
|
|
548
|
+
url = f"//{user}:{urllib.parse.quote(secret(password), safe='')}@{where}"
|
|
549
|
+
# -N: never prompt. Without smb_password, the Keychain's internet
|
|
550
|
+
# password for the server (Finder's "Remember") has to do.
|
|
551
|
+
said = subprocess.run(["mount_smbfs", "-N", url, mount],
|
|
552
|
+
capture_output=True, text=True, stdin=subprocess.DEVNULL)
|
|
553
|
+
if said.returncode != 0:
|
|
554
|
+
raise GuestError(f"could not mount the share on {mount}: {said.stderr.strip()}")
|
|
555
|
+
|
|
556
|
+
def _wait_for_the_clock(self, patience: float = 60.0) -> None:
|
|
557
|
+
"""A snapshot comes back with the time it was taken; VMware Tools
|
|
558
|
+
sets it right within seconds. Wait for that, so the run's own record
|
|
559
|
+
of times means something."""
|
|
560
|
+
deadline = time.monotonic() + patience
|
|
561
|
+
while time.monotonic() < deadline:
|
|
562
|
+
said = self.guest.ssh("[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()"
|
|
563
|
+
if self.guest.windows else "date +%s")
|
|
564
|
+
try:
|
|
565
|
+
if abs(int(said.stdout.strip()) - time.time()) < 30:
|
|
566
|
+
return
|
|
567
|
+
except ValueError:
|
|
568
|
+
pass
|
|
569
|
+
time.sleep(3)
|
|
570
|
+
print("vm: the guest's clock is still off - carrying on")
|
|
571
|
+
|
|
572
|
+
def __exit__(self, *exc):
|
|
573
|
+
if self.guest.settings.get("smb"):
|
|
574
|
+
subprocess.run(["umount", str(self.guest.host_share)], capture_output=True)
|
|
575
|
+
# hard: the snapshot it is reverted to next time is all that counts.
|
|
576
|
+
if self._vmrun("stop", self.vmx, "hard", check=False):
|
|
577
|
+
print(f"vm: {self.guest.alias} stopped")
|
|
578
|
+
else:
|
|
579
|
+
print(f"vm: could not stop {self.guest.alias} - stop it before opening Fusion")
|
|
580
|
+
return False
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Scripts run in the guest, from a run's stage - never imported on the host.
|
|
2
|
+
|
|
3
|
+
``launch.py`` starts a product's script in the desktop session with no console
|
|
4
|
+
window; ``screen_agent.py`` performs one screen operation there. Both use
|
|
5
|
+
only the standard library and the quality_loop files the stage carries
|
|
6
|
+
(`quality_loop.guest.Guest.stage`).
|
|
7
|
+
"""
|