runspec 0.75.2__tar.gz → 0.76.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {runspec-0.75.2 → runspec-0.76.0}/PKG-INFO +1 -1
- {runspec-0.75.2 → runspec-0.76.0}/pyproject.toml +1 -1
- {runspec-0.75.2 → runspec-0.76.0}/runspec/ansiblegen.py +3 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/inbox.py +2 -2
- {runspec-0.75.2 → runspec-0.76.0}/runspec/logging_setup.py +6 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/models.py +1 -1
- {runspec-0.75.2 → runspec-0.76.0}/runspec/parser.py +9 -1
- {runspec-0.75.2 → runspec-0.76.0}/runspec/rundeckgen.py +15 -4
- {runspec-0.75.2 → runspec-0.76.0}/runspec/runexec.py +11 -5
- {runspec-0.75.2 → runspec-0.76.0}/runspec/runsdb.py +10 -3
- {runspec-0.75.2 → runspec-0.76.0}/runspec/serve.py +1 -1
- {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/PKG-INFO +1 -1
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_ansiblegen.py +1 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_exec.py +59 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_inbox.py +5 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_models.py +1 -1
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_parser.py +2 -2
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_run_summary.py +46 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_rundeckgen.py +24 -11
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_runsdb.py +3 -1
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_serve.py +1 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_testing.py +34 -34
- {runspec-0.75.2 → runspec-0.76.0}/runspec/__init__.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/auditlog.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/become.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/cli.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/denylist.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/discovery.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/emitutil.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/env.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/errors.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/execute.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/finder.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/inference.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/jump.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/loader.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/logs.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/mock.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/py.typed +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/rebrand.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/runspec.toml +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/schemacheck.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/stubgen.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/testing.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/types.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec/validator.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/SOURCES.txt +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/dependency_links.txt +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/entry_points.txt +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/requires.txt +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/runspec.egg-info/top_level.txt +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/setup.cfg +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/__init__.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/conftest.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_agent.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_audit.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_become.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_discover.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_emit.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_init.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_cli_test.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_denylist.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_discovery.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_env.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_finder.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_inference.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_jump.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_loader.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_log_resilience.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_logging_setup.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_logs.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_mock.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_password.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_rebrand.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_run_as_enforce.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_runsdb_events.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_runsdb_sync.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_schemacheck.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_secret_argv.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_serve_e2e.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_stubgen.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_types.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_typing.py +0 -0
- {runspec-0.75.2 → runspec-0.76.0}/tests/test_validator.py +0 -0
|
@@ -24,6 +24,8 @@ Design points
|
|
|
24
24
|
`check_mode` short-circuits to `changed=false` (conservative).
|
|
25
25
|
- **Non-interactive:** the module injects `RUNSPEC_AUTONOMY=autonomous` so a
|
|
26
26
|
`confirm` runnable runs in a playbook without prompting.
|
|
27
|
+
- **Label:** it sets `RUNSPEC_LABEL=ansible`, so the run is labelled `ansible`
|
|
28
|
+
in the runs index.
|
|
27
29
|
"""
|
|
28
30
|
|
|
29
31
|
from __future__ import annotations
|
|
@@ -279,6 +281,7 @@ def build_command(params):
|
|
|
279
281
|
def build_env(params):
|
|
280
282
|
env = dict(os.environ)
|
|
281
283
|
env["RUNSPEC_AUTONOMY"] = "autonomous"
|
|
284
|
+
env["RUNSPEC_LABEL"] = "ansible"
|
|
282
285
|
for param, kind, _cli, _position, secret_env in ARGS:
|
|
283
286
|
if kind == "secret" and params.get(param) is not None:
|
|
284
287
|
env[secret_env] = str(params[param])
|
|
@@ -482,10 +482,10 @@ def row_from_records(run_id: str, records: list[dict[str, Any]]) -> dict[str, An
|
|
|
482
482
|
row["runnable"] = start.get("runnable") or src.get("runnable") or ""
|
|
483
483
|
cmd = start.get("command_path") or src.get("command_path") or []
|
|
484
484
|
row["command"] = " ".join(cmd) if isinstance(cmd, list) else str(cmd or "")
|
|
485
|
-
for k in ("user", "user_target", "agent", "trigger", "skill", "autonomy"):
|
|
485
|
+
for k in ("user", "user_target", "agent", "trigger", "skill", "autonomy", "label"):
|
|
486
486
|
v = start.get(k)
|
|
487
487
|
row[k] = v if not _empty(v) else src.get(k)
|
|
488
|
-
for k in ("pid", "host", "proc_start", "run_as", "
|
|
488
|
+
for k in ("pid", "host", "proc_start", "run_as", "exec_uid", "venv"):
|
|
489
489
|
row[k] = start.get(k)
|
|
490
490
|
args = start.get("invocation_args") or src.get("invocation_args") or {}
|
|
491
491
|
row["args"] = args if isinstance(args, dict) else {}
|
|
@@ -155,6 +155,7 @@ def configure_logging(
|
|
|
155
155
|
agent: bool = False,
|
|
156
156
|
trigger: str = "",
|
|
157
157
|
skill: str = "",
|
|
158
|
+
label: str = "",
|
|
158
159
|
command_path: list[str] | None = None,
|
|
159
160
|
invocation_args: dict[str, Any] | None = None,
|
|
160
161
|
history_cfg: dict[str, Any] | None = None,
|
|
@@ -366,6 +367,8 @@ def configure_logging(
|
|
|
366
367
|
# The saved skill that drove this run (RUNSPEC_SKILL), "" otherwise — a
|
|
367
368
|
# further provenance axis alongside trigger/agent.
|
|
368
369
|
"skill": skill,
|
|
370
|
+
# Where the run was launched from (RUNSPEC_LABEL, "cli" when unset).
|
|
371
|
+
"label": label,
|
|
369
372
|
"command_path": list(command_path or []),
|
|
370
373
|
"exception": None,
|
|
371
374
|
"emitted": False,
|
|
@@ -516,6 +519,7 @@ def _write_run_start(state: dict[str, Any], *, indexed: bool) -> None:
|
|
|
516
519
|
"agent": state["agent"],
|
|
517
520
|
"trigger": state["trigger"],
|
|
518
521
|
"skill": state.get("skill", ""),
|
|
522
|
+
"label": state.get("label") or None,
|
|
519
523
|
"autonomy": state["autonomy"],
|
|
520
524
|
"run_as": state.get("run_as") or "",
|
|
521
525
|
"invocation_args": {k: v["value"] for k, v in state.get("invocation_args", {}).items()},
|
|
@@ -1004,6 +1008,7 @@ def _begin_history_row(state: dict[str, Any], log_dir: Path, log_file: str) -> N
|
|
|
1004
1008
|
"agent": state["agent"],
|
|
1005
1009
|
"trigger": state["trigger"],
|
|
1006
1010
|
"skill": state.get("skill", ""),
|
|
1011
|
+
"label": state.get("label") or None,
|
|
1007
1012
|
"autonomy": state["autonomy"],
|
|
1008
1013
|
"pid": os.getpid(),
|
|
1009
1014
|
"host": socket.gethostname(),
|
|
@@ -1053,6 +1058,7 @@ def _emit_run_summary() -> None:
|
|
|
1053
1058
|
"agent": state["agent"],
|
|
1054
1059
|
"trigger": state["trigger"],
|
|
1055
1060
|
"skill": state.get("skill", ""),
|
|
1061
|
+
"label": state.get("label") or None,
|
|
1056
1062
|
"autonomy": state["autonomy"],
|
|
1057
1063
|
"exception": state["exception"],
|
|
1058
1064
|
"events": dict(state["counter"].counts),
|
|
@@ -222,7 +222,7 @@ class RunSpec:
|
|
|
222
222
|
you don't have to guard every access:
|
|
223
223
|
|
|
224
224
|
since = args.get("since").value # None if not in this path
|
|
225
|
-
symbol = args.get("symbol", "
|
|
225
|
+
symbol = args.get("symbol", "ABC123").value
|
|
226
226
|
|
|
227
227
|
When the arg is in the active command path its real Arg is returned;
|
|
228
228
|
otherwise a synthetic ``not_set`` Arg wrapping ``default`` is returned,
|
|
@@ -265,7 +265,14 @@ def _parse_impl(
|
|
|
265
265
|
# The skill (saved runbook) that drove this run, when started from the console's
|
|
266
266
|
# `$` skills menu. Recorded in run_summary as `extra.skill` — another provenance
|
|
267
267
|
# axis alongside `trigger`/`agent`, so History/Analytics can attribute runs to it.
|
|
268
|
-
|
|
268
|
+
# The console sends the runbook id as RUNSPEC_RUNBOOK (skills were renamed to
|
|
269
|
+
# runbooks); RUNSPEC_SKILL is the older spelling, still honoured.
|
|
270
|
+
skill = (os.environ.get("RUNSPEC_SKILL", "") or os.environ.get("RUNSPEC_RUNBOOK", "")).strip()
|
|
271
|
+
# Where the run was launched from (RUNSPEC_LABEL, set by each launcher: the
|
|
272
|
+
# console, `runspec serve`, a Rundeck job, ...); a run nobody labelled was
|
|
273
|
+
# started from a shell, cron or a script, so it records "cli". Stored in the
|
|
274
|
+
# runs index's `label` column, the one `runspec exec --label` fills.
|
|
275
|
+
label = os.environ.get("RUNSPEC_LABEL", "").strip() or "cli"
|
|
269
276
|
# A `runspec exec` wrapper's run id (RUNSPEC_RUN_ID). Taken — and removed from
|
|
270
277
|
# the environment — here, unconditionally, so it never leaks to runnables this
|
|
271
278
|
# one shells out to, even when this runnable has no [config.logging].
|
|
@@ -341,6 +348,7 @@ def _parse_impl(
|
|
|
341
348
|
agent=agent,
|
|
342
349
|
trigger=trigger,
|
|
343
350
|
skill=skill,
|
|
351
|
+
label=label,
|
|
344
352
|
command_path=command_path,
|
|
345
353
|
invocation_args=invocation_args,
|
|
346
354
|
history_cfg=config.get("history"),
|
|
@@ -241,6 +241,11 @@ def rd_option_token(name: str) -> str:
|
|
|
241
241
|
return f"@option.{name}@"
|
|
242
242
|
|
|
243
243
|
|
|
244
|
+
# Recorded as the run's label in the runs index (SPEC "Run labels"), so History
|
|
245
|
+
# shows the run came from Rundeck.
|
|
246
|
+
_LABEL_ASSIGN = "RUNSPEC_LABEL=rundeck"
|
|
247
|
+
|
|
248
|
+
|
|
244
249
|
def _env_prelude_argv(invocation: str, venv: str | None) -> list[str]:
|
|
245
250
|
"""The env-prelude wrapper for a run behind `sudo` (SPEC "Command
|
|
246
251
|
building"): the venv's own `bin/python` when the venv is known, so a sudoers
|
|
@@ -273,13 +278,15 @@ def _exists_check(runnable: str, invocation: str, venv: str | None, run_as: str
|
|
|
273
278
|
|
|
274
279
|
def _direct(env_vars: list[tuple[str, str]], cmd: str, indent: str = "") -> list[str]:
|
|
275
280
|
"""Export the non-empty values (an empty option stays unset so the
|
|
276
|
-
runnable's own default applies) and exec the binary as
|
|
281
|
+
runnable's own default applies) and the run's label, and exec the binary as
|
|
282
|
+
this account."""
|
|
277
283
|
lines: list[str] = []
|
|
278
284
|
if env_vars:
|
|
279
285
|
names = " ".join(var for var, _ in env_vars)
|
|
280
286
|
lines.append(f"{indent}for rs_var in {names}; do")
|
|
281
287
|
lines.append(f'{indent} if [ -n "${{!rs_var}}" ]; then export "$rs_var"; else unset "$rs_var"; fi')
|
|
282
288
|
lines.append(f"{indent}done")
|
|
289
|
+
lines.append(f"{indent}export {_LABEL_ASSIGN}")
|
|
283
290
|
lines.append(f"{indent}exec {cmd}")
|
|
284
291
|
return lines
|
|
285
292
|
|
|
@@ -324,7 +331,6 @@ def render_script(
|
|
|
324
331
|
path.
|
|
325
332
|
"""
|
|
326
333
|
env_vars = [(secret_env_var(runnable, name), name) for name in args]
|
|
327
|
-
names = " ".join(var for var, _ in env_vars)
|
|
328
334
|
|
|
329
335
|
lines = ["#!/bin/bash", _PRIVATE, _GUARD, "set -e"]
|
|
330
336
|
for var, name in env_vars:
|
|
@@ -366,15 +372,20 @@ def render_script(
|
|
|
366
372
|
lines.append("fi")
|
|
367
373
|
|
|
368
374
|
become = 'sudo -n -u "$rs_user"'
|
|
369
|
-
if not env_vars:
|
|
375
|
+
if not env_vars and not venv:
|
|
376
|
+
# Nothing to send but the label, and the `sh` prelude would need a
|
|
377
|
+
# sudoers rule that allows a shell: run the binary directly (the run then
|
|
378
|
+
# records the "cli" label).
|
|
370
379
|
lines.append(f"exec {become} {cmd}")
|
|
371
380
|
return _finish(lines)
|
|
372
381
|
|
|
373
382
|
prelude = PY_ENV_PRELUDE if venv else ENV_PRELUDE
|
|
374
383
|
lines.append(f"RS_PRELUDE='{prelude}'")
|
|
375
384
|
lines.append(_FRAME_FUNC)
|
|
385
|
+
lines.append(_LABEL_ASSIGN)
|
|
376
386
|
wrapped = " ".join([*_env_prelude_argv(invocation, venv), cmd])
|
|
377
|
-
|
|
387
|
+
frame_vars = " ".join([*(var for var, _ in env_vars), "RUNSPEC_LABEL"])
|
|
388
|
+
lines.append(f"rs_frame {frame_vars} | {become} {wrapped}")
|
|
378
389
|
return _finish(lines)
|
|
379
390
|
|
|
380
391
|
|
|
@@ -199,6 +199,11 @@ def exec_runnable(
|
|
|
199
199
|
out_stream = stdout if stdout is not None else sys.stdout
|
|
200
200
|
err_stream = stderr if stderr is not None else sys.stderr
|
|
201
201
|
label = label or None
|
|
202
|
+
# The label recorded on the run and handed to the child: --label, else the
|
|
203
|
+
# launcher's RUNSPEC_LABEL, else "cli" (what the child's parse() would record).
|
|
204
|
+
# `label` alone stays the --skip-if-running key, so an unlabelled run never
|
|
205
|
+
# waits on every other unlabelled run.
|
|
206
|
+
run_label = label or os.environ.get("RUNSPEC_LABEL", "").strip() or "cli"
|
|
202
207
|
trigger = trigger if trigger is not None else os.environ.get("RUNSPEC_TRIGGER", "").strip()
|
|
203
208
|
run_id = str(uuid.uuid4())
|
|
204
209
|
start = _now()
|
|
@@ -237,7 +242,7 @@ def exec_runnable(
|
|
|
237
242
|
"pid": os.getpid(),
|
|
238
243
|
"host": socket.gethostname(),
|
|
239
244
|
"proc_start": runsdb.proc_start_token(),
|
|
240
|
-
"label":
|
|
245
|
+
"label": run_label,
|
|
241
246
|
"run_as": (spec or {}).get("run_as") or None,
|
|
242
247
|
"os_user": inbox.effective_user() or None,
|
|
243
248
|
"source": "direct",
|
|
@@ -268,7 +273,7 @@ def exec_runnable(
|
|
|
268
273
|
_record(
|
|
269
274
|
message,
|
|
270
275
|
run_id,
|
|
271
|
-
{"event": "exec_result", "status": status, "exit_code": exit_code, "duration_ms": 0, "label":
|
|
276
|
+
{"event": "exec_result", "status": status, "exit_code": exit_code, "duration_ms": 0, "label": run_label, "trigger": trigger, "reason": reason},
|
|
272
277
|
level="INFO" if status == "skipped" else "ERROR",
|
|
273
278
|
)
|
|
274
279
|
],
|
|
@@ -322,6 +327,7 @@ def exec_runnable(
|
|
|
322
327
|
child_env["RUNSPEC_RUN_LOG"] = str(box.path)
|
|
323
328
|
if trigger:
|
|
324
329
|
child_env["RUNSPEC_TRIGGER"] = trigger
|
|
330
|
+
child_env["RUNSPEC_LABEL"] = run_label
|
|
325
331
|
cmd, extra_env, frame = build_command(spec, list(argv), child_env)
|
|
326
332
|
capture = capture_bytes > 0
|
|
327
333
|
t0 = time.monotonic()
|
|
@@ -337,7 +343,7 @@ def exec_runnable(
|
|
|
337
343
|
if write_row: # drop the running placeholder; record the launch failure
|
|
338
344
|
runsdb.finish_run(log_dir, run_id, exit_code=EXIT_NOT_EXECUTABLE, duration_ms=0, status="failed", cfg=history_cfg, runnable=name)
|
|
339
345
|
log_path = box.path if box is not None else _log_file_for(log_dir, None, name, start, run_id)
|
|
340
|
-
result = {"event": "exec_result", "status": "failed", "exit_code": EXIT_NOT_EXECUTABLE, "duration_ms": 0, "label":
|
|
346
|
+
result = {"event": "exec_result", "status": "failed", "exit_code": EXIT_NOT_EXECUTABLE, "duration_ms": 0, "label": run_label, "trigger": trigger, "reason": "launch_error"}
|
|
341
347
|
sink.write(log_path, [_record(f"could not start '{name}': {exc}", run_id, result, level="ERROR")])
|
|
342
348
|
with contextlib.suppress(Exception):
|
|
343
349
|
err_stream.write(f"runspec exec: could not start '{name}': {exc}\n")
|
|
@@ -381,7 +387,7 @@ def exec_runnable(
|
|
|
381
387
|
if write_row and row is not None and row.get("pid") == os.getpid() and row.get("status") == "running":
|
|
382
388
|
# The child never touched our row — no logging, failed before logging,
|
|
383
389
|
# or an older runspec that minted its own id. Adopt the latter.
|
|
384
|
-
adopted = runsdb.adopt_child_row(log_dir, run_id, runnable=name, since_ts=start_iso, label=
|
|
390
|
+
adopted = runsdb.adopt_child_row(log_dir, run_id, runnable=name, since_ts=start_iso, label=run_label)
|
|
385
391
|
if adopted:
|
|
386
392
|
target_id = adopted
|
|
387
393
|
if write_row:
|
|
@@ -405,7 +411,7 @@ def exec_runnable(
|
|
|
405
411
|
_record(
|
|
406
412
|
result_msg,
|
|
407
413
|
target_id,
|
|
408
|
-
{"event": "exec_result", "status": status, "exit_code": rc, "duration_ms": duration_ms, "label":
|
|
414
|
+
{"event": "exec_result", "status": status, "exit_code": rc, "duration_ms": duration_ms, "label": run_label, "trigger": trigger, "reason": "timeout" if timed_out else ""},
|
|
409
415
|
level="INFO" if rc == 0 else "ERROR",
|
|
410
416
|
)
|
|
411
417
|
)
|
|
@@ -657,13 +657,19 @@ def _normalise_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
|
657
657
|
_HAS_UPSERT = sqlite3.sqlite_version_info >= (3, 24, 0)
|
|
658
658
|
|
|
659
659
|
|
|
660
|
+
# Kept columns where the value already on the row wins over a later upsert's.
|
|
661
|
+
_FIRST_WINS = ("label",)
|
|
662
|
+
|
|
663
|
+
|
|
660
664
|
def _upsert(con: sqlite3.Connection, row: dict[str, Any]) -> None:
|
|
661
665
|
"""INSERT or overwrite a row by ``run_id``. ``label``, ``events`` and
|
|
662
666
|
``exception`` (and v6 ``os_user`` / ``source`` / ``exec_uid``) are written on
|
|
663
667
|
insert but only ever *kept* on conflict: under
|
|
664
668
|
``runspec exec`` the wrapper writes the labelled row first and the child's own
|
|
665
669
|
begin/finish upserts (which don't know the label) must not clear it — and a
|
|
666
|
-
begin that races a finish must not clear the finish's events.
|
|
670
|
+
begin that races a finish must not clear the finish's events. ``label`` goes
|
|
671
|
+
further: the first non-empty value stays, so a child's own label (``cli`` when
|
|
672
|
+
nothing set ``RUNSPEC_LABEL``) never replaces the wrapper's ``--label``."""
|
|
667
673
|
kept = ("label", "events", "exception", "os_user", "source", "exec_uid")
|
|
668
674
|
cols = ", ".join((*_COLUMNS, *kept))
|
|
669
675
|
placeholders = ", ".join("?" for _ in range(len(_COLUMNS) + len(kept)))
|
|
@@ -674,7 +680,7 @@ def _upsert(con: sqlite3.Connection, row: dict[str, Any]) -> None:
|
|
|
674
680
|
# left a row without user / args / agent. UPDATE then INSERT does the same in
|
|
675
681
|
# one write transaction (the UPDATE takes the write lock).
|
|
676
682
|
sets = ", ".join(f"{c}=?" for c in _COLUMNS if c != "run_id")
|
|
677
|
-
sets += "".join(f", {c}=COALESCE(?, {c})" for c in kept)
|
|
683
|
+
sets += "".join(f", {c}=COALESCE({c}, ?)" if c in _FIRST_WINS else f", {c}=COALESCE(?, {c})" for c in kept)
|
|
678
684
|
cur = con.execute(
|
|
679
685
|
f"UPDATE runs SET {sets} WHERE run_id=?",
|
|
680
686
|
(*(row[c] for c in _COLUMNS if c != "run_id"), *kept_vals, row["run_id"]),
|
|
@@ -683,7 +689,7 @@ def _upsert(con: sqlite3.Connection, row: dict[str, Any]) -> None:
|
|
|
683
689
|
con.execute(f"INSERT INTO runs ({cols}) VALUES ({placeholders})", (*(row[c] for c in _COLUMNS), *kept_vals))
|
|
684
690
|
return
|
|
685
691
|
updates = ", ".join(f"{c}=excluded.{c}" for c in _COLUMNS if c != "run_id")
|
|
686
|
-
updates += "".join(f", {c}=COALESCE(excluded.{c}, runs.{c})" for c in kept)
|
|
692
|
+
updates += "".join(f", {c}=COALESCE(runs.{c}, excluded.{c})" if c in _FIRST_WINS else f", {c}=COALESCE(excluded.{c}, runs.{c})" for c in kept)
|
|
687
693
|
con.execute(
|
|
688
694
|
f"INSERT INTO runs ({cols}) VALUES ({placeholders}) ON CONFLICT(run_id) DO UPDATE SET {updates}",
|
|
689
695
|
(*(row[c] for c in _COLUMNS), *kept_vals),
|
|
@@ -1277,6 +1283,7 @@ def reindex(log_dir: Path, records: list[dict[str, Any]]) -> int:
|
|
|
1277
1283
|
"trigger": rec.get("trigger", ""),
|
|
1278
1284
|
"skill": rec.get("skill", ""),
|
|
1279
1285
|
"autonomy": rec.get("autonomy", ""),
|
|
1286
|
+
"label": rec.get("label"),
|
|
1280
1287
|
"args": rec.get("invocation_args") or {},
|
|
1281
1288
|
"events": rec.get("events"),
|
|
1282
1289
|
"exception": exc,
|
|
@@ -316,7 +316,7 @@ def _handle_tools_call(
|
|
|
316
316
|
# RUNSPEC_* vars on the child's stdin (the SPEC env frame) because sudo and
|
|
317
317
|
# friends strip the environment — never on the command line.
|
|
318
318
|
spec = exec_specs.get(name, {})
|
|
319
|
-
final_cmd, extra_env, frame = _build_command(spec, argv, {"RUNSPEC_AGENT": "1", **runspec_env})
|
|
319
|
+
final_cmd, extra_env, frame = _build_command(spec, argv, {"RUNSPEC_AGENT": "1", "RUNSPEC_LABEL": "mcp", **runspec_env})
|
|
320
320
|
env = {**os.environ, **extra_env}
|
|
321
321
|
# The child must never inherit serve's stdin: that's the MCP JSON-RPC stream.
|
|
322
322
|
feed: dict[str, Any] = {"input": frame.decode("ascii")} if frame else {"stdin": subprocess.DEVNULL}
|
|
@@ -154,6 +154,7 @@ def test_render_module_is_valid_python():
|
|
|
154
154
|
assert "from ansible.module_utils.basic import AnsibleModule" in src
|
|
155
155
|
assert "supports_check_mode=True" in src
|
|
156
156
|
assert "RUNSPEC_AUTONOMY" in src # non-interactive
|
|
157
|
+
assert 'env["RUNSPEC_LABEL"] = "ansible"' in src # the run is labelled ansible
|
|
157
158
|
|
|
158
159
|
|
|
159
160
|
def test_render_module_secret_via_env_not_argv():
|
|
@@ -26,6 +26,7 @@ def logs_dir(tmp_path, monkeypatch):
|
|
|
26
26
|
d.mkdir()
|
|
27
27
|
monkeypatch.setattr(runexec, "_resolve_log_dir", lambda: d)
|
|
28
28
|
monkeypatch.delenv("RUNSPEC_TRIGGER", raising=False)
|
|
29
|
+
monkeypatch.delenv("RUNSPEC_LABEL", raising=False)
|
|
29
30
|
return d
|
|
30
31
|
|
|
31
32
|
|
|
@@ -119,6 +120,44 @@ class TestPlainChild:
|
|
|
119
120
|
assert list(logs_dir.glob("demo.*.log"))
|
|
120
121
|
|
|
121
122
|
|
|
123
|
+
class TestLabel:
|
|
124
|
+
"""The run's label: --label, else the launcher's RUNSPEC_LABEL, else "cli" —
|
|
125
|
+
recorded on the row and handed to the child as RUNSPEC_LABEL."""
|
|
126
|
+
|
|
127
|
+
_BODY = "import os\nprint(os.environ.get('RUNSPEC_LABEL', '<unset>'))\n"
|
|
128
|
+
|
|
129
|
+
def test_unlabelled_run_is_cli(self, logs_dir, tmp_path, monkeypatch):
|
|
130
|
+
_install(monkeypatch, tmp_path, self._BODY)
|
|
131
|
+
_, out, _ = _run()
|
|
132
|
+
[row] = _rows(logs_dir)
|
|
133
|
+
assert row["label"] == "cli" and out.strip() == "cli"
|
|
134
|
+
|
|
135
|
+
def test_launcher_label_from_env(self, logs_dir, tmp_path, monkeypatch):
|
|
136
|
+
_install(monkeypatch, tmp_path, self._BODY)
|
|
137
|
+
monkeypatch.setenv("RUNSPEC_LABEL", "console")
|
|
138
|
+
_, out, _ = _run()
|
|
139
|
+
[row] = _rows(logs_dir)
|
|
140
|
+
assert row["label"] == "console" and out.strip() == "console"
|
|
141
|
+
|
|
142
|
+
def test_explicit_label_wins(self, logs_dir, tmp_path, monkeypatch):
|
|
143
|
+
_install(monkeypatch, tmp_path, self._BODY)
|
|
144
|
+
monkeypatch.setenv("RUNSPEC_LABEL", "console")
|
|
145
|
+
_, out, _ = _run(label="tui")
|
|
146
|
+
[row] = _rows(logs_dir)
|
|
147
|
+
assert row["label"] == "tui" and out.strip() == "tui"
|
|
148
|
+
|
|
149
|
+
def test_unlabelled_runs_do_not_skip_each_other(self, logs_dir, tmp_path, monkeypatch):
|
|
150
|
+
# --skip-if-running keys on --label only, so the "cli" default never
|
|
151
|
+
# makes one unlabelled runnable wait on another.
|
|
152
|
+
_install(monkeypatch, tmp_path, "print('ran')\n")
|
|
153
|
+
runsdb.begin_run(
|
|
154
|
+
logs_dir,
|
|
155
|
+
{"run_id": "live1", "runnable": "other", "status": "running", "pid": os.getpid(), "host": runsdb._hostname(), "proc_start": runsdb.proc_start_token(), "label": "cli"},
|
|
156
|
+
)
|
|
157
|
+
_, out, _ = _run(skip_if_running=True)
|
|
158
|
+
assert "ran" in out
|
|
159
|
+
|
|
160
|
+
|
|
122
161
|
class TestNotRunnable:
|
|
123
162
|
def test_not_installed_is_recorded(self, logs_dir, monkeypatch):
|
|
124
163
|
monkeypatch.setattr(runexec, "find_runnable", lambda name: None)
|
|
@@ -191,6 +230,26 @@ class TestHandoff:
|
|
|
191
230
|
assert row["command"] == "sub" # the child's own detail kept
|
|
192
231
|
assert row["events"] == '{"INFO": 2}' # the wrapper's finalize kept the child's events
|
|
193
232
|
|
|
233
|
+
def test_child_label_does_not_replace_the_wrappers(self, logs_dir, tmp_path, monkeypatch):
|
|
234
|
+
# A child that records its own label (an older wrapper never handed one
|
|
235
|
+
# down, so parse() fell back to "cli") must not overwrite --label.
|
|
236
|
+
body = f"""
|
|
237
|
+
import os, sys
|
|
238
|
+
sys.path.insert(0, {str(Path(runsdb.__file__).parent.parent)!r})
|
|
239
|
+
from pathlib import Path
|
|
240
|
+
from runspec import runsdb
|
|
241
|
+
from runspec.logging_setup import adopt_exec_run_id
|
|
242
|
+
rid = adopt_exec_run_id()
|
|
243
|
+
d = Path({str(logs_dir)!r})
|
|
244
|
+
runsdb.begin_run(d, {{"run_id": rid, "runnable": "demo", "status": "running", "label": "cli",
|
|
245
|
+
"pid": os.getpid(), "host": runsdb._hostname()}})
|
|
246
|
+
runsdb.finish_run(d, rid, exit_code=0, duration_ms=1, status="completed")
|
|
247
|
+
"""
|
|
248
|
+
_install(monkeypatch, tmp_path, body)
|
|
249
|
+
_run(label="sched:x")
|
|
250
|
+
[row] = _rows(logs_dir)
|
|
251
|
+
assert row["label"] == "sched:x"
|
|
252
|
+
|
|
194
253
|
def test_older_child_row_is_adopted(self, logs_dir, tmp_path, monkeypatch):
|
|
195
254
|
# What a <= 0.61 child does: ignore RUNSPEC_RUN_ID and mint its own id.
|
|
196
255
|
body = f"""
|
|
@@ -101,6 +101,11 @@ class TestParser:
|
|
|
101
101
|
assert row["label"] == "sched:x" and row["command"] == "sub" and row["args"] == {"a": "b"}
|
|
102
102
|
assert row["exec"]["exit_code"] == 137
|
|
103
103
|
|
|
104
|
+
def test_label_comes_from_the_summary_when_the_start_has_none(self):
|
|
105
|
+
rid = _rid()
|
|
106
|
+
row = inbox.row_from_records(rid, [json.loads(_rec(rid, "run_start", runnable="demo")), json.loads(_rec(rid, "run_summary", exit_code=0, label="console"))])
|
|
107
|
+
assert row is not None and row["label"] == "console"
|
|
108
|
+
|
|
104
109
|
def test_history_db_false_marker(self):
|
|
105
110
|
rid = _rid()
|
|
106
111
|
row = inbox.row_from_records(rid, [json.loads(_rec(rid, "run_start", history_db=False)), json.loads(_rec(rid, "run_summary", exit_code=0))])
|
|
@@ -256,7 +256,7 @@ class TestRunSpecArgAccess:
|
|
|
256
256
|
|
|
257
257
|
def test_get_absent_with_default(self):
|
|
258
258
|
rs = self._make_with_args(region=arg("eu", name="region"))
|
|
259
|
-
assert rs.get("symbol", "
|
|
259
|
+
assert rs.get("symbol", "ABC123").value == "ABC123"
|
|
260
260
|
|
|
261
261
|
def test_get_never_raises_supports_safe_value_access(self):
|
|
262
262
|
# The boilerplate-killer: no up-front guard needed.
|
|
@@ -747,11 +747,11 @@ class TestSubcommandGlobals:
|
|
|
747
747
|
self._write(tmp_path, monkeypatch)
|
|
748
748
|
result = runspec.parse(
|
|
749
749
|
script_name="sample-tool",
|
|
750
|
-
argv=["-r", "europe", "-e", "qa", "show", "--symbol", "
|
|
750
|
+
argv=["-r", "europe", "-e", "qa", "show", "--symbol", "ABC123"],
|
|
751
751
|
)
|
|
752
752
|
assert result.region.value == "europe"
|
|
753
753
|
assert result.env.value == "qa"
|
|
754
|
-
assert result.symbol.value == "
|
|
754
|
+
assert result.symbol.value == "ABC123"
|
|
755
755
|
assert result.runspec_command_path == ["show"]
|
|
756
756
|
assert result.runspec_command == "show"
|
|
757
757
|
|
|
@@ -159,6 +159,16 @@ class TestEmit:
|
|
|
159
159
|
s = next(obj for obj in lines if obj["logger"] == "runspec.runsummary")
|
|
160
160
|
assert s["extra"]["skill"] == ""
|
|
161
161
|
|
|
162
|
+
def test_start_and_summary_record_the_label(self, tmp_path):
|
|
163
|
+
# `label` is where the run was launched from (RUNSPEC_LABEL, "cli" when
|
|
164
|
+
# unset — parse() fills that default); the run records carry it.
|
|
165
|
+
configure_logging(_cfg(), runnable_name="myscript", label="console")
|
|
166
|
+
_emit_run_summary()
|
|
167
|
+
lines = [json.loads(line) for line in (tmp_path / "logs" / "myscript.log").read_text().strip().splitlines()]
|
|
168
|
+
recs = [obj["extra"] for obj in lines if obj["logger"] == "runspec.runsummary"]
|
|
169
|
+
assert "run_summary" in {r["event"] for r in recs}
|
|
170
|
+
assert all(r["label"] == "console" for r in recs)
|
|
171
|
+
|
|
162
172
|
def test_summary_console_writes_line_to_stderr(self, capsys):
|
|
163
173
|
configure_logging(_cfg(summary_console=True), runnable_name="myscript")
|
|
164
174
|
logging.getLogger("test.stderr").info("ran")
|
|
@@ -362,6 +372,35 @@ class TestEarlyExcepthookCoverage:
|
|
|
362
372
|
assert seen["hook"] is not sys.__excepthook__
|
|
363
373
|
|
|
364
374
|
|
|
375
|
+
class TestParseProvenance:
|
|
376
|
+
"""parse() reads the launcher's provenance env vars and hands them to
|
|
377
|
+
configure_logging."""
|
|
378
|
+
|
|
379
|
+
def _kwargs(self, tmp_path, monkeypatch):
|
|
380
|
+
import runspec.parser as parser
|
|
381
|
+
|
|
382
|
+
cfg = tmp_path / "runspec.toml"
|
|
383
|
+
cfg.write_text("[demo]\n")
|
|
384
|
+
seen: dict[str, object] = {}
|
|
385
|
+
monkeypatch.setattr(parser, "configure_logging", lambda *a, **kw: seen.update(kw))
|
|
386
|
+
parser.parse(script_name="demo", argv=[], config_path=cfg)
|
|
387
|
+
return seen
|
|
388
|
+
|
|
389
|
+
def test_unlabelled_run_is_cli(self, tmp_path, monkeypatch):
|
|
390
|
+
monkeypatch.delenv("RUNSPEC_LABEL", raising=False)
|
|
391
|
+
assert self._kwargs(tmp_path, monkeypatch)["label"] == "cli"
|
|
392
|
+
|
|
393
|
+
def test_launcher_label(self, tmp_path, monkeypatch):
|
|
394
|
+
monkeypatch.setenv("RUNSPEC_LABEL", " console ")
|
|
395
|
+
assert self._kwargs(tmp_path, monkeypatch)["label"] == "console"
|
|
396
|
+
|
|
397
|
+
def test_runbook_env_is_the_skill(self, tmp_path, monkeypatch):
|
|
398
|
+
# The console sends RUNSPEC_RUNBOOK; RUNSPEC_SKILL is the older spelling.
|
|
399
|
+
monkeypatch.delenv("RUNSPEC_SKILL", raising=False)
|
|
400
|
+
monkeypatch.setenv("RUNSPEC_RUNBOOK", "restart-web")
|
|
401
|
+
assert self._kwargs(tmp_path, monkeypatch)["skill"] == "restart-web"
|
|
402
|
+
|
|
403
|
+
|
|
365
404
|
class TestExcStructuredHelpers:
|
|
366
405
|
def test_build_exc_structured_shape(self):
|
|
367
406
|
try:
|
|
@@ -517,6 +556,13 @@ class TestRunsIndexHook:
|
|
|
517
556
|
assert done["status"] == "completed" and done["exit_code"] == 0
|
|
518
557
|
assert isinstance(done["duration_ms"], int)
|
|
519
558
|
|
|
559
|
+
def test_row_carries_the_label(self, tmp_path):
|
|
560
|
+
from runspec import runsdb
|
|
561
|
+
|
|
562
|
+
configure_logging(_cfg(summary=True), runnable_name="demo", history_cfg={"db": True}, label="console")
|
|
563
|
+
_emit_run_summary()
|
|
564
|
+
assert runsdb.list_runs(tmp_path / "logs", live=False)[0]["label"] == "console"
|
|
565
|
+
|
|
520
566
|
def test_failure_finalizes_as_failed(self, tmp_path):
|
|
521
567
|
from runspec import runsdb
|
|
522
568
|
|
|
@@ -184,10 +184,13 @@ def test_script_no_args_just_execs():
|
|
|
184
184
|
def test_script_run_as_sends_env_frame_on_stdin_to_venv_python():
|
|
185
185
|
s = rundeckgen.render_script("deploy", ["start"], {"token": {"type": "password"}}, "/opt/app/.venv/bin/deploy", run_as="svc", venv="/opt/app/.venv")
|
|
186
186
|
assert "rs_user=svc\n" in s
|
|
187
|
-
assert
|
|
188
|
-
|
|
189
|
-
#
|
|
190
|
-
|
|
187
|
+
assert "\nRUNSPEC_LABEL=rundeck\n" in s
|
|
188
|
+
assert 'rs_frame RUNSPEC_DEPLOY_ARG_TOKEN RUNSPEC_LABEL | sudo -n -u "$rs_user" /opt/app/.venv/bin/python -c "$RS_PRELUDE" /opt/app/.venv/bin/deploy start' in s
|
|
189
|
+
# sudo resets the environment, so values (and the label) ride stdin; the only
|
|
190
|
+
# exports are in the no-sudo branch taken when the Rundeck user already is
|
|
191
|
+
# the account.
|
|
192
|
+
assert s.count("export") == 2
|
|
193
|
+
assert " export RUNSPEC_LABEL=rundeck\n" in s
|
|
191
194
|
assert 'if [ "$rs_user" = "$(id -un)" ]; then' in s
|
|
192
195
|
|
|
193
196
|
|
|
@@ -197,9 +200,19 @@ def test_script_run_as_without_venv_uses_shell_prelude():
|
|
|
197
200
|
assert "command -v" not in s # sudo's PATH isn't the Rundeck user's
|
|
198
201
|
|
|
199
202
|
|
|
200
|
-
def
|
|
203
|
+
def test_script_run_as_no_args_sends_only_the_label():
|
|
204
|
+
"""With the venv known the venv-python prelude is covered by a venv `bin/`
|
|
205
|
+
sudoers rule, so even a job with no options sends its label in the frame."""
|
|
201
206
|
s = rundeckgen.render_script("deploy", [], {}, "/opt/v/bin/deploy", run_as="svc", venv="/opt/v")
|
|
202
|
-
assert s.strip().endswith('
|
|
207
|
+
assert s.strip().endswith('rs_frame RUNSPEC_LABEL | sudo -n -u "$rs_user" /opt/v/bin/python -c "$RS_PRELUDE" /opt/v/bin/deploy')
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_script_run_as_no_args_no_venv_execs_sudo():
|
|
211
|
+
"""Without the venv the prelude would be `sh`, which a sudoers rule may
|
|
212
|
+
refuse, so a job with no options runs the binary directly (label "cli")."""
|
|
213
|
+
s = rundeckgen.render_script("deploy", [], {}, "deploy", run_as="svc")
|
|
214
|
+
assert s.strip().endswith('exec sudo -n -u "$rs_user" deploy')
|
|
215
|
+
assert "RS_PRELUDE" not in s
|
|
203
216
|
|
|
204
217
|
|
|
205
218
|
def test_script_run_as_rejects_shell_metacharacters():
|
|
@@ -227,7 +240,7 @@ def _show_binary(tmp_path):
|
|
|
227
240
|
|
|
228
241
|
show = tmp_path / "bin" / "show"
|
|
229
242
|
show.parent.mkdir(exist_ok=True)
|
|
230
|
-
show.write_text(f"#!{sys.executable}\nimport json, os, sys\nprint(json.dumps([{{k: v for k, v in os.environ.items() if k.startswith('RUNSPEC_SHOW_')}}, sys.argv[1:]]))\n")
|
|
243
|
+
show.write_text(f"#!{sys.executable}\nimport json, os, sys\nprint(json.dumps([{{k: v for k, v in os.environ.items() if k.startswith('RUNSPEC_SHOW_') or k == 'RUNSPEC_LABEL'}}, sys.argv[1:]]))\n")
|
|
231
244
|
show.chmod(0o755)
|
|
232
245
|
return show
|
|
233
246
|
|
|
@@ -257,7 +270,7 @@ def test_script_values_round_trip_through_bash(tmp_path):
|
|
|
257
270
|
out = _run_substituted(tmp_path, script, values)
|
|
258
271
|
assert out.returncode == 0, out.stderr
|
|
259
272
|
env, argv = json.loads(out.stdout)
|
|
260
|
-
assert env == {"RUNSPEC_SHOW_ARG_PLAIN": "3", "RUNSPEC_SHOW_ARG_TRICKY": values["tricky"], "RUNSPEC_SHOW_ARG_DELIM": "RUNSPEC_VALUE"}
|
|
273
|
+
assert env == {"RUNSPEC_SHOW_ARG_PLAIN": "3", "RUNSPEC_SHOW_ARG_TRICKY": values["tricky"], "RUNSPEC_SHOW_ARG_DELIM": "RUNSPEC_VALUE", "RUNSPEC_LABEL": "rundeck"}
|
|
261
274
|
assert argv == ["sub"]
|
|
262
275
|
assert not (tmp_path / "pwned").exists()
|
|
263
276
|
|
|
@@ -324,7 +337,7 @@ def test_script_guard_tolerates_blank_lines(tmp_path):
|
|
|
324
337
|
script = rundeckgen.render_script("show", [], {"a": {"type": "str"}}, str(show)) + "\n\n"
|
|
325
338
|
out = _run_substituted(tmp_path, script, {"a": "\n"})
|
|
326
339
|
assert out.returncode == 0, out.stderr
|
|
327
|
-
assert json.loads(out.stdout)[0] == {}
|
|
340
|
+
assert json.loads(out.stdout)[0] == {"RUNSPEC_LABEL": "rundeck"}
|
|
328
341
|
|
|
329
342
|
|
|
330
343
|
def test_script_guard_count_matches_rendered_script():
|
|
@@ -380,7 +393,7 @@ def test_script_owner_runs_directly_when_already_the_owner(tmp_path):
|
|
|
380
393
|
env = {**os.environ, "PATH": f"{tmp_path}/bin:/usr/bin:/bin"}
|
|
381
394
|
out = _run_substituted(tmp_path, script, {"a": "v"}, env=env)
|
|
382
395
|
assert out.returncode == 0, out.stderr
|
|
383
|
-
assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v"}, ["sub"]]
|
|
396
|
+
assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v", "RUNSPEC_LABEL": "rundeck"}, ["sub"]]
|
|
384
397
|
|
|
385
398
|
|
|
386
399
|
def test_script_owner_escalates_to_the_venv_owner(tmp_path):
|
|
@@ -404,7 +417,7 @@ def test_script_owner_escalates_to_the_venv_owner(tmp_path):
|
|
|
404
417
|
script = rundeckgen.render_script("show", ["sub"], {"a": {"type": "str"}, "b": {"type": "password"}}, invocation, run_as="@owner", venv=venv)
|
|
405
418
|
out = _run_substituted(tmp_path, script, {"a": "v", "b": "s3cret"}, env=env)
|
|
406
419
|
assert out.returncode == 0, out.stderr
|
|
407
|
-
assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v", "RUNSPEC_SHOW_ARG_B": "s3cret"}, ["sub"]]
|
|
420
|
+
assert json.loads(out.stdout) == [{"RUNSPEC_SHOW_ARG_A": "v", "RUNSPEC_SHOW_ARG_B": "s3cret", "RUNSPEC_LABEL": "rundeck"}, ["sub"]]
|
|
408
421
|
sudo_argv = (tmp_path / "sudo.log").read_text()
|
|
409
422
|
assert sudo_argv.startswith("-n -u svc-owner ")
|
|
410
423
|
assert "s3cret" not in sudo_argv and str(show) in sudo_argv
|
|
@@ -210,6 +210,7 @@ class TestReindex:
|
|
|
210
210
|
"trigger": "schedule",
|
|
211
211
|
"skill": "",
|
|
212
212
|
"autonomy": "autonomous",
|
|
213
|
+
"label": "rundeck",
|
|
213
214
|
"exception": None,
|
|
214
215
|
"user": "svc",
|
|
215
216
|
"invocation_args": {},
|
|
@@ -230,7 +231,8 @@ class TestReindex:
|
|
|
230
231
|
assert n == 2
|
|
231
232
|
rows = {r["run_id"]: r for r in runsdb.list_runs(logs_dir, live=False)}
|
|
232
233
|
assert rows["h1"]["status"] == "completed" and rows["h1"]["agent"] == 1
|
|
233
|
-
assert rows["h1"]["trigger"] == "schedule"
|
|
234
|
+
assert rows["h1"]["trigger"] == "schedule" and rows["h1"]["label"] == "rundeck"
|
|
235
|
+
assert rows["h2"]["label"] is None # a summary from before labels
|
|
234
236
|
assert rows["h2"]["status"] == "failed" and rows["h2"]["command"] == "push"
|
|
235
237
|
|
|
236
238
|
def test_reindex_skips_records_without_run_id(self, logs_dir):
|
|
@@ -316,6 +316,7 @@ def test_tools_call_no_run_as_does_not_escalate(monkeypatch, tmp_path):
|
|
|
316
316
|
# The env rides the child's environment; the child never inherits serve's
|
|
317
317
|
# stdin (the MCP JSON-RPC stream).
|
|
318
318
|
assert captured["kw"]["env"]["RUNSPEC_AGENT"] == "1"
|
|
319
|
+
assert captured["kw"]["env"]["RUNSPEC_LABEL"] == "mcp"
|
|
319
320
|
assert captured["kw"]["stdin"] is subprocess.DEVNULL
|
|
320
321
|
|
|
321
322
|
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
Tests for runspec.testing.ParseHarness — and a worked example of how a
|
|
3
3
|
developer scaffolds tests for a real, subcommand-heavy runnable.
|
|
4
4
|
|
|
5
|
-
The `
|
|
6
|
-
|
|
5
|
+
The `catalog` fixture (tests/fixtures/catalog.toml) is a large catalogue
|
|
6
|
+
runnable: ~11 subcommands, parent-level args
|
|
7
7
|
(region/env/source/jira), patterns, multiple values, and require-command.
|
|
8
8
|
It stands in for any large migrated runnable so the patterns below transfer
|
|
9
9
|
directly.
|
|
10
10
|
|
|
11
11
|
Two layers are demonstrated:
|
|
12
12
|
|
|
13
|
-
Layer 1 — wiring/parsing, no mocks (
|
|
13
|
+
Layer 1 — wiring/parsing, no mocks (TestCatalogWiring, TestSmoke).
|
|
14
14
|
Layer 2 — the actual work, mocking only the I/O boundary (TestLayerTwoPattern).
|
|
15
15
|
"""
|
|
16
16
|
|
|
@@ -28,50 +28,50 @@ from runspec.testing import ParseHarness, harness, no_run_as_check
|
|
|
28
28
|
posix_only = pytest.mark.skipif(not hasattr(os, "geteuid"), reason="POSIX uid model required")
|
|
29
29
|
|
|
30
30
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
31
|
-
|
|
31
|
+
CATALOG = FIXTURES / "catalog.toml"
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
@pytest.fixture()
|
|
35
35
|
def h() -> ParseHarness:
|
|
36
|
-
return ParseHarness(script_name="
|
|
36
|
+
return ParseHarness(script_name="catalog", config_path=CATALOG)
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
# ── Layer 1: wiring / parsing (no mocks) ──────────────────────────────────────
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
class
|
|
42
|
+
class TestCatalogWiring:
|
|
43
43
|
def test_subcommand_is_detected(self, h: ParseHarness) -> None:
|
|
44
|
-
spec = h.expect_ok(["create", "-s", "
|
|
44
|
+
spec = h.expect_ok(["create", "-s", "WIDGET1,GADGET2", "--region", "americas", "--env", "prod"])
|
|
45
45
|
assert spec.runspec_command == "create"
|
|
46
46
|
assert spec.runspec_command_path == ["create"]
|
|
47
47
|
|
|
48
48
|
def test_types_are_coerced(self, h: ParseHarness) -> None:
|
|
49
|
-
spec = h.expect_ok(["create", "-s", "
|
|
50
|
-
assert spec.
|
|
49
|
+
spec = h.expect_ok(["create", "-s", "WIDGET1", "--price", "12.5", "--region", "americas", "--env", "prod"])
|
|
50
|
+
assert spec.skus.value == ["WIDGET1"] # multiple + delimiter -> list
|
|
51
51
|
assert spec.price.value == 12.5 # float
|
|
52
52
|
assert isinstance(spec.price.value, float)
|
|
53
53
|
|
|
54
54
|
def test_flag_defaults_false(self, h: ParseHarness) -> None:
|
|
55
|
-
spec = h.expect_ok(["create", "-s", "
|
|
55
|
+
spec = h.expect_ok(["create", "-s", "WIDGET1", "--region", "americas", "--env", "prod"])
|
|
56
56
|
assert spec.persist.value is False
|
|
57
57
|
|
|
58
|
-
def
|
|
59
|
-
# `
|
|
58
|
+
def test_missing_required_skus_exits(self, h: ParseHarness) -> None:
|
|
59
|
+
# `skus` is required on `create`; omit it.
|
|
60
60
|
out = h.expect_exit(["create", "--region", "americas", "--env", "prod"])
|
|
61
|
-
assert "
|
|
61
|
+
assert "sku" in out.lower() or "-s" in out
|
|
62
62
|
|
|
63
63
|
def test_bad_choice_names_the_flag(self, h: ParseHarness) -> None:
|
|
64
64
|
h.expect_exit(
|
|
65
|
-
["create", "-s", "
|
|
65
|
+
["create", "-s", "WIDGET1", "--region", "mars", "--env", "prod"],
|
|
66
66
|
contains="--region",
|
|
67
67
|
)
|
|
68
68
|
|
|
69
69
|
def test_pattern_violation_is_rejected(self, h: ParseHarness) -> None:
|
|
70
|
-
#
|
|
71
|
-
h.expect_exit(["show", "-s", "not a
|
|
70
|
+
# skus pattern forbids whitespace / lowercase.
|
|
71
|
+
h.expect_exit(["show", "-s", "not a sku", "--region", "americas", "--env", "prod"])
|
|
72
72
|
|
|
73
73
|
def test_require_command_enforced(self, h: ParseHarness) -> None:
|
|
74
|
-
#
|
|
74
|
+
# catalog sets require-command = true.
|
|
75
75
|
h.expect_exit([])
|
|
76
76
|
|
|
77
77
|
def test_help_exits_zero(self, h: ParseHarness) -> None:
|
|
@@ -92,12 +92,12 @@ class TestSmoke:
|
|
|
92
92
|
values={
|
|
93
93
|
"region": "americas",
|
|
94
94
|
"env": "prod",
|
|
95
|
-
"
|
|
95
|
+
"skus": "WIDGET1",
|
|
96
96
|
"price": "12.5",
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
97
|
+
"avg_sales": "1000",
|
|
98
|
+
"avg_orders": "50",
|
|
99
|
+
"launch_date": "20240101",
|
|
100
|
+
"quantity": "100",
|
|
101
101
|
"field": "SomeField",
|
|
102
102
|
"value": "x",
|
|
103
103
|
}
|
|
@@ -111,7 +111,7 @@ class TestSmoke:
|
|
|
111
111
|
def test_command_discovery(self, h: ParseHarness) -> None:
|
|
112
112
|
names = {p[-1] for p in h.command_paths()}
|
|
113
113
|
assert {"show", "create", "clone", "refresh"} <= names
|
|
114
|
-
assert h.required_args(["create"]) == ["
|
|
114
|
+
assert h.required_args(["create"]) == ["skus"]
|
|
115
115
|
assert h.requires_command() is True
|
|
116
116
|
|
|
117
117
|
|
|
@@ -301,22 +301,22 @@ class TestLayerTwoPattern:
|
|
|
301
301
|
# Stand-in for your real module: a pure logic function + the one I/O call.
|
|
302
302
|
calls: list[tuple] = []
|
|
303
303
|
|
|
304
|
-
def fake_store_call(region: str, action: str,
|
|
305
|
-
calls.append((region, action, tuple(
|
|
306
|
-
return {"ok": True, "applied": list(
|
|
304
|
+
def fake_store_call(region: str, action: str, skus: list[str], **fields):
|
|
305
|
+
calls.append((region, action, tuple(skus), fields))
|
|
306
|
+
return {"ok": True, "applied": list(skus)}
|
|
307
307
|
|
|
308
|
-
def
|
|
308
|
+
def create_items(region, skus, *, price=None, _call=fake_store_call):
|
|
309
309
|
# the kind of plain function your main() should delegate to
|
|
310
|
-
return _call(region, "create",
|
|
310
|
+
return _call(region, "create", skus, price=price)
|
|
311
311
|
|
|
312
312
|
# The harness gives you the parsed RunSpec to feed the logic function.
|
|
313
|
-
h = ParseHarness(script_name="
|
|
314
|
-
spec = h.expect_ok(["create", "-s", "
|
|
313
|
+
h = ParseHarness(script_name="catalog", config_path=CATALOG)
|
|
314
|
+
spec = h.expect_ok(["create", "-s", "WIDGET1,GADGET2", "--price", "12.5", "--region", "americas", "--env", "prod"])
|
|
315
315
|
|
|
316
|
-
result =
|
|
316
|
+
result = create_items(spec.region.value, spec.skus.value, price=spec.price.value)
|
|
317
317
|
|
|
318
|
-
assert result == {"ok": True, "applied": ["
|
|
319
|
-
assert calls == [("americas", "create", ("
|
|
318
|
+
assert result == {"ok": True, "applied": ["WIDGET1", "GADGET2"]}
|
|
319
|
+
assert calls == [("americas", "create", ("WIDGET1", "GADGET2"), {"price": 12.5})]
|
|
320
320
|
|
|
321
321
|
|
|
322
322
|
# ── Harness behaviour itself ──────────────────────────────────────────────────
|
|
@@ -352,7 +352,7 @@ class TestHarnessApi:
|
|
|
352
352
|
with pytest.raises(ValueError):
|
|
353
353
|
ParseHarness(script_name="greet")
|
|
354
354
|
with pytest.raises(ValueError):
|
|
355
|
-
ParseHarness(script_name="greet", toml=GREET, config_path=
|
|
355
|
+
ParseHarness(script_name="greet", toml=GREET, config_path=CATALOG)
|
|
356
356
|
|
|
357
357
|
def test_unknown_runnable_raises(self) -> None:
|
|
358
358
|
with pytest.raises(KeyError):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|