Skip to content

Commit c3e1573

Browse files
authored
✨ catalog: new service inherit icon and thumbnail from latest patched compatible releases (#7769)
1 parent 79d6080 commit c3e1573

File tree

9 files changed

+487
-160
lines changed

9 files changed

+487
-160
lines changed

services/catalog/src/simcore_service_catalog/core/background_tasks.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,29 +70,36 @@ def _by_version(t: tuple[ServiceKey, ServiceVersion]) -> Version:
7070
(service_key, service_version)
7171
]
7272
try:
73-
## Set deprecation date to null (is valid date value for postgres)
74-
75-
# DEFAULT policies
73+
# 1. Evaluate DEFAULT ownership and access rights
7674
(
7775
owner_gid,
7876
service_access_rights,
79-
) = await access_rights.evaluate_default_policy(app, service_metadata)
77+
) = await access_rights.evaluate_default_service_ownership_and_rights(
78+
app,
79+
service=service_metadata,
80+
product_name=app.state.default_product_name,
81+
)
8082

81-
# AUTO-UPGRADE PATCH policy
82-
inherited_access_rights = await access_rights.evaluate_auto_upgrade_policy(
83-
service_metadata, services_repo
83+
# 2. Inherit access rights from the latest compatible release
84+
inherited_data = await access_rights.inherit_from_latest_compatible_release(
85+
service_metadata=service_metadata,
86+
services_repo=services_repo,
8487
)
8588

86-
service_access_rights += inherited_access_rights
89+
# 3. Aggregates access rights and metadata updates
90+
service_access_rights += inherited_data["access_rights"]
8791
service_access_rights = access_rights.reduce_access_rights(
8892
service_access_rights
8993
)
9094

91-
# set the service in the DB
95+
metadata_updates = {
96+
**service_metadata.model_dump(exclude_unset=True),
97+
**inherited_data["metadata_updates"],
98+
}
99+
100+
# 4. Upsert values in database
92101
await services_repo.create_or_update_service(
93-
ServiceMetaDataDBCreate(
94-
**service_metadata.model_dump(exclude_unset=True), owner=owner_gid
95-
),
102+
ServiceMetaDataDBCreate(**metadata_updates, owner=owner_gid),
96103
service_access_rights,
97104
)
98105

services/catalog/src/simcore_service_catalog/service/access_rights.py

Lines changed: 123 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@
44
import operator
55
from collections.abc import Callable
66
from datetime import UTC, datetime
7-
from typing import cast
7+
from typing import Any, TypedDict, cast
88

99
import arrow
1010
from fastapi import FastAPI
11+
from models_library.groups import GroupID
12+
from models_library.products import ProductName
1113
from models_library.services import ServiceMetaDataPublished
1214
from models_library.services_types import ServiceKey, ServiceVersion
1315
from packaging.version import Version
1416
from pydantic.types import PositiveInt
1517
from sqlalchemy.ext.asyncio import AsyncEngine
1618

1719
from ..api._dependencies.director import get_director_client
18-
from ..models.services_db import ServiceAccessRightsDB
20+
from ..models.services_db import ServiceAccessRightsDB, ServiceMetaDataDBGet
1921
from ..repository.groups import GroupsRepository
2022
from ..repository.services import ServicesRepository
2123
from ..utils.versioning import as_version, is_patch_release
@@ -25,6 +27,11 @@
2527
_LEGACY_SERVICES_DATE: datetime = datetime(year=2020, month=8, day=19, tzinfo=UTC)
2628

2729

30+
class InheritedData(TypedDict):
31+
access_rights: list[ServiceAccessRightsDB]
32+
metadata_updates: dict[str, Any]
33+
34+
2835
def _is_frontend_service(service: ServiceMetaDataPublished) -> bool:
2936
return "/frontend/" in service.key
3037

@@ -41,36 +48,46 @@ async def _is_old_service(app: FastAPI, service: ServiceMetaDataPublished) -> bo
4148
return bool(service_build_data < _LEGACY_SERVICES_DATE)
4249

4350

44-
async def evaluate_default_policy(
45-
app: FastAPI, service: ServiceMetaDataPublished
46-
) -> tuple[PositiveInt | None, list[ServiceAccessRightsDB]]:
47-
"""Given a service, it returns the owner's group-id (gid) and a list of access rights following
48-
default access-rights policies
51+
async def evaluate_default_service_ownership_and_rights(
52+
app: FastAPI, *, service: ServiceMetaDataPublished, product_name: ProductName
53+
) -> tuple[GroupID | None, list[ServiceAccessRightsDB]]:
54+
"""Evaluates the owner (group_id) and the access rights for a service
4955
50-
- DEFAULT Access Rights policies:
51-
1. All services published in osparc prior 19.08.2020 will be visible to everyone (refered as 'old service').
52-
2. Services published after 19.08.2020 will be visible ONLY to his/her owner
53-
3. Front-end services are have execute-access to everyone
56+
This function determines:
57+
1. Who owns the service (based on contact or author email)
58+
2. Who can access the service based on the following rules:
59+
- All services published before August 19, 2020 (_LEGACY_SERVICES_DATE) are accessible to everyone
60+
- Services published after August 19, 2020 are only accessible to their owner
61+
- Frontend services are accessible to everyone regardless of publication date
5462
63+
Returns:
64+
A tuple containing:
65+
- The owner's group ID (gid) if found, None otherwise
66+
- A list of ServiceAccessRightsDB objects representing the default access rights
67+
for the service, including who can execute and/or modify the service
5568
5669
Raises:
57-
HTTPException: from calls to director's rest API. Maps director errors into catalog's server error
58-
SQLAlchemyError: from access to pg database
59-
ValidationError: from pydantic model errors
70+
HTTPException: If there's an error communicating with the director API
71+
SQLAlchemyError: If there's an error accessing the database
72+
ValidationError: If there's an error validating the Pydantic models
6073
"""
6174
db_engine: AsyncEngine = app.state.engine
6275

6376
groups_repo = GroupsRepository(db_engine)
6477
owner_gid = None
6578
group_ids: list[PositiveInt] = []
6679

80+
# 1. If service is old or frontend, we add the everyone group
6781
if _is_frontend_service(service) or await _is_old_service(app, service):
6882
everyone_gid = (await groups_repo.get_everyone_group()).gid
69-
_logger.debug("service %s:%s is old or frontend", service.key, service.version)
70-
# let's make that one available to everyone
71-
group_ids.append(everyone_gid)
83+
group_ids.append(everyone_gid) # let's make that one available to everyone
84+
_logger.debug(
85+
"service %s:%s is old or frontend. Set available to everyone",
86+
service.key,
87+
service.version,
88+
)
7289

73-
# try to find the owner
90+
# 2. Deducing the owner gid
7491
possible_owner_email = [service.contact] + [
7592
author.email for author in service.authors
7693
]
@@ -80,78 +97,129 @@ async def evaluate_default_policy(
8097
if possible_gid and not owner_gid:
8198
owner_gid = possible_gid
8299
if not owner_gid:
83-
_logger.warning("service %s:%s has no owner", service.key, service.version)
100+
_logger.warning("Service %s:%s has no owner", service.key, service.version)
84101
else:
85102
group_ids.append(owner_gid)
86103

87-
# we add the owner with full rights, unless it's everyone
104+
# 3. Aplying default access rights
88105
default_access_rights = [
89106
ServiceAccessRightsDB(
90107
key=service.key,
91108
version=service.version,
92109
gid=gid,
93110
execute_access=True,
94-
write_access=(gid == owner_gid),
95-
product_name=app.state.default_product_name,
111+
write_access=(
112+
gid == owner_gid
113+
), # we add the owner with full rights, unless it's everyone
114+
product_name=product_name,
96115
)
97116
for gid in set(group_ids)
98117
]
99118

100119
return (owner_gid, default_access_rights)
101120

102121

103-
async def evaluate_auto_upgrade_policy(
104-
service_metadata: ServiceMetaDataPublished, services_repo: ServicesRepository
105-
) -> list[ServiceAccessRightsDB]:
106-
# AUTO-UPGRADE PATCH policy:
107-
#
108-
# - Any new patch released, inherits the access rights from previous compatible version
109-
# - IDEA: add as option in the publication contract, i.e. in ServiceDockerData?
110-
# - Does NOT apply to front-end services
111-
#
112-
# SEE https://github.com/ITISFoundation/osparc-simcore/issues/2244)
113-
#
122+
async def _find_latest_patch_compatible_release(
123+
services_repo: ServicesRepository, *, service_metadata: ServiceMetaDataPublished
124+
) -> ServiceMetaDataDBGet | None:
125+
"""
126+
Finds the previous patched release for a service.
127+
128+
Args:
129+
services_repo: Instance of ServicesRepository for database access.
130+
service_metadata: Metadata of the service being evaluated.
131+
132+
Returns:
133+
Latest patch release of the service if it exists, otherwise None.
134+
"""
114135
if _is_frontend_service(service_metadata):
115-
return []
136+
return None
116137

117-
service_access_rights = []
118138
new_version: Version = as_version(service_metadata.version)
119-
latest_releases = await services_repo.list_service_releases(
139+
patch_releases_latest_first = await services_repo.list_service_releases(
120140
service_metadata.key,
121141
major=new_version.major,
122142
minor=new_version.minor,
123143
)
124144

125-
previous_release = None
126-
for release in latest_releases:
127-
# NOTE: latest_release is sorted from newer to older
128-
# Here we search for the previous version patched by new-version
145+
# latest_releases is sorted from newer to older
146+
for release in patch_releases_latest_first:
147+
# COMPATIBILITY RULE:
148+
# - a patch release is compatible with the previous patch release
149+
# - WARNING: this does not account for custom compatibility policies!!!!
129150
if is_patch_release(new_version, release.version):
130-
previous_release = release
131-
break
151+
return release
152+
153+
return None
154+
155+
156+
async def inherit_from_latest_compatible_release(
157+
services_repo: ServicesRepository, *, service_metadata: ServiceMetaDataPublished
158+
) -> InheritedData:
159+
"""
160+
Inherits metadata and access rights from a previous compatible release.
161+
162+
This function applies inheritance policies:
163+
- AUTO-UPGRADE PATCH policy: new patch releases inherit access rights from previous compatible versions
164+
- Metadata inheritance: icon and thumbnail fields are inherited if not specified in the new version
165+
166+
Args:
167+
services_repo: Instance of ServicesRepository for database access.
168+
service_metadata: Metadata of the service being evaluated.
132169
133-
if previous_release:
134-
previous_access_rights = await services_repo.get_service_access_rights(
135-
previous_release.key, previous_release.version
170+
Returns:
171+
An InheritedData object containing:
172+
- access_rights: List of ServiceAccessRightsDB objects inherited from the previous release
173+
- metadata_updates: Dict of metadata fields that should be updated in the new service
174+
175+
Notes:
176+
- The policy is described in https://github.com/ITISFoundation/osparc-simcore/issues/2244
177+
- Inheritance is only for patch releases (i.e., same major and minor version).
178+
"""
179+
inherited_data: InheritedData = {
180+
"access_rights": [],
181+
"metadata_updates": {},
182+
}
183+
184+
previous_release = await _find_latest_patch_compatible_release(
185+
services_repo, service_metadata=service_metadata
186+
)
187+
188+
if not previous_release:
189+
return inherited_data
190+
191+
# 1. ACCESS-RIGHTS:
192+
# Inherit access rights (from all products) from the previous release
193+
previous_access_rights = await services_repo.get_service_access_rights(
194+
previous_release.key, previous_release.version
195+
)
196+
197+
inherited_data["access_rights"] = [
198+
access.model_copy(
199+
update={"version": service_metadata.version},
200+
deep=True,
136201
)
202+
for access in previous_access_rights
203+
]
204+
205+
# 2. ServiceMetaDataPublished
206+
# Inherit some fields if not specified in the new service
207+
if not service_metadata.icon and previous_release.icon:
208+
inherited_data["metadata_updates"]["icon"] = previous_release.icon
209+
if not service_metadata.thumbnail and previous_release.thumbnail:
210+
inherited_data["metadata_updates"]["thumbnail"] = previous_release.thumbnail
137211

138-
service_access_rights = [
139-
access.model_copy(
140-
update={"version": service_metadata.version},
141-
deep=True,
142-
)
143-
for access in previous_access_rights
144-
]
145-
return service_access_rights
212+
return inherited_data
146213

147214

148215
def reduce_access_rights(
149216
access_rights: list[ServiceAccessRightsDB],
150217
reduce_operation: Callable = operator.ior,
151218
) -> list[ServiceAccessRightsDB]:
152-
"""
153-
Reduces a list of access-rights per target
219+
"""Reduces a list of access-rights per target
220+
154221
By default, the reduction is OR (i.e. preserves True flags)
222+
155223
"""
156224
# TODO: probably a lot of room to optimize
157225
# helper functions to simplify operation of access rights

services/catalog/tests/unit/with_dbs/conftest.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,13 @@ async def other_user(
168168
faker: Faker,
169169
) -> AsyncIterator[dict[str, Any]]:
170170

171-
_user = random_user(fake=faker, id=user_id + 1)
171+
_other_user = random_user(fake=faker, id=user_id + 1)
172172
async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup
173173
sqlalchemy_async_engine,
174174
table=users,
175-
values=_user,
175+
values=_other_user,
176176
pk_col=users.c.id,
177-
pk_value=_user["id"],
177+
pk_value=_other_user["id"],
178178
) as row:
179179
yield row
180180

@@ -247,22 +247,27 @@ async def services_db_tables_injector(
247247

248248
async def _inject_in_db(fake_catalog: list[tuple]):
249249
# [(service, ar1, ...), (service2, ar1, ...) ]
250+
iter_services = (items[0] for items in fake_catalog)
251+
iter_access_rights = itertools.chain(items[1:] for items in fake_catalog)
250252

251253
async with sqlalchemy_async_engine.begin() as conn:
252254
# NOTE: The 'default' dialect with current database version settings does not support in-place multirow inserts
253-
for service in [items[0] for items in fake_catalog]:
254-
insert_meta = pg_insert(services_meta_data).values(**service)
255-
upsert_meta = insert_meta.on_conflict_do_update(
256-
index_elements=[
257-
services_meta_data.c.key,
258-
services_meta_data.c.version,
259-
],
260-
set_=service,
255+
for service in iter_services:
256+
257+
insert_stmt = pg_insert(services_meta_data).values(**service)
258+
259+
update_stmt = insert_stmt.on_conflict_do_update(
260+
index_elements=["key", "version"],
261+
set_={
262+
column_name: insert_stmt.excluded[column_name]
263+
for column_name in service
264+
if column_name not in ("key", "version")
265+
},
261266
)
262-
await conn.execute(upsert_meta)
267+
await conn.execute(update_stmt)
263268
inserted_services.add((service["key"], service["version"]))
264269

265-
for access_rights in itertools.chain(items[1:] for items in fake_catalog):
270+
for access_rights in iter_access_rights:
266271
stmt_access = services_access_rights.insert().values(access_rights)
267272
await conn.execute(stmt_access)
268273

0 commit comments

Comments
 (0)