quality-loop 0.3.0__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.3.0/quality_loop.egg-info → quality_loop-0.4.0}/PKG-INFO +3 -1
- {quality_loop-0.3.0 → quality_loop-0.4.0}/README.md +2 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/__init__.py +1 -1
- 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.3.0 → quality_loop-0.4.0/quality_loop.egg-info}/PKG-INFO +3 -1
- {quality_loop-0.3.0 → 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.3.0 → quality_loop-0.4.0}/LICENSE +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/pyproject.toml +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/OPERATOR.md +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/__main__.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/adapter.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/audit.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/cli.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/compare.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/config.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/__init__.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/_mac.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/_windows.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/keys.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/drive/terminal.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/install.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/mcp.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/operator.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/pages.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/processes.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/progress.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/report.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/room.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/runner.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/scenario.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/screens/__init__.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/screens/_mac.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/screens/_windows.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/session.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/skill/SKILL.md +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/skill/references/filing.md +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/skill/references/setup.md +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/standin.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop/transcript.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/dependency_links.txt +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/entry_points.txt +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/requires.txt +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/quality_loop.egg-info/top_level.txt +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/setup.cfg +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_core.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_drive.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_loop.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_mcp.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_operator.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_scenario_report.py +0 -0
- {quality_loop-0.3.0 → quality_loop-0.4.0}/tests/test_screens.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.
|
|
@@ -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
|
+
"""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A product's script in a Windows guest's desktop session, with no console.
|
|
2
|
+
|
|
3
|
+
Not run on the host. `quality_loop.guest.Guest.launch` has a scheduled task
|
|
4
|
+
run this with the image's pythonw.exe, the path of a JSON file as its one
|
|
5
|
+
argument. A batch file would do the same with less code, but cmd.exe opens a
|
|
6
|
+
console - on Windows 11 a Windows Terminal window - on the desktop the room
|
|
7
|
+
looks at, and an app that hands the foreground back would hand it to that.
|
|
8
|
+
|
|
9
|
+
What the batch file did is done here instead, from the JSON: the environment,
|
|
10
|
+
the search path in front of the interpreter's own, the working directory, and
|
|
11
|
+
stdout and stderr into the log. Then the script runs as __main__.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import runpy
|
|
17
|
+
import site
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
with open(sys.argv[1], encoding="utf-8") as f:
|
|
21
|
+
settings = json.load(f)
|
|
22
|
+
|
|
23
|
+
# pythonw has no stdout or stderr: the log is both.
|
|
24
|
+
log = open(settings["log"], "w", encoding="utf-8", buffering=1)
|
|
25
|
+
sys.stdout = sys.stderr = log
|
|
26
|
+
|
|
27
|
+
os.environ.update(settings.get("environ") or {})
|
|
28
|
+
if site.USER_SITE in sys.path:
|
|
29
|
+
sys.path.remove(site.USER_SITE)
|
|
30
|
+
sys.path[0:0] = settings.get("path") or []
|
|
31
|
+
if settings.get("cwd"):
|
|
32
|
+
os.chdir(settings["cwd"])
|
|
33
|
+
sys.argv = [settings["script"], *settings.get("argv", [])]
|
|
34
|
+
|
|
35
|
+
runpy.run_path(settings["script"], run_name="__main__")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""One screen operation of quality-loop's own OS backend, run in the guest.
|
|
2
|
+
|
|
3
|
+
Not run on the host. `quality_loop.guest.screens`, the screen backend the
|
|
4
|
+
operator uses when the product runs in a VM, writes a request on the run's
|
|
5
|
+
stage and runs this there: over SSH on macOS, from a scheduled task in the
|
|
6
|
+
desktop session on Windows (a window opened from SSH lands where nobody sees
|
|
7
|
+
it). The request names the operation, its arguments, and where to write the
|
|
8
|
+
answer; the backend waits for that file.
|
|
9
|
+
|
|
10
|
+
The operations are quality-loop's own (quality_loop.screens._mac and
|
|
11
|
+
_windows, copied onto the stage), so a page or a terminal in the guest is put
|
|
12
|
+
up and recognised exactly as on a desktop the operator shares.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import traceback
|
|
19
|
+
|
|
20
|
+
# <stage>/ql/quality_loop/guest/boot/screen_agent.py -> <stage>/ql
|
|
21
|
+
QL = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
22
|
+
sys.path.insert(0, QL)
|
|
23
|
+
|
|
24
|
+
with open(sys.argv[1], encoding="utf-8") as f:
|
|
25
|
+
request = json.load(f)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from quality_loop.screens import ScreenError
|
|
29
|
+
if sys.platform == "darwin":
|
|
30
|
+
from quality_loop.screens import _mac as os_backend
|
|
31
|
+
else:
|
|
32
|
+
from quality_loop.screens import _windows as os_backend
|
|
33
|
+
op = request["op"]
|
|
34
|
+
if op == "open_page":
|
|
35
|
+
value = os_backend.open_page(request["url"], request["title"], request["patience"])
|
|
36
|
+
elif op == "open_terminal":
|
|
37
|
+
value = os_backend.open_terminal(request["command"], request["title"],
|
|
38
|
+
request["patience"])
|
|
39
|
+
elif op == "close":
|
|
40
|
+
os_backend.close(request["kind"], request["handle"])
|
|
41
|
+
value = None
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(f"no operation {op!r}")
|
|
44
|
+
answer = {"ok": True, "value": value}
|
|
45
|
+
except ScreenError as error:
|
|
46
|
+
answer = {"ok": False, "error": str(error)}
|
|
47
|
+
except Exception:
|
|
48
|
+
answer = {"ok": False, "error": traceback.format_exc()}
|
|
49
|
+
|
|
50
|
+
# Written whole, then renamed: the host polls for the name.
|
|
51
|
+
partial = request["result"] + ".partial"
|
|
52
|
+
with open(partial, "w", encoding="utf-8") as f:
|
|
53
|
+
json.dump(answer, f)
|
|
54
|
+
os.replace(partial, request["result"])
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The operator's screens, put up in the VM the product runs in.
|
|
2
|
+
|
|
3
|
+
An adapter whose app runs in a guest returns this file from
|
|
4
|
+
``screen_backend()``, with :func:`settings` as its settings::
|
|
5
|
+
|
|
6
|
+
def screen_backend(self):
|
|
7
|
+
return {"path": quality_loop.guest.screens.__file__,
|
|
8
|
+
"settings": quality_loop.guest.screens.settings(guest, stage)}
|
|
9
|
+
|
|
10
|
+
and quality-loop loads it (`quality_loop.screens.load_backend`) in place of
|
|
11
|
+
this machine's own, so `screen_open` puts a page or a terminal up on the
|
|
12
|
+
guest's desktop - where the app reads the screen - and not on the host's.
|
|
13
|
+
|
|
14
|
+
Each operation is quality-loop's own (screens._mac / _windows), run in the
|
|
15
|
+
guest by ``boot/screen_agent.py`` from the run's stage: over SSH on macOS,
|
|
16
|
+
from a scheduled task in the desktop session on Windows. A request and its
|
|
17
|
+
answer are files on the share.
|
|
18
|
+
|
|
19
|
+
Pages are served on the host's 127.0.0.1 by the runner. A reverse tunnel
|
|
20
|
+
(`ssh -R`, same port) makes that address the guest's too, so the URL, and the
|
|
21
|
+
page's POSTed events, work unchanged. One tunnel per port, started by the
|
|
22
|
+
first page that needs it; its pid is kept on the stage, and the run's end
|
|
23
|
+
(`quality_loop.guest.end_tunnels`) ends it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import pathlib
|
|
31
|
+
import shutil
|
|
32
|
+
import time
|
|
33
|
+
import urllib.parse
|
|
34
|
+
import uuid
|
|
35
|
+
|
|
36
|
+
from quality_loop.guest import Guest, sh_quote
|
|
37
|
+
from quality_loop.screens import ScreenError
|
|
38
|
+
|
|
39
|
+
_guest: Guest | None = None
|
|
40
|
+
_stage: pathlib.Path | None = None
|
|
41
|
+
_terminal_python: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def settings(guest: Guest, stage: pathlib.Path, terminal_python: str | None = None) -> dict:
|
|
45
|
+
"""What :func:`configure` needs, from the adapter's live guest and stage.
|
|
46
|
+
``terminal_python`` runs a terminal screen's script in the guest; the
|
|
47
|
+
image's interpreter by default."""
|
|
48
|
+
return dict(guest.to_settings(), stage=str(stage),
|
|
49
|
+
terminal_python=terminal_python or guest.python)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def configure(settings: dict) -> None:
|
|
53
|
+
global _guest, _stage, _terminal_python
|
|
54
|
+
_guest = Guest.from_screen_settings(settings)
|
|
55
|
+
_stage = pathlib.Path(settings["stage"])
|
|
56
|
+
_terminal_python = settings.get("terminal_python") or _guest.python
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _call(request: dict, patience: float):
|
|
60
|
+
"""Run one operation in the guest and answer its value."""
|
|
61
|
+
requests = _stage / "screens"
|
|
62
|
+
requests.mkdir(exist_ok=True)
|
|
63
|
+
name = uuid.uuid4().hex[:12]
|
|
64
|
+
asked, answered = requests / f"{name}.json", requests / f"{name}-answer.json"
|
|
65
|
+
request = dict(request, result=_guest.path(answered))
|
|
66
|
+
asked.write_text(json.dumps(request), encoding="utf-8")
|
|
67
|
+
agent = _guest.boot_script(_stage, "screen_agent.py")
|
|
68
|
+
task = f"ql-screen-{name}"
|
|
69
|
+
try:
|
|
70
|
+
if _guest.windows:
|
|
71
|
+
try:
|
|
72
|
+
_guest.run_in_session(_stage, task, _guest.windowless(_guest.python),
|
|
73
|
+
agent, _guest.path(asked))
|
|
74
|
+
except Exception as error:
|
|
75
|
+
raise ScreenError(str(error)) from None
|
|
76
|
+
else:
|
|
77
|
+
# Synchronous over SSH; Safari and Terminal are driven through
|
|
78
|
+
# Apple Events, which the guest's base image has allowed for SSH.
|
|
79
|
+
said = _guest.ssh(f"{sh_quote(_guest.python)} {sh_quote(_guest.path(agent))} "
|
|
80
|
+
f"{sh_quote(_guest.path(asked))}", timeout=patience + 120)
|
|
81
|
+
if said.returncode != 0 and not answered.exists():
|
|
82
|
+
raise ScreenError(f"the VM did not run it: {said.stderr.strip()[:500]}")
|
|
83
|
+
deadline = time.monotonic() + patience + 60
|
|
84
|
+
while not answered.exists():
|
|
85
|
+
if time.monotonic() > deadline:
|
|
86
|
+
raise ScreenError(f"no answer from the VM within {patience + 60:.0f}s")
|
|
87
|
+
time.sleep(0.5)
|
|
88
|
+
answer = json.loads(answered.read_text(encoding="utf-8"))
|
|
89
|
+
finally:
|
|
90
|
+
if _guest.windows:
|
|
91
|
+
_guest.forget_task(task)
|
|
92
|
+
for leftover in (asked, answered, _stage / f"{task}.xml"):
|
|
93
|
+
leftover.unlink(missing_ok=True)
|
|
94
|
+
if not answer["ok"]:
|
|
95
|
+
raise ScreenError(f"in the VM: {answer['error']}")
|
|
96
|
+
return answer["value"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _tunnel(port: int) -> None:
|
|
100
|
+
"""The host's 127.0.0.1:port as the guest's, once per port."""
|
|
101
|
+
tunnels = _stage / "tunnels"
|
|
102
|
+
tunnels.mkdir(exist_ok=True)
|
|
103
|
+
pid_file = tunnels / f"{port}.pid"
|
|
104
|
+
if pid_file.exists():
|
|
105
|
+
try:
|
|
106
|
+
os.kill(int(pid_file.read_text()), 0)
|
|
107
|
+
return
|
|
108
|
+
except (OSError, ValueError):
|
|
109
|
+
pass
|
|
110
|
+
process = _guest.reverse(port)
|
|
111
|
+
pid_file.write_text(str(process.pid))
|
|
112
|
+
time.sleep(2)
|
|
113
|
+
if process.poll() is not None:
|
|
114
|
+
pid_file.unlink(missing_ok=True)
|
|
115
|
+
raise ScreenError(f"could not forward the pages' port {port} into the VM")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def open_page(url: str, title: str, patience: float):
|
|
119
|
+
_tunnel(urllib.parse.urlsplit(url).port)
|
|
120
|
+
return _call({"op": "open_page", "url": url, "title": title, "patience": patience},
|
|
121
|
+
patience)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def open_terminal(command: list[str], title: str, patience: float):
|
|
125
|
+
# The runner's command is this machine's: its interpreter, and a copy of
|
|
126
|
+
# the script outside the checkout. The guest gets its own interpreter and
|
|
127
|
+
# a copy of the script on the share.
|
|
128
|
+
script = pathlib.Path(command[-1])
|
|
129
|
+
scripts = _stage / "scripts"
|
|
130
|
+
scripts.mkdir(exist_ok=True)
|
|
131
|
+
shutil.copy2(script, scripts / script.name)
|
|
132
|
+
guest_command = [_terminal_python, _guest.path(scripts / script.name)]
|
|
133
|
+
return _call({"op": "open_terminal", "command": guest_command, "title": title,
|
|
134
|
+
"patience": patience}, patience)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def close(kind: str, handle) -> None:
|
|
138
|
+
_call({"op": "close", "kind": kind, "handle": handle}, 15)
|
|
@@ -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.
|
|
@@ -33,6 +33,11 @@ quality_loop/drive/_mac.py
|
|
|
33
33
|
quality_loop/drive/_windows.py
|
|
34
34
|
quality_loop/drive/keys.py
|
|
35
35
|
quality_loop/drive/terminal.py
|
|
36
|
+
quality_loop/guest/__init__.py
|
|
37
|
+
quality_loop/guest/screens.py
|
|
38
|
+
quality_loop/guest/boot/__init__.py
|
|
39
|
+
quality_loop/guest/boot/launch.py
|
|
40
|
+
quality_loop/guest/boot/screen_agent.py
|
|
36
41
|
quality_loop/screens/__init__.py
|
|
37
42
|
quality_loop/screens/_mac.py
|
|
38
43
|
quality_loop/screens/_windows.py
|
|
@@ -41,6 +46,7 @@ quality_loop/skill/references/filing.md
|
|
|
41
46
|
quality_loop/skill/references/setup.md
|
|
42
47
|
tests/test_core.py
|
|
43
48
|
tests/test_drive.py
|
|
49
|
+
tests/test_guest.py
|
|
44
50
|
tests/test_loop.py
|
|
45
51
|
tests/test_mcp.py
|
|
46
52
|
tests/test_operator.py
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The guest layer, without a VM: SSH is faked, and what runs in the guest -
|
|
2
|
+
the boot scripts - is run here, on the stage it would read in the guest.
|
|
3
|
+
|
|
4
|
+
A real VM is checked by the products that use it (Keyhac's and XeFM's qa/).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from quality_loop import guest as guests
|
|
15
|
+
from quality_loop.guest import Guest, GuestError
|
|
16
|
+
from quality_loop.guest import screens as guest_screens
|
|
17
|
+
from quality_loop.screens import ScreenError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def share(tmp_path):
|
|
22
|
+
share = tmp_path / "share"
|
|
23
|
+
share.mkdir()
|
|
24
|
+
return share.resolve()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _guest(share, windows=False, **settings):
|
|
28
|
+
guest = Guest("qa-test", share, r"C:\qa" if windows else str(share),
|
|
29
|
+
{"python": sys.executable, **settings})
|
|
30
|
+
guest._windows = windows
|
|
31
|
+
return guest
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FakeSSH:
|
|
35
|
+
"""Records commands; answers with ``answer(command)``."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, answer=lambda command: ("", 0)):
|
|
38
|
+
self.commands, self.answer = [], answer
|
|
39
|
+
|
|
40
|
+
def __call__(self, command, timeout=60):
|
|
41
|
+
self.commands.append(command)
|
|
42
|
+
out, code = self.answer(command)
|
|
43
|
+
return subprocess.CompletedProcess([], code, out, "")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- settings ---------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def test_settings_by_alias_from_the_named_file(tmp_path, monkeypatch, share):
|
|
49
|
+
where = tmp_path / "guests.toml"
|
|
50
|
+
where.write_text(f'[qa-win]\nshare = "{share}=C:\\\\qa"\npython = "C:\\\\P\\\\python.exe"\n')
|
|
51
|
+
monkeypatch.setenv("QUALITY_LOOP_GUESTS", str(where))
|
|
52
|
+
assert guests.settings_for("qa-win")["python"] == r"C:\P\python.exe"
|
|
53
|
+
assert guests.settings_for("nobody") == {}
|
|
54
|
+
monkeypatch.setenv("QA_GUEST", "qa-win")
|
|
55
|
+
guest = Guest.from_environment("QA_GUEST")
|
|
56
|
+
assert (guest.host_share, guest.guest_share) == (share, r"C:\qa")
|
|
57
|
+
assert Guest.from_environment("UNSET_QA_GUEST") is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_a_share_needs_both_sides_and_an_existing_host_side(share):
|
|
61
|
+
with pytest.raises(GuestError, match="share ="):
|
|
62
|
+
Guest.from_settings("x", {"share": str(share)})
|
|
63
|
+
with pytest.raises(GuestError, match="not a directory"):
|
|
64
|
+
Guest.from_settings("x", {"share": f"{share}/missing=C:\\qa"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_python_must_be_named(share):
|
|
68
|
+
guest = Guest("x", share, "C:\\qa", {})
|
|
69
|
+
with pytest.raises(GuestError, match="python ="):
|
|
70
|
+
guest.python
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_a_keychain_secret_is_read_and_anything_else_passes(monkeypatch):
|
|
74
|
+
assert guests.secret("plain") == "plain"
|
|
75
|
+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: subprocess.CompletedProcess(
|
|
76
|
+
a, 0, "s3cret\n", ""))
|
|
77
|
+
assert guests.secret("keychain:qa-vm") == "s3cret"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- paths and quoting ---------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def test_a_host_path_as_the_guest_sees_it(share):
|
|
83
|
+
(share / "stage").mkdir()
|
|
84
|
+
assert _guest(share, windows=True).path(share / "stage" / "a.json") == r"C:\qa\stage\a.json"
|
|
85
|
+
assert _guest(share).path(share / "stage") == f"{share}/stage"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_the_task_keeps_program_and_arguments_apart():
|
|
89
|
+
xml = guests.task_xml(r"C:\P\pythonw.exe", '"-s" "C:\\qa\\a & b.py"')
|
|
90
|
+
assert "<Command>C:\\P\\pythonw.exe</Command>" in xml
|
|
91
|
+
assert "a & b.py" in xml and "InteractiveToken" in xml
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_quoting():
|
|
95
|
+
assert guests.ps_quote("it's") == "'it''s'"
|
|
96
|
+
assert guests.sh_quote("it's") == "'it'\\''s'"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_windowless_is_pythonw_beside_it(share):
|
|
100
|
+
assert _guest(share, windows=True).windowless(r"C:\P\python.exe") == r"C:\P\pythonw.exe"
|
|
101
|
+
assert _guest(share).windowless("/usr/bin/python3") == "/usr/bin/python3"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- the stage, the venv, the session --------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def test_the_stage_carries_the_screens_and_the_boot_scripts(share):
|
|
107
|
+
stage = _guest(share).stage()
|
|
108
|
+
ql = stage / "ql" / "quality_loop"
|
|
109
|
+
assert (ql / "__init__.py").exists() and (ql / "screens" / "_windows.py").exists()
|
|
110
|
+
assert _guest(share).boot_script(stage, "screen_agent.py").exists()
|
|
111
|
+
assert not list(stage.rglob("__pycache__"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_the_venv_is_made_in_the_guest_with_the_cache_on_the_share(share, monkeypatch):
|
|
115
|
+
guest = _guest(share, windows=True, python=r"C:\Python314\python.exe")
|
|
116
|
+
ssh = FakeSSH(lambda c: ("C:\\Users\\qa\\AppData\\Local\\ql-venv-x\\Scripts\\python.exe\n", 0))
|
|
117
|
+
monkeypatch.setattr(guest, "ssh", ssh)
|
|
118
|
+
stage = guest.stage()
|
|
119
|
+
python = guest.venv(stage, ["pillow>=9", "truststore"])
|
|
120
|
+
assert python.endswith(r"Scripts\python.exe")
|
|
121
|
+
(command,) = ssh.commands
|
|
122
|
+
assert r"'C:\Python314\python.exe' -m venv" in command
|
|
123
|
+
assert r"--cache-dir 'C:\qa\.pip-cache'" in command
|
|
124
|
+
assert (share / ".pip-cache").is_dir()
|
|
125
|
+
assert (stage / "requirements.txt").read_text() == "pillow>=9\ntruststore\n"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_a_failed_install_says_what_pip_said(share, monkeypatch):
|
|
129
|
+
guest = _guest(share)
|
|
130
|
+
monkeypatch.setattr(guest, "ssh", FakeSSH(lambda c: ("No matching distribution", 1)))
|
|
131
|
+
with pytest.raises(GuestError, match="No matching distribution"):
|
|
132
|
+
guest.venv(guest.stage(), ["nothing-like-this"])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_run_in_session_is_a_task_and_launch_hands_it_the_json(share, monkeypatch):
|
|
136
|
+
guest = _guest(share, windows=True)
|
|
137
|
+
ssh = FakeSSH(lambda c: ("SUCCESS: Attempted to run the scheduled task", 0))
|
|
138
|
+
monkeypatch.setattr(guest, "ssh", ssh)
|
|
139
|
+
stage = guest.stage()
|
|
140
|
+
guest.launch(stage, "ql-app", r"C:\P\pythonw.exe", script=r"C:\qa\app.py",
|
|
141
|
+
log=stage / "app.log", environ={"HOME": r"C:\qa\home"}, argv=["--x"])
|
|
142
|
+
(command,) = ssh.commands
|
|
143
|
+
assert "schtasks /create /tn 'ql-app'" in command and "schtasks /run" in command
|
|
144
|
+
launch = json.loads((stage / "ql-app.json").read_text())
|
|
145
|
+
assert launch["environ"] == {"HOME": r"C:\qa\home"} and launch["argv"] == ["--x"]
|
|
146
|
+
assert "launch.py" in (stage / "ql-app.xml").read_text(encoding="utf-16")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_run_in_session_says_when_the_task_did_not_start(share, monkeypatch):
|
|
150
|
+
guest = _guest(share, windows=True)
|
|
151
|
+
monkeypatch.setattr(guest, "ssh", FakeSSH(lambda c: ("ERROR: Access is denied.", 0)))
|
|
152
|
+
with pytest.raises(GuestError, match="Access is denied"):
|
|
153
|
+
guest.run_in_session(guest.stage(), "t", "pythonw.exe", share / "x.py")
|
|
154
|
+
with pytest.raises(GuestError, match="Windows guest"):
|
|
155
|
+
_guest(share).run_in_session(share, "t", "python", share / "x.py")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_launch_runs_the_script_with_its_environment_path_and_log(share, tmp_path):
|
|
159
|
+
"""boot/launch.py itself, run here as the task would run it there."""
|
|
160
|
+
stage = _guest(share).stage()
|
|
161
|
+
lib = tmp_path / "lib"
|
|
162
|
+
lib.mkdir()
|
|
163
|
+
(lib / "helper.py").write_text("WHO = 'staged'\n")
|
|
164
|
+
script = tmp_path / "app.py"
|
|
165
|
+
script.write_text("import os, sys, helper\n"
|
|
166
|
+
"print(helper.WHO, os.environ['QL_X'], sys.argv[1:], os.getcwd())\n")
|
|
167
|
+
settings = {"log": str(stage / "app.log"), "script": str(script),
|
|
168
|
+
"environ": {"QL_X": "set"}, "path": [str(lib)], "cwd": str(tmp_path),
|
|
169
|
+
"argv": ["--flag"]}
|
|
170
|
+
(stage / "launch.json").write_text(json.dumps(settings))
|
|
171
|
+
subprocess.run([sys.executable, "-s", str(_guest(share).boot_script(stage, "launch.py")),
|
|
172
|
+
str(stage / "launch.json")], check=True)
|
|
173
|
+
assert (stage / "app.log").read_text().strip() == f"staged set ['--flag'] {tmp_path}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- the screen backend ----------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def test_the_backend_runs_the_agent_in_the_guest_and_reads_its_answer(share, monkeypatch):
|
|
179
|
+
"""macOS shape: the agent over SSH - here, run locally on the stage."""
|
|
180
|
+
guest = _guest(share)
|
|
181
|
+
stage = guest.stage()
|
|
182
|
+
settings = guest_screens.settings(guest, stage)
|
|
183
|
+
assert settings["terminal_python"] == sys.executable
|
|
184
|
+
guest_screens.configure(settings)
|
|
185
|
+
|
|
186
|
+
def run_agent(command, timeout=60):
|
|
187
|
+
agent = guest.boot_script(stage, "screen_agent.py")
|
|
188
|
+
asked = command.rsplit(" ", 1)[1].strip("'")
|
|
189
|
+
return subprocess.run([sys.executable, str(agent), asked],
|
|
190
|
+
capture_output=True, text=True)
|
|
191
|
+
|
|
192
|
+
monkeypatch.setattr(guest_screens._guest, "ssh", run_agent)
|
|
193
|
+
with pytest.raises(ScreenError, match="no operation 'paint'"):
|
|
194
|
+
guest_screens._call({"op": "paint"}, patience=5)
|
|
195
|
+
assert not list((stage / "screens").iterdir()), "the request and answer are cleaned up"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_a_terminal_script_is_staged_and_run_with_the_guest_python(share, monkeypatch, tmp_path):
|
|
199
|
+
guest = _guest(share)
|
|
200
|
+
stage = guest.stage()
|
|
201
|
+
guest_screens.configure(guest_screens.settings(guest, stage, terminal_python="/g/python"))
|
|
202
|
+
seen = {}
|
|
203
|
+
monkeypatch.setattr(guest_screens, "_call", lambda request, patience: seen.update(request))
|
|
204
|
+
script = tmp_path / "deploy.py"
|
|
205
|
+
script.write_text("print('x')\n")
|
|
206
|
+
guest_screens.open_terminal(["/host/python", str(script)], "deploy", 5)
|
|
207
|
+
assert seen["command"] == ["/g/python", str(stage / "scripts" / "deploy.py")]
|
|
208
|
+
assert (stage / "scripts" / "deploy.py").exists()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_machine_follows_the_settings(share):
|
|
212
|
+
assert isinstance(_guest(share, tart="base").machine(), guests.TartMachine)
|
|
213
|
+
assert isinstance(_guest(share, vmx="/v.vmx", snapshot="s").machine(), guests.VMwareMachine)
|
|
214
|
+
with _guest(share).machine():
|
|
215
|
+
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|