fractal-server 2.5.1__py3-none-any.whl → 2.6.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 (40) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +24 -9
  3. fractal_server/app/models/__init__.py +1 -0
  4. fractal_server/app/models/security.py +8 -0
  5. fractal_server/app/models/user_settings.py +38 -0
  6. fractal_server/app/routes/api/v1/_aux_functions.py +6 -1
  7. fractal_server/app/routes/api/v1/project.py +11 -24
  8. fractal_server/app/routes/api/v1/task.py +12 -9
  9. fractal_server/app/routes/api/v2/_aux_functions.py +6 -1
  10. fractal_server/app/routes/api/v2/submit.py +29 -21
  11. fractal_server/app/routes/api/v2/task.py +12 -9
  12. fractal_server/app/routes/api/v2/task_collection.py +17 -2
  13. fractal_server/app/routes/api/v2/task_collection_custom.py +6 -1
  14. fractal_server/app/routes/auth/_aux_auth.py +5 -5
  15. fractal_server/app/routes/auth/current_user.py +41 -0
  16. fractal_server/app/routes/auth/users.py +42 -0
  17. fractal_server/app/routes/aux/validate_user_settings.py +74 -0
  18. fractal_server/app/runner/executors/slurm/ssh/executor.py +24 -4
  19. fractal_server/app/runner/executors/slurm/sudo/executor.py +6 -2
  20. fractal_server/app/runner/v2/__init__.py +5 -7
  21. fractal_server/app/schemas/__init__.py +2 -0
  22. fractal_server/app/schemas/user.py +1 -62
  23. fractal_server/app/schemas/user_settings.py +94 -0
  24. fractal_server/app/schemas/v2/task_collection.py +5 -4
  25. fractal_server/app/security/__init__.py +22 -9
  26. fractal_server/app/user_settings.py +42 -0
  27. fractal_server/config.py +0 -16
  28. fractal_server/data_migrations/2_6_0.py +49 -0
  29. fractal_server/data_migrations/tools.py +17 -0
  30. fractal_server/main.py +12 -10
  31. fractal_server/migrations/versions/9c5ae74c9b98_add_user_settings_table.py +74 -0
  32. fractal_server/ssh/_fabric.py +193 -48
  33. fractal_server/string_tools.py +2 -0
  34. fractal_server/tasks/v2/background_operations_ssh.py +14 -5
  35. {fractal_server-2.5.1.dist-info → fractal_server-2.6.0.dist-info}/METADATA +1 -1
  36. {fractal_server-2.5.1.dist-info → fractal_server-2.6.0.dist-info}/RECORD +39 -33
  37. fractal_server/data_migrations/2_4_0.py +0 -61
  38. {fractal_server-2.5.1.dist-info → fractal_server-2.6.0.dist-info}/LICENSE +0 -0
  39. {fractal_server-2.5.1.dist-info → fractal_server-2.6.0.dist-info}/WHEEL +0 -0
  40. {fractal_server-2.5.1.dist-info → fractal_server-2.6.0.dist-info}/entry_points.txt +0 -0
fractal_server/main.py CHANGED
@@ -92,32 +92,34 @@ async def lifespan(app: FastAPI):
92
92
  settings = Inject(get_settings)
93
93
 
94
94
  if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
95
- from fractal_server.ssh._fabric import get_ssh_connection
96
- from fractal_server.ssh._fabric import FractalSSH
97
95
 
98
- connection = get_ssh_connection()
99
- app.state.fractal_ssh = FractalSSH(connection=connection)
96
+ from fractal_server.ssh._fabric import FractalSSHList
97
+
98
+ app.state.fractal_ssh_list = FractalSSHList()
99
+
100
100
  logger.info(
101
- f"Created SSH connection "
102
- f"({app.state.fractal_ssh.is_connected=})."
101
+ "Added empty FractalSSHList to app.state "
102
+ f"(id={id(app.state.fractal_ssh_list)})."
103
103
  )
104
104
  else:
105
- app.state.fractal_ssh = None
105
+ app.state.fractal_ssh_list = None
106
106
 
107
107
  config_uvicorn_loggers()
108
108
  logger.info("End application startup")
109
109
  reset_logger_handlers(logger)
110
+
110
111
  yield
112
+
111
113
  logger = get_logger("fractal_server.lifespan")
112
114
  logger.info("Start application shutdown")
113
115
 
114
116
  if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
115
117
  logger.info(
116
- f"Closing SSH connection "
117
- f"(current: {app.state.fractal_ssh.is_connected=})."
118
+ "Close FractalSSH connections "
119
+ f"(current size: {app.state.fractal_ssh_list.size})."
118
120
  )
