Add reusable dry-run execution controls

This commit is contained in:
ajp_anton
2026-08-16 01:51:21 +00:00
parent 58b4f69d63
commit 531992fe47
3 changed files with 111 additions and 2 deletions
+65 -2
View File
@@ -29,6 +29,7 @@ class Task:
inputs: tuple["InputField", ...]
wait_for_result: bool = False
download_artifacts: bool = False
execution: "ExecutionMode | None" = None
@dataclass(frozen=True)
@@ -64,6 +65,15 @@ class InputField:
sensitive: bool = False
@dataclass(frozen=True)
class ExecutionMode:
"""A task-declared report/apply mode exposed as a generated choice field."""
field: InputField
dry_run_value: str
execute_value: str
@dataclass(frozen=True)
class Group:
id: str
@@ -162,9 +172,14 @@ class TaskCatalog:
path=path,
display_name=display_name(path.stem),
description=read_description(path),
inputs=declaration.inputs if declaration else (),
inputs=(
(*declaration.inputs, declaration.execution.field)
if declaration and declaration.execution
else declaration.inputs if declaration else ()
),
wait_for_result=declaration.wait_for_result if declaration else False,
download_artifacts=declaration.download_artifacts if declaration else False,
execution=declaration.execution if declaration else None,
)
)
return tasks
@@ -287,6 +302,7 @@ class TaskDeclaration:
inputs: tuple[InputField, ...]
wait_for_result: bool
download_artifacts: bool
execution: ExecutionMode | None
def parse_task_declaration(
@@ -296,7 +312,7 @@ def parse_task_declaration(
) -> TaskDeclaration:
if (
not isinstance(declaration, dict)
or set(declaration) - {"inputs", "wait_for_result", "download_artifacts"}
or set(declaration) - {"inputs", "wait_for_result", "download_artifacts", "execution"}
):
raise ValueError(f"{manifest_path} task {filename} has an invalid declaration.")
raw_inputs = declaration.get("inputs", [])
@@ -306,6 +322,7 @@ def parse_task_declaration(
names = [field.name for field in fields]
if len(names) != len(set(names)):
raise ValueError(f"{manifest_path} task {filename} has duplicate input names.")
execution = parse_execution_mode(manifest_path, filename, declaration.get("execution"), set(names))
wait_for_result = declaration.get("wait_for_result", False)
if not isinstance(wait_for_result, bool):
raise ValueError(f"{manifest_path} task {filename} wait_for_result must be true or false.")
@@ -318,6 +335,52 @@ def parse_task_declaration(
inputs=fields,
wait_for_result=wait_for_result,
download_artifacts=download_artifacts,
execution=execution,
)
def parse_execution_mode(
manifest_path: Path,
filename: str,
raw: Any,
input_names: set[str],
) -> ExecutionMode | None:
if raw is None:
return None
required_keys = {
"field", "label", "dry_run_value", "dry_run_label", "execute_value", "execute_label",
}
if not isinstance(raw, dict) or set(raw) != required_keys:
raise ValueError(f"{manifest_path} task {filename} has an invalid execution declaration.")
values = {key: raw[key] for key in required_keys}
if not all(isinstance(value, str) and value.strip() for value in values.values()):
raise ValueError(f"{manifest_path} task {filename} execution values must be non-empty text.")
field_name = values["field"]
if not FIELD_NAME.fullmatch(field_name) or field_name in input_names:
raise ValueError(f"{manifest_path} task {filename} has an invalid execution field.")
if values["dry_run_value"] == values["execute_value"]:
raise ValueError(f"{manifest_path} task {filename} execution values must differ.")
field = InputField(
name=field_name,
label=values["label"],
type="choice",
required=True,
default=values["dry_run_value"],
minimum=None,
maximum=None,
step=None,
pattern=None,
options=(
InputOption(values["dry_run_value"], values["dry_run_label"]),
InputOption(values["execute_value"], values["execute_label"]),
),
accept=(),
maximum_bytes=None,
)
return ExecutionMode(
field=field,
dry_run_value=values["dry_run_value"],
execute_value=values["execute_value"],
)
+41
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import ipaddress
import json
from datetime import datetime
from pathlib import Path
@@ -135,13 +136,53 @@ def create_app(settings: Settings | None = None) -> Flask:
run = store.get(run_id)
if run is None:
abort(404)
execution_task = execution_replay_task(run)
return render_template(
"run.html",
run=run,
children=store.children(run.id),
output=read_run_output(run),
execution_task=execution_task,
)
def execution_replay_task(run):
if run.status != "succeeded" or run.target_kind != "task":
return None
task = catalog.task(run.target_path)
if task is None or task.execution is None:
return None
if any(field.type == "file" or field.sensitive for field in task.inputs):
return None
try:
input_data = json.loads(run.input_json)
except json.JSONDecodeError:
return None
if not isinstance(input_data, dict):
return None
if input_data.get(task.execution.field.name) != task.execution.dry_run_value:
return None
return task
@app.post("/runs/<int:run_id>/execute")
def execute_dry_run(run_id: int):
run = store.get(run_id)
if run is None:
abort(404)
task = execution_replay_task(run)
if task is None:
abort(404)
input_data = json.loads(run.input_json)
input_data[task.execution.field.name] = task.execution.execute_value
raw_values = {
name: [str(value) for value in value] if isinstance(value, list) else [str(value)]
for name, value in input_data.items()
}
validated, errors = validate_inputs(task.inputs, raw_values)
if errors:
abort(409)
executed, _ = manager.run_task(task, validated)
return redirect(url_for("run_detail", run_id=executed.id))
@app.post("/runs/<int:run_id>/cancel")
def cancel_run(run_id: int):
run = manager.request_cancel(run_id)
+5
View File
@@ -16,6 +16,11 @@
<button type="submit">Cancel</button>
</form>
{% endif %}
{% if execution_task %}
<form method="post" action="{{ url_for('execute_dry_run', run_id=run.id) }}">
<button type="submit">{{ execution_task.execution.field.options[1].label }}</button>
</form>
{% endif %}
{% if children %}
<h2>Group tasks</h2>
<ul>