Add reusable dry-run execution controls
This commit is contained in:
@@ -29,6 +29,7 @@ class Task:
|
|||||||
inputs: tuple["InputField", ...]
|
inputs: tuple["InputField", ...]
|
||||||
wait_for_result: bool = False
|
wait_for_result: bool = False
|
||||||
download_artifacts: bool = False
|
download_artifacts: bool = False
|
||||||
|
execution: "ExecutionMode | None" = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -64,6 +65,15 @@ class InputField:
|
|||||||
sensitive: bool = False
|
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)
|
@dataclass(frozen=True)
|
||||||
class Group:
|
class Group:
|
||||||
id: str
|
id: str
|
||||||
@@ -162,9 +172,14 @@ class TaskCatalog:
|
|||||||
path=path,
|
path=path,
|
||||||
display_name=display_name(path.stem),
|
display_name=display_name(path.stem),
|
||||||
description=read_description(path),
|
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,
|
wait_for_result=declaration.wait_for_result if declaration else False,
|
||||||
download_artifacts=declaration.download_artifacts if declaration else False,
|
download_artifacts=declaration.download_artifacts if declaration else False,
|
||||||
|
execution=declaration.execution if declaration else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tasks
|
return tasks
|
||||||
@@ -287,6 +302,7 @@ class TaskDeclaration:
|
|||||||
inputs: tuple[InputField, ...]
|
inputs: tuple[InputField, ...]
|
||||||
wait_for_result: bool
|
wait_for_result: bool
|
||||||
download_artifacts: bool
|
download_artifacts: bool
|
||||||
|
execution: ExecutionMode | None
|
||||||
|
|
||||||
|
|
||||||
def parse_task_declaration(
|
def parse_task_declaration(
|
||||||
@@ -296,7 +312,7 @@ def parse_task_declaration(
|
|||||||
) -> TaskDeclaration:
|
) -> TaskDeclaration:
|
||||||
if (
|
if (
|
||||||
not isinstance(declaration, dict)
|
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.")
|
raise ValueError(f"{manifest_path} task {filename} has an invalid declaration.")
|
||||||
raw_inputs = declaration.get("inputs", [])
|
raw_inputs = declaration.get("inputs", [])
|
||||||
@@ -306,6 +322,7 @@ def parse_task_declaration(
|
|||||||
names = [field.name for field in fields]
|
names = [field.name for field in fields]
|
||||||
if len(names) != len(set(names)):
|
if len(names) != len(set(names)):
|
||||||
raise ValueError(f"{manifest_path} task {filename} has duplicate input 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)
|
wait_for_result = declaration.get("wait_for_result", False)
|
||||||
if not isinstance(wait_for_result, bool):
|
if not isinstance(wait_for_result, bool):
|
||||||
raise ValueError(f"{manifest_path} task {filename} wait_for_result must be true or false.")
|
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,
|
inputs=fields,
|
||||||
wait_for_result=wait_for_result,
|
wait_for_result=wait_for_result,
|
||||||
download_artifacts=download_artifacts,
|
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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -135,13 +136,53 @@ def create_app(settings: Settings | None = None) -> Flask:
|
|||||||
run = store.get(run_id)
|
run = store.get(run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
execution_task = execution_replay_task(run)
|
||||||
return render_template(
|
return render_template(
|
||||||
"run.html",
|
"run.html",
|
||||||
run=run,
|
run=run,
|
||||||
children=store.children(run.id),
|
children=store.children(run.id),
|
||||||
output=read_run_output(run),
|
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")
|
@app.post("/runs/<int:run_id>/cancel")
|
||||||
def cancel_run(run_id: int):
|
def cancel_run(run_id: int):
|
||||||
run = manager.request_cancel(run_id)
|
run = manager.request_cancel(run_id)
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
<button type="submit">Cancel</button>
|
<button type="submit">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if children %}
|
||||||
<h2>Group tasks</h2>
|
<h2>Group tasks</h2>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user