dsh-crate-web 0.1.0
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.
- package/README.md +302 -0
- package/README.zh-CN.md +304 -0
- package/cordis.patch.yml +11 -0
- package/core/dsh_pack/__init__.py +77 -0
- package/core/dsh_pack/__main__.py +4 -0
- package/core/dsh_pack/cli.py +372 -0
- package/core/dsh_pack/errors.py +167 -0
- package/core/dsh_pack/export.py +912 -0
- package/core/dsh_pack/importer.py +1347 -0
- package/core/dsh_pack/network.py +77 -0
- package/core/dsh_pack/pack.py +248 -0
- package/core/dsh_pack/preflight.py +811 -0
- package/core/dsh_pack/user_cli.py +462 -0
- package/core/dsh_pack/validation.py +272 -0
- package/core/dsh_pack/verify.py +1336 -0
- package/lib/client.js +1623 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +1275 -0
- package/package.json +73 -0
- package/restart-helper.mjs +121 -0
- package/runtime/python/LICENSE.txt +676 -0
- package/runtime/python/__install__.json +82 -0
- package/runtime/python/_asyncio.pyd +0 -0
- package/runtime/python/_bz2.pyd +0 -0
- package/runtime/python/_ctypes.pyd +0 -0
- package/runtime/python/_decimal.pyd +0 -0
- package/runtime/python/_elementtree.pyd +0 -0
- package/runtime/python/_hashlib.pyd +0 -0
- package/runtime/python/_lzma.pyd +0 -0
- package/runtime/python/_multiprocessing.pyd +0 -0
- package/runtime/python/_overlapped.pyd +0 -0
- package/runtime/python/_queue.pyd +0 -0
- package/runtime/python/_remote_debugging.pyd +0 -0
- package/runtime/python/_socket.pyd +0 -0
- package/runtime/python/_sqlite3.pyd +0 -0
- package/runtime/python/_ssl.pyd +0 -0
- package/runtime/python/_uuid.pyd +0 -0
- package/runtime/python/_wmi.pyd +0 -0
- package/runtime/python/_zoneinfo.pyd +0 -0
- package/runtime/python/_zstd.pyd +0 -0
- package/runtime/python/libcrypto-3.dll +0 -0
- package/runtime/python/libffi-8.dll +0 -0
- package/runtime/python/libssl-3.dll +0 -0
- package/runtime/python/pyexpat.pyd +0 -0
- package/runtime/python/python.cat +0 -0
- package/runtime/python/python.exe +0 -0
- package/runtime/python/python3.dll +0 -0
- package/runtime/python/python314._pth +6 -0
- package/runtime/python/python314.dll +0 -0
- package/runtime/python/python314.zip +0 -0
- package/runtime/python/pythonw.exe +0 -0
- package/runtime/python/select.pyd +0 -0
- package/runtime/python/sqlite3.dll +0 -0
- package/runtime/python/unicodedata.pyd +0 -0
- package/runtime/python/vcruntime140.dll +0 -0
- package/runtime/python/vcruntime140_1.dll +0 -0
- package/runtime/python/winsound.pyd +0 -0
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
"""Phase 5 verification for a prepared DSH Profile.
|
|
2
|
+
|
|
3
|
+
The core owns composition checks, status aggregation, evidence shape, and the
|
|
4
|
+
plugin smoke-test contract. Runtime execution is deliberately injected via a
|
|
5
|
+
``VerifyAdapter`` so this module never guesses a DSH command, health endpoint,
|
|
6
|
+
or model interaction. Without an adapter, runtime checks are UNTESTED.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import posixpath
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from .errors import _default_checks
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Mapping, Protocol, Sequence
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
VERIFY_STATUSES = ("PASS", "FAIL", "UNTESTED", "DEGRADED")
|
|
28
|
+
RUNTIME_STEPS = (
|
|
29
|
+
("boot", "DSH boot"),
|
|
30
|
+
("surface_ready", "Web/headless ready"),
|
|
31
|
+
("new_session", "new session"),
|
|
32
|
+
("basic_response", "basic response"),
|
|
33
|
+
("core_tool", "core tool"),
|
|
34
|
+
("restart", "restart"),
|
|
35
|
+
("restart_surface_ready", "verify after restart"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SmokeTestContract:
|
|
41
|
+
"""Explicit plugin smoke tests; no test means UNTESTED, never PASS."""
|
|
42
|
+
|
|
43
|
+
plugin: str
|
|
44
|
+
tests: tuple[str, ...]
|
|
45
|
+
required: bool = True
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "SmokeTestContract":
|
|
49
|
+
plugin = value.get("plugin")
|
|
50
|
+
tests = value.get("tests")
|
|
51
|
+
required = value.get("required", True)
|
|
52
|
+
if not isinstance(plugin, str) or not plugin.strip():
|
|
53
|
+
raise ValueError("plugin smoke contract requires a non-empty plugin")
|
|
54
|
+
if not isinstance(tests, list) or any(not isinstance(item, str) or not item.strip() for item in tests):
|
|
55
|
+
raise ValueError(f"plugin smoke contract tests must be a list of names: {plugin}")
|
|
56
|
+
if not isinstance(required, bool):
|
|
57
|
+
raise ValueError(f"plugin smoke contract required must be boolean: {plugin}")
|
|
58
|
+
return cls(plugin=plugin, tests=tuple(tests), required=required)
|
|
59
|
+
|
|
60
|
+
def as_dict(self) -> dict[str, Any]:
|
|
61
|
+
return {"plugin": self.plugin, "required": self.required, "tests": list(self.tests)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class VerifyStep:
|
|
66
|
+
name: str
|
|
67
|
+
status: str
|
|
68
|
+
message: str
|
|
69
|
+
required: bool = True
|
|
70
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
diagnostic: dict[str, Any] | None = None
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if self.status not in VERIFY_STATUSES:
|
|
75
|
+
raise ValueError(f"unsupported verify status: {self.status}")
|
|
76
|
+
|
|
77
|
+
def as_dict(self) -> dict[str, Any]:
|
|
78
|
+
value: dict[str, Any] = {
|
|
79
|
+
"name": self.name,
|
|
80
|
+
"status": self.status,
|
|
81
|
+
"message": self.message,
|
|
82
|
+
"required": self.required,
|
|
83
|
+
"evidence": self.evidence,
|
|
84
|
+
}
|
|
85
|
+
if self.diagnostic is not None:
|
|
86
|
+
value["diagnostic"] = self.diagnostic
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class VerifyContext:
|
|
92
|
+
dsh_home: Path
|
|
93
|
+
profile_name: str
|
|
94
|
+
profile_path: Path
|
|
95
|
+
mode: str
|
|
96
|
+
current_environment: Mapping[str, Any]
|
|
97
|
+
available_secret_names: frozenset[str]
|
|
98
|
+
metadata_path: Path
|
|
99
|
+
previous_steps: Mapping[str, VerifyStep]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class VerifyAdapter(Protocol):
|
|
103
|
+
"""Runtime adapter contract used by ``verify_profile``.
|
|
104
|
+
|
|
105
|
+
``run_step`` must perform the requested real action and return either a
|
|
106
|
+
``VerifyStep`` or a mapping with ``status``, ``message`` and ``evidence``.
|
|
107
|
+
``run_plugin_test`` is optional; if absent, plugin tests are UNTESTED.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def run_step(self, step: str, context: VerifyContext) -> VerifyStep | Mapping[str, Any]:
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _step(
|
|
115
|
+
name: str,
|
|
116
|
+
status: str,
|
|
117
|
+
message: str,
|
|
118
|
+
*,
|
|
119
|
+
required: bool = True,
|
|
120
|
+
evidence: Mapping[str, Any] | None = None,
|
|
121
|
+
diagnostic: Mapping[str, Any] | None = None,
|
|
122
|
+
) -> VerifyStep:
|
|
123
|
+
return VerifyStep(
|
|
124
|
+
name=name,
|
|
125
|
+
status=status,
|
|
126
|
+
message=message,
|
|
127
|
+
required=required,
|
|
128
|
+
evidence=dict(evidence or {}),
|
|
129
|
+
diagnostic=dict(diagnostic) if diagnostic else None,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _runtime_diagnostic(
|
|
134
|
+
*,
|
|
135
|
+
code: str,
|
|
136
|
+
stage: str,
|
|
137
|
+
message: str,
|
|
138
|
+
evidence: Mapping[str, Any],
|
|
139
|
+
item: str,
|
|
140
|
+
expected: str,
|
|
141
|
+
impact: str,
|
|
142
|
+
can_continue: bool = False,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Structured diagnostic attached to a failed runtime VerifyStep."""
|
|
145
|
+
return {
|
|
146
|
+
"code": code,
|
|
147
|
+
"stage": stage,
|
|
148
|
+
"severity": "BLOCKER",
|
|
149
|
+
"item": item,
|
|
150
|
+
"expected": expected,
|
|
151
|
+
"observed": {"message": message, "code": code},
|
|
152
|
+
"evidence": dict(evidence),
|
|
153
|
+
"impact": impact,
|
|
154
|
+
"canContinue": can_continue,
|
|
155
|
+
"suggestedChecks": _default_checks(stage, code),
|
|
156
|
+
"message": message,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _normalise_observation(name: str, value: VerifyStep | Mapping[str, Any], *, required: bool) -> VerifyStep:
|
|
161
|
+
if isinstance(value, VerifyStep):
|
|
162
|
+
return value
|
|
163
|
+
if not isinstance(value, Mapping):
|
|
164
|
+
return _step(
|
|
165
|
+
name,
|
|
166
|
+
"FAIL",
|
|
167
|
+
"Verify adapter returned an invalid observation",
|
|
168
|
+
required=required,
|
|
169
|
+
evidence={"observedType": type(value).__name__},
|
|
170
|
+
)
|
|
171
|
+
status = value.get("status")
|
|
172
|
+
if status not in VERIFY_STATUSES:
|
|
173
|
+
return _step(
|
|
174
|
+
name,
|
|
175
|
+
"FAIL",
|
|
176
|
+
"Verify adapter returned an unsupported status",
|
|
177
|
+
required=required,
|
|
178
|
+
evidence={"observed": dict(value)},
|
|
179
|
+
)
|
|
180
|
+
return _step(
|
|
181
|
+
name,
|
|
182
|
+
status,
|
|
183
|
+
str(value.get("message", name)),
|
|
184
|
+
required=bool(value.get("required", required)),
|
|
185
|
+
evidence=value.get("evidence") if isinstance(value.get("evidence"), Mapping) else {},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
190
|
+
try:
|
|
191
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
192
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
193
|
+
return None, str(error)
|
|
194
|
+
if not isinstance(value, dict):
|
|
195
|
+
return None, "JSON root must be an object"
|
|
196
|
+
return value, None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _package_manifest(profile_path: Path, name: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
200
|
+
package_path = profile_path / "node_modules" / Path(*name.split("/")) / "package.json"
|
|
201
|
+
value, error = _json_object(package_path)
|
|
202
|
+
if error:
|
|
203
|
+
return None, f"{package_path}: {error}"
|
|
204
|
+
return value, None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _profile_bundles(profile_manifest: Mapping[str, Any]) -> tuple[list[str] | None, str | None]:
|
|
208
|
+
dsh = profile_manifest.get("dsh", {})
|
|
209
|
+
if dsh is None:
|
|
210
|
+
dsh = {}
|
|
211
|
+
if not isinstance(dsh, Mapping):
|
|
212
|
+
return None, "profile package.json dsh must be an object"
|
|
213
|
+
profile = dsh.get("profile", {})
|
|
214
|
+
if profile is None:
|
|
215
|
+
profile = {}
|
|
216
|
+
if not isinstance(profile, Mapping):
|
|
217
|
+
return None, "profile package.json dsh.profile must be an object"
|
|
218
|
+
bundles = profile.get("bundles", [])
|
|
219
|
+
if not isinstance(bundles, list) or any(not isinstance(name, str) or not name for name in bundles):
|
|
220
|
+
return None, "profile package.json dsh.profile.bundles must be a list of names"
|
|
221
|
+
if len(set(bundles)) != len(bundles):
|
|
222
|
+
return None, "profile package.json dsh.profile.bundles contains duplicates"
|
|
223
|
+
return list(bundles), None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_installed_package_dir(anchor_dir: Path, name: str) -> Path | None:
|
|
227
|
+
"""Node-style node_modules lookup from an anchor directory.
|
|
228
|
+
|
|
229
|
+
Mirrors DSH ``packageDirFromAnchor`` (installation anchor, then the profile
|
|
230
|
+
directory): probes the profile-local ``node_modules``, the shared
|
|
231
|
+
``profiles/node_modules`` layer, home-level ``node_modules``, then each
|
|
232
|
+
ancestor. This matches what the Cordis Loader would import from the same
|
|
233
|
+
anchor.
|
|
234
|
+
"""
|
|
235
|
+
parts = Path(*name.split("/"))
|
|
236
|
+
current = anchor_dir
|
|
237
|
+
while True:
|
|
238
|
+
candidate = current / "node_modules" / parts
|
|
239
|
+
if (candidate / "package.json").is_file():
|
|
240
|
+
return candidate
|
|
241
|
+
parent = current.parent
|
|
242
|
+
if parent == current:
|
|
243
|
+
return None
|
|
244
|
+
current = parent
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _internal_composition_issues(
|
|
249
|
+
profile_path: Path,
|
|
250
|
+
dependencies: Mapping[str, Any],
|
|
251
|
+
bundles: Sequence[str],
|
|
252
|
+
) -> list[dict[str, Any]]:
|
|
253
|
+
"""Verify a Profile against its own manifest when no Pack metadata exists.
|
|
254
|
+
|
|
255
|
+
Used when the Crate Import metadata directory for this Profile is absent
|
|
256
|
+
(the Profile was not created by a Crate Import, or the metadata was
|
|
257
|
+
removed). This can never claim a Pack round-trip: it only checks that
|
|
258
|
+
declared Bundles are installed with their patch files, and that declared
|
|
259
|
+
dependencies resolve. The caller marks the Pack-comparison dimension as
|
|
260
|
+
UNTESTED.
|
|
261
|
+
"""
|
|
262
|
+
issues: list[dict[str, Any]] = []
|
|
263
|
+
declared = dict(dependencies) if isinstance(dependencies, Mapping) else {}
|
|
264
|
+
for name in bundles:
|
|
265
|
+
package_dir = _resolve_installed_package_dir(profile_path, name)
|
|
266
|
+
if package_dir is None:
|
|
267
|
+
issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": "not resolvable from the profile or shared node_modules layers"})
|
|
268
|
+
continue
|
|
269
|
+
installed, error = _json_object(package_dir / "package.json")
|
|
270
|
+
if installed is None:
|
|
271
|
+
issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": error})
|
|
272
|
+
continue
|
|
273
|
+
dsh = installed.get("dsh", {})
|
|
274
|
+
if not isinstance(dsh, Mapping):
|
|
275
|
+
dsh = {}
|
|
276
|
+
bundle = dsh.get("bundle", {})
|
|
277
|
+
patch = bundle.get("patch") if isinstance(bundle, Mapping) else None
|
|
278
|
+
normalized_patch = posixpath.normpath(patch.replace("\\", "/")) if isinstance(patch, str) else ""
|
|
279
|
+
safe_patch = (
|
|
280
|
+
normalized_patch not in {"", ".", ".."}
|
|
281
|
+
and not normalized_patch.startswith("../")
|
|
282
|
+
and not normalized_patch.startswith("/")
|
|
283
|
+
and ".." not in normalized_patch.split("/")
|
|
284
|
+
)
|
|
285
|
+
patch_path = package_dir / Path(*normalized_patch.split("/")) if safe_patch else None
|
|
286
|
+
if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
|
|
287
|
+
issues.append({
|
|
288
|
+
"code": "BUNDLE_PATCH_MISSING",
|
|
289
|
+
"bundle": name,
|
|
290
|
+
"patch": patch,
|
|
291
|
+
"path": str(patch_path) if patch_path else None,
|
|
292
|
+
})
|
|
293
|
+
for name, spec in declared.items():
|
|
294
|
+
package_dir = _resolve_installed_package_dir(profile_path, name)
|
|
295
|
+
if package_dir is None:
|
|
296
|
+
issues.append({
|
|
297
|
+
"code": "DEPENDENCY_NOT_INSTALLED",
|
|
298
|
+
"plugin": name,
|
|
299
|
+
"spec": spec,
|
|
300
|
+
"error": "not resolvable from the profile or shared node_modules layers",
|
|
301
|
+
})
|
|
302
|
+
return issues
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _composition_step(options: "VerifyOptions") -> VerifyStep:
|
|
306
|
+
profile_path = options.dsh_home / "profiles" / options.profile_name
|
|
307
|
+
metadata_path = options.dsh_home / ".dsh-pack" / "imports" / options.profile_name
|
|
308
|
+
package_path = profile_path / "package.json"
|
|
309
|
+
profile_manifest, profile_error = _json_object(package_path)
|
|
310
|
+
if profile_error or profile_manifest is None:
|
|
311
|
+
return _step(
|
|
312
|
+
"composition",
|
|
313
|
+
"FAIL",
|
|
314
|
+
"prepared Profile package.json cannot be read",
|
|
315
|
+
evidence={"path": str(package_path), "error": profile_error},
|
|
316
|
+
)
|
|
317
|
+
bundles, bundle_error = _profile_bundles(profile_manifest)
|
|
318
|
+
if bundle_error or bundles is None:
|
|
319
|
+
return _step("composition", "FAIL", bundle_error or "invalid Bundle composition", evidence={"path": str(package_path)})
|
|
320
|
+
|
|
321
|
+
manifest_file = metadata_path / "manifest.json"
|
|
322
|
+
lock_file = metadata_path / "plugins.lock.json"
|
|
323
|
+
prepared_file = metadata_path / "prepared.json"
|
|
324
|
+
metadata_present = metadata_path.is_dir() and any(
|
|
325
|
+
candidate.is_file() for candidate in (manifest_file, lock_file, prepared_file)
|
|
326
|
+
)
|
|
327
|
+
if not metadata_present:
|
|
328
|
+
# This Profile was not created by a Crate Import, or the Import
|
|
329
|
+
# metadata was removed. The Pack-comparison dimension is UNTESTED:
|
|
330
|
+
# only an internal consistency check against the Profile's own
|
|
331
|
+
# manifest is possible, never a claimed round-trip.
|
|
332
|
+
dependencies = profile_manifest.get("dependencies", {})
|
|
333
|
+
if not isinstance(dependencies, Mapping):
|
|
334
|
+
return _step(
|
|
335
|
+
"composition",
|
|
336
|
+
"FAIL",
|
|
337
|
+
"Profile dependencies is not an object",
|
|
338
|
+
evidence={"path": str(package_path)},
|
|
339
|
+
)
|
|
340
|
+
internal_issues = _internal_composition_issues(profile_path, dependencies, bundles)
|
|
341
|
+
if internal_issues:
|
|
342
|
+
return _step(
|
|
343
|
+
"composition",
|
|
344
|
+
"FAIL",
|
|
345
|
+
"Crate Import metadata is absent and the Profile fails its internal consistency check",
|
|
346
|
+
evidence={
|
|
347
|
+
"metadataPath": str(metadata_path),
|
|
348
|
+
"packComparison": "UNTESTED",
|
|
349
|
+
"issues": internal_issues,
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
return _step(
|
|
353
|
+
"composition",
|
|
354
|
+
"DEGRADED",
|
|
355
|
+
"Crate Import metadata is absent; only internal consistency was checked, Pack round-trip UNTESTED",
|
|
356
|
+
evidence={
|
|
357
|
+
"metadataPath": str(metadata_path),
|
|
358
|
+
"profileBundles": bundles,
|
|
359
|
+
"packComparison": "UNTESTED",
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
metadata_manifest, manifest_error = _json_object(manifest_file)
|
|
363
|
+
lock, lock_error = _json_object(lock_file)
|
|
364
|
+
prepared, prepared_error = _json_object(prepared_file)
|
|
365
|
+
if manifest_error or lock_error or prepared_error or metadata_manifest is None or lock is None or prepared is None:
|
|
366
|
+
return _step(
|
|
367
|
+
"composition",
|
|
368
|
+
"FAIL",
|
|
369
|
+
"prepared Import metadata is incomplete",
|
|
370
|
+
evidence={
|
|
371
|
+
"metadataPath": str(metadata_path),
|
|
372
|
+
"manifestError": manifest_error,
|
|
373
|
+
"lockError": lock_error,
|
|
374
|
+
"preparedError": prepared_error,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
if prepared.get("status") != "prepared":
|
|
378
|
+
return _step(
|
|
379
|
+
"composition",
|
|
380
|
+
"FAIL",
|
|
381
|
+
"Import metadata is not marked prepared",
|
|
382
|
+
evidence={"metadataPath": str(metadata_path), "preparedStatus": prepared.get("status")},
|
|
383
|
+
)
|
|
384
|
+
raw_plugins = lock.get("plugins")
|
|
385
|
+
if not isinstance(raw_plugins, list) or any(not isinstance(plugin, Mapping) for plugin in raw_plugins):
|
|
386
|
+
return _step("composition", "FAIL", "plugins.lock.json plugins is invalid", evidence={"path": str(metadata_path / "plugins.lock.json")})
|
|
387
|
+
|
|
388
|
+
dependencies = profile_manifest.get("dependencies", {})
|
|
389
|
+
if not isinstance(dependencies, Mapping):
|
|
390
|
+
return _step("composition", "FAIL", "Profile dependencies is not an object", evidence={"path": str(package_path)})
|
|
391
|
+
|
|
392
|
+
expected_bundles: list[str] = []
|
|
393
|
+
installed_rows: list[dict[str, Any]] = []
|
|
394
|
+
issues: list[dict[str, Any]] = []
|
|
395
|
+
for index, plugin in enumerate(raw_plugins):
|
|
396
|
+
name = plugin.get("name")
|
|
397
|
+
if not isinstance(name, str) or not name:
|
|
398
|
+
issues.append({"code": "PLUGIN_NAME_MISSING", "index": index})
|
|
399
|
+
continue
|
|
400
|
+
requested = plugin.get("requested")
|
|
401
|
+
requested_specifier = requested.get("specifier") if isinstance(requested, Mapping) else None
|
|
402
|
+
resolved = plugin.get("resolved")
|
|
403
|
+
resolved_version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
404
|
+
declared_spec = dependencies.get(name)
|
|
405
|
+
if declared_spec is not None and declared_spec != requested_specifier:
|
|
406
|
+
issues.append({
|
|
407
|
+
"code": "PROFILE_SPEC_MISMATCH",
|
|
408
|
+
"plugin": name,
|
|
409
|
+
"expected": requested_specifier,
|
|
410
|
+
"observed": declared_spec,
|
|
411
|
+
})
|
|
412
|
+
bundle = plugin.get("bundle")
|
|
413
|
+
enabled = isinstance(bundle, Mapping) and bundle.get("enabled") is True
|
|
414
|
+
if enabled:
|
|
415
|
+
expected_bundles.append(name)
|
|
416
|
+
patch = bundle.get("patch")
|
|
417
|
+
package_path_for_plugin = profile_path / "node_modules" / Path(*name.split("/"))
|
|
418
|
+
normalized_patch = posixpath.normpath(patch.replace("\\", "/")) if isinstance(patch, str) else ""
|
|
419
|
+
safe_patch = (
|
|
420
|
+
normalized_patch not in {"", ".", ".."}
|
|
421
|
+
and not normalized_patch.startswith("../")
|
|
422
|
+
and not normalized_patch.startswith("/")
|
|
423
|
+
and ".." not in normalized_patch.split("/")
|
|
424
|
+
)
|
|
425
|
+
patch_path = package_path_for_plugin / Path(*normalized_patch.split("/")) if safe_patch else None
|
|
426
|
+
if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
|
|
427
|
+
issues.append({
|
|
428
|
+
"code": "BUNDLE_PATCH_MISSING",
|
|
429
|
+
"plugin": name,
|
|
430
|
+
"patch": patch,
|
|
431
|
+
"path": str(patch_path) if patch_path else None,
|
|
432
|
+
})
|
|
433
|
+
installed, installed_error = _package_manifest(profile_path, name)
|
|
434
|
+
installed_row = {
|
|
435
|
+
"name": name,
|
|
436
|
+
"required": plugin.get("required") is True,
|
|
437
|
+
"mode": (plugin.get("artifact") or {}).get("mode") if isinstance(plugin.get("artifact"), Mapping) else None,
|
|
438
|
+
"expectedVersion": resolved_version,
|
|
439
|
+
"installed": installed,
|
|
440
|
+
"error": installed_error,
|
|
441
|
+
}
|
|
442
|
+
installed_rows.append(installed_row)
|
|
443
|
+
if installed is None:
|
|
444
|
+
issue = {"code": "PLUGIN_NOT_INSTALLED", "plugin": name, "error": installed_error}
|
|
445
|
+
if plugin.get("required") is True:
|
|
446
|
+
issues.append(issue)
|
|
447
|
+
else:
|
|
448
|
+
issue["optional"] = True
|
|
449
|
+
issues.append(issue)
|
|
450
|
+
else:
|
|
451
|
+
mismatch = installed.get("name") != name or installed.get("version") != resolved_version
|
|
452
|
+
if mismatch:
|
|
453
|
+
issues.append({
|
|
454
|
+
"code": "PACKAGE_IDENTITY_MISMATCH",
|
|
455
|
+
"plugin": name,
|
|
456
|
+
"expected": {"name": name, "version": resolved_version},
|
|
457
|
+
"observed": {"name": installed.get("name"), "version": installed.get("version")},
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
if bundles != expected_bundles:
|
|
461
|
+
issues.append({"code": "BUNDLE_ORDER_MISMATCH", "expected": expected_bundles, "observed": bundles})
|
|
462
|
+
|
|
463
|
+
required_issues = [issue for issue in issues if not issue.get("optional")]
|
|
464
|
+
if required_issues:
|
|
465
|
+
status = "FAIL"
|
|
466
|
+
message = "Profile composition does not match prepared Pack metadata"
|
|
467
|
+
elif issues:
|
|
468
|
+
status = "DEGRADED"
|
|
469
|
+
message = "Profile composition is usable but optional plugin evidence is incomplete"
|
|
470
|
+
else:
|
|
471
|
+
status = "PASS"
|
|
472
|
+
message = "Profile, lock, Bundle order, patches, and installed package identities agree"
|
|
473
|
+
return _step(
|
|
474
|
+
"composition",
|
|
475
|
+
status,
|
|
476
|
+
message,
|
|
477
|
+
evidence={
|
|
478
|
+
"profilePath": str(profile_path),
|
|
479
|
+
"metadataPath": str(metadata_path),
|
|
480
|
+
"profileBundles": bundles,
|
|
481
|
+
"lockBundleOrder": expected_bundles,
|
|
482
|
+
"installedPlugins": installed_rows,
|
|
483
|
+
"pack": prepared.get("pack"),
|
|
484
|
+
"issues": issues,
|
|
485
|
+
"requiredSecrets": metadata_manifest.get("requiredSecrets", []),
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@dataclass(frozen=True)
|
|
491
|
+
class VerifyOptions:
|
|
492
|
+
dsh_home: Path
|
|
493
|
+
profile_name: str
|
|
494
|
+
mode: str = "web"
|
|
495
|
+
current_environment: Mapping[str, Any] = field(default_factory=dict)
|
|
496
|
+
available_secret_names: frozenset[str] = frozenset()
|
|
497
|
+
adapter: VerifyAdapter | None = None
|
|
498
|
+
plugin_contracts: tuple[SmokeTestContract, ...] = ()
|
|
499
|
+
|
|
500
|
+
def __post_init__(self) -> None:
|
|
501
|
+
if self.mode not in {"web", "headless"}:
|
|
502
|
+
raise ValueError("VerifyOptions.mode must be 'web' or 'headless'")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@dataclass(frozen=True)
|
|
506
|
+
class VerifyResult:
|
|
507
|
+
status: str
|
|
508
|
+
dsh_home: Path
|
|
509
|
+
profile_name: str
|
|
510
|
+
mode: str
|
|
511
|
+
current_environment: Mapping[str, Any]
|
|
512
|
+
available_secret_names: frozenset[str]
|
|
513
|
+
steps: tuple[VerifyStep, ...]
|
|
514
|
+
plugin_contracts: tuple[SmokeTestContract, ...]
|
|
515
|
+
|
|
516
|
+
def __post_init__(self) -> None:
|
|
517
|
+
if self.status not in VERIFY_STATUSES:
|
|
518
|
+
raise ValueError(f"unsupported verify result status: {self.status}")
|
|
519
|
+
|
|
520
|
+
def as_dict(self) -> dict[str, Any]:
|
|
521
|
+
step_map = {step.name: step.as_dict() for step in self.steps}
|
|
522
|
+
plugin_smokes = [
|
|
523
|
+
step.as_dict()
|
|
524
|
+
for step in self.steps
|
|
525
|
+
if step.name.startswith("plugin:")
|
|
526
|
+
]
|
|
527
|
+
return {
|
|
528
|
+
"status": self.status,
|
|
529
|
+
"profile": {
|
|
530
|
+
"name": self.profile_name,
|
|
531
|
+
"dshHome": str(self.dsh_home),
|
|
532
|
+
"path": str(self.dsh_home / "profiles" / self.profile_name),
|
|
533
|
+
},
|
|
534
|
+
"mode": self.mode,
|
|
535
|
+
"environment": {"current": dict(self.current_environment)},
|
|
536
|
+
"secrets": {
|
|
537
|
+
"availableNames": sorted(self.available_secret_names),
|
|
538
|
+
"valuesIncluded": False,
|
|
539
|
+
},
|
|
540
|
+
# Keep the ordered steps array for compatibility, while exposing
|
|
541
|
+
# the stable Phase 5 machine-report fields required by callers.
|
|
542
|
+
"composition": step_map.get("composition"),
|
|
543
|
+
"boot": step_map.get("boot"),
|
|
544
|
+
"ready": step_map.get("surface_ready"),
|
|
545
|
+
"session": step_map.get("new_session"),
|
|
546
|
+
"basicResponse": step_map.get("basic_response"),
|
|
547
|
+
"coreTool": step_map.get("core_tool"),
|
|
548
|
+
"pluginSmokes": plugin_smokes,
|
|
549
|
+
"restart": step_map.get("restart"),
|
|
550
|
+
"restartReady": step_map.get("restart_surface_ready"),
|
|
551
|
+
"steps": [step.as_dict() for step in self.steps],
|
|
552
|
+
"pluginSmokeContracts": [contract.as_dict() for contract in self.plugin_contracts],
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _overall_status(steps: Sequence[VerifyStep]) -> str:
|
|
557
|
+
required = [step for step in steps if step.required]
|
|
558
|
+
optional = [step for step in steps if not step.required]
|
|
559
|
+
if any(step.status == "FAIL" for step in required):
|
|
560
|
+
return "FAIL"
|
|
561
|
+
if any(step.status == "UNTESTED" for step in required):
|
|
562
|
+
return "UNTESTED"
|
|
563
|
+
if any(step.status in {"DEGRADED", "FAIL"} for step in required):
|
|
564
|
+
return "DEGRADED"
|
|
565
|
+
if any(step.status != "PASS" for step in optional):
|
|
566
|
+
return "DEGRADED"
|
|
567
|
+
return "PASS"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _loader_composition_failure(composition: VerifyStep, boot: VerifyStep) -> dict[str, Any] | None:
|
|
571
|
+
"""Classify a real DSH Loader activation failure as composition failure.
|
|
572
|
+
|
|
573
|
+
DSH resolves Bundle patches and activates their Loader entries during boot.
|
|
574
|
+
A structurally valid Pack can therefore pass the local file checks while
|
|
575
|
+
the effective composition still fails in the real runtime. Keep the
|
|
576
|
+
original ``boot=FAIL`` step, but attach the same runtime evidence to the
|
|
577
|
+
composition step so a report can answer which Bundle prevented startup.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
if boot.status != "FAIL":
|
|
581
|
+
return None
|
|
582
|
+
evidence = boot.evidence
|
|
583
|
+
stderr = str(evidence.get("stderr", ""))
|
|
584
|
+
stdout = str(evidence.get("stdout", ""))
|
|
585
|
+
combined = f"{stderr}\n{stdout}"
|
|
586
|
+
lowered = combined.lower()
|
|
587
|
+
markers = (
|
|
588
|
+
"plugin tree failed to load",
|
|
589
|
+
"did not activate",
|
|
590
|
+
"failed to apply loader entry",
|
|
591
|
+
"loader activation failed",
|
|
592
|
+
)
|
|
593
|
+
if not any(marker in lowered for marker in markers):
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
failed_bundle: str | None = None
|
|
597
|
+
installed_plugins = composition.evidence.get("installedPlugins", [])
|
|
598
|
+
if isinstance(installed_plugins, list):
|
|
599
|
+
for row in installed_plugins:
|
|
600
|
+
if not isinstance(row, Mapping):
|
|
601
|
+
continue
|
|
602
|
+
name = row.get("name")
|
|
603
|
+
if not isinstance(name, str) or not name:
|
|
604
|
+
continue
|
|
605
|
+
if name in combined:
|
|
606
|
+
failed_bundle = name
|
|
607
|
+
break
|
|
608
|
+
if failed_bundle is None:
|
|
609
|
+
match = re.search(r"(?:failed to apply loader entry|did not activate)\s+([^\s:(]+)", combined, re.IGNORECASE)
|
|
610
|
+
if match:
|
|
611
|
+
failed_bundle = match.group(1)
|
|
612
|
+
|
|
613
|
+
observed = "Loader activation failed" if "loader activation failed" in lowered else "DSH Loader activation failed"
|
|
614
|
+
return {
|
|
615
|
+
"stage": "composition",
|
|
616
|
+
"failedBundle": failed_bundle,
|
|
617
|
+
"observed": observed,
|
|
618
|
+
"stderr": stderr,
|
|
619
|
+
"impact": "Imported profile cannot start.",
|
|
620
|
+
"bootMessage": boot.message,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _adapter_step(
|
|
625
|
+
adapter: VerifyAdapter | None,
|
|
626
|
+
name: str,
|
|
627
|
+
context: VerifyContext,
|
|
628
|
+
*,
|
|
629
|
+
required: bool = True,
|
|
630
|
+
) -> VerifyStep:
|
|
631
|
+
if adapter is None:
|
|
632
|
+
return _step(
|
|
633
|
+
name,
|
|
634
|
+
"UNTESTED",
|
|
635
|
+
"No runtime VerifyAdapter was supplied",
|
|
636
|
+
required=required,
|
|
637
|
+
evidence={"reason": "runtime execution is not implicit"},
|
|
638
|
+
)
|
|
639
|
+
method = getattr(adapter, "run_step", None)
|
|
640
|
+
if not callable(method):
|
|
641
|
+
return _step(
|
|
642
|
+
name,
|
|
643
|
+
"UNTESTED",
|
|
644
|
+
"VerifyAdapter has no run_step implementation",
|
|
645
|
+
required=required,
|
|
646
|
+
evidence={"reason": "adapter contract missing"},
|
|
647
|
+
)
|
|
648
|
+
try:
|
|
649
|
+
return _normalise_observation(name, method(name, context), required=required)
|
|
650
|
+
except Exception as error: # adapters are external execution boundaries
|
|
651
|
+
return _step(
|
|
652
|
+
name,
|
|
653
|
+
"FAIL",
|
|
654
|
+
f"VerifyAdapter failed during {name}",
|
|
655
|
+
required=required,
|
|
656
|
+
evidence={"error": str(error), "exception": type(error).__name__},
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _plugin_step(
|
|
661
|
+
adapter: VerifyAdapter | None,
|
|
662
|
+
contract: SmokeTestContract,
|
|
663
|
+
test_name: str,
|
|
664
|
+
context: VerifyContext,
|
|
665
|
+
) -> VerifyStep:
|
|
666
|
+
step_name = f"plugin:{contract.plugin}:{test_name}"
|
|
667
|
+
if adapter is None or not callable(getattr(adapter, "run_plugin_test", None)):
|
|
668
|
+
return _step(
|
|
669
|
+
step_name,
|
|
670
|
+
"UNTESTED",
|
|
671
|
+
"No plugin smoke-test runner was supplied",
|
|
672
|
+
required=contract.required,
|
|
673
|
+
evidence={"plugin": contract.plugin, "test": test_name},
|
|
674
|
+
)
|
|
675
|
+
try:
|
|
676
|
+
value = adapter.run_plugin_test(contract.plugin, test_name, context) # type: ignore[attr-defined]
|
|
677
|
+
observed = _normalise_observation(step_name, value, required=contract.required)
|
|
678
|
+
if observed.required != contract.required:
|
|
679
|
+
observed = VerifyStep(
|
|
680
|
+
name=observed.name,
|
|
681
|
+
status=observed.status,
|
|
682
|
+
message=observed.message,
|
|
683
|
+
required=contract.required,
|
|
684
|
+
evidence=observed.evidence,
|
|
685
|
+
)
|
|
686
|
+
return observed
|
|
687
|
+
except Exception as error: # plugin tests are an external execution boundary
|
|
688
|
+
return _step(
|
|
689
|
+
step_name,
|
|
690
|
+
"FAIL",
|
|
691
|
+
f"Plugin smoke test failed to execute: {contract.plugin}/{test_name}",
|
|
692
|
+
required=contract.required,
|
|
693
|
+
evidence={"plugin": contract.plugin, "test": test_name, "error": str(error)},
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
698
|
+
"""Verify one prepared Profile without changing its files."""
|
|
699
|
+
|
|
700
|
+
if not options.dsh_home.is_dir():
|
|
701
|
+
result = VerifyResult(
|
|
702
|
+
status="FAIL",
|
|
703
|
+
dsh_home=options.dsh_home,
|
|
704
|
+
profile_name=options.profile_name,
|
|
705
|
+
mode=options.mode,
|
|
706
|
+
current_environment=options.current_environment,
|
|
707
|
+
available_secret_names=options.available_secret_names,
|
|
708
|
+
steps=(_step("composition", "FAIL", "DSH_HOME does not exist", evidence={"dshHome": str(options.dsh_home)}),),
|
|
709
|
+
plugin_contracts=options.plugin_contracts,
|
|
710
|
+
)
|
|
711
|
+
return result
|
|
712
|
+
|
|
713
|
+
composition = _composition_step(options)
|
|
714
|
+
steps: list[VerifyStep] = [composition]
|
|
715
|
+
previous: dict[str, VerifyStep] = {composition.name: composition}
|
|
716
|
+
context_base = {
|
|
717
|
+
"dsh_home": options.dsh_home,
|
|
718
|
+
"profile_name": options.profile_name,
|
|
719
|
+
"profile_path": options.dsh_home / "profiles" / options.profile_name,
|
|
720
|
+
"mode": options.mode,
|
|
721
|
+
"current_environment": options.current_environment,
|
|
722
|
+
"available_secret_names": options.available_secret_names,
|
|
723
|
+
"metadata_path": options.dsh_home / ".dsh-pack" / "imports" / options.profile_name,
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
for name, label in RUNTIME_STEPS:
|
|
727
|
+
blocking_runtime_step = next(
|
|
728
|
+
(step.name for step in steps if step.name in {"boot", "restart"} and step.status == "FAIL"),
|
|
729
|
+
None,
|
|
730
|
+
)
|
|
731
|
+
if composition.status == "FAIL":
|
|
732
|
+
current = _step(
|
|
733
|
+
name,
|
|
734
|
+
"UNTESTED",
|
|
735
|
+
f"{label} was not run because composition failed",
|
|
736
|
+
evidence={"blockedBy": "composition"},
|
|
737
|
+
)
|
|
738
|
+
elif blocking_runtime_step is not None:
|
|
739
|
+
current = _step(
|
|
740
|
+
name,
|
|
741
|
+
"UNTESTED",
|
|
742
|
+
f"{label} was not run because an earlier runtime step failed",
|
|
743
|
+
evidence={"blockedBy": blocking_runtime_step},
|
|
744
|
+
)
|
|
745
|
+
else:
|
|
746
|
+
context = VerifyContext(previous_steps=dict(previous), **context_base)
|
|
747
|
+
current = _adapter_step(options.adapter, name, context)
|
|
748
|
+
steps.append(current)
|
|
749
|
+
previous[current.name] = current
|
|
750
|
+
|
|
751
|
+
for contract in options.plugin_contracts:
|
|
752
|
+
for test_name in contract.tests:
|
|
753
|
+
context = VerifyContext(previous_steps=dict(previous), **context_base)
|
|
754
|
+
current = _plugin_step(options.adapter, contract, test_name, context)
|
|
755
|
+
steps.append(current)
|
|
756
|
+
previous[current.name] = current
|
|
757
|
+
|
|
758
|
+
boot = next((step for step in steps if step.name == "boot"), None)
|
|
759
|
+
runtime_composition_failure = (
|
|
760
|
+
_loader_composition_failure(composition, boot)
|
|
761
|
+
if boot is not None
|
|
762
|
+
else None
|
|
763
|
+
)
|
|
764
|
+
if runtime_composition_failure is not None:
|
|
765
|
+
composition = _step(
|
|
766
|
+
"composition",
|
|
767
|
+
"FAIL",
|
|
768
|
+
"Bundle composition failed during DSH Loader activation",
|
|
769
|
+
evidence={
|
|
770
|
+
**composition.evidence,
|
|
771
|
+
"runtimeFailure": runtime_composition_failure,
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
steps[0] = composition
|
|
775
|
+
|
|
776
|
+
result = VerifyResult(
|
|
777
|
+
status=_overall_status(steps),
|
|
778
|
+
dsh_home=options.dsh_home,
|
|
779
|
+
profile_name=options.profile_name,
|
|
780
|
+
mode=options.mode,
|
|
781
|
+
current_environment=options.current_environment,
|
|
782
|
+
available_secret_names=options.available_secret_names,
|
|
783
|
+
steps=tuple(steps),
|
|
784
|
+
plugin_contracts=options.plugin_contracts,
|
|
785
|
+
)
|
|
786
|
+
close = getattr(options.adapter, "close", None)
|
|
787
|
+
if callable(close):
|
|
788
|
+
try:
|
|
789
|
+
close()
|
|
790
|
+
except Exception:
|
|
791
|
+
# Runtime cleanup evidence belongs to the adapter; do not replace
|
|
792
|
+
# the actual verification result with a cleanup exception.
|
|
793
|
+
pass
|
|
794
|
+
return result
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
class SubprocessVerifyAdapter:
|
|
798
|
+
"""Explicit subprocess adapter for a real DSH or test runner.
|
|
799
|
+
|
|
800
|
+
Configuration shape::
|
|
801
|
+
|
|
802
|
+
{
|
|
803
|
+
"mode": "web",
|
|
804
|
+
"command": ["node", ".../bin.js", "--profile", "{profile}"],
|
|
805
|
+
"cwd": "...",
|
|
806
|
+
"timeoutSeconds": 60,
|
|
807
|
+
"readyRegex": "dsh web: (?P<url>https?://[^\\s]+)",
|
|
808
|
+
"expectedStatus": 200,
|
|
809
|
+
"probes": {"new_session": ["..."], "core_tool": ["..."]},
|
|
810
|
+
"pluginTests": {"plugin-a": {"loads": ["..."]}}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
Commands are arrays, never shell strings. ``{dsh_home}``, ``{profile}``,
|
|
814
|
+
``{profile_path}``, ``{url}``, and ``{pid}`` placeholders are available.
|
|
815
|
+
Missing probe commands remain UNTESTED. The adapter records full
|
|
816
|
+
stdout/stderr in step evidence and never records environment values.
|
|
817
|
+
"""
|
|
818
|
+
|
|
819
|
+
def __init__(self, config: Mapping[str, Any]) -> None:
|
|
820
|
+
self.config = dict(config)
|
|
821
|
+
self.mode = self.config.get("mode", "web")
|
|
822
|
+
if self.mode not in {"web", "headless"}:
|
|
823
|
+
raise ValueError("SubprocessVerifyAdapter mode must be 'web' or 'headless'")
|
|
824
|
+
self.process: subprocess.Popen[str] | None = None
|
|
825
|
+
self._logs: dict[str, list[str]] = {"stdout": [], "stderr": []}
|
|
826
|
+
self._threads: list[threading.Thread] = []
|
|
827
|
+
self._url: str | None = None
|
|
828
|
+
self._last_headless: dict[str, Any] | None = None
|
|
829
|
+
|
|
830
|
+
def _timeout(self) -> float:
|
|
831
|
+
value = self.config.get("timeoutSeconds", 60)
|
|
832
|
+
return float(value) if isinstance(value, (int, float)) and value > 0 else 60.0
|
|
833
|
+
|
|
834
|
+
def _format(self, value: str, context: VerifyContext) -> str:
|
|
835
|
+
values = {
|
|
836
|
+
"dsh_home": str(context.dsh_home),
|
|
837
|
+
"profile": context.profile_name,
|
|
838
|
+
"profile_path": str(context.profile_path),
|
|
839
|
+
"url": self._url or "",
|
|
840
|
+
"pid": str(self.process.pid if self.process is not None else ""),
|
|
841
|
+
}
|
|
842
|
+
return value.format_map(values)
|
|
843
|
+
|
|
844
|
+
def _command(self, key: str, context: VerifyContext) -> list[str] | None:
|
|
845
|
+
raw = self.config.get("command") if key in {"boot", "restart"} else (self.config.get("probes", {}) or {}).get(key)
|
|
846
|
+
if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
|
|
847
|
+
return None
|
|
848
|
+
return [self._format(item, context) for item in raw]
|
|
849
|
+
|
|
850
|
+
def _environment(self, context: VerifyContext) -> dict[str, str]:
|
|
851
|
+
env = dict(os.environ) if self.config.get("inheritEnvironment", True) is not False else {}
|
|
852
|
+
env["DSH_HOME"] = str(context.dsh_home)
|
|
853
|
+
env["DSH_PROFILE"] = context.profile_name
|
|
854
|
+
if self._url:
|
|
855
|
+
env["DSH_WEB_URL"] = self._url
|
|
856
|
+
extra = self.config.get("environment", {})
|
|
857
|
+
if isinstance(extra, Mapping):
|
|
858
|
+
env.update({str(key): str(value) for key, value in extra.items()})
|
|
859
|
+
return env
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _drain(stream, target: list[str]) -> None:
|
|
863
|
+
try:
|
|
864
|
+
for line in stream:
|
|
865
|
+
target.append(line)
|
|
866
|
+
finally:
|
|
867
|
+
stream.close()
|
|
868
|
+
|
|
869
|
+
def _logs_evidence(self) -> dict[str, Any]:
|
|
870
|
+
return {"stdout": "".join(self._logs["stdout"]), "stderr": "".join(self._logs["stderr"])}
|
|
871
|
+
|
|
872
|
+
def _spawn_web(self, context: VerifyContext) -> VerifyStep:
|
|
873
|
+
command = self._command("boot", context)
|
|
874
|
+
if command is None:
|
|
875
|
+
return _step("boot", "UNTESTED", "runner config has no boot command", evidence={"mode": self.mode})
|
|
876
|
+
self._logs = {"stdout": [], "stderr": []}
|
|
877
|
+
try:
|
|
878
|
+
cwd = self.config.get("cwd")
|
|
879
|
+
self.process = subprocess.Popen(
|
|
880
|
+
command,
|
|
881
|
+
cwd=str(cwd) if isinstance(cwd, str) else None,
|
|
882
|
+
env=self._environment(context),
|
|
883
|
+
stdout=subprocess.PIPE,
|
|
884
|
+
stderr=subprocess.PIPE,
|
|
885
|
+
text=True,
|
|
886
|
+
bufsize=1,
|
|
887
|
+
)
|
|
888
|
+
assert self.process.stdout is not None and self.process.stderr is not None
|
|
889
|
+
self._threads = [
|
|
890
|
+
threading.Thread(target=self._drain, args=(self.process.stdout, self._logs["stdout"]), daemon=True),
|
|
891
|
+
threading.Thread(target=self._drain, args=(self.process.stderr, self._logs["stderr"]), daemon=True),
|
|
892
|
+
]
|
|
893
|
+
for thread in self._threads:
|
|
894
|
+
thread.start()
|
|
895
|
+
except (OSError, ValueError) as error:
|
|
896
|
+
evidence = {"command": command, "error": str(error)}
|
|
897
|
+
return _step(
|
|
898
|
+
"boot",
|
|
899
|
+
"FAIL",
|
|
900
|
+
"could not start runtime command",
|
|
901
|
+
evidence=evidence,
|
|
902
|
+
diagnostic=_runtime_diagnostic(
|
|
903
|
+
code="RUNNER_SPAWN_FAILED",
|
|
904
|
+
stage="runtime",
|
|
905
|
+
message="could not start runtime command",
|
|
906
|
+
evidence=evidence,
|
|
907
|
+
item="command",
|
|
908
|
+
expected="the runtime command starts and prints a ready URL",
|
|
909
|
+
impact="DSH runtime could not be started for verification.",
|
|
910
|
+
),
|
|
911
|
+
)
|
|
912
|
+
pattern = self.config.get("readyRegex", r"dsh web: (?P<url>https?://[^\s]+)")
|
|
913
|
+
try:
|
|
914
|
+
regex = re.compile(pattern if isinstance(pattern, str) else r"dsh web: (?P<url>https?://[^\s]+)")
|
|
915
|
+
except re.error as error:
|
|
916
|
+
self.close()
|
|
917
|
+
evidence = {"error": str(error)}
|
|
918
|
+
return _step(
|
|
919
|
+
"boot",
|
|
920
|
+
"FAIL",
|
|
921
|
+
"runner readyRegex is invalid",
|
|
922
|
+
evidence=evidence,
|
|
923
|
+
diagnostic=_runtime_diagnostic(
|
|
924
|
+
code="RUNNER_READY_REGEX_INVALID",
|
|
925
|
+
stage="runtime",
|
|
926
|
+
message="runner readyRegex is invalid",
|
|
927
|
+
evidence=evidence,
|
|
928
|
+
item="readyRegex",
|
|
929
|
+
expected="a valid readyRegex that extracts the ready URL",
|
|
930
|
+
impact="DSH readiness could not be detected.",
|
|
931
|
+
),
|
|
932
|
+
)
|
|
933
|
+
deadline = time.monotonic() + self._timeout()
|
|
934
|
+
while time.monotonic() < deadline:
|
|
935
|
+
combined = "".join(self._logs["stdout"])
|
|
936
|
+
match = regex.search(combined)
|
|
937
|
+
if match:
|
|
938
|
+
self._url = match.groupdict().get("url") or (match.group(1) if match.groups() else match.group(0))
|
|
939
|
+
return _step("boot", "PASS", "runtime printed its ready URL", evidence={"pid": self.process.pid, "url": self._url, **self._logs_evidence()})
|
|
940
|
+
code = self.process.poll()
|
|
941
|
+
if code is not None:
|
|
942
|
+
evidence = {"exitCode": code, **self._logs_evidence()}
|
|
943
|
+
return _step(
|
|
944
|
+
"boot",
|
|
945
|
+
"FAIL",
|
|
946
|
+
"runtime exited before ready",
|
|
947
|
+
evidence=evidence,
|
|
948
|
+
diagnostic=_runtime_diagnostic(
|
|
949
|
+
code="RUNNER_EXITED_BEFORE_READY",
|
|
950
|
+
stage="runtime",
|
|
951
|
+
message="runtime exited before ready",
|
|
952
|
+
evidence=evidence,
|
|
953
|
+
item="runtime",
|
|
954
|
+
expected="the runtime process stays alive and prints a ready URL",
|
|
955
|
+
impact="The imported Profile cannot boot in the target DSH runtime.",
|
|
956
|
+
),
|
|
957
|
+
)
|
|
958
|
+
time.sleep(0.05)
|
|
959
|
+
self.close()
|
|
960
|
+
evidence = {"timeoutSeconds": self._timeout(), **self._logs_evidence()}
|
|
961
|
+
return _step(
|
|
962
|
+
"boot",
|
|
963
|
+
"FAIL",
|
|
964
|
+
"runtime did not print a ready URL before timeout",
|
|
965
|
+
evidence=evidence,
|
|
966
|
+
diagnostic=_runtime_diagnostic(
|
|
967
|
+
code="RUNNER_READY_TIMEOUT",
|
|
968
|
+
stage="runtime",
|
|
969
|
+
message="runtime did not print a ready URL before timeout",
|
|
970
|
+
evidence=evidence,
|
|
971
|
+
item="runtime",
|
|
972
|
+
expected="the runtime prints a ready URL within the configured timeout",
|
|
973
|
+
impact="DSH readiness could not be confirmed within the timeout.",
|
|
974
|
+
),
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
def _run_headless(self, context: VerifyContext, step_name: str = "boot") -> VerifyStep:
|
|
978
|
+
command = self._command("boot", context)
|
|
979
|
+
if command is None:
|
|
980
|
+
return _step(step_name, "UNTESTED", "runner config has no headless command", evidence={"mode": self.mode})
|
|
981
|
+
try:
|
|
982
|
+
completed = subprocess.run(
|
|
983
|
+
command,
|
|
984
|
+
cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None,
|
|
985
|
+
env=self._environment(context),
|
|
986
|
+
capture_output=True,
|
|
987
|
+
text=True,
|
|
988
|
+
timeout=self._timeout(),
|
|
989
|
+
check=False,
|
|
990
|
+
)
|
|
991
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
992
|
+
evidence = {"command": command, "error": str(error)}
|
|
993
|
+
return _step(
|
|
994
|
+
step_name,
|
|
995
|
+
"FAIL",
|
|
996
|
+
"headless runtime command failed to complete",
|
|
997
|
+
evidence=evidence,
|
|
998
|
+
diagnostic=_runtime_diagnostic(
|
|
999
|
+
code="HEADLESS_RUNNER_FAILED",
|
|
1000
|
+
stage="runtime",
|
|
1001
|
+
message="headless runtime command failed to complete",
|
|
1002
|
+
evidence=evidence,
|
|
1003
|
+
item="command",
|
|
1004
|
+
expected="the headless runtime command completes successfully",
|
|
1005
|
+
impact="The imported Profile could not be verified in headless mode.",
|
|
1006
|
+
),
|
|
1007
|
+
)
|
|
1008
|
+
self._last_headless = {"exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
|
1009
|
+
evidence = {"command": command, **self._last_headless}
|
|
1010
|
+
if completed.returncode == 0:
|
|
1011
|
+
return _step(step_name, "PASS", "headless runtime completed", evidence=evidence)
|
|
1012
|
+
return _step(
|
|
1013
|
+
step_name,
|
|
1014
|
+
"FAIL",
|
|
1015
|
+
"headless runtime exited with failure",
|
|
1016
|
+
evidence=evidence,
|
|
1017
|
+
diagnostic=_runtime_diagnostic(
|
|
1018
|
+
code="HEADLESS_RUNNER_FAILED",
|
|
1019
|
+
stage="runtime",
|
|
1020
|
+
message="headless runtime exited with failure",
|
|
1021
|
+
evidence=evidence,
|
|
1022
|
+
item="runtime",
|
|
1023
|
+
expected="the headless runtime exits with code 0",
|
|
1024
|
+
impact="The imported Profile failed in headless runtime.",
|
|
1025
|
+
),
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
def _probe(self, step: str, context: VerifyContext) -> VerifyStep:
|
|
1029
|
+
command = self._command(step, context)
|
|
1030
|
+
if command is None:
|
|
1031
|
+
return _step(step, "UNTESTED", f"no explicit {step} probe command was supplied", evidence={"mode": self.mode})
|
|
1032
|
+
try:
|
|
1033
|
+
completed = subprocess.run(
|
|
1034
|
+
command,
|
|
1035
|
+
cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None,
|
|
1036
|
+
env=self._environment(context),
|
|
1037
|
+
capture_output=True,
|
|
1038
|
+
text=True,
|
|
1039
|
+
timeout=self._timeout(),
|
|
1040
|
+
check=False,
|
|
1041
|
+
)
|
|
1042
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1043
|
+
evidence = {"command": command, "error": str(error)}
|
|
1044
|
+
return _step(
|
|
1045
|
+
step,
|
|
1046
|
+
"FAIL",
|
|
1047
|
+
f"{step} probe failed to complete",
|
|
1048
|
+
evidence=evidence,
|
|
1049
|
+
diagnostic=_runtime_diagnostic(
|
|
1050
|
+
code="PROBE_FAILED",
|
|
1051
|
+
stage="probe",
|
|
1052
|
+
message=f"{step} probe failed to complete",
|
|
1053
|
+
evidence=evidence,
|
|
1054
|
+
item=step,
|
|
1055
|
+
expected=f"the {step} probe command completes successfully",
|
|
1056
|
+
impact=f"The {step} runtime capability could not be verified.",
|
|
1057
|
+
),
|
|
1058
|
+
)
|
|
1059
|
+
evidence = {"command": command, "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
|
1060
|
+
if completed.returncode == 0:
|
|
1061
|
+
return _step(step, "PASS", f"{step} probe completed", evidence=evidence)
|
|
1062
|
+
return _step(
|
|
1063
|
+
step,
|
|
1064
|
+
"FAIL",
|
|
1065
|
+
f"{step} probe failed",
|
|
1066
|
+
evidence=evidence,
|
|
1067
|
+
diagnostic=_runtime_diagnostic(
|
|
1068
|
+
code="PROBE_FAILED",
|
|
1069
|
+
stage="probe",
|
|
1070
|
+
message=f"{step} probe failed",
|
|
1071
|
+
evidence=evidence,
|
|
1072
|
+
item=step,
|
|
1073
|
+
expected=f"the {step} probe command exits with code 0",
|
|
1074
|
+
impact=f"The {step} runtime capability could not be verified.",
|
|
1075
|
+
),
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
def _surface_ready(self, context: VerifyContext, step: str = "surface_ready") -> VerifyStep:
|
|
1079
|
+
if self.mode == "headless":
|
|
1080
|
+
if self._last_headless is None:
|
|
1081
|
+
return _step(step, "UNTESTED", "headless boot has not completed", evidence={})
|
|
1082
|
+
status = "PASS" if self._last_headless["exitCode"] == 0 else "FAIL"
|
|
1083
|
+
return _step(step, status, "headless completion is the ready signal", evidence=dict(self._last_headless))
|
|
1084
|
+
if not self._url:
|
|
1085
|
+
return _step(step, "UNTESTED", "boot did not provide a Web URL", evidence={})
|
|
1086
|
+
ownership = self._port_owner()
|
|
1087
|
+
if ownership["checked"] is not True:
|
|
1088
|
+
return _step(
|
|
1089
|
+
step,
|
|
1090
|
+
"UNTESTED",
|
|
1091
|
+
"could not verify that the Web port belongs to the test PID",
|
|
1092
|
+
evidence={"url": self._url, **ownership},
|
|
1093
|
+
)
|
|
1094
|
+
if ownership["portBelongsToProcess"] is not True:
|
|
1095
|
+
evidence = {"url": self._url, **ownership}
|
|
1096
|
+
return _step(
|
|
1097
|
+
step,
|
|
1098
|
+
"FAIL",
|
|
1099
|
+
"Web port does not belong to the test PID",
|
|
1100
|
+
evidence=evidence,
|
|
1101
|
+
diagnostic=_runtime_diagnostic(
|
|
1102
|
+
code="WEB_PORT_NOT_OWNED",
|
|
1103
|
+
stage="surface",
|
|
1104
|
+
message="Web port does not belong to the test PID",
|
|
1105
|
+
evidence=evidence,
|
|
1106
|
+
item="port",
|
|
1107
|
+
expected="the listening port belongs to the runtime process being verified",
|
|
1108
|
+
impact="Another process may occupy the DSH Web port.",
|
|
1109
|
+
),
|
|
1110
|
+
)
|
|
1111
|
+
try:
|
|
1112
|
+
with urllib.request.urlopen(self._url, timeout=self._timeout()) as response:
|
|
1113
|
+
code = response.status
|
|
1114
|
+
body = response.read()
|
|
1115
|
+
except (OSError, urllib.error.URLError) as error:
|
|
1116
|
+
evidence = {"url": self._url, "error": str(error), "pid": self.process.pid if self.process else None}
|
|
1117
|
+
return _step(
|
|
1118
|
+
step,
|
|
1119
|
+
"FAIL",
|
|
1120
|
+
"Web URL did not return an HTTP response",
|
|
1121
|
+
evidence=evidence,
|
|
1122
|
+
diagnostic=_runtime_diagnostic(
|
|
1123
|
+
code="WEB_HTTP_UNREACHABLE",
|
|
1124
|
+
stage="surface",
|
|
1125
|
+
message="Web URL did not return an HTTP response",
|
|
1126
|
+
evidence=evidence,
|
|
1127
|
+
item="url",
|
|
1128
|
+
expected="the DSH Web URL returns an HTTP response",
|
|
1129
|
+
impact="The DSH Web surface is not reachable.",
|
|
1130
|
+
),
|
|
1131
|
+
)
|
|
1132
|
+
expected = self.config.get("expectedStatus", 200)
|
|
1133
|
+
status = "PASS" if code == expected else "FAIL"
|
|
1134
|
+
evidence = {"url": self._url, "pid": self.process.pid if self.process else None, **ownership, "status": code, "expectedStatus": expected, "responseBytes": len(body)}
|
|
1135
|
+
if status == "PASS":
|
|
1136
|
+
return _step(step, "PASS", "Web HTTP readiness probe completed", evidence=evidence)
|
|
1137
|
+
return _step(
|
|
1138
|
+
step,
|
|
1139
|
+
"FAIL",
|
|
1140
|
+
"Web HTTP status did not match expected",
|
|
1141
|
+
evidence=evidence,
|
|
1142
|
+
diagnostic=_runtime_diagnostic(
|
|
1143
|
+
code="WEB_HTTP_STATUS_MISMATCH",
|
|
1144
|
+
stage="surface",
|
|
1145
|
+
message="Web HTTP status did not match expected",
|
|
1146
|
+
evidence=evidence,
|
|
1147
|
+
item="url",
|
|
1148
|
+
expected=f"the DSH Web URL returns HTTP {expected}",
|
|
1149
|
+
impact="The DSH Web surface returned an unexpected HTTP status.",
|
|
1150
|
+
),
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
def _port_owner(self) -> dict[str, Any]:
|
|
1154
|
+
"""Check the listening PID for the adapter's URL without third-party dependencies."""
|
|
1155
|
+
|
|
1156
|
+
if self.process is None or not self._url:
|
|
1157
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "process or URL is unavailable"}
|
|
1158
|
+
return self._port_owner_for(self._url, self.process.pid)
|
|
1159
|
+
|
|
1160
|
+
@staticmethod
|
|
1161
|
+
def _port_owner_for(url: str | None, pid: int | None) -> dict[str, Any]:
|
|
1162
|
+
if not url or pid is None:
|
|
1163
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "process or URL is unavailable"}
|
|
1164
|
+
parsed = urllib.parse.urlparse(url)
|
|
1165
|
+
if parsed.port is None:
|
|
1166
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "URL has no port"}
|
|
1167
|
+
if os.name != "nt":
|
|
1168
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": "port ownership probe is currently implemented for Windows netstat"}
|
|
1169
|
+
try:
|
|
1170
|
+
completed = subprocess.run(
|
|
1171
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
1172
|
+
capture_output=True,
|
|
1173
|
+
text=True,
|
|
1174
|
+
timeout=10,
|
|
1175
|
+
check=False,
|
|
1176
|
+
)
|
|
1177
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1178
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": str(error)}
|
|
1179
|
+
port_owner: int | None = None
|
|
1180
|
+
for line in completed.stdout.splitlines():
|
|
1181
|
+
fields = line.split()
|
|
1182
|
+
if len(fields) < 5 or fields[0].upper() != "TCP" or fields[3].upper() != "LISTENING":
|
|
1183
|
+
continue
|
|
1184
|
+
port_match = re.search(r":(\d+)$", fields[1])
|
|
1185
|
+
if port_match is None or int(port_match.group(1)) != parsed.port:
|
|
1186
|
+
continue
|
|
1187
|
+
try:
|
|
1188
|
+
port_owner = int(fields[4])
|
|
1189
|
+
except ValueError:
|
|
1190
|
+
continue
|
|
1191
|
+
break
|
|
1192
|
+
return {
|
|
1193
|
+
"checked": True,
|
|
1194
|
+
"portOwnerPid": port_owner,
|
|
1195
|
+
"portBelongsToProcess": port_owner == pid,
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
def run_step(self, step: str, context: VerifyContext) -> VerifyStep:
|
|
1199
|
+
if step == "boot":
|
|
1200
|
+
return self._run_headless(context) if self.mode == "headless" else self._spawn_web(context)
|
|
1201
|
+
if step == "surface_ready" or step == "restart_surface_ready":
|
|
1202
|
+
return self._surface_ready(context, step)
|
|
1203
|
+
if step == "restart":
|
|
1204
|
+
stopped = self.close()
|
|
1205
|
+
restarted = self._run_headless(context, step) if self.mode == "headless" else self._rename(self._spawn_web(context), step)
|
|
1206
|
+
evidence = dict(restarted.evidence)
|
|
1207
|
+
evidence["stoppedProcess"] = stopped
|
|
1208
|
+
if restarted.status == "PASS" and stopped.get("portReleased") is not True:
|
|
1209
|
+
return _step(
|
|
1210
|
+
step,
|
|
1211
|
+
"FAIL",
|
|
1212
|
+
"restart completed without proof that the previous process port was released",
|
|
1213
|
+
evidence=evidence,
|
|
1214
|
+
diagnostic=_runtime_diagnostic(
|
|
1215
|
+
code="RESTART_PORT_NOT_RELEASED",
|
|
1216
|
+
stage="restart",
|
|
1217
|
+
message="restart completed without proof that the previous process port was released",
|
|
1218
|
+
evidence=evidence,
|
|
1219
|
+
item="port",
|
|
1220
|
+
expected="the previous runtime process releases the Web port before restart",
|
|
1221
|
+
impact="The restarted DSH may conflict with a stale process on the same port.",
|
|
1222
|
+
),
|
|
1223
|
+
)
|
|
1224
|
+
return VerifyStep(
|
|
1225
|
+
name=restarted.name,
|
|
1226
|
+
status=restarted.status,
|
|
1227
|
+
message=restarted.message,
|
|
1228
|
+
required=restarted.required,
|
|
1229
|
+
evidence=evidence,
|
|
1230
|
+
diagnostic=restarted.diagnostic,
|
|
1231
|
+
)
|
|
1232
|
+
if step in {"new_session", "basic_response", "core_tool"}:
|
|
1233
|
+
return self._probe(step, context)
|
|
1234
|
+
return _step(step, "UNTESTED", f"unsupported subprocess verify step: {step}", evidence={})
|
|
1235
|
+
|
|
1236
|
+
@staticmethod
|
|
1237
|
+
def _rename(value: VerifyStep, name: str) -> VerifyStep:
|
|
1238
|
+
return VerifyStep(name=name, status=value.status, message=value.message, required=value.required, evidence=value.evidence, diagnostic=value.diagnostic)
|
|
1239
|
+
|
|
1240
|
+
def run_plugin_test(self, plugin: str, test_name: str, context: VerifyContext) -> VerifyStep:
|
|
1241
|
+
configured = self.config.get("pluginTests", {})
|
|
1242
|
+
command = configured.get(plugin, {}).get(test_name) if isinstance(configured, Mapping) and isinstance(configured.get(plugin), Mapping) else None
|
|
1243
|
+
if not isinstance(command, list) or any(not isinstance(item, str) for item in command):
|
|
1244
|
+
return _step(f"plugin:{plugin}:{test_name}", "UNTESTED", "no explicit plugin smoke command was supplied", required=True, evidence={"plugin": plugin, "test": test_name})
|
|
1245
|
+
command = [self._format(item, context) for item in command]
|
|
1246
|
+
try:
|
|
1247
|
+
completed = subprocess.run(command, cwd=str(self.config["cwd"]) if isinstance(self.config.get("cwd"), str) else None, env=self._environment(context), capture_output=True, text=True, timeout=self._timeout(), check=False)
|
|
1248
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1249
|
+
evidence = {"command": command, "error": str(error)}
|
|
1250
|
+
return _step(
|
|
1251
|
+
f"plugin:{plugin}:{test_name}",
|
|
1252
|
+
"FAIL",
|
|
1253
|
+
"plugin smoke command failed to complete",
|
|
1254
|
+
required=True,
|
|
1255
|
+
evidence=evidence,
|
|
1256
|
+
diagnostic=_runtime_diagnostic(
|
|
1257
|
+
code="PLUGIN_SMOKE_FAILED",
|
|
1258
|
+
stage="plugin-smoke",
|
|
1259
|
+
message="plugin smoke command failed to complete",
|
|
1260
|
+
evidence=evidence,
|
|
1261
|
+
item=plugin,
|
|
1262
|
+
expected=f"the plugin smoke command for {plugin} completes successfully",
|
|
1263
|
+
impact=f"Plugin {plugin} smoke test could not be verified.",
|
|
1264
|
+
),
|
|
1265
|
+
)
|
|
1266
|
+
evidence = {"command": command, "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
|
1267
|
+
if completed.returncode == 0:
|
|
1268
|
+
return _step(f"plugin:{plugin}:{test_name}", "PASS", "plugin smoke command completed", required=True, evidence=evidence)
|
|
1269
|
+
return _step(
|
|
1270
|
+
f"plugin:{plugin}:{test_name}",
|
|
1271
|
+
"FAIL",
|
|
1272
|
+
"plugin smoke command failed",
|
|
1273
|
+
required=True,
|
|
1274
|
+
evidence=evidence,
|
|
1275
|
+
diagnostic=_runtime_diagnostic(
|
|
1276
|
+
code="PLUGIN_SMOKE_FAILED",
|
|
1277
|
+
stage="plugin-smoke",
|
|
1278
|
+
message="plugin smoke command failed",
|
|
1279
|
+
evidence=evidence,
|
|
1280
|
+
item=plugin,
|
|
1281
|
+
expected=f"the plugin smoke command for {plugin} exits with code 0",
|
|
1282
|
+
impact=f"Plugin {plugin} smoke test failed.",
|
|
1283
|
+
),
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
def close(self) -> dict[str, Any]:
|
|
1287
|
+
process = self.process
|
|
1288
|
+
url = self._url
|
|
1289
|
+
if process is None:
|
|
1290
|
+
return {"pid": None, "url": url, "processExited": True, "portReleased": True, "reason": "no persistent process"}
|
|
1291
|
+
pid = process.pid
|
|
1292
|
+
if process.poll() is None:
|
|
1293
|
+
process.terminate()
|
|
1294
|
+
try:
|
|
1295
|
+
process.wait(timeout=5)
|
|
1296
|
+
except subprocess.TimeoutExpired:
|
|
1297
|
+
process.kill()
|
|
1298
|
+
process.wait(timeout=5)
|
|
1299
|
+
exit_code = process.poll()
|
|
1300
|
+
port_evidence: dict[str, Any] = {"checked": True, "portOwnerPid": None, "portBelongsToProcess": False}
|
|
1301
|
+
port_released = True
|
|
1302
|
+
if self.mode == "web" and url:
|
|
1303
|
+
deadline = time.monotonic() + 2.0
|
|
1304
|
+
while True:
|
|
1305
|
+
port_evidence = self._port_owner_for(url, pid)
|
|
1306
|
+
if port_evidence.get("checked") is not True:
|
|
1307
|
+
port_released = False
|
|
1308
|
+
break
|
|
1309
|
+
if port_evidence.get("portOwnerPid") is None:
|
|
1310
|
+
port_released = True
|
|
1311
|
+
break
|
|
1312
|
+
if time.monotonic() >= deadline:
|
|
1313
|
+
port_released = False
|
|
1314
|
+
break
|
|
1315
|
+
time.sleep(0.05)
|
|
1316
|
+
evidence = {
|
|
1317
|
+
"pid": pid,
|
|
1318
|
+
"url": url,
|
|
1319
|
+
"exitCode": exit_code,
|
|
1320
|
+
"processExited": process.poll() is not None,
|
|
1321
|
+
"portReleased": port_released,
|
|
1322
|
+
"portEvidence": port_evidence,
|
|
1323
|
+
}
|
|
1324
|
+
self.process = None
|
|
1325
|
+
self._url = None
|
|
1326
|
+
return evidence
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def load_smoke_contracts(path: str | os.PathLike[str]) -> tuple[SmokeTestContract, ...]:
|
|
1330
|
+
"""Load one JSON object or an array of plugin smoke contracts."""
|
|
1331
|
+
|
|
1332
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
1333
|
+
values = value if isinstance(value, list) else [value]
|
|
1334
|
+
if any(not isinstance(item, Mapping) for item in values):
|
|
1335
|
+
raise ValueError("plugin contract JSON must contain an object or an array of objects")
|
|
1336
|
+
return tuple(SmokeTestContract.from_mapping(item) for item in values)
|