fractal-server 2.8.0__py3-none-any.whl → 2.9.0__py3-none-any.whl

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.
Files changed (82) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/db/__init__.py +2 -35
  3. fractal_server/app/models/v2/__init__.py +3 -3
  4. fractal_server/app/models/v2/task.py +0 -72
  5. fractal_server/app/models/v2/task_group.py +113 -0
  6. fractal_server/app/routes/admin/v1.py +13 -30
  7. fractal_server/app/routes/admin/v2/__init__.py +4 -0
  8. fractal_server/app/routes/admin/v2/job.py +13 -24
  9. fractal_server/app/routes/admin/v2/task.py +13 -0
  10. fractal_server/app/routes/admin/v2/task_group.py +75 -14
  11. fractal_server/app/routes/admin/v2/task_group_lifecycle.py +267 -0
  12. fractal_server/app/routes/api/v1/project.py +7 -19
  13. fractal_server/app/routes/api/v2/__init__.py +11 -2
  14. fractal_server/app/routes/api/v2/{_aux_functions_task_collection.py → _aux_functions_task_lifecycle.py} +83 -0
  15. fractal_server/app/routes/api/v2/_aux_functions_tasks.py +27 -17
  16. fractal_server/app/routes/api/v2/submit.py +19 -24
  17. fractal_server/app/routes/api/v2/task_collection.py +33 -65
  18. fractal_server/app/routes/api/v2/task_collection_custom.py +3 -3
  19. fractal_server/app/routes/api/v2/task_group.py +86 -14
  20. fractal_server/app/routes/api/v2/task_group_lifecycle.py +272 -0
  21. fractal_server/app/routes/api/v2/workflow.py +1 -1
  22. fractal_server/app/routes/api/v2/workflow_import.py +2 -2
  23. fractal_server/app/routes/auth/current_user.py +60 -17
  24. fractal_server/app/routes/auth/group.py +67 -39
  25. fractal_server/app/routes/auth/users.py +97 -99
  26. fractal_server/app/routes/aux/__init__.py +20 -0
  27. fractal_server/app/runner/executors/slurm/_slurm_config.py +0 -17
  28. fractal_server/app/runner/executors/slurm/ssh/executor.py +49 -204
  29. fractal_server/app/runner/executors/slurm/sudo/executor.py +26 -109
  30. fractal_server/app/runner/executors/slurm/utils_executors.py +58 -0
  31. fractal_server/app/runner/v2/_local_experimental/executor.py +2 -1
  32. fractal_server/app/schemas/_validators.py +1 -16
  33. fractal_server/app/schemas/user.py +16 -10
  34. fractal_server/app/schemas/user_group.py +0 -11
  35. fractal_server/app/schemas/v1/applyworkflow.py +0 -8
  36. fractal_server/app/schemas/v1/dataset.py +0 -5
  37. fractal_server/app/schemas/v1/project.py +0 -5
  38. fractal_server/app/schemas/v1/state.py +0 -5
  39. fractal_server/app/schemas/v1/workflow.py +0 -5
  40. fractal_server/app/schemas/v2/__init__.py +4 -2
  41. fractal_server/app/schemas/v2/dataset.py +1 -7
  42. fractal_server/app/schemas/v2/job.py +0 -8
  43. fractal_server/app/schemas/v2/project.py +0 -5
  44. fractal_server/app/schemas/v2/task_collection.py +13 -31
  45. fractal_server/app/schemas/v2/task_group.py +59 -8
  46. fractal_server/app/schemas/v2/workflow.py +0 -5
  47. fractal_server/app/security/__init__.py +17 -0
  48. fractal_server/config.py +61 -59
  49. fractal_server/migrations/versions/d256a7379ab8_taskgroup_activity_and_venv_info_to_.py +117 -0
  50. fractal_server/ssh/_fabric.py +156 -83
  51. fractal_server/string_tools.py +10 -3
  52. fractal_server/tasks/utils.py +2 -12
  53. fractal_server/tasks/v2/local/__init__.py +3 -0
  54. fractal_server/tasks/v2/local/_utils.py +70 -0
  55. fractal_server/tasks/v2/local/collect.py +291 -0
  56. fractal_server/tasks/v2/local/deactivate.py +218 -0
  57. fractal_server/tasks/v2/local/reactivate.py +159 -0
  58. fractal_server/tasks/v2/ssh/__init__.py +3 -0
  59. fractal_server/tasks/v2/ssh/_utils.py +87 -0
  60. fractal_server/tasks/v2/ssh/collect.py +311 -0
  61. fractal_server/tasks/v2/ssh/deactivate.py +253 -0
  62. fractal_server/tasks/v2/ssh/reactivate.py +202 -0
  63. fractal_server/tasks/v2/templates/{_2_preliminary_pip_operations.sh → 1_create_venv.sh} +6 -7
  64. fractal_server/tasks/v2/templates/{_3_pip_install.sh → 2_pip_install.sh} +8 -1
  65. fractal_server/tasks/v2/templates/{_4_pip_freeze.sh → 3_pip_freeze.sh} +0 -7
  66. fractal_server/tasks/v2/templates/{_5_pip_show.sh → 4_pip_show.sh} +5 -6
  67. fractal_server/tasks/v2/templates/5_get_venv_size_and_file_number.sh +10 -0
  68. fractal_server/tasks/v2/templates/6_pip_install_from_freeze.sh +35 -0
  69. fractal_server/tasks/v2/utils_background.py +42 -127
  70. fractal_server/tasks/v2/utils_templates.py +32 -2
  71. fractal_server/utils.py +4 -2
  72. fractal_server/zip_tools.py +21 -4
  73. {fractal_server-2.8.0.dist-info → fractal_server-2.9.0.dist-info}/METADATA +3 -5
  74. {fractal_server-2.8.0.dist-info → fractal_server-2.9.0.dist-info}/RECORD +78 -65
  75. fractal_server/app/models/v2/collection_state.py +0 -22
  76. fractal_server/tasks/v2/collection_local.py +0 -357
  77. fractal_server/tasks/v2/collection_ssh.py +0 -352
  78. fractal_server/tasks/v2/templates/_1_create_venv.sh +0 -42
  79. /fractal_server/tasks/v2/{database_operations.py → utils_database.py} +0 -0
  80. {fractal_server-2.8.0.dist-info → fractal_server-2.9.0.dist-info}/LICENSE +0 -0
  81. {fractal_server-2.8.0.dist-info → fractal_server-2.9.0.dist-info}/WHEEL +0 -0
  82. {fractal_server-2.8.0.dist-info → fractal_server-2.9.0.dist-info}/entry_points.txt +0 -0
