Skip to content

Commit 32afdd0

Browse files
committed
fix: make artifact-download availability follow the snippet execution seam
1 parent 300e330 commit 32afdd0

8 files changed

Lines changed: 154 additions & 39 deletions

File tree

app/sep/apps/snippets/app.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
the library (``app.sep.snippets.celery``), not this package, so the app owns no
3434
Celery module to prefix it with and its schedule is seeded unconditionally by
3535
``get_system_periodic_tasks``.
36+
37+
Snippets declares no ``artifact_base_dirs``: the snippet artifact type follows
38+
snippet execution, which is library-owned, so it is declared statically in
39+
``app.sep.artifact_constants`` and resolves whether or not this app is
40+
activated. Declaring it here as well would trip the duplicate-type guard in
41+
``collect_base_dirs``.
3642
"""
3743

3844
from app.sep.apps.framework.apps import TaskExecutionApp
@@ -43,7 +49,6 @@
4349
maintenance_router,
4450
)
4551
from app.sep.snippets.config import snippets_settings
46-
from app.sep.snippets.constants import ARTIFACT_TYPE_SNIPPET
4752
from app.sep.snippets.crud import SnippetManager
4853
from app.sep.snippets.models.responses import SnippetsCapabilitiesResponse
4954
from app.sep.snippets.script_source import snippet_source
@@ -77,5 +82,4 @@ def _snippets_capabilities_provider() -> SnippetsCapabilitiesResponse:
7782
list_query_spec=SnippetManager.list_query_spec,
7883
capabilities_provider=_snippets_capabilities_provider,
7984
extra_routes=(approval_router, maintenance_router, artifact_router),
80-
artifact_base_dirs={ARTIFACT_TYPE_SNIPPET: lambda: snippets_settings.SNIPPETS_DIR},
8185
)

app/sep/artifact_constants.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,30 @@
1313
# You should have received a copy of the GNU Affero General Public License
1414
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1515

16-
"""Define constant for signing artifact-download URLs.
16+
"""Define the constants the artifact-download surface is built from.
1717
1818
Houses the itsdangerous salt shared by the generic ``app.sep.routes.artifacts``
1919
route and the framework signer in ``app.sep.apps.framework.script_helpers``, so
20-
both sign and verify download tokens under the same namespace.
20+
both sign and verify download tokens under the same namespace, plus the
21+
base-dir declarations that are not owned by any activatable app.
2122
"""
2223

23-
__all__ = ["ARTIFACT_DOWNLOAD_SALT"]
24+
from collections.abc import Callable, Mapping
25+
from pathlib import Path
26+
27+
from app.sep.snippets.config import snippets_settings
28+
from app.sep.snippets.constants import ARTIFACT_TYPE_SNIPPET
29+
30+
__all__ = ["ARTIFACT_DOWNLOAD_SALT", "STATIC_ARTIFACT_BASE_DIRS"]
2431

2532
ARTIFACT_DOWNLOAD_SALT = "artifact-download"
33+
34+
#: Artifact base dirs seeding the download map. Not owned by a ``SEP.APPS`` app,
35+
#: so they are not registry-derived; ``collect_base_dirs`` seeds them ahead of
36+
#: the per-app declarations. The snippet directory is declared here because
37+
#: snippet execution is library-owned: ATW builds signed snippet-download URLs
38+
#: through ``app.sep.snippets.script_source`` whether or not the snippets app is
39+
#: activated, so the type it names must resolve on the same terms.
40+
STATIC_ARTIFACT_BASE_DIRS: Mapping[str, Callable[[], Path]] = {
41+
ARTIFACT_TYPE_SNIPPET: lambda: snippets_settings.SNIPPETS_DIR,
42+
}

