Skip to content

Commit 17f013e

Browse files
author
Michael Buchar
committed
feat(cli): add delete workflow restarts functionality (#759)
Closes reanahub/reana-ui#448
1 parent 03d2270 commit 17f013e

File tree

4 files changed

+353
-8
lines changed

4 files changed

+353
-8
lines changed

reana_client/cli/utils.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import shlex
1414
import sys
1515
import time
16-
from typing import Callable, NoReturn, Optional, List, Tuple, Union
16+
import re
17+
from typing import Callable, NoReturn, Optional, List, Tuple, Union, Iterable
1718

1819
import click
1920
import tablib
@@ -27,6 +28,7 @@
2728
JSON,
2829
CLI_LOGS_FOLLOW_MIN_INTERVAL,
2930
CLI_LOGS_FOLLOW_DEFAULT_INTERVAL,
31+
MAX_RUN_LABELS_SHOWN,
3032
)
3133
from reana_client.printer import display_message
3234
from reana_client.utils import workflow_uuid_or_name
@@ -523,3 +525,115 @@ def follow_workflow_logs(
523525
return
524526
previous_logs = logs
525527
time.sleep(interval)
528+
529+
530+
# Helpers for deleting restarted workflows
531+
def parse_workflow_run_number(
532+
full_name: str,
533+
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
534+
"""
535+
Parse a workflow run name into base name, run_number_major and run_number_minor.
536+
537+
Workflow runs can be suffixed with dot-separated numeric components, e.g.:
538+
"<base>.<major>[.<minor>...]". The first numeric component is interpreted as
539+
`run_number_major`. Any remaining numeric components are joined with dots and
540+
returned as `run_number_minor`.
541+
542+
Args:
543+
full_name: Full workflow run name (e.g. "myflow.7.1").
544+
545+
Returns:
546+
A tuple (base, run_number_major, run_number_minor) where:
547+
- base is the non-numeric prefix, or None
548+
- run_number_major is the first numeric suffix, or None
549+
- run_number_minor is the remaining numeric suffixes joined by '.', or None
550+
551+
Examples:
552+
"name.7.1" -> ("name", "7", "1")
553+
"name.7" -> ("name", "7", None)
554+
"name.7.1.2" -> ("name", "7", "1.2")
555+
"name" -> ("name", None, None)
556+
"""
557+
if not full_name:
558+
return None, None, None
559+
560+
parts = str(full_name).split(".")
561+
i = len(parts) - 1
562+
while i >= 0 and re.fullmatch(r"\d+", parts[i]):
563+
i -= 1
564+
565+
base = ".".join(parts[: i + 1]) if i >= 0 else ""
566+
numeric = parts[i + 1 :]
567+
568+
if not base:
569+
base = None
570+
571+
if not numeric:
572+
return base, None, None
573+
574+
run_number_major = numeric[0]
575+
run_number_minor = ".".join(numeric[1:]) or None
576+
return base, run_number_major, run_number_minor
577+
578+
579+
def get_run_number_major_key(full_name: str) -> Optional[str]:
580+
"""
581+
Return a stable key for grouping restarted runs by run_number_major.
582+
583+
This returns "<base>.<run_number_major>" so that all restarts/minor runs
584+
belonging to the same major run can be treated as a single group.
585+
586+
Args:
587+
full_name: Full workflow run name (e.g. "myflow.7.1").
588+
589+
Returns:
590+
A grouping key "<base>.<run_number_major>", or None if not applicable.
591+
592+
Examples:
593+
"helloworld-demo.1" -> "helloworld-demo.1"
594+
"helloworld-demo.1.1" -> "helloworld-demo.1"
595+
"helloworld-demo" -> None
596+
"""
597+
base, major, _ = parse_workflow_run_number(full_name)
598+
if not base or not major:
599+
return None
600+
return f"{base}.{major}"
601+
602+
603+
def format_run_number_label(full_name: str) -> str:
604+
"""
605+
Format a user-facing label from a workflow run name.
606+
607+
If the run name contains numeric suffixes, it is rendered as "#<major>[.<minor>]".
608+
Otherwise, the original name is returned.
609+
610+
Examples:
611+
"name.7.1" -> "#7.1"
612+
"name.7" -> "#7"
613+
"name" -> "name"
614+
"""
615+
_, major, minor = parse_workflow_run_number(full_name)
616+
if not major:
617+
return str(full_name)
618+
return f"#{major}.{minor}" if minor else f"#{major}"
619+
620+
621+
def format_run_label_list(
622+
labels: Optional[Iterable[str]], max_labels: int = MAX_RUN_LABELS_SHOWN
623+
) -> str:
624+
"""
625+
Format a list of run labels for compact CLI display.
626+
627+
Falsy items are ignored. If more than `max_labels` entries are present, the
628+
output is truncated and suffixed with a "+N more" indicator.
629+
630+
Examples:
631+
["#7.1", "#7.2"] with max_labels=6
632+
-> "#7.1, #7.2"
633+
["#1", "#2", "#3", "#4", "#5", "#6", "#7"] with max_labels=6
634+
-> "#1, #2, #3, #4, #5, #6, +1 more"
635+
"""
636+
xs = [x for x in (labels or []) if x]
637+
shown = xs[:max_labels]
638+
more = len(xs) - len(shown)
639+
return f"{', '.join(shown)}, +{more} more" if more > 0 else ", ".join(shown)

reana_client/cli/workflow.py

Lines changed: 136 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,18 @@
3333
requires_environments,
3434
retrieve_workflow_logs,
3535
follow_workflow_logs,
36+
parse_workflow_run_number,
37+
get_run_number_major_key,
38+
format_run_number_label,
39+
format_run_label_list,
3640
)
3741
from reana_client.config import (
3842
ERROR_MESSAGES,
3943
RUN_STATUSES,
4044
TIMECHECK,
4145
CLI_LOGS_FOLLOW_DEFAULT_INTERVAL,
46+
MAX_RUN_LABELS_SHOWN,
47+
CLI_WORKFLOWS_LIST_MAX_RESULTS,
4248
)
4349
from reana_client.printer import display_message
4450
from reana_client.utils import (
@@ -1261,6 +1267,15 @@ def workflow_run(
12611267
is_flag=True,
12621268
help="Delete all runs of a given workflow.",
12631269
)
1270+
@click.option(
1271+
"--include-all-restarts",
1272+
"include_all_restarts",
1273+
is_flag=True,
1274+
help=(
1275+
"Delete all restarted runs that share the same workspace as the selected run. "
1276+
"Without this flag, deletion will fail if the run is part of a restart chain."
1277+
),
1278+
)
12641279
@click.option(
12651280
"--include-workspace",
12661281
"should_delete_workspace",
@@ -1272,7 +1287,12 @@ def workflow_run(
12721287
@check_connection
12731288
@click.pass_context
12741289
def workflow_delete(
1275-
ctx, workflow: str, all_runs: bool, should_delete_workspace: bool, access_token: str
1290+
ctx,
1291+
workflow: str,
1292+
all_runs: bool,
1293+
include_all_restarts: bool,
1294+
should_delete_workspace: bool,
1295+
access_token: str,
12761296
): # noqa: D301
12771297
"""Delete a workflow.
12781298
@@ -1285,7 +1305,11 @@ def workflow_delete(
12851305
\t $ reana-client delete -w myanalysis.42\n
12861306
\t $ reana-client delete -w myanalysis.42 --include-all-runs
12871307
"""
1288-
from reana_client.api.client import delete_workflow
1308+
from reana_client.api.client import (
1309+
delete_workflow,
1310+
get_workflow_status,
1311+
get_workflows,
1312+
)
12891313
from reana_client.utils import get_api_url
12901314

12911315
should_delete_workspace = True
@@ -1297,14 +1321,119 @@ def workflow_delete(
12971321
if workflow:
12981322
try:
12991323
logging.info("Connecting to {0}".format(get_api_url()))
1300-
delete_workflow(workflow, all_runs, should_delete_workspace, access_token)
1324+
1325+
# If deleting all runs, keep existing behaviour (including restarts)
13011326
if all_runs:
1327+
# Check if any run for given workflow exists
1328+
wf_status = get_workflow_status(workflow, access_token) or {}
1329+
full_name = wf_status.get("name") or workflow
1330+
base_name, _, _ = parse_workflow_run_number(full_name)
1331+
workflow_base = base_name or full_name
1332+
status_filter = RUN_STATUSES.copy()
1333+
if "deleted" in status_filter:
1334+
status_filter.remove("deleted")
1335+
runs = get_workflows(
1336+
access_token=access_token,
1337+
type="batch",
1338+
page=1,
1339+
size=1, # we only need to know if there is at least one
1340+
status=status_filter,
1341+
workflow=workflow_base,
1342+
)
1343+
if not runs:
1344+
display_message(
1345+
f"All runs of '{workflow_base}' are already deleted.",
1346+
msg_type="info",
1347+
)
1348+
return
1349+
1350+
# Delete all runs
1351+
delete_workflow(
1352+
workflow, all_runs, should_delete_workspace, access_token
1353+
)
13021354
message = "All workflows named '{}' have been deleted.".format(
1303-
workflow.split(".")[0]
1355+
workflow_base
13041356
)
1305-
else:
1306-
message = get_workflow_status_change_msg(workflow, "deleted")
1307-
display_message(message, msg_type="success")
1357+
display_message(message, msg_type="success")
1358+
return
1359+
1360+
# Otherwise, detect whether this run has restarts
1361+
wf_status = get_workflow_status(workflow, access_token) or {}
1362+
full_name = wf_status.get("name") or workflow
1363+
wf_id = wf_status.get("id") # may be None
1364+
1365+
# If already deleted, do not delete again
1366+
if wf_status.get("status") == "deleted":
1367+
display_message(
1368+
f"Workflow run '{full_name}' is already deleted.",
1369+
msg_type="info",
1370+
)
1371+
return
1372+
1373+
base_name, _, _ = parse_workflow_run_number(full_name)
1374+
major_key = get_run_number_major_key(full_name)
1375+
related_runs = []
1376+
if base_name and major_key:
1377+
# List non-deleted runs of this workflow name, keep only same workspace group/number
1378+
status_filter = RUN_STATUSES.copy()
1379+
if "deleted" in status_filter:
1380+
status_filter.remove("deleted")
1381+
runs = get_workflows(
1382+
access_token=access_token,
1383+
type="batch",
1384+
page=1,
1385+
size=CLI_WORKFLOWS_LIST_MAX_RESULTS,
1386+
status=status_filter,
1387+
workflow=base_name,
1388+
)
1389+
related_runs = [
1390+
r
1391+
for r in (runs or [])
1392+
if get_run_number_major_key(r.get("name")) == major_key
1393+
]
1394+
1395+
has_restart_series = len(related_runs) > 1
1396+
if has_restart_series and not include_all_restarts:
1397+
labels = [format_run_number_label(r.get("name")) for r in related_runs]
1398+
display_message(
1399+
"Cannot delete workflow run '{}': it is part of a restart series. "
1400+
"Restarted runs share the same workspace, so deleting one run would remove the "
1401+
"shared workspace and leave other runs in an inconsistent state.\n"
1402+
"Related runs: {}\n"
1403+
"Rerun the command with --include-all-restarts to delete this run and its restarts.".format(
1404+
full_name, format_run_label_list(labels, MAX_RUN_LABELS_SHOWN)
1405+
),
1406+
msg_type="error",
1407+
)
1408+
sys.exit(1)
1409+
1410+
if has_restart_series and include_all_restarts:
1411+
# Delete workspace once, mark related runs deleted without deleting workspace again
1412+
workflow_id_or_name = wf_id or full_name
1413+
delete_workflow(workflow_id_or_name, False, True, access_token)
1414+
primary_id = wf_id
1415+
primary_name = full_name
1416+
for r in related_runs:
1417+
rid = r.get("id")
1418+
rname = r.get("name")
1419+
if (primary_id and rid == primary_id) or (rname == primary_name):
1420+
continue
1421+
delete_workflow(rid or rname, False, False, access_token)
1422+
1423+
labels = [format_run_number_label(r.get("name")) for r in related_runs]
1424+
display_message(
1425+
"Workflow run '{}' including its restarts have been deleted ({}).".format(
1426+
full_name, format_run_label_list(labels, MAX_RUN_LABELS_SHOWN)
1427+
),
1428+
msg_type="success",
1429+
)
1430+
return
1431+
1432+
# Normal single workflow run delete (no restarts)
1433+
delete_workflow(full_name, False, should_delete_workspace, access_token)
1434+
display_message(
1435+
get_workflow_status_change_msg(full_name, "deleted"), msg_type="success"
1436+
)
13081437

13091438
except Exception as e:
13101439
logging.debug(traceback.format_exc())

reana_client/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,9 @@
8282

8383
CLI_LOGS_FOLLOW_DEFAULT_INTERVAL = 10
8484
"""Default interval between log requests in seconds."""
85+
86+
MAX_RUN_LABELS_SHOWN = 10
87+
"""Maximum number of run labels to print in CLI output, extra labels are collapsed as '+N more'."""
88+
89+
CLI_WORKFLOWS_LIST_MAX_RESULTS = 1000
90+
"""Max number of workflow runs to fetch in a single API call (used when resolving restarts)."""

0 commit comments

Comments
 (0)