@@ -11,7 +11,6 @@ from typing import Optional
11
11
 
12
12
  import paramiko.sftp_client
13
13
  from fabric import Connection
14
- from fabric import Result
15
14
  from invoke import UnexpectedExit
16
15
  from paramiko.ssh_exception import NoValidConnectionsError
17
16
 
@@ -116,50 +115,34 @@ class FractalSSH(object):
116
115
  def logger(self) -> logging.Logger:
117
116
  return get_logger(self.logger_name)
118
117
 
119
- def _put(
120
- self,
121
- *,
122
- local: str,
123
- remote: str,
124
- label: str,
125
- lock_timeout: Optional[float] = None,
126
- ) -> Result:
127
- """
128
- Transfer a local file to a remote path, via SFTP.
118
+ def log_and_raise(self, *, e: Exception, message: str) -> None:
129
119
  """
130
- actual_lock_timeout = self.default_lock_timeout
131
- if lock_timeout is not None:
132
- actual_lock_timeout = lock_timeout
133
- with _acquire_lock_with_timeout(
134
- lock=self._lock,
135
- label=label,
136
- timeout=actual_lock_timeout,
137
- ):
138
- return self._sftp_unsafe().put(local, remote)
120
+ Log and re-raise an exception from a FractalSSH method.
139
121
 