app/sep/main.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from app.sep.config import sep_settings, warn_if_base_url_lacks_root_path
5151
from app.sep.db import get_async_session_maker
5252
from app.sep.db.seed import get_system_periodic_tasks, init_sep_db
53+
from app.sep.routes.artifacts import router as artifacts_router
5354
from app.sep.settings_override import (
5455
build_sep_override_proxies,
5556
invalidate_pmm_clients,
@@ -342,10 +343,7 @@ async def sep_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
342343
sep_app.include_router(download_files_router, prefix="/files")
343344
sep_app.include_router(execution_events_router, prefix="/execution-events")
344345

345-
if any(app.artifact_base_dirs for app in get_app_registry()):
346-
from app.sep.routes.artifacts import router as artifacts_router
347-
348-
sep_app.include_router(artifacts_router, prefix="/artifacts")
346+
sep_app.include_router(artifacts_router, prefix="/artifacts")
349347

350348
sep_app.include_router(api_router)
351349
sep_app.include_router(top_level_api_router, include_in_schema=False)

app/sep/routes/artifacts.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,31 @@
2525
from app.core.exceptions import HTTPBadRequestException, HTTPNotFoundException
2626
from app.core.security import crypto_timestamp_serializer
2727
from app.sep.apps.framework.registry import get_app_registry
28-
from app.sep.artifact_constants import ARTIFACT_DOWNLOAD_SALT
28+
from app.sep.artifact_constants import ARTIFACT_DOWNLOAD_SALT, STATIC_ARTIFACT_BASE_DIRS
2929
from app.sep.config import sep_settings
3030

3131
router = APIRouter(include_in_schema=False)
3232

3333

3434
def collect_base_dirs() -> dict[str, Callable[[], Path]]:
35-
"""Collect every app's ``artifact_base_dirs`` into a single lookup map.
35+
"""Collect the static and per-app ``artifact_base_dirs`` into one lookup map.
36+
37+
Seeded from :data:`~app.sep.artifact_constants.STATIC_ARTIFACT_BASE_DIRS`
38+
so a type whose producer is library-owned rather than app-owned resolves
39+
regardless of which apps an image activates, then overlaid with every
40+
registered app's own declaration.
3641
3742
:return: A mapping from artifact-type discriminator to its base-dir thunk.
38-
:raises ValueError: If two apps declare the same artifact type, which
39-
would ambiguously route one app's downloads into another's directory.
43+
:raises ValueError: If an artifact type is declared more than once — by two
44+
apps, or by an app that re-declares a static type — which would
45+
ambiguously route one producer's downloads into another's directory.
4046
"""
41-
base_dirs = {}
47+
base_dirs = dict(STATIC_ARTIFACT_BASE_DIRS)
4248
for app in get_app_registry():
4349
for artifact_type, thunk in app.artifact_base_dirs.items():
4450
if artifact_type in base_dirs:
4551
raise ValueError(
46-
f"Artifact type {artifact_type!r} declared by more than one app"
52+
f"Artifact type {artifact_type!r} declared more than once"
4753
)
4854
base_dirs[artifact_type] = thunk
4955
return base_dirs

tests/app/sep/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
ALEMBIC_INI = REPO_ROOT / "alembic.ini"
6262

6363
REDUCED_ACTIVATION = [
64-
App(module_name=name) for name in ("inventory", "snippets", "atw", "mysql_backups")
64+
App(module_name=name) for name in ("inventory", "atw", "mysql_backups")
6565
]
6666
"""The PMM-embedded side-car activation list (``sidecar/settings.yaml``)."""
6767

tests/app/sep/routes/test_artifacts.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@
2828

2929
from app.core.security import crypto_timestamp_serializer
3030
from app.sep.apps.framework.base import BaseApp
31-
from app.sep.artifact_constants import ARTIFACT_DOWNLOAD_SALT
31+
from app.sep.artifact_constants import ARTIFACT_DOWNLOAD_SALT, STATIC_ARTIFACT_BASE_DIRS
3232
from app.sep.routes.artifacts import collect_base_dirs
33+
from app.sep.snippets.constants import ARTIFACT_TYPE_SNIPPET
3334

3435

3536
def _make_token(payload: dict, salt: str = ARTIFACT_DOWNLOAD_SALT) -> str:
@@ -60,29 +61,69 @@ def test_raises_on_duplicate_artifact_type(self, mocker) -> None:
6061
collect_base_dirs()
6162

6263
def test_flattens_distinct_artifact_types(self, mocker) -> None:
63-
"""Merge distinct per-app declarations into one map."""
64-
snippet_dir = Path("/tmp/snippets")
64+
"""Merge distinct per-app declarations over the static seed."""
6565
dipper_dir = Path("/tmp/dipper")
66-
first = BaseApp(
67-
name="snippets",
68-
uri_path="/snippets",
69-
artifact_base_dirs={"snippet": lambda: snippet_dir},
70-
)
71-
second = BaseApp(
66+
other_dir = Path("/tmp/other")
67+
dipper = BaseApp(
7268
name="dipper",
7369
uri_path="/dipper",
7470
artifact_base_dirs={"dipper": lambda: dipper_dir},
7571
)
72+
other = BaseApp(
73+
name="other",
74+
uri_path="/other",
75+
artifact_base_dirs={"other": lambda: other_dir},
76+
)
7677
mocker.patch(
7778
"app.sep.routes.artifacts.get_app_registry",
78-
return_value=[first, second],
79+
return_value=[dipper, other],
7980
)
8081

8182
result = collect_base_dirs()
8283

83-
assert result.keys() == {"snippet", "dipper"}
84-
assert result["snippet"]() == snippet_dir
84+
assert result.keys() == {ARTIFACT_TYPE_SNIPPET, "dipper", "other"}
8585
assert result["dipper"]() == dipper_dir
86+
assert result["other"]() == other_dir
87+
88+
def test_seeds_the_static_snippet_type_without_the_snippets_app(
89+
self, mocker
90+
) -> None:
91+
"""Resolve the snippet type from the static map, with no app declaring it."""
92+
mocker.patch(
93+
"app.sep.routes.artifacts.get_app_registry",
94+
return_value=[BaseApp(name="atw", uri_path="/atw")],
95+
)
96+
97+
assert ARTIFACT_TYPE_SNIPPET in collect_base_dirs()
98+
99+
def test_raises_when_an_app_redeclares_a_static_type(self, mocker) -> None:
100+
"""Reject an app that re-declares a statically registered type."""
101+
redeclaring = BaseApp(
102+
name="snippets",
103+
uri_path="/snippets",
104+
artifact_base_dirs={ARTIFACT_TYPE_SNIPPET: lambda: Path("/tmp/other")},
105+
)
106+
mocker.patch(
107+
"app.sep.routes.artifacts.get_app_registry", return_value=[redeclaring]
108+
)
109+
110+
with pytest.raises(ValueError, match=ARTIFACT_TYPE_SNIPPET):
111+
collect_base_dirs()
112+
113+
def test_does_not_mutate_the_static_map(self, mocker) -> None:
114+
"""Return a fresh dict so an app declaration cannot leak into the constant."""
115+
declaring = BaseApp(
116+
name="dipper",
117+
uri_path="/dipper",
118+
artifact_base_dirs={"dipper": lambda: Path("/tmp/dipper")},
119+
)
120+
mocker.patch(
121+
"app.sep.routes.artifacts.get_app_registry", return_value=[declaring]
122+
)
123+
124+
collect_base_dirs()
125+
126+
assert "dipper" not in STATIC_ARTIFACT_BASE_DIRS
86127

87128

88129
class TestDownloadArtifact:
@@ -100,9 +141,7 @@ def test_valid_snippet_token_existing_file_returns_200(self, test_client, tmp_pa
100141
}
101142
token = _make_token(payload)
102143

103-
with patch(
104-
"app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path
105-
):
144+
with patch("app.sep.snippets.config.snippets_settings.SNIPPETS_DIR", tmp_path):
106145
response = test_client.get(f"/artifacts/download/{token}")
107146

108147
assert response.status_code == HTTP_200_OK
@@ -143,7 +182,7 @@ def test_expired_token_returns_400(self, test_client, tmp_path):
143182

144183
with (
145184
patch("app.sep.routes.artifacts.sep_settings.ARTIFACT_DOWNLOAD_TTL", 60),
146-
patch("app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path),
185+
patch("app.sep.snippets.config.snippets_settings.SNIPPETS_DIR", tmp_path),
147186
):
148187
response = test_client.get(
149188
f"/artifacts/download/{token}", follow_redirects=False
@@ -176,9 +215,7 @@ def test_valid_token_nonexistent_file_returns_404(self, test_client, tmp_path):
176215
}
177216
token = _make_token(payload)
178217

179-
with patch(
180-
"app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path
181-
):
218+
with patch("app.sep.snippets.config.snippets_settings.SNIPPETS_DIR", tmp_path):
182219
response = test_client.get(
183220
f"/artifacts/download/{token}", follow_redirects=False
184221
)
@@ -194,9 +231,7 @@ def test_path_traversal_in_filename_returns_400(self, test_client, tmp_path):
194231
}
195232
token = _make_token(payload)
196233

197-
with patch(
198-
"app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path
199-
):
234+
with patch("app.sep.snippets.config.snippets_settings.SNIPPETS_DIR", tmp_path):
200235
response = test_client.get(
201236
f"/artifacts/download/{token}", follow_redirects=False
202237
)

tests/app/sep/test_main.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import importlib
1919
import logging
2020
from contextlib import asynccontextmanager, contextmanager
21-
from unittest.mock import AsyncMock, Mock
21+
from unittest.mock import AsyncMock, Mock, patch
2222

2323
import pytest
2424
from fastapi import FastAPI, HTTPException, status
@@ -27,8 +27,10 @@
2727
from starlette.datastructures import URL
2828

2929
import app.sep.main as main_module
30+
import app.sep.routes.artifacts as artifacts_module
3031
from app.core.alerts.config import alert_settings, AlertSettings
3132
from app.core.auth.exceptions import BaseAuthProviderException
33+
from app.core.security import crypto_timestamp_serializer
3234
from app.core.settings_override.lifecycle import ProxyEntry
3335
from app.core.settings_override.models import SettingClassEnum
3436
from app.sep.api.router import apps_router
@@ -38,6 +40,7 @@
3840
AppRegistry,
3941
get_app_registry,
4042
)
43+
from app.sep.artifact_constants import ARTIFACT_DOWNLOAD_SALT
4144
from app.sep.config import App, sep_settings, SEPSettings
4245
from app.sep.deps import get_session, PROTECTED_APP_KEYS
4346
from app.sep.main import lifespan as sep_module_lifespan
@@ -49,6 +52,7 @@
4952
)
5053
from app.sep.models import AppLifecycleEnum, AppState
5154
from app.sep.snippets.config import snippets_settings
55+
from app.sep.snippets.constants import ARTIFACT_TYPE_SNIPPET
5256
from tests.app.sep.conftest import REDUCED_ACTIVATION
5357

5458
_ORIGINAL_SEP_APP = main_module.sep_app
@@ -295,6 +299,41 @@ def test_sep_app_rebuilds_without_alerts_and_dipper(mocker):
295299
_reload_restoring_identity()
296300

297301

302+
def test_embedded_activation_list_serves_a_snippet_download(mocker, tmp_path):
303+
"""Serve an ATW-dispatched snippet download with the snippets app deactivated.
304+
305+
Both halves of the artifact surface are import-time decisions — the mount in
306+
``main`` and ``_BASE_DIRS`` in the route module — so both are rebuilt against
307+
the embedded activation list before the request. A 404 here means the router
308+
was not mounted; a 400 means the snippet type did not resolve.
309+
"""
310+
original_apps = sep_settings.APPS
311+
original_sep_app = main_module.sep_app
312+
(tmp_path / "collect.sh").write_text("#!/bin/bash\necho hello")
313+
token = crypto_timestamp_serializer.dumps(
314+
{"type": ARTIFACT_TYPE_SNIPPET, "filename": "collect.sh", "md5": "abc123"},
315+
salt=ARTIFACT_DOWNLOAD_SALT,
316+
)
317+
318+
mocker.patch.object(sep_settings, "APPS", REDUCED_ACTIVATION)
319+
get_app_registry.cache_clear()
320+
try:
321+
importlib.reload(artifacts_module)
322+
importlib.reload(main_module)
323+
324+
with patch("app.sep.snippets.config.snippets_settings.SNIPPETS_DIR", tmp_path):
325+
client = TestClient(main_module.sep_app, raise_server_exceptions=False)
326+
response = client.get(f"/artifacts/download/{token}")
327+
328+
assert response.status_code == status.HTTP_200_OK
329+
finally:
330+
sep_settings.APPS = original_apps
331+
get_app_registry.cache_clear()
332+
importlib.reload(artifacts_module)
333+
importlib.reload(main_module)
334+
main_module.sep_app = original_sep_app
335+
336+
298337
async def _refresher_proxy_map(mocker) -> dict[SettingClassEnum, ProxyEntry]:
299338
"""Return the proxy map ``sep_overrides_lifespan`` hands to the refresher.
300339

tests/sidecar/test_embedded_settings.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
collect_app_owned_settings_classes,
3131
)
3232
from app.sep.config import SEPSettings
33+
from app.sep.routes.artifacts import collect_base_dirs
34+
from app.sep.snippets.constants import ARTIFACT_TYPE_SNIPPET
3335
from app.tasks.config import TasksSettings
3436
from app.tasks.settings.routes import TASKS_ADMIN_SETTINGS_CLASSES
3537
from tests.sidecar.conftest import (
@@ -267,6 +269,20 @@ def test_activation_list_builds_an_app_registry():
267269
assert "snippets" not in activated
268270

269271

272+
@pytest.mark.usefixtures("embedded_profile_cwd")
273+
def test_activation_list_resolves_the_snippet_artifact_type(mocker):
274+
"""Assert the baked profile serves ATW-dispatched snippet downloads.
275+
276+
The profile activates atw and no artifact-declaring app, so the type has to
277+
come from the static map rather than the registry; without it the signed URL
278+
ATW emits is rejected as an invalid artifact type.
279+
"""
280+
registry = build_app_registry(SEPSettings().APPS)
281+
mocker.patch("app.sep.routes.artifacts.get_app_registry", return_value=registry)
282+
283+
assert ARTIFACT_TYPE_SNIPPET in collect_base_dirs()
284+
285+
270286
@pytest.mark.usefixtures("embedded_profile_cwd")
271287
def test_uvicorn_ports_match_the_healthcheck_probe():
272288
"""Assert the profile follows the probe's hardcoded ports, which are contract."""

0 commit comments

Comments
 (0)