Skip to content

Commit f1e2d74

Browse files
authored
♻️ Is638/dy sidecar refactoring (round 6) (ITISFoundation#3179)
1 parent 6dcd0e7 commit f1e2d74

File tree

13 files changed

+203
-116
lines changed

13 files changed

+203
-116
lines changed

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

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import asyncio
22
import logging
33
from collections import deque
4+
from dataclasses import dataclass
45
from functools import wraps
5-
from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, List, Optional
6-
7-
import attr
6+
from typing import TYPE_CHECKING, Any, Callable, Deque, Optional
87

98
logger = logging.getLogger(__name__)
109

@@ -22,15 +21,15 @@ class Queue(
2221
pass
2322

2423

25-
@attr.s(auto_attribs=True)
24+
@dataclass
2625
class Context:
2726
in_queue: asyncio.Queue
2827
out_queue: asyncio.Queue
2928
initialized: bool
3029
task: Optional[asyncio.Task] = None
3130

3231

33-
_sequential_jobs_contexts: Dict[str, Context] = {}
32+
_sequential_jobs_contexts: dict[str, Context] = {}
3433

3534

3635
async def stop_sequential_workers() -> None:
@@ -44,8 +43,8 @@ async def stop_sequential_workers() -> None:
4443

4544

4645
def run_sequentially_in_context(
47-
target_args: List[str] = None,
48-
) -> Callable[[Any], Any]:
46+
target_args: Optional[list[str]] = None,
47+
) -> Callable:
4948
"""All request to function with same calling context will be run sequentially.
5049
5150
Example:
@@ -83,10 +82,8 @@ async def func(param1, param2, param3):
8382
"""
8483
target_args = [] if target_args is None else target_args
8584

86-
def internal(
87-
decorated_function: Callable[[Any], Optional[Any]]
88-
) -> Callable[[Any], Optional[Any]]:
89-
def get_context(args: Any, kwargs: Dict[Any, Any]) -> Context:
85+
def decorator(decorated_function: Callable[[Any], Optional[Any]]):
86+
def _get_context(args: Any, kwargs: dict) -> Context:
9087
arg_names = decorated_function.__code__.co_varnames[
9188
: decorated_function.__code__.co_argcount
9289
]
@@ -98,17 +95,17 @@ def get_context(args: Any, kwargs: Dict[Any, Any]) -> Context:
9895
sub_args = arg.split(".")
9996
main_arg = sub_args[0]
10097
if main_arg not in search_args:
101-
message = (
98+
raise ValueError(
10299
f"Expected '{main_arg}' in '{decorated_function.__name__}'"
103100
f" arguments. Got '{search_args}'"
104101
)
105-
raise ValueError(message)
106102
context_key = search_args[main_arg]
107103
for attribute in sub_args[1:]:
108104
potential_key = getattr(context_key, attribute)
109105
if not potential_key:
110-
message = f"Expected '{attribute}' attribute in '{context_key.__name__}' arguments."
111-
raise ValueError(message)
106+
raise ValueError(
107+
f"Expected '{attribute}' attribute in '{context_key.__name__}' arguments."
108+
)
112109
context_key = potential_key
113110

114111
key_parts.append(f"{decorated_function.__name__}_{context_key}")
@@ -124,9 +121,10 @@ def get_context(args: Any, kwargs: Dict[Any, Any]) -> Context:
124121

125122
return _sequential_jobs_contexts[key]
126123

124+
# --------------------
127125
@wraps(decorated_function)
128126
async def wrapper(*args: Any, **kwargs: Any) -> Any:
129-
context: Context = get_context(args, kwargs)
127+
context: Context = _get_context(args, kwargs)
130128

131129
if not context.initialized:
132130
context.initialized = True
@@ -164,4 +162,4 @@ async def worker(in_q: Queue, out_q: Queue) -> None:
164162

165163
return wrapper
166164

167-
return internal
165+
return decorator

packages/service-library/src/servicelib/fastapi/openapi.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010

1111
from ..functools_utils import copy_func
1212

13-
# Some common values for FastAPI(... server=[ ... ]) parameter
14-
# It will be added to the OpenAPI Specs (OAS).
13+
# SEE https://swagger.io/docs/specification/api-host-and-base-path/
14+
_OAS_DEFAULT_SERVER = {
15+
"description": "Default server: requests directed to serving url",
16+
"url": "/",
17+
}
1518
_OAS_DEVELOPMENT_SERVER = {
16-
"description": "Development server",
19+
"description": "Development server: can configure any base url",
1720
"url": "http://{host}:{port}",
1821
"variables": {
1922
"host": {"default": "127.0.0.1"},
@@ -24,14 +27,14 @@
2427

2528
def get_common_oas_options(is_devel_mode: bool) -> dict[str, Any]:
2629
"""common OAS options for FastAPI constructor"""
27-
servers = None
30+
servers = [
31+
_OAS_DEFAULT_SERVER,
32+
]
2833
if is_devel_mode:
2934
# NOTE: for security, only exposed in devel mode
3035
# Make sure also that this is NOT used in edge services
3136
# SEE https://sonarcloud.io/project/security_hotspots?id=ITISFoundation_osparc-simcore&pullRequest=3165&hotspots=AYHPqDfX5LRQZ1Ko6y4-
32-
servers = [
33-
_OAS_DEVELOPMENT_SERVER,
34-
]
37+
servers.append(_OAS_DEVELOPMENT_SERVER)
3538

3639
return dict(
3740
servers=servers,

packages/service-library/tests/test_async_utils.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
# pylint: disable=redefined-outer-name
22
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
34

45
import asyncio
56
import copy
67
import random
78
from collections import deque
89
from dataclasses import dataclass
910
from time import time
10-
from typing import Any, AsyncIterable, Dict, List, Optional
11+
from typing import Any, AsyncIterable, Optional
1112

1213
import pytest
14+
from faker import Faker
1315
from servicelib.async_utils import (
1416
_sequential_jobs_contexts,
1517
run_sequentially_in_context,
@@ -30,8 +32,8 @@ async def ensure_run_in_sequence_context_is_empty() -> AsyncIterable[None]:
3032

3133

3234
@pytest.fixture
33-
def payload() -> str:
34-
return "some string payload"
35+
def payload(faker: Faker) -> str:
36+
return faker.text()
3537

3638

3739
@pytest.fixture
@@ -55,7 +57,7 @@ async def push(self, item: Any):
5557
async with self._lock:
5658
self._queue.append(item)
5759

58-
async def get_all(self) -> List[Any]:
60+
async def get_all(self) -> list[Any]:
5961
async with self._lock:
6062
return list(self._queue)
6163

@@ -78,7 +80,7 @@ async def orderly(c1: Any, c2: Any, c3: Any, control: Any) -> None:
7880
context = dict(c1=c1, c2=c2, c3=c3)
7981
await locked_stores[make_key_from_context(context)].push(control)
8082

81-
def make_key_from_context(context: Dict) -> str:
83+
def make_key_from_context(context: dict) -> str:
8284
return ".".join([f"{k}:{v}" for k, v in context.items()])
8385

8486
def make_context():

services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/task.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,9 @@ async def observing_single_service(service_name: str) -> None:
365365

366366
error_code = create_error_code(e)
367367
logger.exception(
368-
"Observation of %s unexpectedly failed",
369-
f"{service_name=}",
368+
"Observation of %s unexpectedly failed [%s]",
369+
f"{service_name=} ",
370+
f"{error_code}",
370371
extra={"error_code": error_code},
371372
)
372373
scheduler_data.dynamic_sidecar.status.update_failing_status(

services/dynamic-sidecar/docker/boot.sh

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ IFS=$(printf '\n\t')
66

77
INFO="INFO: [$(basename "$0")] "
88

9-
# BOOTING application ---------------------------------------------
109
echo "$INFO" "Booting in ${SC_BOOT_MODE} mode ..."
1110
echo "$INFO" "User :$(id "$(whoami)")"
1211
echo "$INFO" "Workdir : $(pwd)"
1312

13+
#
14+
# DEVELOPMENT MODE
15+
#
16+
# - prints environ info
17+
# - installs requirements in mounted volume
18+
#
1419
if [ "${SC_BUILD_TARGET}" = "development" ]; then
1520
echo "$INFO" "Environment :"
1621
printenv | sed 's/=/: /' | sed 's/^/ /' | sort
@@ -25,8 +30,10 @@ if [ "${SC_BUILD_TARGET}" = "development" ]; then
2530
pip list | sed 's/^/ /'
2631
fi
2732

28-
# RUNNING application ----------------------------------------
29-
APP_LOG_LEVEL=${DIRECTOR_V2_LOGLEVEL:-${LOG_LEVEL:-${LOGLEVEL:-INFO}}}
33+
#
34+
# RUNNING application
35+
#
36+
APP_LOG_LEVEL=${DYNAMIC_SIDECAR_LOGLEVEL:-${LOG_LEVEL:-${LOGLEVEL:-INFO}}}
3037
SERVER_LOG_LEVEL=$(echo "${APP_LOG_LEVEL}" | tr '[:upper:]' '[:lower:]')
3138
echo "$INFO" "Log-level app/server: $APP_LOG_LEVEL/$SERVER_LOG_LEVEL"
3239

@@ -46,5 +53,4 @@ else
4653
exec uvicorn simcore_service_dynamic_sidecar.main:the_app \
4754
--host 0.0.0.0 \
4855
--log-level "${SERVER_LOG_LEVEL}"
49-
5056
fi

services/dynamic-sidecar/openapi.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
"version": "1.1.0"
77
},
88
"servers": [
9+
{
10+
"url": "/",
11+
"description": "Default server: requests directed to serving url"
12+
},
913
{
1014
"url": "http://{host}:{port}",
11-
"description": "Development server",
15+
"description": "Development server: can configure any base url",
1216
"variables": {
1317
"host": {
1418
"default": "127.0.0.1"

services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/core/application.py

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from typing import Optional
23

34
from fastapi import FastAPI
45
from models_library.basic_types import BootModeEnum
@@ -29,19 +30,68 @@
2930
# https://patorjk.com/software/taag/#p=display&f=AMC%20Tubes&t=DYSIDECAR
3031
#
3132

32-
WELCOME_MSG = r"""
33+
APP_STARTED_BANNER_MSG = r"""
3334
d ss Ss sS sss. d d ss d sss sSSs. d s. d ss.
3435
S ~o S S d S S ~o S S S ~O S b
3536
S b S Y S S b S S S `b S P
3637
S S S ss. S S S S sSSs S S sSSO S sS'
3738
S P S b S S P S S S O S S
3839
S S S P S S S S S S O S S
39-
P ss" P ` ss' P P ss" P sSSss "sss' P P P P {}
40+
P ss" P ` ss' P P ss" P sSSss "sss' P P P P {} 🚀
4041
4142
""".format(
4243
f"v{__version__}"
4344
)
4445

46+
APP_FINISHED_BANNER_MSG = "{:=^100}".format("🎉 App shutdown completed 🎉")
47+
48+
49+
class AppState:
50+
"""Exposes states of an initialized app
51+
52+
Provides a stricter control on the read/write access
53+
of the different app.state fields during the app's lifespan
54+
"""
55+
56+
_STATES = {
57+
"settings": DynamicSidecarSettings,
58+
"mounted_volumes": MountedVolumes,
59+
"shared_store": SharedStore,
60+
}
61+
62+
def __init__(self, initialized_app: FastAPI):
63+
# guarantees app states are in place
64+
errors = [
65+
"app.state.{name}"
66+
for name, type_ in AppState._STATES.items()
67+
if not isinstance(getattr(initialized_app.state, name, None), type_)
68+
]
69+
if errors:
70+
raise ValueError(
71+
f"These app states were not properly initialized: {errors}"
72+
)
73+
74+
self._app = initialized_app
75+
76+
@property
77+
def settings(self) -> DynamicSidecarSettings:
78+
assert isinstance(self._app.state.settings, DynamicSidecarSettings) # nosec
79+
return self._app.state.settings
80+
81+
@property
82+
def mounted_volumes(self) -> MountedVolumes:
83+
assert isinstance(self._app.state.mounted_volumes, MountedVolumes) # nosec
84+
return self._app.state.mounted_volumes
85+
86+
@property
87+
def shared_store(self) -> SharedStore:
88+
assert isinstance(self._app.state.shared_store, SharedStore) # nosec
89+
return self._app.state.shared_store
90+
91+
@property
92+
def compose_spec(self) -> Optional[str]:
93+
return self.shared_store.compose_spec
94+
4595

4696
def setup_logger(settings: DynamicSidecarSettings):
4797
# SEE https://github.com/ITISFoundation/osparc-simcore/issues/3148
@@ -101,43 +151,33 @@ def create_app():
101151
app.add_exception_handler(BaseDynamicSidecarError, http_error_handler)
102152

103153
# EVENTS ---------------------
154+
app_state = AppState(app)
155+
104156
async def _on_startup() -> None:
105-
await login_registry(app.state.settings.REGISTRY_SETTINGS)
106-
await volumes_fix_permissions(app.state.mounted_volumes)
107-
print(WELCOME_MSG, flush=True)
157+
await login_registry(app_state.settings.REGISTRY_SETTINGS)
158+
await volumes_fix_permissions(app_state.mounted_volumes)
159+
# STARTED
160+
print(APP_STARTED_BANNER_MSG, flush=True)
108161

109162
async def _on_shutdown() -> None:
110-
logger.info("Going to remove spawned containers")
111-
result = await docker_compose_down(
112-
shared_store=app.state.shared_store,
113-
settings=app.state.settings,
114-
command_timeout=app.state.settings.DYNAMIC_SIDECAR_DOCKER_COMPOSE_DOWN_TIMEOUT,
115-
)
116-
logger.info("Container removal did_succeed=%s\n%s", result[0], result[1])
117-
118-
logger.info("shutdown cleanup completed")
163+
if docker_compose_yaml := app_state.compose_spec:
164+
logger.info("Removing spawned containers%s", docker_compose_yaml)
165+
166+
result = await docker_compose_down(
167+
app.state.shared_store,
168+
app.state.settings,
169+
command_timeout=app.state.settings.DYNAMIC_SIDECAR_DOCKER_COMPOSE_DOWN_TIMEOUT,
170+
)
171+
172+
logger.log(
173+
logging.INFO if result.success else logging.ERROR,
174+
"Removed spawned containers:\n%s",
175+
result.decoded_stdout,
176+
)
177+
# FINISHED
178+
print(APP_FINISHED_BANNER_MSG, flush=True)
119179

120180
app.add_event_handler("startup", _on_startup)
121181
app.add_event_handler("shutdown", _on_shutdown)
122182

123183
return app
124-
125-
126-
class AppState:
127-
def __init__(self, app: FastAPI):
128-
self._app = app
129-
130-
@property
131-
def settings(self) -> DynamicSidecarSettings:
132-
assert isinstance(self._app.state.settings, DynamicSidecarSettings) # nosec
133-
return self._app.state.settings
134-
135-
@property
136-
def mounted_volumes(self) -> MountedVolumes:
137-
assert isinstance(self._app.state.mounted_volumes, MountedVolumes) # nosec
138-
return self._app.state.mounted_volumes
139-
140-
@property
141-
def shared_store(self) -> SharedStore:
142-
assert isinstance(self._app.state.shared_store, SharedStore) # nosec
143-
return self._app.state.shared_store

services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/core/settings.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ class DynamicSidecarSettings(BaseCustomSettings, MixinLoggingSettings):
3232
)
3333

3434
# LOGGING
35-
LOG_LEVEL: str = Field(default="WARNING")
35+
LOG_LEVEL: str = Field(
36+
default="WARNING", env=["DYNAMIC_SIDECAR_LOGLEVEL", "LOG_LEVEL", "LOGLEVEL"]
37+
)
3638

3739
# SERVICE SERVER (see : https://www.uvicorn.org/settings/)
3840
DYNAMIC_SIDECAR_HOST: str = Field(

0 commit comments

Comments
 (0)