Repository: reanahub/reana-client

Issue:
new command: prune workflow's workspace
**Current behaviour**

When a user has consumed all the possible disk quota, there are several techniques that can help to liberate disk space:

- remove unnecessary workflow runs by deleting them;
- keep workflow runs but remove any unnecessary large temporary files in their workspaces;
- configure automated workspace file retention and deletion rules so that any temporary files will be garbage-collected by REANA itself.

See:

- https://docs.reana.io/advanced-usage/user-quotas/#what-to-do-when-quotas-are-exceeded
- https://blog.reana.io/posts/2022/workspace-file-retention-rules/

This works well, but it may be either a bit tedious to set up, or by deleting workflows manually one could loose possibly interesting information about which workflow runs succeeded, which runs failed, etc.

(The behaviour of REANA is unlike that of a typical CI, which deletes all artefacts unless declared otherwise, whilst REANA keeps all artefacts unless declared otherwise.)

**Expected behaviour**

It would be useful to offer one more way to the user to easily remove any unnecessary temporary files. Let's call it "pruning" the workspace.

We could introduce a new `prune` command:

```console
$ reana-client prune -w myanalysis.42
```

which, when run, will immediately remove all workspace files for the myanalysis run 42 _except for_ those inputs and outputs that are declared in the `reana.yaml` file.

In this way, the workspace would get rid of any large temporary files immediately, the inputs would be kept though so that workflow could eventually be relaunched any time if necessary, and the outputs would be kept too so that there would be no need to relaunch anything by default if the user only wants to access the results some time in the future.

**Notes**

- Note that some workflow-engine-produced output files, such as Yadage-produced workflow animated gif, or Snakemake-produced report, would be interesting to keep as well.

- The `prune` command could offer additional options such as `--include-all-runs` which would prune all runs of the given workflow; similarly to the behaviour of `reana-client delete`.

- We could also plug this option into the web UI in the hamburger menu and offer a new 'Prune workspace' option alongside the 'Delete workflow' option in the menu there.


Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/reanahub/reana-client/pull/652?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub) Report
> Merging [#652](https://codecov.io/gh/reanahub/reana-client/pull/652?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub) (7f006bc) into [master](https://codecov.io/gh/reanahub/reana-client/commit/13eba1b6fc0e5a60a6d4532c109c64a3d129c348?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub) (13eba1b) will **decrease** coverage by `0.41%`.
> The diff coverage is `28.12%`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub)

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://codecov.io/gh/reanahub/reana-client/pull/652/graphs/tree.svg?width=650&height=150&src=pr&token=b85cSEiOhs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub)](https://codecov.io/gh/reanahub/reana-client/pull/652?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub)

```diff
@@            Coverage Diff             @@
##           master     #652      +/-   ##
==========================================
- Coverage   57.71%   57.31%   -0.41%     
==========================================
  Files          24       24              
  Lines        2327     2359      +32     
==========================================
+ Hits         1343     1352       +9     
- Misses        984     1007      +23     
```