119
121
 
120
- app.state.fractal_ssh.close()
122
+ app.state.fractal_ssh_list.close_all()
121
123
 
122
124
  logger.info(
123
125
  f"Current worker with pid {os.getpid()} is shutting down. "
@@ -0,0 +1,74 @@
1
+ """Add user_settings table
2
+
3
+ Revision ID: 9c5ae74c9b98
4
+ Revises: d9a140db5d42
5
+ Create Date: 2024-09-24 12:01:13.393326
6
+
7
+ """
8
+ import sqlalchemy as sa
9
+ import sqlmodel
10
+ from alembic import op
11
+
12
+
13
+ # revision identifiers, used by Alembic.
14
+ revision = "9c5ae74c9b98"
15
+ down_revision = "d9a140db5d42"
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+ # Manually define constraint name, see issue #1777
20
+ CONSTRAINT_NAME = "fk_user_oauth_user_settings_id_user_settings"
21
+
22
+
23
+ def upgrade() -> None:
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.create_table(
26
+ "user_settings",
27
+ sa.Column("id", sa.Integer(), nullable=False),
28
+ sa.Column(
29
+ "slurm_accounts", sa.JSON(), server_default="[]", nullable=False
30
+ ),
31
+ sa.Column(
32
+ "ssh_host", sqlmodel.sql.sqltypes.AutoString(), nullable=True
33
+ ),
34
+ sa.Column(
35
+ "ssh_username", sqlmodel.sql.sqltypes.AutoString(), nullable=True
36
+ ),
37
+ sa.Column(
38
+ "ssh_private_key_path",
39
+ sqlmodel.sql.sqltypes.AutoString(),
40
+ nullable=True,
41
+ ),
42
+ sa.Column(
43
+ "ssh_tasks_dir", sqlmodel.sql.sqltypes.AutoString(), nullable=True
44
+ ),
45
+ sa.Column(
46
+ "ssh_jobs_dir", sqlmodel.sql.sqltypes.AutoString(), nullable=True
47
+ ),
48
+ sa.Column(
49
+ "slurm_user", sqlmodel.sql.sqltypes.AutoString(), nullable=True
50
+ ),
51
+ sa.Column(
52
+ "cache_dir", sqlmodel.sql.sqltypes.AutoString(), nullable=True
53
+ ),
54
+ sa.PrimaryKeyConstraint("id"),
55
+ )
56
+ with op.batch_alter_table("user_oauth", schema=None) as batch_op:
57
+ batch_op.add_column(
58
+ sa.Column("user_settings_id", sa.Integer(), nullable=True)
59
+ )
60
+ batch_op.create_foreign_key(
61
+ CONSTRAINT_NAME, "user_settings", ["user_settings_id"], ["id"]
62
+ )
63
+
64
+ # ### end Alembic commands ###
65
+
66
+
67
+ def downgrade() -> None:
68
+ # ### commands auto generated by Alembic - please adjust! ###
69
+ with op.batch_alter_table("user_oauth", schema=None) as batch_op:
70
+ batch_op.drop_constraint(CONSTRAINT_NAME, type_="foreignkey")
71
+ batch_op.drop_column("user_settings_id")
72
+
73
+ op.drop_table("user_settings")
74
+ # ### end Alembic commands ###
@@ -16,20 +16,21 @@ from paramiko.ssh_exception import NoValidConnectionsError
16
16
 
17
17
  from ..logger import get_logger
18
18
  from ..logger import set_logger
19
- from fractal_server.config import get_settings
20
19
  from fractal_server.string_tools import validate_cmd
21
- from fractal_server.syringe import Inject
22
20
 
23
21
 
24
22
  class FractalSSHTimeoutError(RuntimeError):
25
23
  pass
26
24
 
27
25
 
26
+ class FractalSSHListTimeoutError(RuntimeError):
27
+ pass
28
+
29
+
28
30
  logger = set_logger(__name__)
29
31
 
30
32
 
31
33
  class FractalSSH(object):
32
-
33
34
  """
34
35
  FIXME SSH: Fix docstring
35
36
 
@@ -111,7 +112,6 @@ class FractalSSH(object):
111
112
  def run(
112
113
  self, *args, lock_timeout: Optional[float] = None, **kwargs
113
114
  ) -> Any:
114
-
115
115
  actual_lock_timeout = self.default_lock_timeout
116
116
  if lock_timeout is not None:
117
117
  actual_lock_timeout = lock_timeout
@@ -138,7 +138,19 @@ class FractalSSH(object):
138
138
  )
139
139
 
140
140
  def close(self) -> None:
141
- return self._connection.close()
141
+ """
142
+ Aggressively close `self._connection`.
143
+
144
+ When `Connection.is_connected` is `False`, `Connection.close()` does
145
+ not call `Connection.client.close()`. Thus we do this explicitly here,
146
+ because we observed cases where `is_connected=False` but the underlying
147
+ `Transport` object was not closed.
148
+ """
149
+
150
+ self._connection.close()
151
+
152
+ if self._connection.client is not None:
153
+ self._connection.client.close()
142
154
 
143
155
  def run_command(
144
156
  self,
@@ -293,21 +305,21 @@ class FractalSSH(object):
293
305
  safe_root: If `folder` is not a subfolder of the absolute
294
306
  `safe_root` path, raise an error.
295
307
  """
296
- invalid_characters = {" ", "\n", ";", "$", "`"}
297
-
298
- if (
299
- not isinstance(folder, str)
300
- or not isinstance(safe_root, str)
301
- or len(invalid_characters.intersection(folder)) > 0
302
- or len(invalid_characters.intersection(safe_root)) > 0
303
- or not Path(folder).is_absolute()
304
- or not Path(safe_root).is_absolute()
305
- or not Path(folder).resolve().is_relative_to(safe_root)
308
+ validate_cmd(folder)
309
+ validate_cmd(safe_root)
310
+
311
+ if " " in folder:
312
+ raise ValueError(f"folder='{folder}' includes whitespace.")
313
+ elif " " in safe_root:
314
+ raise ValueError(f"safe_root='{safe_root}' includes whitespace.")
315
+ elif not Path(folder).is_absolute():
316
+ raise ValueError(f"{folder=} is not an absolute path.")
317
+ elif not Path(safe_root).is_absolute():
318
+ raise ValueError(f"{safe_root=} is not an absolute path.")
319
+ elif not (
320
+ Path(folder).resolve().is_relative_to(Path(safe_root).resolve())
306
321
  ):
307
- raise ValueError(
308
- f"{folder=} argument is invalid or it is not "
309
- f"relative to {safe_root=}."
310
- )
322
+ raise ValueError(f"{folder=} is not a subfolder of {safe_root=}.")
311
323
  else:
312
324
  cmd = f"rm -r {folder}"
313
325
  self.run_command(cmd=cmd)
@@ -335,36 +347,169 @@ class FractalSSH(object):
335
347
  f.write(content)
336
348
 
337
349
 
338
- def get_ssh_connection(
339
- *,
340
- host: Optional[str] = None,
341
- user: Optional[str] = None,
342
- key_filename: Optional[str] = None,
343
- ) -> Connection:
350
+ class FractalSSHList(object):
344
351
  """
345
- Create a `fabric.Connection` object based on fractal-server settings
346
- or explicit arguments.
352
+ Collection of `FractalSSH` objects
347
353
 
348
- Args:
349
- host:
350
- user:
351
- key_filename:
354
+ Attributes are all private, and access to this collection must be
355
+ through methods (mostly the `get` one).
352
356
 
353
- Returns:
354
- Fabric connection object
357
+ Attributes:
358
+ _data:
359
+ Mapping of unique keys (the SSH-credentials tuples) to
360
+ `FractalSSH` objects.
361
+ _lock:
362
+ A `threading.Lock object`, to be acquired when changing `_data`.
363
+ _timeout: Timeout for `_lock` acquisition.
364
+ _logger_name: Logger name.
355
365
  """
356
- settings = Inject(get_settings)
357
- if host is None:
358
- host = settings.FRACTAL_SLURM_SSH_HOST
359
- if user is None:
360
- user = settings.FRACTAL_SLURM_SSH_USER
361
- if key_filename is None:
362
- key_filename = settings.FRACTAL_SLURM_SSH_PRIVATE_KEY_PATH
363
-
364
- connection = Connection(
365
- host=host,
366
- user=user,
367
- forward_agent=False,
368
- connect_kwargs={"key_filename": key_filename},
369
- )
370
- return connection
366
+
367
+ _data: dict[tuple[str, str, str], FractalSSH]
368
+ _lock: Lock
369
+ _timeout: float
370
+ _logger_name: str
371
+
372
+ def __init__(
373
+ self,
374
+ *,
375
+ timeout: float = 5.0,
376
+ logger_name: str = "fractal_server.FractalSSHList",
377
+ ):
378
+ self._lock = Lock()
379
+ self._data = {}
380
+ self._timeout = timeout
381
+ self._logger_name = logger_name
382
+ set_logger(self._logger_name)
383
+
384
+ @property
385
+ def logger(self) -> logging.Logger:
386
+ """
387
+ This property exists so that we never have to propagate the
388
+ `Logger` object.
389
+ """
390
+ return get_logger(self._logger_name)
391
+
392
+ @property
393
+ def size(self) -> int:
394
+ """
395
+ Number of current key-value pairs in `self._data`.
396
+ """
397
+ return len(self._data.values())
398
+
399
+ def get(self, *, host: str, user: str, key_path: str) -> FractalSSH:
400
+ """
401
+ Get the `FractalSSH` for the current credentials, or create one.
402
+
403
+ Note: Changing `_data` requires acquiring `_lock`.
404
+
405
+ Arguments:
406
+ host:
407
+ user:
408
+ key_path:
409
+ """
410
+ key = (host, user, key_path)
411
+ fractal_ssh = self._data.get(key, None)
412
+ if fractal_ssh is not None:
413
+ self.logger.info(
414
+ f"Return existing FractalSSH object for {user}@{host}"
415
+ )
416
+ return fractal_ssh
417
+ else:
418
+ self.logger.info(f"Add new FractalSSH object for {user}@{host}")
419
+ connection = Connection(
420
+ host=host,
421
+ user=user,
422
+ forward_agent=False,
423
+ connect_kwargs={
424
+ "key_filename": key_path,
425
+ "look_for_keys": False,
426
+ },
427
+ )
428
+ with self.acquire_lock_with_timeout():
429
+ self._data[key] = FractalSSH(connection=connection)
430
+ return self._data[key]
431
+
432
+ def contains(
433
+ self,
434
+ *,
435
+ host: str,
436
+ user: str,
437
+ key_path: str,
438
+ ) -> bool:
439
+ """
440
+ Return whether a given key is present in the collection.
441
+
442
+ Arguments:
443
+ host:
444
+ user:
445
+ key_path:
446
+ """
447
+ key = (host, user, key_path)
448
+ return key in self._data.keys()
449
+
450
+ def remove(
451
+ self,
452
+ *,
453
+ host: str,
454
+ user: str,
455
+ key_path: str,
456
+ ) -> None:
457
+ """
458
+ Remove a key from `_data` and close the corresponding connection.
459
+
460
+ Note: Changing `_data` requires acquiring `_lock`.
461
+
462
+ Arguments:
463
+ host:
464
+ user:
465
+ key_path:
466
+ """
467
+ key = (host, user, key_path)
468
+ with self.acquire_lock_with_timeout():
469
+ self.logger.info(
470
+ f"Removing FractalSSH object for {user}@{host} "
471
+ "from collection."
472
+ )
473
+ fractal_ssh_obj = self._data.pop(key)
474
+ self.logger.info(
475
+ f"Closing FractalSSH object for {user}@{host} "
476
+ f"({fractal_ssh_obj.is_connected=})."
477
+ )
478
+ fractal_ssh_obj.close()
479
+
480
+ def close_all(self, *, timeout: float = 5.0):
481
+ """
482
+ Close all `FractalSSH` objects in the collection.
483
+
484
+ Arguments:
485
+ timeout:
486
+ Timeout for `FractalSSH._lock` acquisition, to be obtained
487
+ before closing.
488
+ """
489
+ for key, fractal_ssh_obj in self._data.items():
490
+ host, user, _ = key[:]
491
+ self.logger.info(
492
+ f"Closing FractalSSH object for {user}@{host} "
493
+ f"({fractal_ssh_obj.is_connected=})."
494
+ )
495
+ with fractal_ssh_obj.acquire_timeout(timeout=timeout):
496
+ fractal_ssh_obj.close()
497
+
498
+ @contextmanager
499
+ def acquire_lock_with_timeout(self) -> Generator[Literal[True], Any, None]:
500
+ self.logger.debug(
501
+ f"Trying to acquire lock, with timeout {self._timeout} s"
502
+ )
503
+ result = self._lock.acquire(timeout=self._timeout)
504
+ try:
505
+ if not result:
506
+ self.logger.error("Lock was *NOT* acquired.")
507
+ raise FractalSSHListTimeoutError(
508
+ f"Failed to acquire lock within {self._timeout} ss"
509
+ )
510
+ self.logger.debug("Lock was acquired.")
511
+ yield result
512
+ finally:
513
+ if result:
514
+ self._lock.release()
515
+ self.logger.debug("Lock was released")
@@ -60,6 +60,8 @@ def validate_cmd(command: str, allow_char: Optional[str] = None):
60
60
  command: command to validate.
61
61
  allow_char: chars to accept among the forbidden ones
62
62
  """
63
+ if not isinstance(command, str):
64
+ raise ValueError(f"{command=} is not a string.")
63
65
  forbidden = set(__NOT_ALLOWED_FOR_COMMANDS__)
64
66
  if allow_char is not None:
65
67
  forbidden = forbidden - set(allow_char)
@@ -57,6 +57,7 @@ def _customize_and_run_template(
57
57
  tmpdir: str,
58
58
  logger_name: str,
59
59
  fractal_ssh: FractalSSH,
60
+ tasks_base_dir: str,
60
61
  ) -> str:
61
62
  """
62
63
  Customize one of the template bash scripts, transfer it to the remote host
@@ -72,7 +73,6 @@ def _customize_and_run_template(
72
73
  """
73
74
  logger = get_logger(logger_name)
74
75
  logger.debug(f"_customize_and_run_template {script_filename} - START")
75
- settings = Inject(get_settings)
76
76
 
77
77
  # Read template
78
78
  template_path = templates_folder / script_filename
@@ -88,7 +88,7 @@ def _customize_and_run_template(
88
88
 
89
89
  # Transfer script to remote host
90
90
  script_path_remote = os.path.join(
91
- settings.FRACTAL_SLURM_SSH_WORKING_BASE_DIR,
91
+ tasks_base_dir,
92
92
  f"script_{abs(hash(tmpdir))}{script_filename}",
93
93
  )
94
94
  logger.debug(f"Now transfer {script_path_local=} over SSH.")
@@ -111,6 +111,7 @@ def background_collect_pip_ssh(
111
111
  state_id: int,
112
112
  task_pkg: _TaskCollectPip,
113
113
  fractal_ssh: FractalSSH,
114
+ tasks_base_dir: str,
114
115
  ) -> None:
115
116
  """
116
117
  Collect a task package over SSH
@@ -121,6 +122,13 @@ def background_collect_pip_ssh(
121
122
  NOTE: by making this function sync, it will run within a thread - due to
122
123
  starlette/fastapi handling of background tasks (see
123
124
  https://github.com/encode/starlette/blob/master/starlette/background.py).
125
+
126
+
127
+ Arguments:
128
+ state_id:
129
+ task_pkg:
130
+ fractal_ssh:
131
+ tasks_base_dir:
124
132
  """
125
133
 
126
134
  # Work within a temporary folder, where also logs will be placed
@@ -140,7 +148,6 @@ def background_collect_pip_ssh(
140
148
  with next(get_sync_db()) as db:
141
149
  try:
142
150
  # Prepare replacements for task-collection scripts
143
- settings = Inject(get_settings)
144
151
  python_bin = get_python_interpreter_v2(
145
152
  python_version=task_pkg.python_version
146
153
  )
@@ -163,11 +170,12 @@ def background_collect_pip_ssh(
163
170
  f"{install_string}=={task_pkg.package_version}"
164
171
  )
165
172
  package_env_dir = (
166
- Path(settings.FRACTAL_SLURM_SSH_WORKING_BASE_DIR)
173
+ Path(tasks_base_dir)
167
174
  / ".fractal"
168
175
  / f"{task_pkg.package_name}{package_version}"
169
176
  ).as_posix()
170
177
  logger.debug(f"{package_env_dir=}")
178
+ settings = Inject(get_settings)
171
179
  replacements = [
172
180
  ("__PACKAGE_NAME__", task_pkg.package_name),
173
181
  ("__PACKAGE_ENV_DIR__", package_env_dir),
@@ -186,6 +194,7 @@ def background_collect_pip_ssh(
186
194
  tmpdir=tmpdir,
187
195
  logger_name=LOGGER_NAME,
188
196
  fractal_ssh=fractal_ssh,
197
+ tasks_base_dir=tasks_base_dir,
189
198
  )
190
199
 
191
200
  fractal_ssh.check_connection()
@@ -332,7 +341,7 @@ def background_collect_pip_ssh(
332
341
  )
333
342
  fractal_ssh.remove_folder(
334
343
  folder=package_env_dir,
335
- safe_root=settings.FRACTAL_SLURM_SSH_WORKING_BASE_DIR, # noqa: E501
344
+ safe_root=tasks_base_dir,
336
345
  )
337
346
  logger.info(
338
347
  f"Deleted remoted folder {package_env_dir}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fractal-server
3
- Version: 2.5.1
3
+ Version: 2.6.0
4
4
  Summary: Server component of the Fractal analytics platform
5
5
  Home-page: https://github.com/fractal-analytics-platform/fractal-server
6
6
  License: BSD-3-Clause