fractal-server 2.16.5__py3-none-any.whl → 2.17.0a0__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.
- fractal_server/__init__.py +1 -1
- fractal_server/__main__.py +129 -22
- fractal_server/app/db/__init__.py +9 -11
- fractal_server/app/models/security.py +7 -3
- fractal_server/app/models/user_settings.py +0 -4
- fractal_server/app/models/v2/__init__.py +4 -0
- fractal_server/app/models/v2/job.py +3 -4
- fractal_server/app/models/v2/profile.py +16 -0
- fractal_server/app/models/v2/project.py +3 -0
- fractal_server/app/models/v2/resource.py +130 -0
- fractal_server/app/models/v2/task_group.py +3 -0
- fractal_server/app/routes/admin/v2/__init__.py +4 -0
- fractal_server/app/routes/admin/v2/_aux_functions.py +55 -0
- fractal_server/app/routes/admin/v2/profile.py +86 -0
- fractal_server/app/routes/admin/v2/resource.py +229 -0
- fractal_server/app/routes/admin/v2/task_group_lifecycle.py +48 -82
- fractal_server/app/routes/api/__init__.py +26 -7
- fractal_server/app/routes/api/v2/_aux_functions.py +27 -1
- fractal_server/app/routes/api/v2/_aux_functions_history.py +2 -2
- fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +3 -3
- fractal_server/app/routes/api/v2/_aux_functions_tasks.py +7 -7
- fractal_server/app/routes/api/v2/project.py +5 -1
- fractal_server/app/routes/api/v2/submit.py +32 -24
- fractal_server/app/routes/api/v2/task.py +5 -0
- fractal_server/app/routes/api/v2/task_collection.py +36 -47
- fractal_server/app/routes/api/v2/task_collection_custom.py +11 -5
- fractal_server/app/routes/api/v2/task_collection_pixi.py +34 -40
- fractal_server/app/routes/api/v2/task_group_lifecycle.py +39 -82
- fractal_server/app/routes/api/v2/workflow_import.py +4 -3
- fractal_server/app/routes/auth/_aux_auth.py +3 -3
- fractal_server/app/routes/auth/current_user.py +45 -7
- fractal_server/app/routes/auth/oauth.py +1 -1
- fractal_server/app/routes/auth/users.py +9 -0
- fractal_server/app/routes/aux/_runner.py +2 -1
- fractal_server/app/routes/aux/validate_user_profile.py +62 -0
- fractal_server/app/routes/aux/validate_user_settings.py +12 -9
- fractal_server/app/schemas/user.py +20 -13
- fractal_server/app/schemas/user_settings.py +0 -4
- fractal_server/app/schemas/v2/__init__.py +11 -0
- fractal_server/app/schemas/v2/profile.py +72 -0
- fractal_server/app/schemas/v2/resource.py +117 -0
- fractal_server/app/security/__init__.py +6 -13
- fractal_server/app/security/signup_email.py +2 -2
- fractal_server/app/user_settings.py +2 -12
- fractal_server/config/__init__.py +23 -0
- fractal_server/config/_database.py +58 -0
- fractal_server/config/_email.py +170 -0
- fractal_server/config/_init_data.py +27 -0
- fractal_server/config/_main.py +216 -0
- fractal_server/config/_settings_config.py +7 -0
- fractal_server/images/tools.py +3 -3
- fractal_server/logger.py +3 -3
- fractal_server/main.py +14 -21
- fractal_server/migrations/versions/90f6508c6379_drop_useroauth_username.py +36 -0
- fractal_server/migrations/versions/a80ac5a352bf_resource_profile.py +195 -0
- fractal_server/runner/config/__init__.py +2 -0
- fractal_server/runner/config/_local.py +21 -0
- fractal_server/runner/config/_slurm.py +128 -0
- fractal_server/runner/config/slurm_mem_to_MB.py +63 -0
- fractal_server/runner/exceptions.py +4 -0
- fractal_server/runner/executors/base_runner.py +17 -7
- fractal_server/runner/executors/local/get_local_config.py +21 -86
- fractal_server/runner/executors/local/runner.py +48 -5
- fractal_server/runner/executors/slurm_common/_batching.py +2 -2
- fractal_server/runner/executors/slurm_common/base_slurm_runner.py +59 -25
- fractal_server/runner/executors/slurm_common/get_slurm_config.py +38 -54
- fractal_server/runner/executors/slurm_common/remote.py +1 -1
- fractal_server/runner/executors/slurm_common/{_slurm_config.py → slurm_config.py} +3 -254
- fractal_server/runner/executors/slurm_common/slurm_job_task_models.py +1 -1
- fractal_server/runner/executors/slurm_ssh/runner.py +12 -14
- fractal_server/runner/executors/slurm_sudo/_subprocess_run_as_user.py +2 -2
- fractal_server/runner/executors/slurm_sudo/runner.py +12 -12
- fractal_server/runner/v2/_local.py +36 -21
- fractal_server/runner/v2/_slurm_ssh.py +40 -4
- fractal_server/runner/v2/_slurm_sudo.py +41 -11
- fractal_server/runner/v2/db_tools.py +1 -1
- fractal_server/runner/v2/runner.py +3 -11
- fractal_server/runner/v2/runner_functions.py +42 -28
- fractal_server/runner/v2/submit_workflow.py +87 -108
- fractal_server/runner/versions.py +8 -3
- fractal_server/ssh/_fabric.py +6 -6
- fractal_server/tasks/config/__init__.py +3 -0
- fractal_server/tasks/config/_pixi.py +127 -0
- fractal_server/tasks/config/_python.py +51 -0
- fractal_server/tasks/v2/local/_utils.py +7 -7
- fractal_server/tasks/v2/local/collect.py +13 -5
- fractal_server/tasks/v2/local/collect_pixi.py +26 -10
- fractal_server/tasks/v2/local/deactivate.py +7 -1
- fractal_server/tasks/v2/local/deactivate_pixi.py +5 -1
- fractal_server/tasks/v2/local/delete.py +4 -0
- fractal_server/tasks/v2/local/reactivate.py +13 -5
- fractal_server/tasks/v2/local/reactivate_pixi.py +27 -9
- fractal_server/tasks/v2/ssh/_pixi_slurm_ssh.py +11 -10
- fractal_server/tasks/v2/ssh/_utils.py +6 -7
- fractal_server/tasks/v2/ssh/collect.py +19 -12
- fractal_server/tasks/v2/ssh/collect_pixi.py +34 -16
- fractal_server/tasks/v2/ssh/deactivate.py +12 -8
- fractal_server/tasks/v2/ssh/deactivate_pixi.py +14 -10
- fractal_server/tasks/v2/ssh/delete.py +12 -9
- fractal_server/tasks/v2/ssh/reactivate.py +18 -12
- fractal_server/tasks/v2/ssh/reactivate_pixi.py +36 -17
- fractal_server/tasks/v2/templates/4_pip_show.sh +4 -6
- fractal_server/tasks/v2/utils_database.py +2 -2
- fractal_server/tasks/v2/utils_python_interpreter.py +8 -16
- fractal_server/tasks/v2/utils_templates.py +7 -10
- fractal_server/utils.py +1 -1
- {fractal_server-2.16.5.dist-info → fractal_server-2.17.0a0.dist-info}/METADATA +5 -5
- {fractal_server-2.16.5.dist-info → fractal_server-2.17.0a0.dist-info}/RECORD +112 -90
- {fractal_server-2.16.5.dist-info → fractal_server-2.17.0a0.dist-info}/WHEEL +1 -1
- fractal_server/config.py +0 -906
- /fractal_server/{runner → app}/shutdown.py +0 -0
- {fractal_server-2.16.5.dist-info → fractal_server-2.17.0a0.dist-info}/entry_points.txt +0 -0
- {fractal_server-2.16.5.dist-info → fractal_server-2.17.0a0.dist-info/licenses}/LICENSE +0 -0
fractal_server/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__VERSION__ = "2.
|
|
1
|
+
__VERSION__ = "2.17.0a0"
|
fractal_server/__main__.py
CHANGED
|
@@ -2,9 +2,12 @@ import argparse as ap
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
import uvicorn
|
|
8
|
+
from pydantic import ValidationError
|
|
7
9
|
|
|
10
|
+
from fractal_server.app.schemas.v2 import ResourceType
|
|
8
11
|
|
|
9
12
|
parser = ap.ArgumentParser(description="fractal-server commands")
|
|
10
13
|
|
|
@@ -50,11 +53,23 @@ set_db_parser = subparsers.add_parser(
|
|
|
50
53
|
"Initialise/upgrade database schemas and create first group&user."
|
|
51
54
|
),
|
|
52
55
|
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
|
|
57
|
+
# fractalctl init-db-data
|
|
58
|
+
init_db_data_parser = subparsers.add_parser(
|
|
59
|
+
"init-db-data",
|
|
60
|
+
description="Populate database with initial data.",
|
|
61
|
+
)
|
|
62
|
+
init_db_data_parser.add_argument(
|
|
63
|
+
"--resource",
|
|
64
|
+
type=str,
|
|
65
|
+
help=("Either `default` or path to the JSON file of the first resource."),
|
|
66
|
+
required=True,
|
|
67
|
+
)
|
|
68
|
+
init_db_data_parser.add_argument(
|
|
69
|
+
"--profile",
|
|
70
|
+
type=str,
|
|
71
|
+
help=("Either `default` or path to the JSON file of the first profile."),
|
|
72
|
+
required=True,
|
|
58
73
|
)
|
|
59
74
|
|
|
60
75
|
# fractalctl update-db-data
|
|
@@ -83,28 +98,22 @@ def save_openapi(dest="openapi.json"):
|
|
|
83
98
|
json.dump(openapi_schema, f)
|
|
84
99
|
|
|
85
100
|
|
|
86
|
-
def set_db(
|
|
101
|
+
def set_db():
|
|
87
102
|
"""
|
|
88
|
-
Upgrade database
|
|
103
|
+
Upgrade database schemas.
|
|
89
104
|
|
|
90
105
|
Call alembic to upgrade to the latest migration.
|
|
91
106
|
Ref: https://stackoverflow.com/a/56683030/283972
|
|
92
|
-
|
|
93
|
-
Arguments:
|
|
94
|
-
skip_init_data: If `True`, skip creation of first group and user.
|
|
95
107
|
"""
|
|
96
|
-
from fractal_server.app.security import _create_first_user
|
|
97
|
-
from fractal_server.app.security import _create_first_group
|
|
98
108
|
from fractal_server.syringe import Inject
|
|
99
|
-
from fractal_server.config import
|
|
109
|
+
from fractal_server.config import get_db_settings
|
|
100
110
|
|
|
101
111
|
import alembic.config
|
|
102
112
|
from pathlib import Path
|
|
103
113
|
import fractal_server
|
|
104
114
|
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
settings.check_db()
|
|
115
|
+
# Validate DB settings
|
|
116
|
+
Inject(get_db_settings)
|
|
108
117
|
|
|
109
118
|
# Perform migrations
|
|
110
119
|
alembic_ini = Path(fractal_server.__file__).parent / "alembic.ini"
|
|
@@ -113,8 +122,24 @@ def set_db(skip_init_data: bool = False):
|
|
|
113
122
|
alembic.config.main(argv=alembic_args)
|
|
114
123
|
print("END: alembic.config")
|
|
115
124
|
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
|
|
126
|
+
def init_db_data(
|
|
127
|
+
*,
|
|
128
|
+
resource: str,
|
|
129
|
+
profile: str,
|
|
130
|
+
) -> None:
|
|
131
|
+
from fractal_server.app.security import _create_first_user
|
|
132
|
+
from fractal_server.app.security import _create_first_group
|
|
133
|
+
from fractal_server.syringe import Inject
|
|
134
|
+
from fractal_server.config import get_init_data_settings
|
|
135
|
+
from fractal_server.app.db import get_sync_db
|
|
136
|
+
from sqlalchemy import select, func
|
|
137
|
+
from fractal_server.app.models.security import UserOAuth
|
|
138
|
+
from fractal_server.app.models import Resource, Profile
|
|
139
|
+
from fractal_server.app.schemas.v2.resource import validate_resource_data
|
|
140
|
+
from fractal_server.app.schemas.v2.profile import validate_profile_data
|
|
141
|
+
|
|
142
|
+
init_data_settings = Inject(get_init_data_settings)
|
|
118
143
|
|
|
119
144
|
# Create default group and user
|
|
120
145
|
print()
|
|
@@ -122,17 +147,94 @@ def set_db(skip_init_data: bool = False):
|
|
|
122
147
|
print()
|
|
123
148
|
asyncio.run(
|
|
124
149
|
_create_first_user(
|
|
125
|
-
email=
|
|
150
|
+
email=init_data_settings.FRACTAL_DEFAULT_ADMIN_EMAIL,
|
|
126
151
|
password=(
|
|
127
|
-
|
|
152
|
+
init_data_settings.FRACTAL_DEFAULT_ADMIN_PASSWORD.get_secret_value() # noqa E501
|
|
128
153
|
),
|
|
129
|
-
username=settings.FRACTAL_DEFAULT_ADMIN_USERNAME,
|
|
130
154
|
is_superuser=True,
|
|
131
155
|
is_verified=True,
|
|
132
156
|
)
|
|
133
157
|
)
|
|
134
158
|
print()
|
|
135
159
|
|
|
160
|
+
# Create resource and profile
|
|
161
|
+
with next(get_sync_db()) as db:
|
|
162
|
+
# Preliminary check
|
|
163
|
+
num_resources = db.execute(select(func.count(Resource.id))).scalar()
|
|
164
|
+
if num_resources != 0:
|
|
165
|
+
print(f"There exist already {num_resources=} resources. Exit.")
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
# Get resource/profile data
|
|
169
|
+
if resource == "default":
|
|
170
|
+
_python_version = (
|
|
171
|
+
f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
172
|
+
)
|
|
173
|
+
resource_data = {
|
|
174
|
+
"name": "Local resource",
|
|
175
|
+
"type": ResourceType.LOCAL,
|
|
176
|
+
"jobs_local_dir": (Path.cwd() / "data-jobs").as_posix(),
|
|
177
|
+
"tasks_local_dir": (Path.cwd() / "data-tasks").as_posix(),
|
|
178
|
+
"tasks_python_config": {
|
|
179
|
+
"default_version": _python_version,
|
|
180
|
+
"versions": {
|
|
181
|
+
_python_version: sys.executable,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
"jobs_poll_interval": 0,
|
|
185
|
+
"jobs_runner_config": {},
|
|
186
|
+
"tasks_pixi_config": {},
|
|
187
|
+
}
|
|
188
|
+
print("Prepared default resource data.")
|
|
189
|
+
else:
|
|
190
|
+
with open(resource) as f:
|
|
191
|
+
resource_data = json.load(f)
|
|
192
|
+
print(f"Read resource data from {resource}.")
|
|
193
|
+
if profile == "default":
|
|
194
|
+
profile_data = {
|
|
195
|
+
"resource_type": "local",
|
|
196
|
+
"name": "Local profile",
|
|
197
|
+
}
|
|
198
|
+
print("Prepared default profile data.")
|
|
199
|
+
else:
|
|
200
|
+
with open(profile) as f:
|
|
201
|
+
profile_data = json.load(f)
|
|
202
|
+
print(f"Read profile data from {profile}.")
|
|
203
|
+
|
|
204
|
+
# Validate resource/profile data
|
|
205
|
+
try:
|
|
206
|
+
validate_resource_data(resource_data)
|
|
207
|
+
except ValidationError as e:
|
|
208
|
+
print(e)
|
|
209
|
+
sys.exit("ERROR: Invalid resource data.")
|
|
210
|
+
try:
|
|
211
|
+
validate_profile_data(profile_data)
|
|
212
|
+
except ValidationError as e:
|
|
213
|
+
print(e)
|
|
214
|
+
sys.exit("ERROR: Invalid profile data.")
|
|
215
|
+
|
|
216
|
+
# Create resource/profile db objects
|
|
217
|
+
resource_obj = Resource(**resource_data)
|
|
218
|
+
db.add(resource_obj)
|
|
219
|
+
db.commit()
|
|
220
|
+
db.refresh(resource_obj)
|
|
221
|
+
profile_data["resource_id"] = resource_obj.id
|
|
222
|
+
profile_obj = Profile(**profile_data)
|
|
223
|
+
db.add(profile_obj)
|
|
224
|
+
db.commit()
|
|
225
|
+
db.refresh(profile_obj)
|
|
226
|
+
|
|
227
|
+
# Associate profile to users
|
|
228
|
+
res = db.execute(select(UserOAuth))
|
|
229
|
+
users = res.unique().scalars().all()
|
|
230
|
+
for user in users:
|
|
231
|
+
print(f"Now set profile_id={profile_obj.id} for {user.email}.")
|
|
232
|
+
user.profile_id = profile_obj.id
|
|
233
|
+
db.add(user)
|
|
234
|
+
db.commit()
|
|
235
|
+
db.expunge_all()
|
|
236
|
+
print()
|
|
237
|
+
|
|
136
238
|
|
|
137
239
|
def update_db_data():
|
|
138
240
|
"""
|
|
@@ -219,7 +321,12 @@ def run():
|
|
|
219
321
|
if args.cmd == "openapi":
|
|
220
322
|
save_openapi(dest=args.openapi_file)
|
|
221
323
|
elif args.cmd == "set-db":
|
|
222
|
-
set_db(
|
|
324
|
+
set_db()
|
|
325
|
+
elif args.cmd == "init-db-data":
|
|
326
|
+
init_db_data(
|
|
327
|
+
resource=args.resource,
|
|
328
|
+
profile=args.profile,
|
|
329
|
+
)
|
|
223
330
|
elif args.cmd == "update-db-data":
|
|
224
331
|
update_db_data()
|
|
225
332
|
elif args.cmd == "start":
|
|
@@ -11,9 +11,9 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
11
11
|
from sqlalchemy.orm import Session as DBSyncSession
|
|
12
12
|
from sqlalchemy.orm import sessionmaker
|
|
13
13
|
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
from
|
|
14
|
+
from fractal_server.config import get_db_settings
|
|
15
|
+
from fractal_server.logger import set_logger
|
|
16
|
+
from fractal_server.syringe import Inject
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
logger = set_logger(__name__)
|
|
@@ -42,12 +42,11 @@ class DB:
|
|
|
42
42
|
|
|
43
43
|
@classmethod
|
|
44
44
|
def set_async_db(cls):
|
|
45
|
-
|
|
46
|
-
settings.check_db()
|
|
45
|
+
db_settings = Inject(get_db_settings)
|
|
47
46
|
|
|
48
47
|
cls._engine_async = create_async_engine(
|
|
49
|
-
|
|
50
|
-
echo=
|
|
48
|
+
db_settings.DATABASE_URL,
|
|
49
|
+
echo=db_settings.DB_ECHO,
|
|
51
50
|
future=True,
|
|
52
51
|
pool_pre_ping=True,
|
|
53
52
|
)
|
|
@@ -60,12 +59,11 @@ class DB:
|
|
|
60
59
|
|
|
61
60
|
@classmethod
|
|
62
61
|
def set_sync_db(cls):
|
|
63
|
-
|
|
64
|
-
settings.check_db()
|
|
62
|
+
db_settings = Inject(get_db_settings)
|
|
65
63
|
|
|
66
64
|
cls._engine_sync = create_engine(
|
|
67
|
-
|
|
68
|
-
echo=
|
|
65
|
+
db_settings.DATABASE_URL,
|
|
66
|
+
echo=db_settings.DB_ECHO,
|
|
69
67
|
future=True,
|
|
70
68
|
pool_pre_ping=True,
|
|
71
69
|
)
|
|
@@ -73,8 +73,9 @@ class UserOAuth(SQLModel, table=True):
|
|
|
73
73
|
is_active:
|
|
74
74
|
is_superuser:
|
|
75
75
|
is_verified:
|
|
76
|
-
username:
|
|
77
76
|
oauth_accounts:
|
|
77
|
+
user_settings_id:
|
|
78
|
+
profile_id:
|
|
78
79
|
settings:
|
|
79
80
|
"""
|
|
80
81
|
|
|
@@ -90,8 +91,6 @@ class UserOAuth(SQLModel, table=True):
|
|
|
90
91
|
is_superuser: bool = Field(default=False, nullable=False)
|
|
91
92
|
is_verified: bool = Field(default=False, nullable=False)
|
|
92
93
|
|
|
93
|
-
username: str | None = None
|
|
94
|
-
|
|
95
94
|
oauth_accounts: list["OAuthAccount"] = Relationship(
|
|
96
95
|
back_populates="user",
|
|
97
96
|
sa_relationship_kwargs={"lazy": "joined", "cascade": "all, delete"},
|
|
@@ -100,6 +99,11 @@ class UserOAuth(SQLModel, table=True):
|
|
|
100
99
|
user_settings_id: int | None = Field(
|
|
101
100
|
foreign_key="user_settings.id", default=None
|
|
102
101
|
)
|
|
102
|
+
profile_id: int | None = Field(
|
|
103
|
+
foreign_key="profile.id",
|
|
104
|
+
default=None,
|
|
105
|
+
ondelete="SET NULL",
|
|
106
|
+
)
|
|
103
107
|
settings: UserSettings | None = Relationship(
|
|
104
108
|
sa_relationship_kwargs=dict(lazy="selectin", cascade="all, delete")
|
|
105
109
|
)
|
|
@@ -15,8 +15,6 @@ class UserSettings(SQLModel, table=True):
|
|
|
15
15
|
ssh_host: SSH-reachable host where a SLURM client is available.
|
|
16
16
|
ssh_username: User on `ssh_host`.
|
|
17
17
|
ssh_private_key_path: Path of private SSH key for `ssh_username`.
|
|
18
|
-
ssh_tasks_dir: Task-venvs base folder on `ssh_host`.
|
|
19
|
-
ssh_jobs_dir: Jobs base folder on `ssh_host`.
|
|
20
18
|
slurm_user: Local user, to be impersonated via `sudo -u`
|
|
21
19
|
project_dir: Folder where `slurm_user` can write.
|
|
22
20
|
"""
|
|
@@ -30,7 +28,5 @@ class UserSettings(SQLModel, table=True):
|
|
|
30
28
|
ssh_host: str | None = None
|
|
31
29
|
ssh_username: str | None = None
|
|
32
30
|
ssh_private_key_path: str | None = None
|
|
33
|
-
ssh_tasks_dir: str | None = None
|
|
34
|
-
ssh_jobs_dir: str | None = None
|
|
35
31
|
slurm_user: str | None = None
|
|
36
32
|
project_dir: str | None = None
|
|
@@ -9,7 +9,9 @@ from .history import HistoryImageCache
|
|
|
9
9
|
from .history import HistoryRun
|
|
10
10
|
from .history import HistoryUnit
|
|
11
11
|
from .job import JobV2
|
|
12
|
+
from .profile import Profile
|
|
12
13
|
from .project import ProjectV2
|
|
14
|
+
from .resource import Resource
|
|
13
15
|
from .task import TaskV2
|
|
14
16
|
from .task_group import TaskGroupActivityV2
|
|
15
17
|
from .task_group import TaskGroupV2
|
|
@@ -31,4 +33,6 @@ __all__ = [
|
|
|
31
33
|
"HistoryRun",
|
|
32
34
|
"HistoryUnit",
|
|
33
35
|
"HistoryImageCache",
|
|
36
|
+
"Resource",
|
|
37
|
+
"Profile",
|
|
34
38
|
]
|
|
@@ -8,9 +8,8 @@ from sqlalchemy.types import DateTime
|
|
|
8
8
|
from sqlmodel import Field
|
|
9
9
|
from sqlmodel import SQLModel
|
|
10
10
|
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from fractal_server.types import AttributeFilters
|
|
11
|
+
from fractal_server.app.schemas.v2 import JobStatusTypeV2
|
|
12
|
+
from fractal_server.utils import get_timestamp
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
class JobV2(SQLModel, table=True):
|
|
@@ -57,7 +56,7 @@ class JobV2(SQLModel, table=True):
|
|
|
57
56
|
log: str | None = None
|
|
58
57
|
executor_error_log: str | None = None
|
|
59
58
|
|
|
60
|
-
attribute_filters:
|
|
59
|
+
attribute_filters: dict[str, list[int | float | str | bool]] = Field(
|
|
61
60
|
sa_column=Column(JSONB, nullable=False, server_default="{}")
|
|
62
61
|
)
|
|
63
62
|
type_filters: dict[str, bool] = Field(
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sqlmodel import Field
|
|
2
|
+
from sqlmodel import SQLModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Profile(SQLModel, table=True):
|
|
6
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
7
|
+
resource_id: int = Field(foreign_key="resource.id", ondelete="CASCADE")
|
|
8
|
+
resource_type: str
|
|
9
|
+
|
|
10
|
+
name: str = Field(unique=True)
|
|
11
|
+
|
|
12
|
+
username: str | None = None
|
|
13
|
+
ssh_key_path: str | None = None
|
|
14
|
+
|
|
15
|
+
jobs_remote_dir: str | None = None
|
|
16
|
+
tasks_remote_dir: str | None = None
|
|
@@ -14,6 +14,9 @@ from fractal_server.utils import get_timestamp
|
|
|
14
14
|
class ProjectV2(SQLModel, table=True):
|
|
15
15
|
id: int | None = Field(default=None, primary_key=True)
|
|
16
16
|
name: str
|
|
17
|
+
resource_id: int | None = Field(
|
|
18
|
+
foreign_key="resource.id", default=None, ondelete="SET NULL"
|
|
19
|
+
)
|
|
17
20
|
timestamp_created: datetime = Field(
|
|
18
21
|
default_factory=get_timestamp,
|
|
19
22
|
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Column
|
|
6
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
7
|
+
from sqlalchemy.types import DateTime
|
|
8
|
+
from sqlmodel import CheckConstraint
|
|
9
|
+
from sqlmodel import Field
|
|
10
|
+
from sqlmodel import SQLModel
|
|
11
|
+
|
|
12
|
+
from fractal_server.utils import get_timestamp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Resource(SQLModel, table=True):
|
|
16
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
17
|
+
|
|
18
|
+
type: str
|
|
19
|
+
"""
|
|
20
|
+
One of `local`, `slurm_sudo` or `slurm_ssh` - matching with
|
|
21
|
+
`settings.FRACTAL_RUNNER_BACKEND`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name: str = Field(unique=True)
|
|
25
|
+
"""
|
|
26
|
+
Resource name.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
timestamp_created: datetime = Field(
|
|
30
|
+
default_factory=get_timestamp,
|
|
31
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
32
|
+
)
|
|
33
|
+
"""
|
|
34
|
+
Creation timestamp (autogenerated).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
host: str | None = None
|
|
38
|
+
"""
|
|
39
|
+
Address for ssh connections, when `type="slurm_ssh"`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
jobs_local_dir: str
|
|
43
|
+
"""
|
|
44
|
+
Base local folder for job subfolders (containing artifacts and logs).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
jobs_runner_config: dict[str, Any] = Field(
|
|
48
|
+
sa_column=Column(JSONB, nullable=False, server_default="{}")
|
|
49
|
+
)
|
|
50
|
+
"""
|
|
51
|
+
Runner configuration, matching one of `JobRunnerConfigLocal` or
|
|
52
|
+
`JobRunnerConfigSLURM` schemas.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
jobs_slurm_python_worker: str | None = None
|
|
56
|
+
"""
|
|
57
|
+
On SLURM deloyments, this is the Python interpreter that runs the
|
|
58
|
+
`fractal-server` worker from within the SLURM jobs.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
jobs_poll_interval: int
|
|
62
|
+
"""
|
|
63
|
+
On SLURM resources: the interval to wait before new `squeue` calls.
|
|
64
|
+
On local resources: ignored.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# task_settings
|
|
68
|
+
tasks_local_dir: str
|
|
69
|
+
"""
|
|
70
|
+
Base local folder for task-package subfolders.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
tasks_python_config: dict[str, Any] = Field(
|
|
74
|
+
sa_column=Column(JSONB, nullable=False, server_default="{}")
|
|
75
|
+
)
|
|
76
|
+
"""
|
|
77
|
+
Python configuration for task collection. Example:
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"default_version": "3.10",
|
|
81
|
+
"versions:{
|
|
82
|
+
"3.10": "/xxx/venv-3.10/bin/python",
|
|
83
|
+
"3.11": "/xxx/venv-3.11/bin/python",
|
|
84
|
+
"3.12": "/xxx/venv-3.12/bin/python"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
tasks_pixi_config: dict[str, Any] = Field(
|
|
91
|
+
sa_column=Column(JSONB, nullable=False, server_default="{}")
|
|
92
|
+
)
|
|
93
|
+
"""
|
|
94
|
+
Pixi configuration for task collection. Basic example:
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"default_version": "0.41.0",
|
|
98
|
+
"versions": {
|
|
99
|
+
"0.40.0": "/xxx/pixi/0.40.0/",
|
|
100
|
+
"0.41.0": "/xxx/pixi/0.41.0/"
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def pip_cache_dir_arg(self: Self) -> str:
|
|
108
|
+
"""
|
|
109
|
+
If `pip_cache_dir` is set (in `self.tasks_python_config`), then
|
|
110
|
+
return `--cache_dir /something`; else return `--no-cache-dir`.
|
|
111
|
+
"""
|
|
112
|
+
_pip_cache_dir = self.tasks_python_config.get("pip_cache_dir", None)
|
|
113
|
+
if _pip_cache_dir is not None:
|
|
114
|
+
return f"--cache-dir {_pip_cache_dir}"
|
|
115
|
+
else:
|
|
116
|
+
return "--no-cache-dir"
|
|
117
|
+
|
|
118
|
+
# Check constraints
|
|
119
|
+
__table_args__ = (
|
|
120
|
+
# `type` column must be one of "local", "slurm_sudo" or "slurm_ssh"
|
|
121
|
+
CheckConstraint(
|
|
122
|
+
"type IN ('local', 'slurm_sudo', 'slurm_ssh')",
|
|
123
|
+
name="ck_resource_correct_type",
|
|
124
|
+
),
|
|
125
|
+
# If `type` is not "local", `jobs_slurm_python_worker` must be set
|
|
126
|
+
CheckConstraint(
|
|
127
|
+
"(type = 'local') OR (jobs_slurm_python_worker IS NOT NULL)",
|
|
128
|
+
name="ck_resource_jobs_slurm_python_worker_set",
|
|
129
|
+
),
|
|
130
|
+
)
|
|
@@ -42,6 +42,9 @@ class TaskGroupV2(SQLModel, table=True):
|
|
|
42
42
|
user_group_id: int | None = Field(
|
|
43
43
|
foreign_key="usergroup.id", default=None, ondelete="SET NULL"
|
|
44
44
|
)
|
|
45
|
+
resource_id: int | None = Field(
|
|
46
|
+
foreign_key="resource.id", default=None, ondelete="SET NULL"
|
|
47
|
+
)
|
|
45
48
|
|
|
46
49
|
origin: str
|
|
47
50
|
pkg_name: str
|
|
@@ -6,7 +6,9 @@ from fastapi import APIRouter
|
|
|
6
6
|
from .accounting import router as accounting_router
|
|
7
7
|
from .impersonate import router as impersonate_router
|
|
8
8
|
from .job import router as job_router
|
|
9
|
+
from .profile import router as profile_router
|
|
9
10
|
from .project import router as project_router
|
|
11
|
+
from .resource import router as resource_router
|
|
10
12
|
from .task import router as task_router
|
|
11
13
|
from .task_group import router as task_group_router
|
|
12
14
|
from .task_group_lifecycle import router as task_group_lifecycle_router
|
|
@@ -22,3 +24,5 @@ router_admin_v2.include_router(
|
|
|
22
24
|
task_group_lifecycle_router, prefix="/task-group"
|
|
23
25
|
)
|
|
24
26
|
router_admin_v2.include_router(impersonate_router, prefix="/impersonate")
|
|
27
|
+
router_admin_v2.include_router(resource_router, prefix="/resource")
|
|
28
|
+
router_admin_v2.include_router(profile_router, prefix="/profile")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from fastapi import HTTPException
|
|
2
|
+
from fastapi import status
|
|
3
|
+
from sqlmodel import select
|
|
4
|
+
|
|
5
|
+
from fractal_server.app.db import AsyncSession
|
|
6
|
+
from fractal_server.app.models.v2 import Profile
|
|
7
|
+
from fractal_server.app.models.v2 import Resource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _get_resource_or_404(
|
|
11
|
+
*,
|
|
12
|
+
resource_id: int,
|
|
13
|
+
db: AsyncSession,
|
|
14
|
+
) -> Resource:
|
|
15
|
+
resource = await db.get(Resource, resource_id)
|
|
16
|
+
if resource is None:
|
|
17
|
+
raise HTTPException(
|
|
18
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
19
|
+
detail=f"Resource {resource_id} not found",
|
|
20
|
+
)
|
|
21
|
+
return resource
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _get_profile_or_404(
|
|
25
|
+
*,
|
|
26
|
+
profile_id: int,
|
|
27
|
+
db: AsyncSession,
|
|
28
|
+
) -> Profile:
|
|
29
|
+
profile = await db.get(Profile, profile_id)
|
|
30
|
+
if profile is None:
|
|
31
|
+
raise HTTPException(
|
|
32
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
33
|
+
detail=f"Profile {profile_id} not found",
|
|
34
|
+
)
|
|
35
|
+
return profile
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _check_profile_name(*, name: str, db: AsyncSession) -> None:
|
|
39
|
+
res = await db.execute(select(Profile).where(Profile.name == name))
|
|
40
|
+
namesake = res.scalars().one_or_none()
|
|
41
|
+
if namesake is not None:
|
|
42
|
+
raise HTTPException(
|
|
43
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
44
|
+
detail=f"Profile with name '{name}' already exists.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _check_resource_name(*, name: str, db: AsyncSession) -> None:
|
|
49
|
+
res = await db.execute(select(Resource).where(Resource.name == name))
|
|
50
|
+
namesake = res.scalars().one_or_none()
|
|
51
|
+
if namesake is not None:
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
54
|
+
detail=f"Resource with name '{name}' already exists.",
|
|
55
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from fastapi import Depends
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
from fastapi import Response
|
|
5
|
+
from fastapi import status
|
|
6
|
+
from sqlmodel import func
|
|
7
|
+
from sqlmodel import select
|
|
8
|
+
|
|
9
|
+
from ._aux_functions import _check_profile_name
|
|
10
|
+
from ._aux_functions import _get_profile_or_404
|
|
11
|
+
from fractal_server.app.db import AsyncSession
|
|
12
|
+
from fractal_server.app.db import get_async_db
|
|
13
|
+
from fractal_server.app.models import UserOAuth
|
|
14
|
+
from fractal_server.app.routes.auth import current_active_superuser
|
|
15
|
+
from fractal_server.app.schemas.v2 import ProfileCreate
|
|
16
|
+
from fractal_server.app.schemas.v2 import ProfileRead
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/{profile_id}/", response_model=ProfileRead, status_code=200)
|
|
22
|
+
async def get_single_profile(
|
|
23
|
+
profile_id: int,
|
|
24
|
+
superuser: UserOAuth = Depends(current_active_superuser),
|
|
25
|
+
db: AsyncSession = Depends(get_async_db),
|
|
26
|
+
) -> ProfileRead:
|
|
27
|
+
"""
|
|
28
|
+
Query single `Profile`.
|
|
29
|
+
"""
|
|
30
|
+
profile = await _get_profile_or_404(profile_id=profile_id, db=db)
|
|
31
|
+
return profile
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.put("/{profile_id}/", response_model=ProfileRead, status_code=200)
|
|
35
|
+
async def put_profile(
|
|
36
|
+
profile_id: int,
|
|
37
|
+
profile_update: ProfileCreate,
|
|
38
|
+
superuser: UserOAuth = Depends(current_active_superuser),
|
|
39
|
+
db: AsyncSession = Depends(get_async_db),
|
|
40
|
+
) -> ProfileRead:
|
|
41
|
+
"""
|
|
42
|
+
Override single `Profile`.
|
|
43
|
+
"""
|
|
44
|
+
profile = await _get_profile_or_404(profile_id=profile_id, db=db)
|
|
45
|
+
|
|
46
|
+
if profile_update.name and profile_update.name != profile.name:
|
|
47
|
+
await _check_profile_name(name=profile_update.name, db=db)
|
|
48
|
+
|
|
49
|
+
for key, value in profile_update.model_dump().items():
|
|
50
|
+
setattr(profile, key, value)
|
|
51
|
+
await db.commit()
|
|
52
|
+
await db.refresh(profile)
|
|
53
|
+
return profile
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.delete("/{profile_id}/", status_code=204)
|
|
57
|
+
async def delete_profile(
|
|
58
|
+
profile_id: int,
|
|
59
|
+
superuser: UserOAuth = Depends(current_active_superuser),
|
|
60
|
+
db: AsyncSession = Depends(get_async_db),
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Delete single `Profile`.
|
|
64
|
+
"""
|
|
65
|
+
profile = await _get_profile_or_404(profile_id=profile_id, db=db)
|
|
66
|
+
|
|
67
|
+
# Fail if at least one UserOAuth is associated with the Profile.
|
|
68
|
+
res = await db.execute(
|
|
69
|
+
select(func.count(UserOAuth.id)).where(
|
|
70
|
+
UserOAuth.profile_id == profile.id
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
associated_users_count = res.scalar()
|
|
74
|
+
if associated_users_count > 0:
|
|
75
|
+
raise HTTPException(
|
|
76
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
77
|
+
detail=(
|
|
78
|
+
f"Cannot delete Profile {profile_id} because it's associated"
|
|
79
|
+
f" with {associated_users_count} UserOAuths."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Delete
|
|
84
|
+
await db.delete(profile)
|
|
85
|
+
await db.commit()
|
|
86
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|