steppy-dev 0.3.0__py3-none-any.whl
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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/coordinator.py
ADDED
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
"""``steppy run --workers N >= 2`` host-side orchestrator.
|
|
2
|
+
|
|
3
|
+
What lives here:
|
|
4
|
+
|
|
5
|
+
- :func:`run_parallel` — entry point dispatched from :mod:`steppy.cli`. Same
|
|
6
|
+
argument set as the existing single-worker run path, plus ``workers`` and
|
|
7
|
+
``no_build``. Returns the process exit code.
|
|
8
|
+
- :func:`cleanup_stacks` — entry point for ``steppy stacks cleanup``. Lists
|
|
9
|
+
any orphaned ``steppy-run-*`` Compose projects and tears them down.
|
|
10
|
+
|
|
11
|
+
The single-worker path in :func:`steppy.cli.run` does NOT import this
|
|
12
|
+
module. The dispatch in cli.run is a lazy ``from steppy.coordinator import
|
|
13
|
+
run_parallel`` so default invocations never load anything from here. See
|
|
14
|
+
``tests/test_no_parallel_import_on_default_path.py`` for the guard test.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
|
|
32
|
+
from . import exclusive, selection
|
|
33
|
+
from .captures import DEFAULT_SCREENSHOTS_DIR, resolve_output_dir
|
|
34
|
+
from .config import ConfigError, load_config
|
|
35
|
+
from .models import SteppyConfig
|
|
36
|
+
from .runmeta import EVENTS_SUBDIR, JUNIT_SUBDIR, new_run_id, write_run_sidecar
|
|
37
|
+
from .parser import ParseError, parse_scenarios_from_directory
|
|
38
|
+
from ._parallel.compose import (
|
|
39
|
+
ComposeError,
|
|
40
|
+
check_compose_version,
|
|
41
|
+
introspect_user_compose,
|
|
42
|
+
render_override,
|
|
43
|
+
)
|
|
44
|
+
from ._parallel.event_bus import EventBus, RawLogChannel
|
|
45
|
+
from ._parallel.image import ensure_worker_image
|
|
46
|
+
from ._parallel.lifecycle import CoordinatorLifecycle
|
|
47
|
+
from ._parallel.progress import WorkerLogTailer
|
|
48
|
+
from ._parallel.queue import Queue
|
|
49
|
+
from ._parallel.renderer import make_renderer, render_summary
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_PROJECT_NAME_PREFIX = "steppy-run-"
|
|
53
|
+
_DEFAULT_AUTO_CAP = 8
|
|
54
|
+
|
|
55
|
+
# How ``ensure_worker_image`` resolved the image, rendered for the run header.
|
|
56
|
+
_IMAGE_MODE_LABELS = {
|
|
57
|
+
"cached": "cached",
|
|
58
|
+
"config": "built from parallel.dockerfile",
|
|
59
|
+
"repo": "built from source checkout",
|
|
60
|
+
"installed": "built from installed package",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _finalize_run(
|
|
65
|
+
*,
|
|
66
|
+
queue,
|
|
67
|
+
bus,
|
|
68
|
+
console,
|
|
69
|
+
tmp_dir,
|
|
70
|
+
project_root,
|
|
71
|
+
tests_dir,
|
|
72
|
+
retry_passes=None,
|
|
73
|
+
run_id=None,
|
|
74
|
+
workers=None,
|
|
75
|
+
verbose=False,
|
|
76
|
+
screenshots_dir=None,
|
|
77
|
+
never_started=None,
|
|
78
|
+
):
|
|
79
|
+
"""Collect results, persist artifacts, refresh lastfailed, print the summary.
|
|
80
|
+
|
|
81
|
+
The shutdown contract, run identically whether the workers drained normally
|
|
82
|
+
or the user hit Ctrl-C — previously spelled out twice, so any new artifact
|
|
83
|
+
writer or summary argument had to be added to both or Ctrl-C'd runs would
|
|
84
|
+
silently lose it.
|
|
85
|
+
|
|
86
|
+
Ordering is load-bearing: artifacts are persisted BEFORE the summary so the
|
|
87
|
+
summary's path references are valid, and well before tmp_dir cleanup wipes
|
|
88
|
+
the per-scenario logs.
|
|
89
|
+
|
|
90
|
+
``retry_passes`` is the list of :class:`~steppy._parallel.retry.PassResult`
|
|
91
|
+
for attempts 2..N (the main pass is reconstructed here from ``queue``).
|
|
92
|
+
Empty/None ⇒ single-pass behaviour, byte-identical to pre-retry.
|
|
93
|
+
|
|
94
|
+
The caller stops the renderer first (the Live overlay would otherwise
|
|
95
|
+
re-paint over the summary). Returns ``(results, orphans, verdict)`` —
|
|
96
|
+
``verdict`` is None when no retry pass ran.
|
|
97
|
+
"""
|
|
98
|
+
results = queue.collect_results()
|
|
99
|
+
orphans = queue.orphaned_running_tasks()
|
|
100
|
+
never_started = set(never_started or ())
|
|
101
|
+
|
|
102
|
+
from ._parallel.failures import persist_failure_artifacts
|
|
103
|
+
from ._parallel.lastfailed import write_lastfailed_cache
|
|
104
|
+
from ._parallel.retry import PassResult, classify, log_roots_by_slug
|
|
105
|
+
|
|
106
|
+
# Fold the retry attempts in. The main pass is attempt 1.
|
|
107
|
+
main_pass = PassResult(
|
|
108
|
+
attempt=1,
|
|
109
|
+
coord_root=tmp_dir,
|
|
110
|
+
results=results,
|
|
111
|
+
orphans=orphans,
|
|
112
|
+
bus=bus,
|
|
113
|
+
never_started=never_started,
|
|
114
|
+
)
|
|
115
|
+
all_passes = [main_pass, *(retry_passes or [])]
|
|
116
|
+
verdict = classify(all_passes) if retry_passes else None
|
|
117
|
+
|
|
118
|
+
if retry_passes:
|
|
119
|
+
# The authoritative view is the LAST attempt's outcome per scenario, so
|
|
120
|
+
# a flaky scenario shows up green and its retry log is the one persisted.
|
|
121
|
+
results = _merge_results(all_passes)
|
|
122
|
+
orphans = _final_orphans(all_passes)
|
|
123
|
+
|
|
124
|
+
persist_failure_artifacts(
|
|
125
|
+
project_root=project_root,
|
|
126
|
+
tmp_dir=tmp_dir,
|
|
127
|
+
bus=bus,
|
|
128
|
+
scenario_results=results,
|
|
129
|
+
orphans=orphans,
|
|
130
|
+
log_roots=log_roots_by_slug(all_passes) if retry_passes else None,
|
|
131
|
+
screenshots_dir=screenshots_dir,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Parallel workers never populate .pytest_cache themselves; without this,
|
|
135
|
+
# `steppy rerun` would pick up a stale single-worker run's failure set.
|
|
136
|
+
write_lastfailed_cache(
|
|
137
|
+
project_root=project_root,
|
|
138
|
+
tests_dir=tests_dir,
|
|
139
|
+
scenario_results=results,
|
|
140
|
+
orphans=orphans,
|
|
141
|
+
bus=bus,
|
|
142
|
+
# Flaky scenarios ended green — re-running them would waste a rerun on
|
|
143
|
+
# known-good tests and bury the real failures.
|
|
144
|
+
failed_slugs=set(verdict.failed) if verdict else None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
render_summary(
|
|
148
|
+
console=console,
|
|
149
|
+
bus=bus,
|
|
150
|
+
scenario_results=results,
|
|
151
|
+
orphans=orphans,
|
|
152
|
+
tmp_dir=tmp_dir,
|
|
153
|
+
project_root=project_root,
|
|
154
|
+
verdict=verdict,
|
|
155
|
+
retry_buses=[p.bus for p in (retry_passes or []) if p.bus is not None],
|
|
156
|
+
verbose=verbose,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
_record_history(
|
|
160
|
+
project_root=project_root,
|
|
161
|
+
run_id=run_id,
|
|
162
|
+
workers=workers,
|
|
163
|
+
passes=all_passes,
|
|
164
|
+
verdict=verdict,
|
|
165
|
+
bus=bus,
|
|
166
|
+
console=console,
|
|
167
|
+
)
|
|
168
|
+
return results, orphans, verdict
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _merge_results(all_passes) -> list:
|
|
172
|
+
"""One :class:`ScenarioResult` per slug, from its LAST attempt.
|
|
173
|
+
|
|
174
|
+
Later attempts overwrite earlier ones, so a scenario that failed under load
|
|
175
|
+
and passed alone reports as a pass (with the retry's worker + duration)."""
|
|
176
|
+
merged: dict[str, Any] = {}
|
|
177
|
+
for p in all_passes:
|
|
178
|
+
for r in p.results:
|
|
179
|
+
merged[r.slug] = r
|
|
180
|
+
return list(merged.values())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _final_orphans(all_passes) -> dict[str, list[str]]:
|
|
184
|
+
"""Orphans that survived every attempt.
|
|
185
|
+
|
|
186
|
+
An orphan retried successfully is no longer an orphan; without this filter
|
|
187
|
+
a recovered worker-death would still be reported as one."""
|
|
188
|
+
resolved = {r.slug for p in all_passes for r in p.results}
|
|
189
|
+
out: dict[str, list[str]] = {}
|
|
190
|
+
for p in all_passes:
|
|
191
|
+
for worker_id, slugs in p.orphans.items():
|
|
192
|
+
remaining = [s for s in slugs if s not in resolved]
|
|
193
|
+
if remaining:
|
|
194
|
+
out.setdefault(worker_id, []).extend(remaining)
|
|
195
|
+
return {wid: sorted(set(slugs)) for wid, slugs in out.items()}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _record_history(*, project_root, run_id, workers, passes, verdict, bus, console) -> None:
|
|
199
|
+
"""Append this run to ``.steppy/history.jsonl``. Best-effort and lazy —
|
|
200
|
+
a history failure must never affect the run's outcome."""
|
|
201
|
+
if run_id is None:
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
from .history import append_run
|
|
205
|
+
|
|
206
|
+
append_run(
|
|
207
|
+
project_root=project_root,
|
|
208
|
+
run_id=run_id,
|
|
209
|
+
workers=workers or 0,
|
|
210
|
+
passes=passes,
|
|
211
|
+
verdict=verdict,
|
|
212
|
+
bus=bus,
|
|
213
|
+
)
|
|
214
|
+
except Exception as e: # noqa: BLE001 — telemetry is never worth a failed run
|
|
215
|
+
console.print(f"[dim]could not write run history: {e}[/dim]")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_parallel(
|
|
219
|
+
*,
|
|
220
|
+
workers: int,
|
|
221
|
+
scenario: Optional[str],
|
|
222
|
+
tag: Optional[list[str]],
|
|
223
|
+
headed: bool,
|
|
224
|
+
screenshots: bool,
|
|
225
|
+
guide: bool,
|
|
226
|
+
no_asserts: bool,
|
|
227
|
+
no_guide: bool,
|
|
228
|
+
locales: Optional[str],
|
|
229
|
+
layouts: Optional[str],
|
|
230
|
+
tests_dir: Path,
|
|
231
|
+
scenarios_dir: Path,
|
|
232
|
+
pytest_passthrough: list[str],
|
|
233
|
+
no_build: bool = False,
|
|
234
|
+
verbose: bool = False,
|
|
235
|
+
coverage: bool = False,
|
|
236
|
+
trace_mode: str = "off",
|
|
237
|
+
video_mode: str = "off",
|
|
238
|
+
retries: int = 0,
|
|
239
|
+
retry_pass: Optional[int] = None,
|
|
240
|
+
strict_flaky: bool = False,
|
|
241
|
+
fail_fast: Optional[bool] = None,
|
|
242
|
+
bundle: bool = False,
|
|
243
|
+
) -> int:
|
|
244
|
+
"""Top-level coordinator. Returns the process exit code (0 on success).
|
|
245
|
+
|
|
246
|
+
Mirrors the user-facing flag dispatch from cli.run for ``--guide`` /
|
|
247
|
+
``--no-guide`` etc. so a parallel run produces the same scenario
|
|
248
|
+
selection as the equivalent single-worker run would.
|
|
249
|
+
|
|
250
|
+
``trace_mode`` / ``video_mode`` / ``retries`` are resolved host-side by
|
|
251
|
+
``cli._resolve_report_modes`` (they need config.yaml) and travel to the
|
|
252
|
+
worker containers as env vars — the coordinator has no pytest process of
|
|
253
|
+
its own. ``pytest_passthrough`` (the user's ``--`` argv) rides along the
|
|
254
|
+
same way.
|
|
255
|
+
|
|
256
|
+
``retry_pass`` / ``fail_fast`` are tri-state: None ⇒ take ``config.parallel``,
|
|
257
|
+
otherwise the CLI flag wins. ``strict_flaky`` makes a pass-on-retry fail the
|
|
258
|
+
build (the default is lenient — see the exit-code block below).
|
|
259
|
+
|
|
260
|
+
``bundle`` turns on the two extra artifact sinks ``steppy bundle create``
|
|
261
|
+
reads. Both land under the shared ``output/`` mount and are keyed by
|
|
262
|
+
scenario, which is safe precisely because the queue guarantees a scenario is
|
|
263
|
+
claimed by exactly one worker.
|
|
264
|
+
"""
|
|
265
|
+
console = Console()
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
config = load_config()
|
|
269
|
+
except ConfigError as e:
|
|
270
|
+
console.print(f"[red]ERROR[/red] {e}")
|
|
271
|
+
return 1
|
|
272
|
+
|
|
273
|
+
# Validate parallel block + per-app internal_url for selected scenarios.
|
|
274
|
+
parallel_cfg = config.parallel
|
|
275
|
+
if parallel_cfg is None:
|
|
276
|
+
console.print(
|
|
277
|
+
"[red]ERROR[/red] --workers >= 2 requires a `parallel:` block in config.yaml. See README for the schema."
|
|
278
|
+
)
|
|
279
|
+
return 1
|
|
280
|
+
|
|
281
|
+
project_root = Path.cwd()
|
|
282
|
+
|
|
283
|
+
# The capture root the workers' conftest will write to. Only `output/` is
|
|
284
|
+
# bind-mounted back to the host (compose.py) and the project itself is
|
|
285
|
+
# mounted read-only, so a screenshots_dir pointing elsewhere cannot survive
|
|
286
|
+
# a worker — say so rather than let the run finish with nothing captured.
|
|
287
|
+
screenshots_dir = resolve_output_dir(project_root, config.output.screenshots_dir, DEFAULT_SCREENSHOTS_DIR)
|
|
288
|
+
output_root = (project_root / "output").resolve()
|
|
289
|
+
if not screenshots_dir.is_relative_to(output_root):
|
|
290
|
+
console.print(
|
|
291
|
+
f"[yellow]WARNING[/yellow] output.screenshots_dir ({config.output.screenshots_dir}) resolves outside "
|
|
292
|
+
f"{output_root}, but --workers only mounts output/ back to the host — captures written there will "
|
|
293
|
+
"stay inside the worker container. Move it under output/ or run single-worker."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
compose_file = (project_root / parallel_cfg.compose_file).resolve()
|
|
297
|
+
if not compose_file.exists():
|
|
298
|
+
console.print(f"[red]ERROR[/red] compose file not found: {compose_file} (parallel.compose_file in config.yaml)")
|
|
299
|
+
return 1
|
|
300
|
+
|
|
301
|
+
# Validate Docker Compose version (need 2.20+ for !reset).
|
|
302
|
+
try:
|
|
303
|
+
check_compose_version()
|
|
304
|
+
except ComposeError as e:
|
|
305
|
+
console.print(f"[red]ERROR[/red] {e}")
|
|
306
|
+
return 1
|
|
307
|
+
|
|
308
|
+
# Discover and filter scenarios — same logic as cli.run for guide /
|
|
309
|
+
# no_guide / scenario / tag, applied here so the worker fan-out matches
|
|
310
|
+
# what the user would have got with --workers 1.
|
|
311
|
+
try:
|
|
312
|
+
slugs = _select_scenario_slugs(
|
|
313
|
+
scenarios_dir=scenarios_dir,
|
|
314
|
+
tests_dir=tests_dir,
|
|
315
|
+
scenario=scenario,
|
|
316
|
+
tag=tag,
|
|
317
|
+
guide=guide,
|
|
318
|
+
no_guide=no_guide,
|
|
319
|
+
console=console,
|
|
320
|
+
)
|
|
321
|
+
except _SelectionExit as e:
|
|
322
|
+
return e.code
|
|
323
|
+
if not slugs:
|
|
324
|
+
console.print("[yellow]INFO[/yellow] no scenarios selected — nothing to do")
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
# Validate every role used by selected scenarios has internal_url set.
|
|
328
|
+
missing_internal = _missing_internal_urls(config, scenarios_dir, slugs)
|
|
329
|
+
if missing_internal:
|
|
330
|
+
roles = ", ".join(sorted(missing_internal))
|
|
331
|
+
console.print(
|
|
332
|
+
f"[red]ERROR[/red] the following roles lack `internal_url` in config.yaml "
|
|
333
|
+
f"but are used by selected scenarios: {roles}\n"
|
|
334
|
+
f"Add `apps.<role>.internal_url: http://<service>:<port>` for each."
|
|
335
|
+
)
|
|
336
|
+
return 1
|
|
337
|
+
|
|
338
|
+
# Exclusion groups. Each declared group collapses into ONE queue task holding
|
|
339
|
+
# its members, decided here — once, single-threaded, on the host, before any
|
|
340
|
+
# container exists. That partition IS the mutual-exclusion mechanism: a worker
|
|
341
|
+
# claims the whole group and runs its members sequentially, so there is no
|
|
342
|
+
# lock to leak and no worker ever waits on another. See steppy.exclusive.
|
|
343
|
+
try:
|
|
344
|
+
group_of = selection.exclusive_groups_for(scenarios_dir, slugs)
|
|
345
|
+
except selection.SelectionError as e:
|
|
346
|
+
console.print(f"[red]ERROR[/red] {e.message}")
|
|
347
|
+
if e.hint:
|
|
348
|
+
console.print(f"[yellow]Hint:[/yellow] {e.hint}")
|
|
349
|
+
return e.exit_code
|
|
350
|
+
tasks = exclusive.partition(slugs, group_of)
|
|
351
|
+
collisions = exclusive.duplicate_task_ids(tasks)
|
|
352
|
+
if collisions:
|
|
353
|
+
for first, dup in collisions:
|
|
354
|
+
console.print(
|
|
355
|
+
f"[red]ERROR[/red] exclusion group / scenario name collision: {first!r} and "
|
|
356
|
+
f"{dup!r} resolve to the same queue entry."
|
|
357
|
+
)
|
|
358
|
+
console.print(
|
|
359
|
+
"[yellow]Hint:[/yellow] task names must be unique ignoring case — the run's "
|
|
360
|
+
"coordination directory lives on the host, where NTFS and APFS treat them as "
|
|
361
|
+
"one file and a whole task would silently never run."
|
|
362
|
+
)
|
|
363
|
+
return 1
|
|
364
|
+
_report_exclusion_groups(tasks, console)
|
|
365
|
+
|
|
366
|
+
# Resolve --guide auto-enables (shared with cli.run / cli.rerun).
|
|
367
|
+
screenshots, no_asserts = selection.apply_guide_defaults(guide, screenshots, no_asserts)
|
|
368
|
+
|
|
369
|
+
# Resolve locales / layouts (mirrors cli.run).
|
|
370
|
+
locale_list = _resolve_locales(locales, guide, config, console)
|
|
371
|
+
layout_list = _resolve_layouts(layouts, guide, config, console)
|
|
372
|
+
|
|
373
|
+
# Resolve the retry pass (CLI flag wins over config, as everywhere else).
|
|
374
|
+
retry_passes_count = parallel_cfg.retry.passes if retry_pass is None else retry_pass
|
|
375
|
+
retry_worker_count = parallel_cfg.retry.workers
|
|
376
|
+
|
|
377
|
+
# Resolve effective worker count.
|
|
378
|
+
effective_workers = _resolve_effective_workers(workers, parallel_cfg, slugs)
|
|
379
|
+
# Parallelism is bounded by the number of TASKS, not scenarios: exclusion
|
|
380
|
+
# groups collapse several scenarios into one claimable unit, and a stack that
|
|
381
|
+
# can never claim anything is a full app copy of wasted RAM.
|
|
382
|
+
effective_workers = min(effective_workers, max(1, len(tasks)))
|
|
383
|
+
if effective_workers < 2:
|
|
384
|
+
if len(tasks) < len(slugs):
|
|
385
|
+
# The collapse — not the selection — is what dropped us below two.
|
|
386
|
+
# Returning 0 here would report success having executed nothing, and
|
|
387
|
+
# "every scenario hits one shared database" is the most obvious first
|
|
388
|
+
# use of this feature. One stack running the group serially IS the
|
|
389
|
+
# correct execution of that declaration, so fall through and run it.
|
|
390
|
+
console.print(
|
|
391
|
+
f"[yellow]INFO[/yellow] exclusion groups collapsed {len(slugs)} scenario(s) "
|
|
392
|
+
f"into {len(tasks)} task(s); running on a single stack."
|
|
393
|
+
)
|
|
394
|
+
effective_workers = 1
|
|
395
|
+
else:
|
|
396
|
+
# Should never happen — cli.run only dispatches here for workers >= 2,
|
|
397
|
+
# and "auto" always resolves to >= 1. If we somehow get here with 1
|
|
398
|
+
# (e.g. only one scenario), fall back to single-worker mode by NOT
|
|
399
|
+
# spinning up Docker — surface a clear message and let the user re-run.
|
|
400
|
+
console.print(
|
|
401
|
+
f"[yellow]INFO[/yellow] only {len(slugs)} scenario(s) selected; "
|
|
402
|
+
f"single stack would be used. Re-run without --workers (or with --workers 1) "
|
|
403
|
+
f"to skip Docker entirely."
|
|
404
|
+
)
|
|
405
|
+
return 0
|
|
406
|
+
|
|
407
|
+
# Build the per-app internal_url JSON map workers will consume.
|
|
408
|
+
base_url_override_json = json.dumps(
|
|
409
|
+
{role: app.internal_url for role, app in config.apps.items() if app.internal_url}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Two ids, deliberately. ``stack_id`` names the Compose projects and the tmp
|
|
413
|
+
# dir, where short and opaque is all that is wanted. ``run_id`` is the run's
|
|
414
|
+
# IDENTITY — it names ``output/coverage/raw/<run_id>/`` and the ``--bundle``
|
|
415
|
+
# sinks, and both are resolved elsewhere by taking the lexicographically last
|
|
416
|
+
# directory (``coverage.merge.latest_run_id``). A bare uuid cannot be sorted,
|
|
417
|
+
# so this path used to make that resolution arbitrary; the sortable form is
|
|
418
|
+
# the same one ``cli._new_run_id`` produces for single-worker runs.
|
|
419
|
+
stack_id = uuid.uuid4().hex[:8]
|
|
420
|
+
run_id = new_run_id()
|
|
421
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix=f"steppy-run-{stack_id}-"))
|
|
422
|
+
console.print(f"\n[bold]steppy parallel run[/bold] (runId={run_id}, workers={effective_workers})")
|
|
423
|
+
console.print(f"[dim]tmp dir: {tmp_dir}[/dim]")
|
|
424
|
+
|
|
425
|
+
# Resource warning — heuristic, not a hard block.
|
|
426
|
+
_maybe_warn_resources(effective_workers, console)
|
|
427
|
+
|
|
428
|
+
# Seed the shared queue, slowest scenario first. Workers claim in seed
|
|
429
|
+
# order, so putting the long ones up front stops a 3-minute scenario from
|
|
430
|
+
# being claimed last and leaving one worker running alone while the rest
|
|
431
|
+
# idle (classic LPT scheduling). No history ⇒ unchanged order.
|
|
432
|
+
queue = Queue(root=tmp_dir)
|
|
433
|
+
tasks = _order_tasks_longest_first(tasks, project_root, console)
|
|
434
|
+
# The flattened task order is the real execution order, which is what the
|
|
435
|
+
# --bundle sidecar wants to record.
|
|
436
|
+
ordered_slugs = [slug for task in tasks for slug in task.slugs]
|
|
437
|
+
batches = {t.task_id: list(t.slugs) for t in tasks if t.is_batch}
|
|
438
|
+
if batches:
|
|
439
|
+
queue.seed([t.task_id for t in tasks], batches=batches)
|
|
440
|
+
else:
|
|
441
|
+
# Pass no keyword at all when nothing is grouped, so the call — and the
|
|
442
|
+
# queue directory it writes — is byte-identical to the pre-feature one.
|
|
443
|
+
queue.seed([t.task_id for t in tasks])
|
|
444
|
+
queued_note = f"queued {len(slugs)} scenario(s)"
|
|
445
|
+
if batches:
|
|
446
|
+
queued_note += f" in {len(tasks)} task(s)"
|
|
447
|
+
console.print(f"[dim]{queued_note} across {effective_workers} worker(s)[/dim]")
|
|
448
|
+
|
|
449
|
+
# Introspect user compose (services with ports, networks, healthcheck check).
|
|
450
|
+
try:
|
|
451
|
+
introspection = introspect_user_compose(compose_file)
|
|
452
|
+
except ComposeError as e:
|
|
453
|
+
console.print(f"[red]ERROR[/red] {e}")
|
|
454
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
455
|
+
return 1
|
|
456
|
+
|
|
457
|
+
# Ensure worker image (auto-build on first run unless --no-build). Build
|
|
458
|
+
# inputs are resolved by _parallel.image: explicit config → source checkout
|
|
459
|
+
# → a context synthesized from the installed package (so parallel mode
|
|
460
|
+
# works off a plain `pip install`, with no steppy source tree on the box).
|
|
461
|
+
package_root = Path(__file__).resolve().parent
|
|
462
|
+
try:
|
|
463
|
+
image_ref, image_mode = ensure_worker_image(
|
|
464
|
+
image_prefix=parallel_cfg.worker_image,
|
|
465
|
+
package_root=package_root,
|
|
466
|
+
project_root=project_root,
|
|
467
|
+
parallel_cfg=parallel_cfg,
|
|
468
|
+
staging_dir=tmp_dir,
|
|
469
|
+
build=(parallel_cfg.build_image and not no_build),
|
|
470
|
+
)
|
|
471
|
+
except RuntimeError as e:
|
|
472
|
+
console.print(f"[red]ERROR[/red] {e}")
|
|
473
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
474
|
+
return 1
|
|
475
|
+
console.print(f"[dim]worker image: {image_ref} ({_IMAGE_MODE_LABELS.get(image_mode, image_mode)})[/dim]")
|
|
476
|
+
|
|
477
|
+
# Echo the report settings that now actually reach the workers, so it is
|
|
478
|
+
# obvious when --trace/--video/--retries took effect (they used to be
|
|
479
|
+
# silently dropped in parallel mode).
|
|
480
|
+
if trace_mode and trace_mode != "off":
|
|
481
|
+
console.print(f"[dim]Playwright trace: {trace_mode}[/dim]")
|
|
482
|
+
if video_mode and video_mode != "off":
|
|
483
|
+
console.print(f"[dim]Playwright video: {video_mode}[/dim]")
|
|
484
|
+
if retries:
|
|
485
|
+
console.print(f"[dim]Flaky-retry: up to {retries} rerun(s) per failure (in-process)[/dim]")
|
|
486
|
+
if pytest_passthrough:
|
|
487
|
+
console.print(f"[dim]pytest passthrough: {' '.join(pytest_passthrough)}[/dim]")
|
|
488
|
+
|
|
489
|
+
# Optional per-worker `.auth` isolation. The shared dir is bind-mounted RW
|
|
490
|
+
# into every worker, so N workers race on one storage-state file; giving
|
|
491
|
+
# each a private (pre-seeded) copy trades one login for N.
|
|
492
|
+
auth_mounts = _prepare_isolated_auth(
|
|
493
|
+
enabled=parallel_cfg.isolate_auth,
|
|
494
|
+
worker_count=effective_workers,
|
|
495
|
+
project_root=project_root,
|
|
496
|
+
tmp_dir=tmp_dir,
|
|
497
|
+
console=console,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Render per-worker overrides. The kwargs are kept as a dict so the retry
|
|
501
|
+
# pass can re-render with exactly the same settings (only `coord_subdir`
|
|
502
|
+
# differs) — a drifted setting there would be invisible until a retry
|
|
503
|
+
# behaved differently from the run that spawned it.
|
|
504
|
+
overrides: list[Path] = []
|
|
505
|
+
locales_csv = ",".join(locale_list)
|
|
506
|
+
layouts_csv = ",".join(layout_list)
|
|
507
|
+
image_prefix, _, image_tag = image_ref.partition(":")
|
|
508
|
+
base_override_kwargs = dict(
|
|
509
|
+
introspection=introspection,
|
|
510
|
+
worker_image=image_prefix,
|
|
511
|
+
image_tag=image_tag,
|
|
512
|
+
run_id=run_id,
|
|
513
|
+
project_root=project_root,
|
|
514
|
+
tmp_dir=tmp_dir,
|
|
515
|
+
base_url_override=base_url_override_json,
|
|
516
|
+
locales=locales_csv,
|
|
517
|
+
layouts=layouts_csv,
|
|
518
|
+
guide_mode="1" if guide else "",
|
|
519
|
+
no_asserts="1" if no_asserts else "",
|
|
520
|
+
screenshots="1" if screenshots else "",
|
|
521
|
+
coverage="1" if coverage else "",
|
|
522
|
+
pre_auth_command=(parallel_cfg.pre_auth.command if parallel_cfg.pre_auth else None),
|
|
523
|
+
pre_auth_image=(parallel_cfg.pre_auth.image if parallel_cfg.pre_auth else None),
|
|
524
|
+
pre_auth_timeout=(parallel_cfg.pre_auth.timeout if parallel_cfg.pre_auth else 600),
|
|
525
|
+
trace_mode=trace_mode,
|
|
526
|
+
video_mode=video_mode,
|
|
527
|
+
retries=retries,
|
|
528
|
+
pytest_extra=pytest_passthrough,
|
|
529
|
+
# In-container paths: /steppy/project is the RO project mount and
|
|
530
|
+
# /steppy/project/output the RW overlay onto the host's output/.
|
|
531
|
+
events_dir=(f"/steppy/project/output/{EVENTS_SUBDIR}/{run_id}" if bundle else ""),
|
|
532
|
+
junit_dir=(f"/steppy/project/output/{JUNIT_SUBDIR}/{run_id}" if bundle else ""),
|
|
533
|
+
# The tour replays' build-id gate, read inside the container.
|
|
534
|
+
tour_expect_build_id=os.environ.get("STEPPY_TOUR_EXPECT_BUILD_ID", ""),
|
|
535
|
+
)
|
|
536
|
+
for worker_index in range(1, effective_workers + 1):
|
|
537
|
+
override_yml = render_override(
|
|
538
|
+
worker_index=worker_index,
|
|
539
|
+
auth_mount=auth_mounts[worker_index - 1] if auth_mounts else "",
|
|
540
|
+
**base_override_kwargs,
|
|
541
|
+
)
|
|
542
|
+
override_path = tmp_dir / f"override-w{worker_index}.yml"
|
|
543
|
+
override_path.write_text(override_yml, encoding="utf-8")
|
|
544
|
+
overrides.append(override_path)
|
|
545
|
+
|
|
546
|
+
# Pre-create overlay dirs the workers will write to. Without this, Docker
|
|
547
|
+
# bind-mounts create them owned by root, which breaks subsequent host-side
|
|
548
|
+
# cleanup on Linux.
|
|
549
|
+
(project_root / ".auth").mkdir(exist_ok=True)
|
|
550
|
+
(project_root / "output").mkdir(exist_ok=True)
|
|
551
|
+
|
|
552
|
+
if bundle:
|
|
553
|
+
write_run_sidecar(
|
|
554
|
+
project_root,
|
|
555
|
+
run_id=run_id,
|
|
556
|
+
spec={
|
|
557
|
+
"guide": guide,
|
|
558
|
+
"no_guide": no_guide,
|
|
559
|
+
"no_asserts": no_asserts,
|
|
560
|
+
"screenshots": screenshots,
|
|
561
|
+
"coverage": coverage,
|
|
562
|
+
"locales": list(locale_list),
|
|
563
|
+
"layouts": list(layout_list),
|
|
564
|
+
"workers": effective_workers,
|
|
565
|
+
"tag": list(tag) if tag else None,
|
|
566
|
+
"scenario": scenario,
|
|
567
|
+
},
|
|
568
|
+
slugs=ordered_slugs,
|
|
569
|
+
)
|
|
570
|
+
console.print(f"[dim]bundle capture: enabled — create it with: steppy bundle create --run-id {run_id}[/dim]")
|
|
571
|
+
|
|
572
|
+
exit_code = 0
|
|
573
|
+
project_names: list[str] = []
|
|
574
|
+
log_tailers: list[WorkerLogTailer] = []
|
|
575
|
+
|
|
576
|
+
# EventBus + RawLogChannel wire worker stdout into the renderer.
|
|
577
|
+
# The raw channel always writes to a per-worker file; it also prints
|
|
578
|
+
# ``[wN] <line>`` to the console when --verbose is set.
|
|
579
|
+
raw_channel = RawLogChannel(console=console, verbose=verbose, log_dir=tmp_dir)
|
|
580
|
+
bus = EventBus(raw_line_sink=raw_channel)
|
|
581
|
+
for i in range(1, effective_workers + 1):
|
|
582
|
+
bus.register_worker(f"w{i}")
|
|
583
|
+
renderer = make_renderer(bus=bus, console=console, total_scenarios=len(slugs))
|
|
584
|
+
|
|
585
|
+
with CoordinatorLifecycle() as lc:
|
|
586
|
+
# 1. Spin up all stacks in parallel via `compose up -d --wait`.
|
|
587
|
+
try:
|
|
588
|
+
project_names = _bring_up_all_stacks(
|
|
589
|
+
stack_id=stack_id,
|
|
590
|
+
worker_count=effective_workers,
|
|
591
|
+
compose_file=compose_file,
|
|
592
|
+
overrides=overrides,
|
|
593
|
+
healthcheck_timeout=parallel_cfg.healthcheck_timeout,
|
|
594
|
+
lifecycle=lc,
|
|
595
|
+
console=console,
|
|
596
|
+
)
|
|
597
|
+
except _StackError as e:
|
|
598
|
+
console.print(f"[red]ERROR[/red] failed to bring up stack: {e}")
|
|
599
|
+
return 1
|
|
600
|
+
|
|
601
|
+
# 1b. Resolve each worker's container ID once so the wait loop can
|
|
602
|
+
# poll via `docker inspect` directly — `compose ps -q` (even with
|
|
603
|
+
# -a) is slower per call and unnecessary once we have the ID.
|
|
604
|
+
try:
|
|
605
|
+
worker_container_ids = _resolve_worker_container_ids(project_names)
|
|
606
|
+
except _StackError as e:
|
|
607
|
+
console.print(f"[red]ERROR[/red] {e}")
|
|
608
|
+
return 1
|
|
609
|
+
|
|
610
|
+
# 2. Start the renderer and log multiplexers. The renderer is
|
|
611
|
+
# registered for teardown first so it stops painting BEFORE the
|
|
612
|
+
# final summary call (otherwise the Live overlay would clobber it).
|
|
613
|
+
renderer.start()
|
|
614
|
+
lc.on_teardown(renderer.stop)
|
|
615
|
+
lc.on_teardown(raw_channel.close)
|
|
616
|
+
|
|
617
|
+
for i, project in enumerate(project_names, start=1):
|
|
618
|
+
tailer = WorkerLogTailer(
|
|
619
|
+
project=project,
|
|
620
|
+
worker_id=f"w{i}",
|
|
621
|
+
bus=bus,
|
|
622
|
+
app_log_file=tmp_dir / f"w{i}-stack.log",
|
|
623
|
+
)
|
|
624
|
+
tailer.start()
|
|
625
|
+
log_tailers.append(tailer)
|
|
626
|
+
lc.on_teardown(tailer.stop)
|
|
627
|
+
|
|
628
|
+
# 3. Wait for every worker container to exit (queue drains naturally).
|
|
629
|
+
# The circuit breaker rides on the wait loop's poll cycle: it watches
|
|
630
|
+
# results land and aborts once nearly everything is failing, so a down
|
|
631
|
+
# app doesn't cost the full wall clock.
|
|
632
|
+
breaker = _make_circuit_breaker(parallel_cfg.circuit_breaker, fail_fast, queue, console)
|
|
633
|
+
breaker_tripped = False
|
|
634
|
+
try:
|
|
635
|
+
worker_exit_codes = _wait_for_workers(worker_container_ids, console, on_poll=breaker)
|
|
636
|
+
except KeyboardInterrupt:
|
|
637
|
+
console.print("\n[yellow]Ctrl-C received — tearing down stacks…[/yellow]")
|
|
638
|
+
for i in range(1, effective_workers + 1):
|
|
639
|
+
bus.mark_worker_finished(f"w{i}", interrupted=True)
|
|
640
|
+
renderer.stop()
|
|
641
|
+
# Same shutdown as the success path, over the partial results — so
|
|
642
|
+
# a Ctrl-C'd run still persists artifacts and refreshes lastfailed.
|
|
643
|
+
_finalize_run(
|
|
644
|
+
queue=queue,
|
|
645
|
+
bus=bus,
|
|
646
|
+
console=console,
|
|
647
|
+
tmp_dir=tmp_dir,
|
|
648
|
+
project_root=project_root,
|
|
649
|
+
tests_dir=tests_dir,
|
|
650
|
+
run_id=run_id,
|
|
651
|
+
workers=effective_workers,
|
|
652
|
+
verbose=verbose,
|
|
653
|
+
screenshots_dir=screenshots_dir,
|
|
654
|
+
)
|
|
655
|
+
if coverage:
|
|
656
|
+
# No auto-merge on Ctrl-C — records already on the output/
|
|
657
|
+
# bind mount; merging is cheap to do once things calm down.
|
|
658
|
+
console.print(f"[dim]coverage records kept — merge with: steppy coverage merge --run-id {run_id}[/dim]")
|
|
659
|
+
return 130
|
|
660
|
+
except _CircuitBreakerTripped as e:
|
|
661
|
+
breaker_tripped = True
|
|
662
|
+
worker_exit_codes = [1] * effective_workers
|
|
663
|
+
renderer.stop()
|
|
664
|
+
console.print()
|
|
665
|
+
console.rule("[bold red]Aborted — circuit breaker[/bold red]")
|
|
666
|
+
console.print(f" [red]{e}[/red]")
|
|
667
|
+
console.print(
|
|
668
|
+
" [dim]This looks like infrastructure, not tests — the app, auth or a\n"
|
|
669
|
+
" seed step is likely broken. Check output/failures/_worker-logs/.[/dim]"
|
|
670
|
+
)
|
|
671
|
+
console.print(" [dim]Disable with --no-fail-fast or parallel.circuit_breaker.enabled: false[/dim]")
|
|
672
|
+
|
|
673
|
+
# 4. Reconcile worker exit codes back into the bus so in-flight
|
|
674
|
+
# tests (worker died mid-test) get a synthetic outcome before the
|
|
675
|
+
# summary renders.
|
|
676
|
+
for i, project_exit in enumerate(worker_exit_codes, start=1):
|
|
677
|
+
bus.mark_worker_finished(f"w{i}", died=project_exit != 0)
|
|
678
|
+
|
|
679
|
+
# 5. Stop the renderer before printing the summary — otherwise
|
|
680
|
+
# the Live overlay re-paints over the summary lines.
|
|
681
|
+
renderer.stop()
|
|
682
|
+
|
|
683
|
+
# 5a. Anything queued and never claimed — every worker died with work
|
|
684
|
+
# left. Captured before the retry pass, which will try to run it.
|
|
685
|
+
never_started = set(queue.unclaimed_slugs())
|
|
686
|
+
|
|
687
|
+
# 5b. Retry pass. Re-runs the failures at reduced concurrency in the
|
|
688
|
+
# still-running stacks, which is what separates a contention flake from
|
|
689
|
+
# a real failure. Skipped when the breaker tripped — that is infra, and
|
|
690
|
+
# retrying it would just take twice as long to reach the same verdict.
|
|
691
|
+
retry_passes = _run_retry_passes(
|
|
692
|
+
enabled=(not breaker_tripped) and retry_passes_count > 0,
|
|
693
|
+
passes=retry_passes_count,
|
|
694
|
+
retry_workers=retry_worker_count,
|
|
695
|
+
queue=queue,
|
|
696
|
+
bus=bus,
|
|
697
|
+
group_of=group_of,
|
|
698
|
+
project_names=project_names,
|
|
699
|
+
compose_file=compose_file,
|
|
700
|
+
tmp_dir=tmp_dir,
|
|
701
|
+
base_override_kwargs=base_override_kwargs,
|
|
702
|
+
auth_mounts=auth_mounts,
|
|
703
|
+
console=console,
|
|
704
|
+
verbose=verbose,
|
|
705
|
+
lifecycle=lc,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# 6. Aggregate results and render the final summary.
|
|
709
|
+
results, orphans, verdict = _finalize_run(
|
|
710
|
+
queue=queue,
|
|
711
|
+
bus=bus,
|
|
712
|
+
console=console,
|
|
713
|
+
tmp_dir=tmp_dir,
|
|
714
|
+
project_root=project_root,
|
|
715
|
+
tests_dir=tests_dir,
|
|
716
|
+
retry_passes=retry_passes,
|
|
717
|
+
run_id=run_id,
|
|
718
|
+
workers=effective_workers,
|
|
719
|
+
verbose=verbose,
|
|
720
|
+
screenshots_dir=screenshots_dir,
|
|
721
|
+
never_started=never_started,
|
|
722
|
+
)
|
|
723
|
+
failed = sum(1 for r in results if r.exit_code != 0)
|
|
724
|
+
orphan_count = sum(len(v) for v in orphans.values())
|
|
725
|
+
# Anything that was queued and never claimed by anybody. Nothing else in
|
|
726
|
+
# the system observes it — orphan detection walks claims/, result
|
|
727
|
+
# collection walks results/, and a task with neither file is invisible to
|
|
728
|
+
# both — so without this a run whose workers all died could report success
|
|
729
|
+
# having executed nothing.
|
|
730
|
+
abandoned = sorted(never_started - {r.slug for r in results})
|
|
731
|
+
if abandoned:
|
|
732
|
+
_report_abandoned(abandoned, console)
|
|
733
|
+
|
|
734
|
+
# Coverage merge: workers wrote raw records to the shared output/
|
|
735
|
+
# bind mount under this run_id; fold them into the map host-side.
|
|
736
|
+
# Lazy import + best-effort: a merge failure never changes the run's
|
|
737
|
+
# exit code (records persist; `steppy coverage merge --run-id` retries).
|
|
738
|
+
if coverage:
|
|
739
|
+
try:
|
|
740
|
+
from .coverage.merge import CoverageSettings, merge_run
|
|
741
|
+
|
|
742
|
+
settings = CoverageSettings.from_config(config, project_root)
|
|
743
|
+
merge_summary = merge_run(settings, run_id)
|
|
744
|
+
console.print(
|
|
745
|
+
f"[green]OK[/green] Coverage: merged "
|
|
746
|
+
f"{merge_summary['totals']['scenarios']} scenario(s), "
|
|
747
|
+
f"{merge_summary['totals']['nodes']} map node(s) → {settings.store_path}"
|
|
748
|
+
)
|
|
749
|
+
except Exception as e:
|
|
750
|
+
console.print(f"[yellow]WARNING[/yellow] Coverage merge failed: {e}")
|
|
751
|
+
console.print(f"[dim]Raw records kept — retry with: steppy coverage merge --run-id {run_id}[/dim]")
|
|
752
|
+
|
|
753
|
+
# Exit code. Without a retry pass this is unchanged: any failure
|
|
754
|
+
# (worker container exit != 0, scenario fail, or orphan) → 1.
|
|
755
|
+
#
|
|
756
|
+
# With a retry pass, FLAKY (failed then passed) is LENIENT by default —
|
|
757
|
+
# a pass is a pass, so only scenarios that failed every attempt fail the
|
|
758
|
+
# build. `--strict-flaky` restores the strict reading for teams that
|
|
759
|
+
# want a flake to break CI. Worker exit codes are ignored once a verdict
|
|
760
|
+
# exists: a worker that reported "1" because a scenario failed, which
|
|
761
|
+
# the retry then passed, must not veto that.
|
|
762
|
+
if verdict is not None:
|
|
763
|
+
any_failure = bool(verdict.failed) or orphan_count > 0 or (strict_flaky and bool(verdict.flaky))
|
|
764
|
+
else:
|
|
765
|
+
any_failure = failed > 0 or orphan_count > 0 or any(c != 0 for c in worker_exit_codes)
|
|
766
|
+
# On BOTH branches, including the verdict one — a scenario that never ran
|
|
767
|
+
# is not a flake to be forgiven, and the verdict branch drops worker exit
|
|
768
|
+
# codes by design.
|
|
769
|
+
if abandoned:
|
|
770
|
+
any_failure = True
|
|
771
|
+
if breaker_tripped:
|
|
772
|
+
any_failure = True
|
|
773
|
+
exit_code = 1 if any_failure else 0
|
|
774
|
+
|
|
775
|
+
# 7. Teardown happens automatically via ExitStack callbacks here.
|
|
776
|
+
|
|
777
|
+
# Clean up tmp dir unless STEPPY_KEEP_RUN_DIR=1 (debugging escape hatch).
|
|
778
|
+
if os.environ.get("STEPPY_KEEP_RUN_DIR") != "1":
|
|
779
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
780
|
+
else:
|
|
781
|
+
console.print(f"[dim]preserved tmp dir: {tmp_dir}[/dim]")
|
|
782
|
+
|
|
783
|
+
return exit_code
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ── Retry pass / circuit breaker / auth isolation ──────────────────────────
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
class _CircuitBreakerTripped(RuntimeError):
|
|
790
|
+
"""Raised from the wait loop's poll callback to abort a doomed run."""
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _make_circuit_breaker(cfg, fail_fast: Optional[bool], queue, console):
|
|
794
|
+
"""Build the ``on_poll`` callback for :func:`_wait_for_workers`, or None.
|
|
795
|
+
|
|
796
|
+
Fires when ``min_completed`` scenarios have reported AND at least
|
|
797
|
+
``fail_ratio`` of them failed. Both thresholds matter: the ratio alone would
|
|
798
|
+
trip on the first failed scenario of a run, and the count alone would trip
|
|
799
|
+
on a suite that is merely red.
|
|
800
|
+
|
|
801
|
+
``fail_fast`` is the tri-state CLI override (None ⇒ use config).
|
|
802
|
+
"""
|
|
803
|
+
enabled = cfg.enabled if fail_fast is None else fail_fast
|
|
804
|
+
if not enabled:
|
|
805
|
+
return None
|
|
806
|
+
|
|
807
|
+
def _check() -> None:
|
|
808
|
+
results = queue.collect_results()
|
|
809
|
+
completed = len(results)
|
|
810
|
+
if completed < cfg.min_completed:
|
|
811
|
+
return
|
|
812
|
+
failed = sum(1 for r in results if r.exit_code != 0)
|
|
813
|
+
if failed / completed >= cfg.fail_ratio:
|
|
814
|
+
raise _CircuitBreakerTripped(
|
|
815
|
+
f"{failed}/{completed} completed scenarios failed "
|
|
816
|
+
f"({failed / completed:.0%} >= {cfg.fail_ratio:.0%} threshold)."
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
return _check
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _report_exclusion_groups(tasks: list, console: Console) -> None:
|
|
823
|
+
"""Print the exclusion-group census, or nothing when none are declared.
|
|
824
|
+
|
|
825
|
+
Every group is named with its member count, on one line, because that is what
|
|
826
|
+
makes a typo visible: ``stripe-sandbox[1], stripe-sandbx[1]`` reads as a
|
|
827
|
+
mistake at a glance, whereas two silently-independent groups do not.
|
|
828
|
+
|
|
829
|
+
A single-member group gets its own warning, since it serialises nothing. It
|
|
830
|
+
is only a warning: ``--tag`` legitimately narrows a group to one selected
|
|
831
|
+
member — though that is worth noticing too, because the guarantee holds
|
|
832
|
+
within one run, so a group split across two shard runs has lost it.
|
|
833
|
+
"""
|
|
834
|
+
batches = [t for t in tasks if t.is_batch]
|
|
835
|
+
if not batches:
|
|
836
|
+
return
|
|
837
|
+
census = ", ".join(f"{t.group}[{len(t.slugs)}]" for t in batches)
|
|
838
|
+
grouped = sum(len(t.slugs) for t in batches)
|
|
839
|
+
console.print(
|
|
840
|
+
f"[dim]exclusion groups: {census} — {len(batches)} group(s) serialising "
|
|
841
|
+
f"{grouped} scenario(s) into {len(batches)} task(s)[/dim]"
|
|
842
|
+
)
|
|
843
|
+
singletons = [t.group for t in batches if len(t.slugs) == 1]
|
|
844
|
+
if singletons:
|
|
845
|
+
console.print(
|
|
846
|
+
f"[yellow]WARNING[/yellow] exclusion group(s) with a single selected scenario: "
|
|
847
|
+
f"{', '.join(singletons)}. A narrowed selection explains this; otherwise it is "
|
|
848
|
+
f"a typo, and the group is serialising nothing."
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
_ABANDONED_LIST_CAP = 10
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _report_abandoned(slugs: list[str], console: Console) -> None:
|
|
856
|
+
"""Print the scenarios that were queued and never started.
|
|
857
|
+
|
|
858
|
+
Capped, because a tripped circuit breaker abandons the whole remaining queue
|
|
859
|
+
and two hundred lines is not a summary. Printed here rather than through
|
|
860
|
+
``render_summary`` so the renderer's contract is untouched.
|
|
861
|
+
"""
|
|
862
|
+
shown = slugs[:_ABANDONED_LIST_CAP]
|
|
863
|
+
console.print()
|
|
864
|
+
console.rule("[bold red]Never started[/bold red]")
|
|
865
|
+
console.print(f" [red]{len(slugs)} scenario(s) were queued but never claimed by any worker.[/red]")
|
|
866
|
+
for slug in shown:
|
|
867
|
+
console.print(f" [dim]{slug}[/dim]")
|
|
868
|
+
if len(slugs) > len(shown):
|
|
869
|
+
console.print(f" [dim]… and {len(slugs) - len(shown)} more[/dim]")
|
|
870
|
+
console.print(
|
|
871
|
+
" [dim]This means every worker exited with work left — check "
|
|
872
|
+
"output/failures/_worker-logs/. The run is reported as failed.[/dim]"
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _order_tasks_longest_first(tasks: list, project_root: Path, console: Console) -> list:
|
|
877
|
+
"""Reorder the queue so recorded-slow tasks are claimed first.
|
|
878
|
+
|
|
879
|
+
A batch is one scheduling unit, so its weight is the **sum** of its members'
|
|
880
|
+
medians — otherwise a large exclusion group claimed last becomes the run's
|
|
881
|
+
tail while every other worker idles. Tasks with no recorded member sort last
|
|
882
|
+
and ties keep declaration order, mirroring ``history.order_by_duration_desc``;
|
|
883
|
+
for an all-singleton run this reduces to it element for element.
|
|
884
|
+
|
|
885
|
+
Best-effort, like the ordering it replaces: never fatal.
|
|
886
|
+
"""
|
|
887
|
+
try:
|
|
888
|
+
from .history import median_durations
|
|
889
|
+
|
|
890
|
+
durations = median_durations(project_root)
|
|
891
|
+
if not durations:
|
|
892
|
+
return tasks
|
|
893
|
+
weights: dict[str, float] = {}
|
|
894
|
+
known: list = []
|
|
895
|
+
unknown: list = []
|
|
896
|
+
for index, task in enumerate(tasks):
|
|
897
|
+
total = sum(durations[s] for s in task.slugs if s in durations)
|
|
898
|
+
if total > 0:
|
|
899
|
+
weights[task.task_id] = total
|
|
900
|
+
known.append((index, task))
|
|
901
|
+
else:
|
|
902
|
+
unknown.append(task)
|
|
903
|
+
known.sort(key=lambda pair: (-weights[pair[1].task_id], pair[0]))
|
|
904
|
+
ordered = [task for _, task in known] + unknown
|
|
905
|
+
console.print(
|
|
906
|
+
f"[dim]queue order: longest-first from run history ({len(known)}/{len(tasks)} with timings)[/dim]"
|
|
907
|
+
)
|
|
908
|
+
return ordered
|
|
909
|
+
except Exception: # noqa: BLE001 — ordering is an optimisation, never fatal
|
|
910
|
+
return tasks
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _prepare_isolated_auth(*, enabled, worker_count, project_root, tmp_dir, console) -> list[str]:
|
|
914
|
+
"""Create a private, pre-seeded ``.auth`` dir per worker. Returns the list of
|
|
915
|
+
absolute host paths (posix), or ``[]`` when the feature is off.
|
|
916
|
+
|
|
917
|
+
Seeding from the project's shared ``.auth`` means a still-valid cached
|
|
918
|
+
session is reused instead of forcing N cold logins on every run; each worker
|
|
919
|
+
then owns its copy, so re-auth writes never race.
|
|
920
|
+
"""
|
|
921
|
+
if not enabled:
|
|
922
|
+
return []
|
|
923
|
+
shared = project_root / ".auth"
|
|
924
|
+
mounts: list[str] = []
|
|
925
|
+
for i in range(1, worker_count + 1):
|
|
926
|
+
worker_auth = tmp_dir / "auth" / f"w{i}"
|
|
927
|
+
worker_auth.mkdir(parents=True, exist_ok=True)
|
|
928
|
+
if shared.is_dir():
|
|
929
|
+
for src in shared.glob("*.json"):
|
|
930
|
+
try:
|
|
931
|
+
shutil.copy2(src, worker_auth / src.name)
|
|
932
|
+
except OSError:
|
|
933
|
+
pass
|
|
934
|
+
mounts.append(worker_auth.resolve().as_posix())
|
|
935
|
+
console.print(f"[dim]auth isolation: {worker_count} private .auth dir(s) (parallel.isolate_auth)[/dim]")
|
|
936
|
+
return mounts
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _run_retry_passes(
|
|
940
|
+
*,
|
|
941
|
+
enabled,
|
|
942
|
+
passes,
|
|
943
|
+
retry_workers,
|
|
944
|
+
queue,
|
|
945
|
+
bus,
|
|
946
|
+
group_of,
|
|
947
|
+
project_names,
|
|
948
|
+
compose_file,
|
|
949
|
+
tmp_dir,
|
|
950
|
+
base_override_kwargs,
|
|
951
|
+
auth_mounts,
|
|
952
|
+
console,
|
|
953
|
+
verbose,
|
|
954
|
+
lifecycle,
|
|
955
|
+
) -> list:
|
|
956
|
+
"""Drive up to ``passes`` retry attempts. Returns the list of PassResults.
|
|
957
|
+
|
|
958
|
+
Each attempt re-runs only what the previous one left failing (including
|
|
959
|
+
orphans, so a dead worker's scenario gets a second chance instead of being
|
|
960
|
+
a hard loss). Stops early once nothing is failing.
|
|
961
|
+
|
|
962
|
+
Never raises: a retry pass that cannot start degrades to "no retry" and the
|
|
963
|
+
main pass's verdict stands.
|
|
964
|
+
"""
|
|
965
|
+
if not enabled:
|
|
966
|
+
return []
|
|
967
|
+
from ._parallel.retry import PassResult, run_retry_pass
|
|
968
|
+
|
|
969
|
+
prior = PassResult(
|
|
970
|
+
attempt=1,
|
|
971
|
+
coord_root=tmp_dir,
|
|
972
|
+
results=queue.collect_results(),
|
|
973
|
+
# Reads the batch map off the Queue that seeded this directory, so orphans
|
|
974
|
+
# are always member slugs — never an exclusion group's NAME, which would
|
|
975
|
+
# seed a retry task for a test file that does not exist and leave the
|
|
976
|
+
# group's real members un-retried.
|
|
977
|
+
orphans=queue.orphaned_running_tasks(),
|
|
978
|
+
bus=bus,
|
|
979
|
+
never_started=set(queue.unclaimed_slugs()),
|
|
980
|
+
)
|
|
981
|
+
pending = sorted(prior.pending_slugs)
|
|
982
|
+
if not pending:
|
|
983
|
+
return []
|
|
984
|
+
|
|
985
|
+
# Slice the stacks down to the configured retry width. The unused stacks
|
|
986
|
+
# keep running (they're torn down with the rest at the end) — we just don't
|
|
987
|
+
# give them a worker.
|
|
988
|
+
projects = project_names[: max(1, min(retry_workers, len(project_names)))]
|
|
989
|
+
|
|
990
|
+
out: list = []
|
|
991
|
+
for attempt in range(2, 2 + passes):
|
|
992
|
+
# Regroup INSIDE the loop: ``pending`` is reassigned per attempt, so
|
|
993
|
+
# partitioning once outside would let attempt 3 run members of one
|
|
994
|
+
# exclusion group concurrently whenever retry.workers is above 1.
|
|
995
|
+
retry_tasks = exclusive.partition(pending, group_of or {})
|
|
996
|
+
retry_batches = {t.task_id: list(t.slugs) for t in retry_tasks if t.is_batch}
|
|
997
|
+
result = run_retry_pass(
|
|
998
|
+
attempt=attempt,
|
|
999
|
+
slugs=[t.task_id for t in retry_tasks],
|
|
1000
|
+
batches=retry_batches or None,
|
|
1001
|
+
projects=projects,
|
|
1002
|
+
compose_file=compose_file,
|
|
1003
|
+
tmp_dir=tmp_dir,
|
|
1004
|
+
override_kwargs={**base_override_kwargs, "auth_mount_by_worker": auth_mounts},
|
|
1005
|
+
console=console,
|
|
1006
|
+
verbose=verbose,
|
|
1007
|
+
resolve_container_ids=_resolve_worker_container_ids,
|
|
1008
|
+
wait_for_workers=_wait_for_workers,
|
|
1009
|
+
register_teardown=lifecycle.on_teardown,
|
|
1010
|
+
)
|
|
1011
|
+
if result is None:
|
|
1012
|
+
break
|
|
1013
|
+
out.append(result)
|
|
1014
|
+
pending = sorted(result.pending_slugs)
|
|
1015
|
+
if not pending:
|
|
1016
|
+
break
|
|
1017
|
+
return out
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
# ── Selection helpers (mirror cli.run's filter logic) ──────────────────────
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
class _SelectionExit(Exception):
|
|
1024
|
+
"""Raised by _select_scenario_slugs to short-circuit on user errors. The
|
|
1025
|
+
code attribute is the exit code we should return from run_parallel."""
|
|
1026
|
+
|
|
1027
|
+
def __init__(self, code: int):
|
|
1028
|
+
super().__init__()
|
|
1029
|
+
self.code = code
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _select_scenario_slugs(
|
|
1033
|
+
*,
|
|
1034
|
+
scenarios_dir: Path,
|
|
1035
|
+
tests_dir: Path,
|
|
1036
|
+
scenario: Optional[str],
|
|
1037
|
+
tag: Optional[list[str]],
|
|
1038
|
+
guide: bool,
|
|
1039
|
+
no_guide: bool,
|
|
1040
|
+
console: Console,
|
|
1041
|
+
) -> list[str]:
|
|
1042
|
+
"""Mirror of the filter logic in cli.run, returning the list of scenario
|
|
1043
|
+
slugs to enqueue. We only need slugs (not test_file paths) since workers
|
|
1044
|
+
re-derive the test_file from the slug. Delegates to the shared
|
|
1045
|
+
``selection.select_scenario_slugs`` (FLAWS §8.3) so this can't drift from the
|
|
1046
|
+
single-worker path; translates its ``SelectionError`` to the ``_SelectionExit``
|
|
1047
|
+
that ``run_parallel`` already catches."""
|
|
1048
|
+
try:
|
|
1049
|
+
return selection.select_scenario_slugs(
|
|
1050
|
+
scenarios_dir=scenarios_dir,
|
|
1051
|
+
tests_dir=tests_dir,
|
|
1052
|
+
scenario=scenario,
|
|
1053
|
+
tag=tag,
|
|
1054
|
+
guide=guide,
|
|
1055
|
+
no_guide=no_guide,
|
|
1056
|
+
)
|
|
1057
|
+
except selection.SelectionError as e:
|
|
1058
|
+
console.print(f"[red]ERROR[/red] {e.message}")
|
|
1059
|
+
if e.hint:
|
|
1060
|
+
console.print(f"[yellow]Hint:[/yellow] {e.hint}")
|
|
1061
|
+
raise _SelectionExit(e.exit_code)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _missing_internal_urls(config: SteppyConfig, scenarios_dir: Path, slugs: list[str]) -> set[str]:
|
|
1065
|
+
"""Return the set of role names that are used by selected scenarios but
|
|
1066
|
+
have no `internal_url` configured. We re-parse to learn which roles each
|
|
1067
|
+
scenario touches."""
|
|
1068
|
+
if not scenarios_dir.exists():
|
|
1069
|
+
return set()
|
|
1070
|
+
try:
|
|
1071
|
+
scenarios = parse_scenarios_from_directory(scenarios_dir)
|
|
1072
|
+
except ParseError:
|
|
1073
|
+
return set()
|
|
1074
|
+
selected = {s.slug: s for s in scenarios if s.slug in set(slugs)}
|
|
1075
|
+
used_roles: set[str] = set()
|
|
1076
|
+
for s in selected.values():
|
|
1077
|
+
for step in s.steps:
|
|
1078
|
+
role = getattr(step, "role", None)
|
|
1079
|
+
if isinstance(role, str) and role and role != "all":
|
|
1080
|
+
used_roles.add(role)
|
|
1081
|
+
missing: set[str] = set()
|
|
1082
|
+
for role in used_roles:
|
|
1083
|
+
app = config.apps.get(role)
|
|
1084
|
+
if app is None:
|
|
1085
|
+
# Role not in config — that's a different validation problem,
|
|
1086
|
+
# surface it as missing internal_url to fail fast.
|
|
1087
|
+
missing.add(role)
|
|
1088
|
+
elif not app.internal_url:
|
|
1089
|
+
missing.add(role)
|
|
1090
|
+
return missing
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _resolve_locales(locales: Optional[str], guide: bool, config: SteppyConfig, console: Console) -> list[str]:
|
|
1094
|
+
try:
|
|
1095
|
+
return selection.resolve_locales(locales, guide, config)
|
|
1096
|
+
except selection.SelectionError as e:
|
|
1097
|
+
console.print(f"[red]ERROR[/red] {e.message}")
|
|
1098
|
+
raise _SelectionExit(e.exit_code)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _resolve_layouts(layouts: Optional[str], guide: bool, config: SteppyConfig, console: Console) -> list[str]:
|
|
1102
|
+
try:
|
|
1103
|
+
return selection.resolve_layouts(layouts, guide, config)
|
|
1104
|
+
except selection.SelectionError as e:
|
|
1105
|
+
console.print(f"[red]ERROR[/red] {e.message}")
|
|
1106
|
+
raise _SelectionExit(e.exit_code)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _resolve_effective_workers(workers: int | str, parallel_cfg, slugs: list[str]) -> int:
|
|
1110
|
+
"""Resolve auto / explicit workers count, capped at scenario count."""
|
|
1111
|
+
if workers == "auto" or (isinstance(workers, str) and workers.lower() == "auto"):
|
|
1112
|
+
cpu = os.cpu_count() or 2
|
|
1113
|
+
return max(1, min(cpu // 2, len(slugs), _DEFAULT_AUTO_CAP))
|
|
1114
|
+
return min(int(workers), len(slugs))
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _maybe_warn_resources(workers: int, console: Console) -> None:
|
|
1118
|
+
"""Print a soft warning if the worker count looks high for this host.
|
|
1119
|
+
Heuristic: > cpu_count / 2 OR > 8 workers triggers the warning. Doesn't
|
|
1120
|
+
block — users may have headroom we can't detect."""
|
|
1121
|
+
cpu = os.cpu_count() or 2
|
|
1122
|
+
if workers > max(1, cpu // 2) or workers > _DEFAULT_AUTO_CAP:
|
|
1123
|
+
console.print(
|
|
1124
|
+
f"[yellow]WARNING[/yellow] requesting {workers} parallel stacks on "
|
|
1125
|
+
f"{cpu}-CPU host — make sure your machine has enough RAM/CPU "
|
|
1126
|
+
f"(each stack runs your full app + chromium)."
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# ── Compose lifecycle helpers ──────────────────────────────────────────────
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
class _StackError(RuntimeError):
|
|
1134
|
+
pass
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _bring_up_all_stacks(
|
|
1138
|
+
*,
|
|
1139
|
+
stack_id: str,
|
|
1140
|
+
worker_count: int,
|
|
1141
|
+
compose_file: Path,
|
|
1142
|
+
overrides: list[Path],
|
|
1143
|
+
healthcheck_timeout: int,
|
|
1144
|
+
lifecycle: CoordinatorLifecycle,
|
|
1145
|
+
console: Console,
|
|
1146
|
+
) -> list[str]:
|
|
1147
|
+
"""Spin up every stack in parallel via `compose up -d --wait`. Registers
|
|
1148
|
+
a teardown callback for each stack as soon as up is invoked. Returns the
|
|
1149
|
+
list of project names.
|
|
1150
|
+
|
|
1151
|
+
Keyed on ``stack_id`` (short, opaque) rather than the run's identity —
|
|
1152
|
+
Compose project names have their own charset rules and end up in
|
|
1153
|
+
``docker ps`` output, and ``steppy stacks cleanup`` matches them by the
|
|
1154
|
+
``steppy-run-`` prefix alone."""
|
|
1155
|
+
project_names = [f"{_PROJECT_NAME_PREFIX}{stack_id}-w{i}" for i in range(1, worker_count + 1)]
|
|
1156
|
+
|
|
1157
|
+
def _up_one(idx: int) -> tuple[int, Optional[str]]:
|
|
1158
|
+
project = project_names[idx]
|
|
1159
|
+
override = overrides[idx]
|
|
1160
|
+
# Register teardown BEFORE invoking up so a crash mid-up still cleans
|
|
1161
|
+
# up partial state. The compose down call is idempotent.
|
|
1162
|
+
lifecycle.on_teardown(lambda p=project: _down(p, console))
|
|
1163
|
+
cmd = [
|
|
1164
|
+
"docker",
|
|
1165
|
+
"compose",
|
|
1166
|
+
"-p",
|
|
1167
|
+
project,
|
|
1168
|
+
"-f",
|
|
1169
|
+
str(compose_file),
|
|
1170
|
+
"-f",
|
|
1171
|
+
str(override),
|
|
1172
|
+
"up",
|
|
1173
|
+
"-d",
|
|
1174
|
+
"--wait",
|
|
1175
|
+
"--wait-timeout",
|
|
1176
|
+
str(healthcheck_timeout),
|
|
1177
|
+
]
|
|
1178
|
+
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
1179
|
+
if result.returncode != 0:
|
|
1180
|
+
return result.returncode, f"{project}: {result.stderr.strip()}"
|
|
1181
|
+
return 0, None
|
|
1182
|
+
|
|
1183
|
+
failures: list[str] = []
|
|
1184
|
+
with ThreadPoolExecutor(max_workers=worker_count) as pool:
|
|
1185
|
+
futures = [pool.submit(_up_one, i) for i in range(worker_count)]
|
|
1186
|
+
for fut in as_completed(futures):
|
|
1187
|
+
code, err = fut.result()
|
|
1188
|
+
if code != 0:
|
|
1189
|
+
failures.append(err or f"unknown error (exit {code})")
|
|
1190
|
+
|
|
1191
|
+
if failures:
|
|
1192
|
+
raise _StackError("; ".join(failures))
|
|
1193
|
+
|
|
1194
|
+
console.print(f"[green]✓[/green] all {worker_count} stack(s) healthy")
|
|
1195
|
+
return project_names
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _resolve_worker_container_ids(project_names: list[str]) -> dict[str, str]:
|
|
1199
|
+
"""Look up each project's steppy-worker container ID once, right after
|
|
1200
|
+
``compose up -d --wait`` returns. Raises ``_StackError`` if any project
|
|
1201
|
+
can't be resolved — better to fail fast than have ``_wait_for_workers``
|
|
1202
|
+
spin in a silent loop later.
|
|
1203
|
+
|
|
1204
|
+
The ``-a`` flag in :func:`_get_worker_container_id` is required: a worker
|
|
1205
|
+
that fails fast on a misconfig may already be in ``exited`` state by the
|
|
1206
|
+
time we get here.
|
|
1207
|
+
"""
|
|
1208
|
+
container_ids: dict[str, str] = {}
|
|
1209
|
+
for project in project_names:
|
|
1210
|
+
cid = _get_worker_container_id(project)
|
|
1211
|
+
if cid is None:
|
|
1212
|
+
raise _StackError(
|
|
1213
|
+
f"could not resolve steppy-worker container for {project} "
|
|
1214
|
+
f"(docker compose ps -a -q steppy-worker returned nothing)"
|
|
1215
|
+
)
|
|
1216
|
+
container_ids[project] = cid
|
|
1217
|
+
return container_ids
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _wait_for_workers(container_ids: dict[str, str], console: Console, on_poll=None) -> list[int]:
|
|
1221
|
+
"""Block until every worker container exits. Returns each project's
|
|
1222
|
+
steppy-worker container exit code, in the same order as the input dict.
|
|
1223
|
+
|
|
1224
|
+
``container_ids`` is the map produced by :func:`_resolve_worker_container_ids`
|
|
1225
|
+
right after ``compose up -d --wait``. Polling uses ``docker inspect``
|
|
1226
|
+
directly on the cached ID — no per-poll ``compose ps`` round-trip.
|
|
1227
|
+
|
|
1228
|
+
``on_poll`` is called once per cycle while workers are still running. It is
|
|
1229
|
+
the circuit breaker's hook: raising :class:`_CircuitBreakerTripped` from it
|
|
1230
|
+
aborts the wait (and, via the caller's ExitStack, the whole run). It is
|
|
1231
|
+
deliberately called only while something is still pending — there is nothing
|
|
1232
|
+
to abort once every worker has exited.
|
|
1233
|
+
"""
|
|
1234
|
+
pending = dict(container_ids)
|
|
1235
|
+
exit_codes: dict[str, int] = {}
|
|
1236
|
+
poll_interval = 1.5
|
|
1237
|
+
|
|
1238
|
+
while pending:
|
|
1239
|
+
for project, container_id in list(pending.items()):
|
|
1240
|
+
state = _inspect_container_state(container_id)
|
|
1241
|
+
if state and state.get("Status") == "exited":
|
|
1242
|
+
exit_codes[project] = int(state.get("ExitCode", 1))
|
|
1243
|
+
del pending[project]
|
|
1244
|
+
console.print(f"[dim]{project}: worker exited (code {exit_codes[project]})[/dim]")
|
|
1245
|
+
if pending:
|
|
1246
|
+
if on_poll is not None:
|
|
1247
|
+
on_poll()
|
|
1248
|
+
time.sleep(poll_interval)
|
|
1249
|
+
|
|
1250
|
+
return [exit_codes.get(p, 1) for p in container_ids.keys()]
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _get_worker_container_id(project: str) -> Optional[str]:
|
|
1254
|
+
"""Return the docker container ID of the steppy-worker service in this
|
|
1255
|
+
project, or None if it doesn't exist.
|
|
1256
|
+
|
|
1257
|
+
``-a`` is load-bearing: without it ``docker compose ps -q`` hides
|
|
1258
|
+
containers whose status is ``exited``. After a worker drains the
|
|
1259
|
+
queue and the container exits cleanly, the wait loop would otherwise
|
|
1260
|
+
see ``None`` forever and hang. See ``tests/test_parallel_coordinator_wait``.
|
|
1261
|
+
"""
|
|
1262
|
+
try:
|
|
1263
|
+
result = subprocess.run(
|
|
1264
|
+
["docker", "compose", "-p", project, "ps", "-a", "-q", "steppy-worker"],
|
|
1265
|
+
capture_output=True,
|
|
1266
|
+
text=True,
|
|
1267
|
+
encoding="utf-8",
|
|
1268
|
+
errors="replace",
|
|
1269
|
+
timeout=10,
|
|
1270
|
+
)
|
|
1271
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
1272
|
+
return None
|
|
1273
|
+
cid = result.stdout.strip()
|
|
1274
|
+
return cid or None
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _inspect_container_state(container_id: str) -> Optional[dict]:
|
|
1278
|
+
try:
|
|
1279
|
+
result = subprocess.run(
|
|
1280
|
+
["docker", "inspect", "--format", "{{json .State}}", container_id],
|
|
1281
|
+
capture_output=True,
|
|
1282
|
+
text=True,
|
|
1283
|
+
encoding="utf-8",
|
|
1284
|
+
errors="replace",
|
|
1285
|
+
timeout=10,
|
|
1286
|
+
)
|
|
1287
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
1288
|
+
return None
|
|
1289
|
+
if result.returncode != 0:
|
|
1290
|
+
return None
|
|
1291
|
+
try:
|
|
1292
|
+
return json.loads(result.stdout)
|
|
1293
|
+
except json.JSONDecodeError:
|
|
1294
|
+
return None
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _down(project: str, console: Optional[Console] = None) -> None:
|
|
1298
|
+
"""Tear down one stack via `docker compose -p <project> down -v`. Best
|
|
1299
|
+
effort — never raises; teardown errors are logged to stderr by the
|
|
1300
|
+
lifecycle's _safe_call."""
|
|
1301
|
+
cmd = ["docker", "compose", "-p", project, "down", "-v", "--timeout", "10"]
|
|
1302
|
+
try:
|
|
1303
|
+
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
|
|
1304
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
1305
|
+
if console:
|
|
1306
|
+
console.print(f"[yellow]teardown[/yellow] {project}: {e}")
|
|
1307
|
+
return
|
|
1308
|
+
if result.returncode != 0 and console:
|
|
1309
|
+
console.print(f"[yellow]teardown[/yellow] {project}: {result.stderr.strip()}")
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
# ── Rescue command (steppy stacks cleanup) ─────────────────────────────────
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def cleanup_stacks() -> int:
|
|
1316
|
+
"""List orphaned ``steppy-run-*`` Compose projects and tear them down.
|
|
1317
|
+
Returns the number of stacks cleaned (for the CLI to surface)."""
|
|
1318
|
+
console = Console()
|
|
1319
|
+
try:
|
|
1320
|
+
result = subprocess.run(
|
|
1321
|
+
["docker", "compose", "ls", "--all", "--format", "json"],
|
|
1322
|
+
capture_output=True,
|
|
1323
|
+
text=True,
|
|
1324
|
+
encoding="utf-8",
|
|
1325
|
+
errors="replace",
|
|
1326
|
+
timeout=15,
|
|
1327
|
+
)
|
|
1328
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
1329
|
+
console.print(f"[red]ERROR[/red] could not list compose projects: {e}")
|
|
1330
|
+
return 1
|
|
1331
|
+
if result.returncode != 0:
|
|
1332
|
+
console.print(f"[red]ERROR[/red] docker compose ls failed: {result.stderr.strip()}")
|
|
1333
|
+
return 1
|
|
1334
|
+
|
|
1335
|
+
try:
|
|
1336
|
+
projects = json.loads(result.stdout) or []
|
|
1337
|
+
except json.JSONDecodeError:
|
|
1338
|
+
console.print("[red]ERROR[/red] could not parse compose ls output")
|
|
1339
|
+
return 1
|
|
1340
|
+
|
|
1341
|
+
targets = [
|
|
1342
|
+
p["Name"] for p in projects if isinstance(p, dict) and p.get("Name", "").startswith(_PROJECT_NAME_PREFIX)
|
|
1343
|
+
]
|
|
1344
|
+
if not targets:
|
|
1345
|
+
console.print("[green]✓[/green] no orphaned steppy-run-* stacks found")
|
|
1346
|
+
return 0
|
|
1347
|
+
|
|
1348
|
+
console.print(f"[bold]Cleaning up {len(targets)} orphaned stack(s):[/bold] {', '.join(targets)}")
|
|
1349
|
+
for project in targets:
|
|
1350
|
+
_down(project, console)
|
|
1351
|
+
console.print("[green]✓[/green] cleanup complete")
|
|
1352
|
+
return 0
|