Skip to content

Commit 73c7bba

Browse files
authored
Merge branch 'master' into pr-osparc-long-running-refactor-2
2 parents 2e752e4 + e5c45de commit 73c7bba

File tree

48 files changed

+532
-225
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+532
-225
lines changed

.github/workflows/ci-testing-deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ jobs:
335335
unit-test-webserver-02:
336336
needs: changes
337337
if: ${{ needs.changes.outputs.webserver == 'true' || github.event_name == 'push' || github.event.inputs.force_all_builds == 'true' }}
338-
timeout-minutes: 25 # if this timeout gets too small, then split the tests
338+
timeout-minutes: 35 # if this timeout gets too small, then split the tests
339339
name: "[unit] webserver 02"
340340
runs-on: ${{ matrix.os }}
341341
strategy:

.vscode/settings.template.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// This is a template. Clone and replace extension ".template.json" by ".json"
22
{
33
"autoDocstring.docstringFormat": "pep257",
4+
45
"editor.tabSize": 2,
56
"editor.insertSpaces": true,
67
"editor.detectIndentation": false,
@@ -33,8 +34,6 @@
3334
"python.analysis.typeCheckingMode": "basic",
3435
"python.analysis.extraPaths": [
3536
"./packages/aws-library/src",
36-
"./packages/common-library/src",
37-
"./packages/dask-task-models-library/src",
3837
"./packages/models-library/src",
3938
"./packages/postgres-database/src",
4039
"./packages/postgres-database/tests",

packages/service-library/src/servicelib/logging_errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,22 @@ def create_troubleshotting_log_message(
2727
error_context -- Additional context surrounding the exception, such as environment variables or function-specific data. This can be derived from exc.error_context() (relevant when using the OsparcErrorMixin)
2828
tip -- Helpful suggestions or possible solutions explaining why the error may have occurred and how it could potentially be resolved
2929
"""
30+
31+
def _collect_causes(exc: BaseException) -> str:
32+
causes = []
33+
current = exc.__cause__
34+
while current is not None:
35+
causes.append(f"[{type(current).__name__}]'{current}'")
36+
current = getattr(current, "__cause__", None)
37+
return " <- ".join(causes)
38+
3039
debug_data = json_dumps(
3140
{
3241
"exception_type": f"{type(error)}",
3342
"exception_details": f"{error}",
3443
"error_code": error_code,
3544
"context": error_context,
45+
"causes": _collect_causes(error),
3646
"tip": tip,
3747
},
3848
default=representation_encoder,

packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
RegisteredFunctionJob,
1717
RegisteredFunctionJobCollection,
1818
)
19+
from models_library.functions import FunctionUserAccessRights
1920
from models_library.products import ProductName
2021
from models_library.rabbitmq_basic_types import RPCMethodName
2122
from models_library.rest_pagination import PageMetaInfoLimitOffset
@@ -388,3 +389,21 @@ async def delete_function_job_collection(
388389
product_name=product_name,
389390
)
390391
assert result is None # nosec
392+
393+
394+
@log_decorator(_logger, level=logging.DEBUG)
395+
async def get_function_user_permissions(
396+
rabbitmq_rpc_client: RabbitMQRPCClient,
397+
*,
398+
user_id: UserID,
399+
product_name: ProductName,
400+
function_id: FunctionID,
401+
) -> FunctionUserAccessRights:
402+
result = await rabbitmq_rpc_client.request(
403+
WEBSERVER_RPC_NAMESPACE,
404+
TypeAdapter(RPCMethodName).validate_python("get_function_user_permissions"),
405+
function_id=function_id,
406+
user_id=user_id,
407+
product_name=product_name,
408+
)
409+
return TypeAdapter(FunctionUserAccessRights).validate_python(result)

services/api-server/src/simcore_service_api_server/api/routes/functions_routes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@
2121
RegisteredFunctionJobCollection,
2222
SolverFunctionJob,
2323
)
24+
from models_library.functions import FunctionUserAccessRights
2425
from models_library.functions_errors import (
26+
FunctionExecuteAccessDeniedError,
2527
FunctionInputsValidationError,
28+
FunctionReadAccessDeniedError,
2629
UnsupportedFunctionClassError,
2730
)
2831
from models_library.products import ProductName
@@ -371,6 +374,22 @@ async def run_function( # noqa: PLR0913
371374
job_service: Annotated[JobService, Depends(get_job_service)],
372375
) -> RegisteredFunctionJob:
373376

377+
user_permissions: FunctionUserAccessRights = (
378+
await wb_api_rpc.get_function_user_permissions(
379+
function_id=function_id, user_id=user_id, product_name=product_name
380+
)
381+
)
382+
if not user_permissions.read:
383+
raise FunctionReadAccessDeniedError(
384+
user_id=user_id,
385+
function_id=function_id,
386+
)
387+
if not user_permissions.execute:
388+
raise FunctionExecuteAccessDeniedError(
389+
user_id=user_id,
390+
function_id=function_id,
391+
)
392+
374393
from .function_jobs_routes import function_job_status
375394

376395
to_run_function = await wb_api_rpc.get_function(

services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
RegisteredFunctionJobCollection,
2424
)
2525
from models_library.api_schemas_webserver.licensed_items import LicensedItemRpcGetPage
26+
from models_library.functions import FunctionUserAccessRights
2627
from models_library.licenses import LicensedItemID
2728
from models_library.products import ProductName
2829
from models_library.projects import ProjectID
@@ -526,6 +527,20 @@ async def delete_function_job_collection(
526527
function_job_collection_id=function_job_collection_id,
527528
)
528529

530+
async def get_function_user_permissions(
531+
self,
532+
*,
533+
user_id: UserID,
534+
product_name: ProductName,
535+
function_id: FunctionID,
536+
) -> FunctionUserAccessRights:
537+
return await functions_rpc_interface.get_function_user_permissions(
538+
self._client,
539+
user_id=user_id,
540+
product_name=product_name,
541+
function_id=function_id,
542+
)
543+
529544

530545
def setup(app: FastAPI, rabbitmq_rmp_client: RabbitMQRPCClient):
531546
wb_api_rpc_client = WbApiRpcClient(_client=rabbitmq_rmp_client)

services/api-server/tests/unit/api_functions/test_api_routers_functions.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from uuid import uuid4
88

99
import httpx
10+
import respx
1011
from httpx import AsyncClient
1112
from models_library.api_schemas_webserver.functions import (
1213
FunctionJobCollection,
@@ -16,11 +17,14 @@
1617
RegisteredProjectFunction,
1718
RegisteredProjectFunctionJob,
1819
)
20+
from models_library.functions import FunctionUserAccessRights
1921
from models_library.functions_errors import (
2022
FunctionIDNotFoundError,
2123
FunctionReadAccessDeniedError,
2224
)
2325
from models_library.rest_pagination import PageMetaInfoLimitOffset
26+
from models_library.users import UserID
27+
from pytest_mock import MockType
2428
from servicelib.aiohttp import status
2529
from simcore_service_api_server._meta import API_VTAG
2630

@@ -580,3 +584,34 @@ async def test_list_function_job_collections_with_function_filter(
580584
RegisteredFunctionJobCollection.model_validate(data["items"][0])
581585
== mock_registered_function_job_collection
582586
)
587+
588+
589+
async def test_run_function_not_allowed(
590+
client: AsyncClient,
591+
mock_handler_in_functions_rpc_interface: Callable[[str, Any], None],
592+
mock_registered_function: RegisteredProjectFunction,
593+
auth: httpx.BasicAuth,
594+
user_id: UserID,
595+
mocked_webserver_rest_api_base: respx.MockRouter,
596+
mocked_webserver_rpc_api: dict[str, MockType],
597+
) -> None:
598+
"""Test that running a function is not allowed."""
599+
mock_handler_in_functions_rpc_interface(
600+
"get_function_user_permissions",
601+
FunctionUserAccessRights(
602+
user_id=user_id,
603+
execute=False,
604+
read=True,
605+
write=True,
606+
),
607+
)
608+
609+
response = await client.post(
610+
f"{API_VTAG}/functions/{mock_registered_function.uid}:run",
611+
json={},
612+
auth=auth,
613+
)
614+
assert response.status_code == status.HTTP_403_FORBIDDEN
615+
assert response.json()["errors"][0] == (
616+
f"Function {mock_registered_function.uid} execute access denied for user {user_id}"
617+
)

services/dask-sidecar/src/simcore_service_dask_sidecar/computational_sidecar/core.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from pprint import pformat
99
from types import TracebackType
10-
from typing import cast
10+
from typing import Final, cast
1111
from uuid import uuid4
1212

1313
from aiodocker import Docker
@@ -32,7 +32,6 @@
3232
from ..utils.dask import TaskPublisher
3333
from ..utils.files import (
3434
check_need_unzipping,
35-
log_partial_file_content,
3635
pull_file_from_remote,
3736
push_file_to_remote,
3837
)
@@ -49,8 +48,8 @@
4948
from .task_shared_volume import TaskSharedVolumes
5049

5150
_logger = logging.getLogger(__name__)
52-
_CONTAINER_WAIT_TIME_SECS = 2
53-
_MAX_LOGGED_FILE_CHARS = 40
51+
CONTAINER_WAIT_TIME_SECS = 2
52+
_TASK_PROCESSING_PROGRESS_WEIGHT: Final[float] = 0.99
5453

5554

5655
@dataclass(kw_only=True, frozen=True, slots=True)
@@ -148,17 +147,11 @@ async def _retrieve_output_data(
148147
upload_tasks = []
149148
for output_params in output_data.values():
150149
if isinstance(output_params, FileUrl):
151-
assert (
150+
assert ( # nosec
152151
output_params.file_mapping
153152
), f"{output_params.model_dump_json(indent=1)} expected resolved in TaskOutputData.from_task_output"
154153

155154
src_path = task_volumes.outputs_folder / output_params.file_mapping
156-
await log_partial_file_content(
157-
src_path,
158-
logger=_logger,
159-
log_level=logging.DEBUG,
160-
max_chars=_MAX_LOGGED_FILE_CHARS,
161-
)
162155
upload_tasks.append(
163156
push_file_to_remote(
164157
src_path,
@@ -274,7 +267,7 @@ async def run(self, command: list[str]) -> TaskOutputData:
274267
)
275268
# wait until the container finished, either success or fail or timeout
276269
while (container_data := await container.show())["State"]["Running"]:
277-
await asyncio.sleep(_CONTAINER_WAIT_TIME_SECS)
270+
await asyncio.sleep(CONTAINER_WAIT_TIME_SECS)
278271
if container_data["State"]["ExitCode"] > os.EX_OK:
279272
raise ServiceRuntimeError(
280273
service_key=self.task_parameters.image,

services/dask-sidecar/src/simcore_service_dask_sidecar/utils/files.py

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from settings_library.s3 import S3Settings
2121
from yarl import URL
2222

23-
_logger = logging.getLogger(__name__)
23+
logger = logging.getLogger(__name__)
2424

2525
HTTP_FILE_SYSTEM_SCHEMES: Final = ["http", "https"]
2626
S3_FILE_SYSTEM_SCHEMES: Final = ["s3", "s3a"]
@@ -208,7 +208,7 @@ async def pull_file_from_remote(
208208
await log_publishing_cb(
209209
f"Uncompressing '{download_dst_path.name}'...", logging.INFO
210210
)
211-
_logger.debug(
211+
logger.debug(
212212
"%s is a zip file and will be now uncompressed", download_dst_path
213213
)
214214
with repro_zipfile.ReproducibleZipFile(download_dst_path, "r") as zip_obj:
@@ -258,7 +258,7 @@ async def _push_file_to_remote(
258258
log_publishing_cb: LogPublishingCB,
259259
s3_settings: S3Settings | None,
260260
) -> None:
261-
_logger.debug("Uploading %s to %s...", file_to_upload, dst_url)
261+
logger.debug("Uploading %s to %s...", file_to_upload, dst_url)
262262
assert dst_url.path # nosec
263263

264264
storage_kwargs: S3FsSettingsDict | dict[str, Any] = {}
@@ -306,7 +306,7 @@ async def push_file_to_remote(
306306
await asyncio.get_event_loop().run_in_executor(
307307
None, zfp.write, src_path, src_path.name
308308
)
309-
_logger.debug("%s created.", archive_file_path)
309+
logger.debug("%s created.", archive_file_path)
310310
assert archive_file_path.exists() # nosec
311311
file_to_upload = archive_file_path
312312
await log_publishing_cb(
@@ -319,7 +319,7 @@ async def push_file_to_remote(
319319
)
320320

321321
if dst_url.scheme in HTTP_FILE_SYSTEM_SCHEMES:
322-
_logger.debug("destination is a http presigned link")
322+
logger.debug("destination is a http presigned link")
323323
await _push_file_to_http_link(file_to_upload, dst_url, log_publishing_cb)
324324
else:
325325
await _push_file_to_remote(
@@ -330,22 +330,3 @@ async def push_file_to_remote(
330330
f"Upload of '{src_path.name}' to '{dst_url.path.strip('/')}' complete",
331331
logging.INFO,
332332
)
333-
334-
335-
async def log_partial_file_content(
336-
src_path: Path, *, logger: logging.Logger, log_level: int, max_chars: int
337-
) -> None:
338-
if max_chars < 0:
339-
msg = "max_chars must be non-negative"
340-
raise ValueError(msg)
341-
if max_chars == 0:
342-
return
343-
if not src_path.exists():
344-
logger.log(log_level, "file does not exist: %s", src_path)
345-
return
346-
async with aiofiles.open(src_path, encoding="utf-8") as f:
347-
content = await f.read(max_chars + 1)
348-
if len(content) > max_chars:
349-
logger.log(log_level, "file content (truncated): %s...", content[:max_chars])
350-
else:
351-
logger.log(log_level, "file content: %s", content)

services/dask-sidecar/tests/unit/test_utils_files.py

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import asyncio
66
import hashlib
7-
import logging
87
import mimetypes
98
import zipfile
109
from collections.abc import AsyncIterable
@@ -22,7 +21,6 @@
2221
from settings_library.s3 import S3Settings
2322
from simcore_service_dask_sidecar.utils.files import (
2423
_s3fs_settings_from_s3_settings,
25-
log_partial_file_content,
2624
pull_file_from_remote,
2725
push_file_to_remote,
2826
)
@@ -513,48 +511,3 @@ async def test_push_file_to_remote_creates_reproducible_zip_archive(
513511
assert dst_path2.exists()
514512

515513
assert _compute_hash(dst_path1) == _compute_hash(dst_path2)
516-
517-
518-
async def test_log_partial_file_content(
519-
tmp_path: Path, caplog: pytest.LogCaptureFixture
520-
):
521-
# Create a file with known content
522-
file_content = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
523-
file_path = tmp_path / "testfile.txt"
524-
file_path.write_text(file_content)
525-
logger = logging.getLogger("pytest.utils.files")
526-
527-
# Case 1: file longer than max_chars
528-
with caplog.at_level(logging.DEBUG, logger=logger.name):
529-
await log_partial_file_content(
530-
file_path, logger=logger, log_level=logging.DEBUG, max_chars=10
531-
)
532-
assert any(
533-
"file content (truncated): abcdefghij..." in record.getMessage()
534-
for record in caplog.records
535-
)
536-
537-
# Case 2: file shorter than max_chars
538-
caplog.clear()
539-
short_content = "short"
540-
short_file = tmp_path / "short.txt"
541-
short_file.write_text(short_content)
542-
with caplog.at_level(logging.DEBUG, logger=logger.name):
543-
await log_partial_file_content(
544-
short_file, logger=logger, log_level=logging.DEBUG, max_chars=10
545-
)
546-
assert any(
547-
"file content: short" in record.getMessage() for record in caplog.records
548-
)
549-
550-
# Case 3: file does not exist
551-
caplog.clear()
552-
non_existent = tmp_path / "doesnotexist.txt"
553-
with caplog.at_level(logging.DEBUG, logger=logger.name):
554-
await log_partial_file_content(
555-
non_existent, logger=logger, log_level=logging.DEBUG, max_chars=10
556-
)
557-
assert any(
558-
f"file does not exist: {non_existent}" in record.getMessage()
559-
for record in caplog.records
560-
)

0 commit comments

Comments
 (0)