Skip to content

Commit 4ed60a0

Browse files
GitHKAndrei Neagu
andauthored
🐛 Fix: dynamic-sidecar self-triggered upload loop when data mounting is not enabled (#9053)
Co-authored-by: Andrei Neagu <neagu@itis.swiss>
1 parent 5c1621f commit 4ed60a0

2 files changed

Lines changed: 126 additions & 15 deletions

File tree

services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/modules/file_notification_subscriber.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,33 @@ def _resolve_local_path_from_storage_id(
3131
mounted_volumes: MountedVolumes,
3232
storage_path: StorageFileID,
3333
) -> Path | None:
34-
"""Maps a StorageFileID to a local disk path within mounted volumes."""
34+
"""Maps a StorageFileID to a local disk path within mounted volumes.
35+
36+
Only state volumes are resolved. Inputs and outputs are intentionally ignored:
37+
- outputs are produced by this node; reacting to a notification about them would
38+
rewrite the file under the outputs watcher and trigger an upload loop
39+
(see also the rclone path which only acts on explicitly registered mounts).
40+
- inputs are handled via the rclone mount path when applicable.
41+
"""
3542
path_parts = storage_path.split("/")
3643
if len(path_parts) < _MIN_STORAGE_PATH_PARTS:
3744
return None
3845

3946
volume_name = path_parts[2]
4047
relative_parts = path_parts[_MIN_STORAGE_PATH_PARTS:]
4148

42-
local_base: Path | None = None
43-
if volume_name == mounted_volumes.inputs_path.name:
44-
local_base = mounted_volumes.disk_inputs_path
45-
elif volume_name == mounted_volumes.outputs_path.name:
46-
local_base = mounted_volumes.disk_outputs_path
47-
else:
48-
for state_path, disk_state_path in zip(
49-
mounted_volumes.state_paths,
50-
mounted_volumes.disk_state_paths_iter(),
51-
strict=True,
52-
):
53-
if volume_name == state_path.name:
54-
local_base = disk_state_path
55-
break
49+
local_base: Path | None = next(
50+
(
51+
disk
52+
for state_path, disk in zip(
53+
mounted_volumes.state_paths,
54+
mounted_volumes.disk_state_paths_iter(),
55+
strict=True,
56+
)
57+
if volume_name == state_path.name
58+
),
59+
None,
60+
)
5661

5762
if local_base is None:
5863
return None

services/dynamic-sidecar/tests/unit/test_modules_file_notification_subscriber.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# pylint:disable=redefined-outer-name
22

3+
from pathlib import Path
34
from unittest.mock import AsyncMock
45

56
import pytest
@@ -10,11 +11,14 @@
1011
FileNotificationEventType,
1112
FileNotificationMessage,
1213
)
14+
from models_library.services_types import ServiceRunID
1315
from models_library.users import UserID
1416
from pytest_mock import MockerFixture
1517
from simcore_service_dynamic_sidecar.modules.file_notification_subscriber import (
1618
_handle_file_notification,
19+
_resolve_local_path_from_storage_id,
1720
)
21+
from simcore_service_dynamic_sidecar.modules.mounted_fs import MountedVolumes
1822

1923