| [Impacted Files](https://codecov.io/gh/reanahub/reana-client/pull/652?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub) | Coverage Δ | |
|---|---|---|
| [reana\_client/api/client.py](https://codecov.io/gh/reanahub/reana-client/pull/652?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub#diff-cmVhbmFfY2xpZW50L2FwaS9jbGllbnQucHk=) | `43.58% <9.09%> (-0.95%)` | :arrow_down: |
| [reana\_client/cli/files.py](https://codecov.io/gh/reanahub/reana-client/pull/652?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=reanahub#diff-cmVhbmFfY2xpZW50L2NsaS9maWxlcy5weQ==) | `73.28% <38.09%> (-2.59%)` | :arrow_down: |

</details>

Patch:
diff --git a/docs/cmd_list.txt b/docs/cmd_list.txt
index 6623ab7..9f9b7f7 100644
--- a/docs/cmd_list.txt
+++ b/docs/cmd_list.txt
@@ -39,6 +39,7 @@ Workspace file management commands:
   du        Get workspace disk usage.
   ls        List workspace files.
   mv        Move files within workspace.
+  prune     Prune workspace files.
   rm        Delete files from workspace.
   upload    Upload files and directories to workspace.
 
diff --git a/reana_client/api/client.py b/reana_client/api/client.py
index f3d1d5f..31211f2 100644
--- a/reana_client/api/client.py
+++ b/reana_client/api/client.py
@@ -1091,3 +1091,32 @@ def get_workflow_retention_rules(workflow, access_token):
             )
         )
         raise Exception(e.response.json()["message"])
+
+
+def prune_workspace(workflow, include_inputs, include_outputs, access_token):
+    """Prune workspace files."""
+    try:
+        (response, http_response) = current_rs_api_client.api.prune_workspace(
+            workflow_id_or_name=workflow,
+            include_inputs=include_inputs,
+            include_outputs=include_outputs,
+            access_token=access_token,
+        ).result()
+
+        if http_response.status_code == 200:
+            return response
+        else:
+            raise Exception(
+                "Expected status code 200 but replied with "
+                "{status_code}".format(status_code=http_response.status_code)
+            )
+
+    except HTTPError as e:
+        logging.debug(
+            "Workspace could not be pruned: "
+            "\nStatus: {}\nReason: {}\n"
+            "Message: {}".format(
+                e.response.status_code, e.response.reason, e.response.json()["message"]
+            )
+        )
+        raise Exception(e.response.json()["message"])
diff --git a/reana_client/cli/files.py b/reana_client/cli/files.py
index b4db2c9..50ff6e5 100644
--- a/reana_client/cli/files.py
+++ b/reana_client/cli/files.py
@@ -534,6 +534,56 @@ def move_files(ctx, source, target, workflow, access_token):  # noqa: D301
         sys.exit(1)
 
 
+@files_group.command("prune")
+@check_connection
+@add_workflow_option
+@add_access_token_options
+@click.option(
+    "--include-inputs",
+    "include_inputs",
+    is_flag=True,
+    help="Delete also the input files of the workflow. Note that this includes the workflow specification file.",
+)
+@click.option(
+    "--include-outputs",
+    "include_outputs",
+    is_flag=True,
+    help="Delete also the output files of the workflow.",
+)
+@click.pass_context
+def prune_files(
+    ctx, workflow, access_token, include_inputs, include_outputs
+):  # noqa: D301
+    """Prune workspace files.
+
+    The ``prune`` command deletes all the intermediate files of a given workflow that are not present
+    in the input or output section of the workflow specification.
+
+    Examples:\n
+    \t $ reana-client prune -w myanalysis.42\n
+    \t $ reana-client prune -w myanalysis.42 --include-inputs
+    """
+    from reana_client.api.client import prune_workspace
+
+    logging.debug("command: {}".format(ctx.command_path.replace(" ", ".")))
+    for p in ctx.params:
+        logging.debug("{param}: {value}".format(param=p, value=ctx.params[p]))
+
+    try:
+        response = prune_workspace(
+            workflow, include_inputs, include_outputs, access_token
+        )
+        display_message(response["message"], msg_type="success")
+    except Exception as e:
+        logging.debug(traceback.format_exc())
+        logging.debug(str(e))
+        display_message(
+            "Workspace could not be pruned: \n{}".format(e),
+            msg_type="error",
+        )
+        sys.exit(1)
+
+
 @files_group.command("du")
 @add_workflow_option
 @check_connection
diff --git a/setup.py b/setup.py
index 497a64f..784f7a4 100644
--- a/setup.py
+++ b/setup.py
@@ -45,7 +45,7 @@ install_requires = [
     "click>=7",
     "pathspec==0.9.0",
     "jsonpointer>=2.0",
-    "reana-commons[yadage,snakemake,cwl]>=0.9.2a1,<0.10.0",
+    "reana-commons[yadage,snakemake,cwl]>=0.9.3a2,<0.10.0",
     "tablib>=0.12.1,<0.13",
     "werkzeug>=0.14.1 ; python_version<'3.10'",
     "werkzeug>=0.15.0 ; python_version>='3.10'",


Test patch:
diff --git a/tests/test_cli_files.py b/tests/test_cli_files.py
index 0579325..8e22e8a 100644
--- a/tests/test_cli_files.py
+++ b/tests/test_cli_files.py
@@ -634,3 +634,37 @@ def test_list_files_filter_with_filename():
             assert len(json_response) == 3
             assert json_response[0]["name"] in response["items"][0]["name"]
             assert "2021-06-14" in json_response[1]["last-modified"]
+
+
+def test_prune_workspace():
+    """Test prune workspace files."""
+    status_code = 200
+    response = {
+        "message": "The workspace has been correctly pruned.",
+        "workflow_id": "string",
+        "workflow_name": "string",
+    }
+    env = {"REANA_SERVER_URL": "localhost"}
+    mock_http_response, mock_response = Mock(), Mock()
+    mock_http_response.status_code = status_code
+    mock_response = response
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch(
+            "reana_client.api.client.current_rs_api_client",
+            make_mock_api_client("reana-server")(mock_response, mock_http_response),
+        ):
+            result = runner.invoke(
+                cli,
+                [
+                    "prune",
+                    "-t",
+                    reana_token,
+                    "--workflow",
+                    "test-worflow.1",
+                    "--include-outputs",
+                ],
+            )
+            assert result.exit_code == 0
+            assert response["message"] in result.output