140
- def _get(
141
- self,
142
- *,
143
- local: str,
144
- remote: str,
145
- label: str,
146
- lock_timeout: Optional[float] = None,
147
- ) -> Result:
148
- actual_lock_timeout = self.default_lock_timeout
149
- if lock_timeout is not None:
150
- actual_lock_timeout = lock_timeout
151
- with _acquire_lock_with_timeout(
152
- lock=self._lock,
153
- label=label,
154
- timeout=actual_lock_timeout,
155
- ):
156
- return self._sftp_unsafe().get(
157
- remote,
158
- local,
159
- prefetch=self.sftp_get_prefetch,
160
- max_concurrent_prefetch_requests=self.sftp_get_max_requests,
122
+ Arguments:
123
+ message: Additional message to be logged.
124
+ e: Original exception
125
+ """
126
+ try:
127
+ self.logger.error(message)
128
+ self.logger.error(f"Original Error {type(e)} : \n{str(e)}")
129
+ # Handle the specific case of `NoValidConnectionsError`s from
130
+ # paramiko, which store relevant information in the `errors`
131
+ # attribute
132
+ if hasattr(e, "errors"):
133
+ self.logger.error(f"{type(e)=}")
134
+ for err in e.errors:
135
+ self.logger.error(f"{err}")
136
+ except Exception as exception:
137
+ # Handle unexpected cases, e.g. (1) `e` has no `type`, or
138
+ # (2) `errors` is not iterable.
139
+ self.logger.error(
140
+ "Unexpected Error while handling exception above: "
141
+ f"{str(exception)}"
161
142
  )
162
143
 
144
+ raise e
145
+
163
146
  def _run(
164
147
  self, *args, label: str, lock_timeout: Optional[float] = None, **kwargs
165
148
  ) -> Any:
@@ -187,8 +170,17 @@ class FractalSSH(object):
187
170
  label="read_remote_json_file",
188
171
  timeout=self.default_lock_timeout,
189
172
  ):
190
- with self._sftp_unsafe().open(filepath, "r") as f:
191
- data = json.load(f)
173
+
174
+ try:
175
+ with self._sftp_unsafe().open(filepath, "r") as f:
176
+ data = json.load(f)
177
+ except Exception as e:
178
+ self.log_and_raise(
179
+ e=e,
180
+ message=(
181
+ f"Error in `read_remote_json_file`, for {filepath=}."
182
+ ),
183
+ )
192
184
  self.logger.info(f"END reading remote JSON file {filepath}.")
193
185
  return data
194
186
 
@@ -196,22 +188,56 @@ class FractalSSH(object):
196
188
  """
197
189
  Open the SSH connection and handle exceptions.
198
190
 
199
- This function can be called from within other functions that use
200
- `connection`, so that we can provide a meaningful error in case the
201
- SSH connection cannot be opened.
191
+ This method should always be called at the beginning of background
192
+ operations that use FractalSSH, so that:
193
+
194
+ 1. We try to restore unusable connections (e.g. due to closed socket).
195
+ 2. We provide an informative error if connection cannot be established.
202
196
  """
203
- if not self._connection.is_connected:
197
+ self.logger.debug(
198
+ f"[check_connection] {self._connection.is_connected=}"
199
+ )
200
+ if self._connection.is_connected:
201
+ # Even if the connection appears open, it could be broken for
202
+ # external reasons (e.g. the socket is closed because the SSH
203
+ # server was restarted). In these cases, we catch the error and
204
+ # try to re-open the connection.
204
205
  try:
205
- with _acquire_lock_with_timeout(
206
- lock=self._lock,
207
- label="_connection.open",
208
- timeout=self.default_lock_timeout,
209
- ):
210
- self._connection.open()
211
- except Exception as e:
212
- raise RuntimeError(
213
- f"Cannot open SSH connection. Original error:\n{str(e)}"
206
+ self.logger.info(
207
+ "[check_connection] Run dummy command to check connection."
214
208
  )
209
+ # Run both an SFTP and an SSH command, as they correspond to
210
+ # different sockets
211
+ self.remote_exists("/dummy/path/")
212
+ self.run_command(cmd="whoami")
213
+ self.logger.info(
214
+ "[check_connection] SSH connection is already OK, exit."
215
+ )
216
+ return
217
+ except (OSError, EOFError) as e:
218
+ self.logger.warning(
219
+ f"[check_connection] Detected error {str(e)}, re-open."
220
+ )
221
+ # Try opening the connection (if it was closed) or to re-open it (if
222
+ # an error happened).
223
+ try:
224
+ self.close()
225
+ with _acquire_lock_with_timeout(
226
+ lock=self._lock,
227
+ label="_connection.open",
228
+ timeout=self.default_lock_timeout,
229
+ logger_name=self.logger_name,
230
+ ):
231
+ self._connection.open()
232
+ self._connection.client.open_sftp()
233
+ self.logger.info(
234
+ "[check_connection] SSH connection opened, exit."
235
+ )
236
+
237
+ except Exception as e:
238
+ raise RuntimeError(
239
+ f"Cannot open SSH connection. Original error:\n{str(e)}"
240
+ )
215
241
 
216
242
  def close(self) -> None:
217
243
  """
@@ -228,9 +254,8 @@ class FractalSSH(object):
228
254
  timeout=self.default_lock_timeout,
229
255
  ):
