diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index 9c0d63ed8e9e..c2d274fec247 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -1321,7 +1321,7 @@ "programs" ], "summary": "Create Program Job", - "description": "Creates a job in a specific release with given inputs.\n\nNOTE: This operation does **not** start the job", + "description": "Creates a program job", "operationId": "create_program_job", "security": [ { @@ -1384,6 +1384,15 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_create_program_job_v0_programs__program_key__releases__version__jobs_post" + } + } + } + }, "responses": { "201": { "description": "Successful Response", @@ -6056,6 +6065,36 @@ ], "title": "Body_complete_multipart_upload_v0_files__file_id__complete_post" }, + "Body_create_program_job_v0_programs__program_key__releases__version__jobs_post": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "type": "object", + "title": "Body_create_program_job_v0_programs__program_key__releases__version__jobs_post" + }, "Body_upload_file_v0_files_content_put": { "properties": { "file": { @@ -7715,6 +7754,17 @@ ], "title": "Url", "description": "Link to get this resource" + }, + "version_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version Display" } }, "type": "object", @@ -7722,7 +7772,8 @@ "id", "version", "title", - "url" + "url", + "version_display" ], "title": "Program", "description": "A released program with a specific version", @@ -7732,7 +7783,8 @@ "maintainer": "info@itis.swiss", "title": "Sim4life", "url": "https://api.osparc.io/v0/solvers/simcore%2Fservices%2Fdynamic%2Fsim4life/releases/8.0.0", - "version": "8.0.0" + "version": "8.0.0", + "version_display": "8.0.0" } }, "RunningState": { diff --git a/services/api-server/src/simcore_service_api_server/_service_job.py b/services/api-server/src/simcore_service_api_server/_service_job.py index 1b7cd79a3171..5b6c477c6f14 100644 --- a/services/api-server/src/simcore_service_api_server/_service_job.py +++ b/services/api-server/src/simcore_service_api_server/_service_job.py @@ -4,13 +4,22 @@ from fastapi import Depends from models_library.api_schemas_webserver.projects import ProjectCreateNew, ProjectGet +from models_library.products import ProductName from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID +from models_library.users import UserID from pydantic import HttpUrl from servicelib.fastapi.app_state import SingletonInAppStateMixin from servicelib.logging_utils import log_context +from .api.dependencies.authentication import ( + get_current_user_id, + get_product_name, +) from .api.dependencies.webserver_http import get_webserver_session +from .api.dependencies.webserver_rpc import ( + get_wb_api_rpc_client, +) from .models.schemas.jobs import Job, JobInputs from .models.schemas.programs import Program from .models.schemas.solvers import Solver @@ -19,6 +28,7 @@ create_new_project_for_job, ) from .services_http.webserver import AuthSession +from .services_rpc.wb_api_server import WbApiRpcClient _logger = logging.getLogger(__name__) @@ -26,11 +36,22 @@ class JobService(SingletonInAppStateMixin): app_state_name = "JobService" _web_rest_api: AuthSession + _web_rpc_api: WbApiRpcClient + _user_id: UserID + _product_name: ProductName def __init__( - self, web_rest_api: Annotated[AuthSession, Depends(get_webserver_session)] + self, + *, + web_rest_api: Annotated[AuthSession, Depends(get_webserver_session)], + web_rpc_api: Annotated[WbApiRpcClient, Depends(get_wb_api_rpc_client)], + user_id: Annotated[UserID, Depends(get_current_user_id)], + product_name: Annotated[ProductName, Depends(get_product_name)], ): self._web_rest_api = web_rest_api + self._web_rpc_api = web_rpc_api + self._user_id = user_id + self._product_name = product_name async def create_job( self, @@ -41,7 +62,10 @@ async def create_job( parent_node_id: NodeID | None, url_for: Callable[..., HttpUrl], hidden: bool, + project_name: str | None, + description: str | None, ) -> tuple[Job, ProjectGet]: + """If no project_name is provided, the job name is used as project name.""" # creates NEW job as prototype pre_job = Job.create_job_from_solver_or_program( solver_or_program_name=solver_or_program.name, inputs=inputs @@ -50,7 +74,11 @@ async def create_job( logger=_logger, level=logging.DEBUG, msg=f"Creating job {pre_job.name}" ): project_in: ProjectCreateNew = create_new_project_for_job( - solver_or_program, pre_job, inputs + solver_or_program=solver_or_program, + job=pre_job, + inputs=inputs, + description=description, + project_name=project_name, ) new_project: ProjectGet = await self._web_rest_api.create_project( project_in, @@ -58,6 +86,12 @@ async def create_job( parent_project_uuid=parent_project_uuid, parent_node_id=parent_node_id, ) + await self._web_rpc_api.mark_project_as_job( + product_name=self._product_name, + user_id=self._user_id, + project_uuid=new_project.uuid, + job_parent_resource_name=pre_job.runner_name, + ) assert new_project # nosec assert new_project.uuid == pre_job.id # nosec diff --git a/services/api-server/src/simcore_service_api_server/api/routes/_constants.py b/services/api-server/src/simcore_service_api_server/api/routes/_constants.py index 5e9f4eb73b67..88a303f1e6e0 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/_constants.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/_constants.py @@ -26,6 +26,8 @@ "Please use `{}` instead.\n\n" ) +DEFAULT_MAX_STRING_LENGTH: Final[int] = 500 + def create_route_description( *, diff --git a/services/api-server/src/simcore_service_api_server/api/routes/programs.py b/services/api-server/src/simcore_service_api_server/api/routes/programs.py index be2f27fe2ef5..ef64b8712139 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/programs.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/programs.py @@ -1,15 +1,16 @@ +# pylint: disable=too-many-arguments import logging from collections.abc import Callable from typing import Annotated -from fastapi import APIRouter, Depends, Header, HTTPException, status +from fastapi import APIRouter, Body, Depends, Header, HTTPException, status from httpx import HTTPStatusError from models_library.api_schemas_storage.storage_schemas import ( LinkType, ) from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID -from pydantic import ByteSize, PositiveInt, ValidationError +from pydantic import ByteSize, PositiveInt, StringConstraints, ValidationError from servicelib.fastapi.dependencies import get_reverse_url_mapper from simcore_sdk.node_ports_common.constants import SIMCORE_LOCATION from simcore_sdk.node_ports_common.filemanager import ( @@ -19,6 +20,7 @@ from ..._service_job import JobService from ..._service_programs import ProgramService +from ...api.routes._constants import DEFAULT_MAX_STRING_LENGTH from ...models.basic_types import VersionStr from ...models.schemas.jobs import Job, JobInputs from ...models.schemas.programs import Program, ProgramKeyId @@ -81,11 +83,14 @@ async def create_program_job( product_name: Annotated[str, Depends(get_product_name)], x_simcore_parent_project_uuid: Annotated[ProjectID | None, Header()] = None, x_simcore_parent_node_id: Annotated[NodeID | None, Header()] = None, + name: Annotated[ + str | None, StringConstraints(max_length=DEFAULT_MAX_STRING_LENGTH), Body() + ] = None, + description: Annotated[ + str | None, StringConstraints(max_length=DEFAULT_MAX_STRING_LENGTH), Body() + ] = None, ): - """Creates a job in a specific release with given inputs. - - NOTE: This operation does **not** start the job - """ + """Creates a program job""" # ensures user has access to solver inputs = JobInputs(values={}) @@ -97,6 +102,8 @@ async def create_program_job( ) job, project = await job_service.create_job( + project_name=name, + description=description, solver_or_program=program, inputs=inputs, parent_project_uuid=x_simcore_parent_project_uuid, @@ -104,6 +111,7 @@ async def create_program_job( url_for=url_for, hidden=False, ) + # create workspace directory so files can be uploaded to it assert len(project.workbench) > 0 # nosec node_id = next(iter(project.workbench)) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py index 37c97d45e860..f594e6373389 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py @@ -32,14 +32,10 @@ from ...services_http.solver_job_models_converters import ( create_jobstatus_from_task, ) -from ...services_rpc.wb_api_server import WbApiRpcClient from ..dependencies.application import get_reverse_url_mapper from ..dependencies.authentication import get_current_user_id, get_product_name from ..dependencies.services import get_api_client from ..dependencies.webserver_http import AuthSession, get_webserver_session -from ..dependencies.webserver_rpc import ( - get_wb_api_rpc_client, -) from ._constants import ( FMSG_CHANGELOG_ADDED_IN_VERSION, FMSG_CHANGELOG_CHANGED_IN_VERSION, @@ -97,7 +93,6 @@ async def create_solver_job( user_id: Annotated[PositiveInt, Depends(get_current_user_id)], solver_service: Annotated[SolverService, Depends()], job_service: Annotated[JobService, Depends()], - wb_api_rpc: Annotated[WbApiRpcClient, Depends(get_wb_api_rpc_client)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], hidden: Annotated[bool, Query()] = True, @@ -116,7 +111,9 @@ async def create_solver_job( version=version, product_name=product_name, ) - job, project = await job_service.create_job( + job, _ = await job_service.create_job( + project_name=None, + description=None, solver_or_program=solver, inputs=inputs, url_for=url_for, @@ -125,12 +122,6 @@ async def create_solver_job( parent_node_id=x_simcore_parent_node_id, ) - await wb_api_rpc.mark_project_as_job( - product_name=product_name, - user_id=user_id, - project_uuid=project.uuid, - job_parent_resource_name=job.runner_name, - ) return job diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py b/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py index 19899f57ef6e..c6479bece287 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py @@ -24,8 +24,6 @@ StringConstraints, TypeAdapter, ValidationError, - ValidationInfo, - field_validator, ) from servicelib.logging_utils import LogLevelInt, LogMessageStr from starlette.datastructures import Headers @@ -275,20 +273,13 @@ class Job(BaseModel): } ) - @field_validator("name", mode="before") - @classmethod - def _check_name(cls, v, info: ValidationInfo): - _id = str(info.data["id"]) - if not v.endswith(f"/{_id}"): - msg = f"Resource name [{v}] and id [{_id}] do not match" - raise ValueError(msg) - return v - # constructors ------ @classmethod def create_now( - cls, parent_name: RelativeResourceName, inputs_checksum: str + cls, + parent_name: RelativeResourceName, + inputs_checksum: str, ) -> "Job": global_uuid = uuid4() diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/programs.py b/services/api-server/src/simcore_service_api_server/models/schemas/programs.py index 719af8a839e9..c8c9bcb2da13 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/programs.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/programs.py @@ -30,6 +30,8 @@ class Program(BaseService, ApiServerOutputSchema): """A released program with a specific version""" + version_display: str | None + model_config = ConfigDict( extra="ignore", json_schema_extra={ @@ -40,6 +42,7 @@ class Program(BaseService, ApiServerOutputSchema): "description": "Simulation framework", "maintainer": "info@itis.swiss", "url": "https://api.osparc.io/v0/solvers/simcore%2Fservices%2Fdynamic%2Fsim4life/releases/8.0.0", + "version_display": "8.0.0", } }, ) @@ -47,26 +50,42 @@ class Program(BaseService, ApiServerOutputSchema): @classmethod def create_from_image(cls, image_meta: ServiceMetaDataPublished) -> "Program": data = image_meta.model_dump( - include={"name", "key", "version", "description", "contact"}, + include={ + "name", + "key", + "version", + "description", + "contact", + "version_display", + }, ) return cls( id=data.pop("key"), version=data.pop("version"), title=data.pop("name"), url=None, + version_display=data.pop("version_display"), **data, ) @classmethod def create_from_service(cls, service: ServiceGetV2) -> "Program": data = service.model_dump( - include={"name", "key", "version", "description", "contact"}, + include={ + "name", + "key", + "version", + "description", + "contact", + "version_display", + }, ) return cls( id=data.pop("key"), version=data.pop("version"), title=data.pop("name"), url=None, + version_display=data.pop("version_display"), **data, ) diff --git a/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py b/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py index 81ea1683a782..1d7e8b7f92ca 100644 --- a/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py +++ b/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py @@ -3,7 +3,6 @@ services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py """ -import urllib.parse import uuid from collections.abc import Callable from datetime import UTC, datetime @@ -119,7 +118,12 @@ def get_node_id(project_id, solver_id) -> str: def create_new_project_for_job( - solver_or_program: Solver | Program, job: Job, inputs: JobInputs + *, + solver_or_program: Solver | Program, + job: Job, + inputs: JobInputs, + description: str | None = None, + project_name: str | None = None, ) -> ProjectCreateNew: """ Creates a project for a solver's job @@ -158,8 +162,9 @@ def create_new_project_for_job( return ProjectCreateNew( uuid=project_id, - name=job.name, # NOTE: this IS an identifier as well. MUST NOT be changed in the case of project APIs! - description=f"Study associated to solver job:\n{job_info}", + name=project_name or job.name, + description=description + or f"Study associated to solver/study/program job:\n{job_info}", thumbnail="https://via.placeholder.com/170x120.png", # type: ignore[arg-type] workbench={solver_id: solver_service}, ui=StudyUI( @@ -194,8 +199,6 @@ def create_job_from_project( raise ValidationError """ assert len(project.workbench) == 1 # nosec - assert solver_or_program.version in project.name # nosec - assert urllib.parse.quote_plus(solver_or_program.id) in project.name # nosec # get solver node node_id = next(iter(project.workbench.keys())) @@ -211,7 +214,9 @@ def create_job_from_project( job = Job( id=job_id, - name=project.name, + name=Job.compose_resource_name( + parent_name=solver_or_program_name, job_id=job_id + ), inputs_checksum=job_inputs.compute_checksum(), created_at=project.creation_date, # type: ignore[arg-type] runner_name=solver_or_program_name, diff --git a/services/api-server/tests/unit/test_api_programs.py b/services/api-server/tests/unit/test_api_programs.py index f087757e0993..da114246308a 100644 --- a/services/api-server/tests/unit/test_api_programs.py +++ b/services/api-server/tests/unit/test_api_programs.py @@ -1,17 +1,20 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -import json +# pylint: disable=too-many-arguments + from functools import partial from pathlib import Path from typing import Any import httpx import pytest +from common_library.json_serialization import json_loads from fastapi import status from httpx import AsyncClient from models_library.api_schemas_storage.storage_schemas import FileUploadSchema from models_library.users import UserID from pytest_mock import MockerFixture, MockType +from pytest_simcore.helpers.faker_factories import DEFAULT_FAKER from pytest_simcore.helpers.httpx_calls_capture_models import ( CreateRespxMockCallback, HttpApiCallCaptureModel, @@ -41,19 +44,32 @@ async def test_get_program_release( program = Program.model_validate(response.json()) assert program.id == program_key assert program.version == version + assert program.version_display is not None +@pytest.mark.parametrize( + "job_name,job_description", + [ + (None, None), + (DEFAULT_FAKER.name(), None), + (None, DEFAULT_FAKER.sentence()), + (DEFAULT_FAKER.name(), DEFAULT_FAKER.sentence()), + ], +) @pytest.mark.parametrize("capture_name", ["create_program_job_success.json"]) async def test_create_program_job( auth: httpx.BasicAuth, client: AsyncClient, mocked_webserver_rest_api_base, + mocked_webserver_rpc_api: dict[str, MockType], mocked_catalog_rpc_api: dict[str, MockType], create_respx_mock_from_capture: CreateRespxMockCallback, mocker: MockerFixture, user_id: UserID, capture_name: str, project_tests_dir: Path, + job_name: str | None, + job_description: str | None, ): mocker.patch( @@ -76,18 +92,33 @@ def _side_effect( response_body = capture.response_body - # first call defines the project uuid + # first call creates project if server_state.get("project_uuid") is None: - _project_uuid = json.loads(request.content.decode("utf-8")).get("uuid") + get_body_field = lambda field: json_loads( + request.content.decode("utf-8") + ).get(field) + + _project_uuid = get_body_field("uuid") assert _project_uuid server_state["project_uuid"] = _project_uuid + _name = get_body_field("name") + assert _name + server_state["name"] = _name + + _description = get_body_field("description") + assert _description + server_state["description"] = _description + + if job_name: + assert job_name == get_body_field("name") + if job_description: + assert job_description == get_body_field("description") + if request.url.path.endswith("/result"): - capture_uuid = response_body["data"]["uuid"] response_body["data"]["uuid"] = server_state["project_uuid"] - response_body["data"]["name"] = response_body["data"]["name"].replace( - capture_uuid, server_state["project_uuid"] - ) + response_body["data"]["name"] = server_state["name"] + response_body["data"]["description"] = server_state["description"] assert isinstance(response_body, dict) return response_body @@ -100,14 +131,15 @@ def _side_effect( side_effects_callbacks=3 * [partial(_side_effect, _server_state)], ) - # Arrange program_key = "simcore/services/dynamic/electrode-selector" version = "2.1.3" + body = {"name": job_name, "description": job_description} + response = await client.post( f"/{API_VTAG}/programs/{program_key}/releases/{version}/jobs", - # headers=headers, auth=auth, + json={k: v for k, v in body.items() if v is not None}, ) # Assert diff --git a/services/api-server/tests/unit/test_services_solver_job_models_converters.py b/services/api-server/tests/unit/test_services_solver_job_models_converters.py index 1e926f09c863..a7d32259165b 100644 --- a/services/api-server/tests/unit/test_services_solver_job_models_converters.py +++ b/services/api-server/tests/unit/test_services_solver_job_models_converters.py @@ -53,7 +53,13 @@ def test_create_project_model_for_job(faker: Faker): ) # body of create project! - createproject_body = create_new_project_for_job(solver, job, inputs) + createproject_body = create_new_project_for_job( + solver_or_program=solver, + job=job, + inputs=inputs, + description=None, + project_name=None, + ) # ensures one-to-one relation assert createproject_body.uuid == job.id @@ -216,15 +222,16 @@ def fake_url_for(*args, **kwargs) -> HttpUrl: ) assert job.id == project.uuid - assert job.name == project.name - url_field_names = {name for name in job.model_fields if name.endswith("url")} - assert all(getattr(job, _) for _ in url_field_names) + non_propagated_fields = { + name for name in job.model_fields if name.endswith("url") + }.union({"name"}) + assert all(getattr(job, _) for _ in non_propagated_fields) # this tends to be a problem assert job.inputs_checksum == expected_job.inputs_checksum - assert job.model_dump(exclude=url_field_names) == expected_job.model_dump( - exclude=url_field_names + assert job.model_dump(exclude=non_propagated_fields) == expected_job.model_dump( + exclude=non_propagated_fields )