2024
@pytest.fixture()
@@ -94,3 +98,105 @@ async def test_handle_file_notification_with_optional_ids(
9498
mock_notify_path_change.assert_awaited_once_with(
9599
app=None, event_type=FileNotificationEventType.FILE_UPLOADED, path=file_id, recursive=False
96100
)
101+
102+
103+
_INPUTS_PATH = Path("/inputs")
104+
_OUTPUTS_PATH = Path("/outputs")
105+
_STATE_PATH_A = Path("/workspace/state-a")
106+
_STATE_PATH_B = Path("/workspace/state-b")
107+
108+
109+
@pytest.fixture()
110+
def mounted_volumes(tmp_path: Path, faker: Faker, node_id: NodeID) -> MountedVolumes:
111+
return MountedVolumes(
112+
service_run_id=ServiceRunID(faker.uuid4()),
113+
node_id=node_id,
114+
inputs_path=_INPUTS_PATH,
115+
outputs_path=_OUTPUTS_PATH,
116+
user_preferences_path=None,
117+
state_paths=[_STATE_PATH_A, _STATE_PATH_B],
118+
state_exclude=set(),
119+
compose_namespace="test-namespace",
120+
dy_volumes=tmp_path,
121+
)
122+
123+
124+
def _make_storage_id(project_id: ProjectID, node_id: NodeID, volume_name: str, *parts: str) -> str:
125+
return "/".join((f"{project_id}", f"{node_id}", volume_name, *parts))
126+
127+
128+
@pytest.mark.parametrize(
129+
"state_path_index, sub_parts",
130+
[
131+
pytest.param(0, ("sub", "file.bin"), id="first-state-volume-with-subdir"),
132+
pytest.param(1, ("file.bin",), id="second-state-volume-flat"),
133+
],
134+
)
135+
def test_resolve_local_path_for_state_volume_resolves_correctly(
136+
mounted_volumes: MountedVolumes,
137+
project_id: ProjectID,
138+
node_id: NodeID,
139+
state_path_index: int,
140+
sub_parts: tuple[str, ...],
141+
):
142+
state_paths = [_STATE_PATH_A, _STATE_PATH_B]
143+
state_disk_paths = list(mounted_volumes.disk_state_paths_iter())
144+
storage_id = _make_storage_id(project_id, node_id, state_paths[state_path_index].name, *sub_parts)
145+
146+
resolved = _resolve_local_path_from_storage_id(mounted_volumes, storage_id)
147+
148+
assert resolved is not None
149+
expected = state_disk_paths[state_path_index].joinpath(*sub_parts).resolve()
150+
assert resolved == expected
151+
152+
153+
@pytest.mark.parametrize(
154+
"volume_path",
155+
[
156+
pytest.param(_INPUTS_PATH, id="inputs"),
157+
pytest.param(_OUTPUTS_PATH, id="outputs"),
158+
],
159+
)
160+
def test_resolve_local_path_for_inputs_and_outputs_returns_none(
161+
mounted_volumes: MountedVolumes,
162+
project_id: ProjectID,
163+
node_id: NodeID,
164+
volume_path: Path,
165+
):
166+
"""Regression: inputs/outputs volumes MUST NOT be resolved by the bind-mount fallback.
167+
168+
Resolving outputs would cause the dynamic-sidecar to download (via the file-notification
169+
fallback) the file it just uploaded, into the very directory the outputs watcher is
170+
observing — re-triggering the upload and producing an infinite loop.
171+
"""
172+
storage_id = _make_storage_id(project_id, node_id, volume_path.name, "some-file.txt")
173+
174+
assert _resolve_local_path_from_storage_id(mounted_volumes, storage_id) is None
175+
176+
177+
@pytest.mark.parametrize(
178+
"storage_id_template",
179+
[
180+
pytest.param(
181+
"{project_id}/{node_id}/not-a-volume/file.bin",
182+
id="unknown-volume",
183+
),
184+
pytest.param(
185+
"only/two",
186+
id="too-few-parts",
187+
),
188+
pytest.param(
189+
f"{{project_id}}/{{node_id}}/{_STATE_PATH_A.name}/../../etc/passwd",
190+
id="path-traversal",
191+
),
192+
],
193+
)
194+
def test_resolve_local_path_returns_none_on_invalid_storage_ids(
195+
mounted_volumes: MountedVolumes,
196+
project_id: ProjectID,
197+
node_id: NodeID,
198+
storage_id_template: str,
199+
):
200+
storage_id = storage_id_template.format(project_id=project_id, node_id=node_id)
201+
202+
assert _resolve_local_path_from_storage_id(mounted_volumes, storage_id) is None

0 commit comments

Comments
 (0)