230
256
  self._connection.close()
231
-
232
- if self._connection.client is not None:
233
- self._connection.client.close()
257
+ if self._connection.client is not None:
258
+ self._connection.client.close()
234
259
 
235
260
  def run_command(
236
261
  self,
@@ -288,8 +313,10 @@ class FractalSSH(object):
288
313
  f"{prefix} END running '{cmd}' over SSH, "
289
314
  f"elapsed {t_1-t_0:.3f}"
290
315
  )
291
- self.logger.debug(f"STDOUT: {res.stdout}")
292
- self.logger.debug(f"STDERR: {res.stderr}")
316
+ self.logger.debug("STDOUT:")
317
+ self.logger.debug(res.stdout)
318
+ self.logger.debug("STDERR:")
319
+ self.logger.debug(res.stderr)
293
320
  return res.stdout
294
321
  except NoValidConnectionsError as e:
295
322
  # Case 2: Command fails with a connection error
@@ -345,21 +372,29 @@ class FractalSSH(object):
345
372
  logger_name: Name of the logger
346
373
  """
347
374
  try:
348
- prefix = "[send_file]"
349
- self.logger.info(f"{prefix} START transfer of '{local}' over SSH.")
350
- self._put(
351
- local=local,
352
- remote=remote,
353
- lock_timeout=lock_timeout,
375
+ self.logger.info(
376
+ f"[send_file] START transfer of '{local}' over SSH."
377
+ )
378
+ actual_lock_timeout = self.default_lock_timeout
379
+ if lock_timeout is not None:
380
+ actual_lock_timeout = lock_timeout
381
+ with _acquire_lock_with_timeout(
382
+ lock=self._lock,
354
383
  label=f"send_file {local=} {remote=}",
384
+ timeout=actual_lock_timeout,
385
+ ):
386
+ self._sftp_unsafe().put(local, remote)
387
+ self.logger.info(
388
+ f"[send_file] END transfer of '{local}' over SSH."
355
389
  )
356
- self.logger.info(f"{prefix} END transfer of '{local}' over SSH.")
357
390
  except Exception as e:
358
- self.logger.error(
359
- f"Transferring {local=} to {remote=} over SSH failed.\n"
360
- f"Original Error:\n{str(e)}."
391
+ self.log_and_raise(
392
+ e=e,
393
+ message=(
394
+ "Error in `send_file`, while "
395
+ f"transferring {local=} to {remote=}."
396
+ ),
361
397
  )
362
- raise e
363
398
 
364
399
  def fetch_file(
365
400
  self,
@@ -380,19 +415,29 @@ class FractalSSH(object):
380
415
  try:
381
416
  prefix = "[fetch_file] "
382
417
  self.logger.info(f"{prefix} START fetching '{remote}' over SSH.")
383
- self._get(
384
- local=local,
385
- remote=remote,
386
- lock_timeout=lock_timeout,
418
+ actual_lock_timeout = self.default_lock_timeout
419
+ if lock_timeout is not None:
420
+ actual_lock_timeout = lock_timeout
421
+ with _acquire_lock_with_timeout(
422
+ lock=self._lock,
387
423
  label=f"fetch_file {local=} {remote=}",
388
- )
424
+ timeout=actual_lock_timeout,
425
+ ):
426
+ self._sftp_unsafe().get(
427
+ remote,
428
+ local,
429
+ prefetch=self.sftp_get_prefetch,
430
+ max_concurrent_prefetch_requests=self.sftp_get_max_requests, # noqa E501
431
+ )
389
432
  self.logger.info(f"{prefix} END fetching '{remote}' over SSH.")
390
433
  except Exception as e:
391
- self.logger.error(
392
- f"Transferring {remote=} to {local=} over SSH failed.\n"
393
- f"Original Error:\n{str(e)}."
434
+ self.log_and_raise(
435
+ e=e,
436
+ message=(
437
+ "Error in `fetch_file`, while "
438
+ f"Transferring {remote=} to {local=}."
439
+ ),
394
440
  )
395
- raise e
396
441
 
397
442
  def mkdir(self, *, folder: str, parents: bool = True) -> None:
398
443
  """
@@ -467,10 +512,38 @@ class FractalSSH(object):
467
512
  label=f"write_remote_file {path=}",
468
513
  timeout=actual_lock_timeout,
469
514
  ):
470
- with self._sftp_unsafe().open(filename=path, mode="w") as f:
471
- f.write(content)
515
+ try:
516
+ with self._sftp_unsafe().open(filename=path, mode="w") as f:
517
+ f.write(content)
518
+ except Exception as e:
519
+ self.log_and_raise(
520
+ e=e, message=f"Error in `write_remote_file`, for {path=}."
521
+ )
522
+
472
523
  self.logger.info(f"END writing to remote file {path}.")
473
524
 
525
+ def remote_exists(self, path: str) -> bool:
526
+ """
527
+ Return whether a remote file/folder exists
528
+ """
529
+ self.logger.info(f"START remote_file_exists {path}")
530
+ with _acquire_lock_with_timeout(
531
+ lock=self._lock,
532
+ label=f"remote_file_exists {path=}",
533
+ timeout=self.default_lock_timeout,
534
+ ):
535
+ try:
536
+ self._sftp_unsafe().stat(path)
537
+ self.logger.info(f"END remote_file_exists {path} / True")
538
+ return True
539
+ except FileNotFoundError:
540
+ self.logger.info(f"END remote_file_exists {path} / False")
541
+ return False
542
+ except Exception as e:
543
+ self.log_and_raise(
544
+ e=e, message=f"Error in `remote_exists`, for {path=}."
545
+ )
546
+
474
547
 
475
548
  class FractalSSHList(object):
476
549
  """
@@ -54,7 +54,12 @@ def slugify_task_name_for_source_v1(task_name: str) -> str:
54
54
  return task_name.replace(" ", "_").lower()
55
55
 
56
56
 
57
- def validate_cmd(command: str, allow_char: Optional[str] = None):
57
+ def validate_cmd(
58
+ command: str,
59
+ *,
60
+ allow_char: Optional[str] = None,
61
+ attribute_name: str = "Command",
62
+ ):
58
63
  """
59
64
  Assert that the provided `command` does not contain any of the forbidden
60
65
  characters for commands
@@ -63,6 +68,7 @@ def validate_cmd(command: str, allow_char: Optional[str] = None):
63
68
  Args:
64
69
  command: command to validate.
65
70
  allow_char: chars to accept among the forbidden ones
71
+ attribute_name: Name of the attribute, to be used in error message.
66
72
  """
67
73
  if not isinstance(command, str):
68
74
  raise ValueError(f"{command=} is not a string.")
@@ -71,6 +77,7 @@ def validate_cmd(command: str, allow_char: Optional[str] = None):
71
77
  forbidden = forbidden - set(allow_char)
72
78
  if not forbidden.isdisjoint(set(command)):
73
79
  raise ValueError(
74
- f"Command must not contain any of this characters: '{forbidden}'\n"
75
- f"Provided command: '{command}'."
80
+ f"{attribute_name} must not contain any of this characters: "
81
+ f"'{forbidden}'\n"
82
+ f"Provided {attribute_name.lower()}: '{command}'."
76
83
  )
@@ -30,19 +30,9 @@ def get_log_path(base: Path) -> Path:
30
30
  return base / COLLECTION_LOG_FILENAME
31
31
 
32
32
 
33
- def get_freeze_path(base: Path) -> Path:
34
- return base / COLLECTION_FREEZE_FILENAME
35
-
36
-
37
33
  def get_collection_log_v1(path: Path) -> str:
38
34
  package_path = get_absolute_venv_path_v1(path)
39
35
  log_path = get_log_path(package_path)
40
- log = log_path.open().read()
36
+ with log_path.open("r") as f:
37
+ log = f.read()
41
38
  return log
42
-
43
-
44
- def get_collection_freeze_v1(venv_path: Path) -> str:
45
- package_path = get_absolute_venv_path_v1(venv_path)
46
- freeze_path = get_freeze_path(package_path)
47
- freeze = freeze_path.open().read()
48
- return freeze
@@ -0,0 +1,3 @@
1
+ from .collect import collect_local # noqa
2
+ from .deactivate import deactivate_local # noqa
3
+ from .reactivate import reactivate_local # noqa
@@ -0,0 +1,70 @@
1
+ from pathlib import Path
2
+
3
+ from fractal_server.app.schemas.v2 import TaskCreateV2
4
+ from fractal_server.logger import get_logger
5
+ from fractal_server.tasks.v2.utils_templates import customize_template
6
+ from fractal_server.utils import execute_command_sync
7
+
8
+
9
+ def _customize_and_run_template(
10
+ template_filename: str,
11
+ replacements: list[tuple[str, str]],
12
+ script_dir: str,
13
+ logger_name: str,
14
+ prefix: int,
15
+ ) -> str:
16
+ """
17
+ Customize one of the template bash scripts.
18
+
19
+ Args:
20
+ template_filename: Filename of the template file (ends with ".sh").
21
+ replacements: Dictionary of replacements.
22
+ script_dir: Local folder where the script will be placed.
23
+ prefix: Prefix for the script filename.
24
+ """
25
+ logger = get_logger(logger_name=logger_name)
26
+ logger.debug(f"_customize_and_run_template {template_filename} - START")
27
+
28
+ # Prepare name and path of script
29
+ if not template_filename.endswith(".sh"):
30
+ raise ValueError(
31
+ f"Invalid {template_filename=} (it must end with '.sh')."
32
+ )
33
+
34
+ script_filename = f"{prefix}{template_filename}"
35
+ script_path_local = Path(script_dir) / script_filename
36
+ # Read template
37
+ customize_template(
38
+ template_name=template_filename,
39
+ replacements=replacements,
40
+ script_path=script_path_local,
41
+ )
42
+ cmd = f"bash {script_path_local}"
43
+ logger.debug(f"Now run '{cmd}' ")
44
+ stdout = execute_command_sync(command=cmd, logger_name=logger_name)
45
+ logger.debug(f"_customize_and_run_template {template_filename} - END")
46
+ return stdout
47
+
48
+
49
+ def check_task_files_exist(task_list: list[TaskCreateV2]) -> None:
50
+ """
51
+ Check that the modules listed in task commands point to existing files.
52
+
53
+ Args:
54
+ task_list:
55
+ """
56
+ for _task in task_list:
57
+ if _task.command_non_parallel is not None:
58
+ _task_path = _task.command_non_parallel.split()[1]
59
+ if not Path(_task_path).exists():
60
+ raise FileNotFoundError(
61
+ f"Task `{_task.name}` has `command_non_parallel` "
62
+ f"pointing to missing file `{_task_path}`."
63
+ )
64
+ if _task.command_parallel is not None:
65
+ _task_path = _task.command_parallel.split()[1]
66
+ if not Path(_task_path).exists():
67
+ raise FileNotFoundError(
68
+ f"Task `{_task.name}` has `command_parallel` "
69
+ f"pointing to missing file `{_task_path}`."
70
+ )