Skip to content

Commit 909465a

Browse files
Merge branch 'main' into SEP-1652
2 parents 644291e + ad1c94c commit 909465a

8 files changed

Lines changed: 174 additions & 41 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: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,32 @@
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
18-
Houses the itsdangerous salt shared by the generic ``app.sep.routes.artifacts``
19-
route and the framework signer in ``app.sep.apps.framework.script_helpers``, so
20-
both sign and verify download tokens under the same namespace.
18+
Houses the itsdangerous salt every artifact-download signer and verifier shares,
19+
so tokens validate under one namespace, plus the base-dir declarations that are
20+
not owned by any activatable app.
2121
"""
2222

23-
__all__ = ["ARTIFACT_DOWNLOAD_SALT"]
23+
from collections.abc import Callable, Mapping
24+
from pathlib import Path
25+
from types import MappingProxyType
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: signed snippet-download URLs are built
38+
#: through ``app.sep.snippets.script_source`` whether or not the snippets app is
39+
#: activated, so the type they name must resolve on the same terms. Frozen so
40+
#: ``collect_base_dirs`` must copy before overlaying the per-app declarations,
41+
#: rather than leaking one image's activation set into the shared constant.
42+
STATIC_ARTIFACT_BASE_DIRS: Mapping[str, Callable[[], Path]] = MappingProxyType(
43+
{ARTIFACT_TYPE_SNIPPET: lambda: snippets_settings.SNIPPETS_DIR}
44+
)

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: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@
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
34+
35+
_SNIPPETS_DIR_TARGET = "app.sep.snippets.config.snippets_settings.SNIPPETS_DIR"
3336

3437

3538
def _make_token(payload: dict, salt: str = ARTIFACT_DOWNLOAD_SALT) -> str:
@@ -60,29 +63,69 @@ def test_raises_on_duplicate_artifact_type(self, mocker) -> None:
6063
collect_base_dirs()
6164

6265
def test_flattens_distinct_artifact_types(self, mocker) -> None:
63-
"""Merge distinct per-app declarations into one map."""
64-
snippet_dir = Path("/tmp/snippets")
66+
"""Merge distinct per-app declarations over the static seed."""
6567
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(
68+
other_dir = Path("/tmp/other")
69+
dipper = BaseApp(
7270
name="dipper",
7371
uri_path="/dipper",
7472
artifact_base_dirs={"dipper": lambda: dipper_dir},
7573
)
74+
other = BaseApp(
75+
name="other",
76+
uri_path="/other",
77+
artifact_base_dirs={"other": lambda: other_dir},
78+
)
7679
mocker.patch(
7780
"app.sep.routes.artifacts.get_app_registry",
78-
return_value=[first, second],
81+
return_value=[dipper, other],
7982
)
8083

8184
result = collect_base_dirs()
8285

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

87130

88131
class TestDownloadArtifact:
@@ -100,9 +143,7 @@ def test_valid_snippet_token_existing_file_returns_200(self, test_client, tmp_pa
100143
}
101144
token = _make_token(payload)
102145

103-
with patch(
104-
"app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path
105-
):
146+
with patch(_SNIPPETS_DIR_TARGET, tmp_path):
106147
response = test_client.get(f"/artifacts/download/{token}")
107148

108149
assert response.status_code == HTTP_200_OK
@@ -143,7 +184,7 @@ def test_expired_token_returns_400(self, test_client, tmp_path):
143184

144185
with (
145186
patch("app.sep.routes.artifacts.sep_settings.ARTIFACT_DOWNLOAD_TTL", 60),
146-
patch("app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path),
187+
patch(_SNIPPETS_DIR_TARGET, tmp_path),
147188
):
148189
response = test_client.get(
149190
f"/artifacts/download/{token}", follow_redirects=False
@@ -176,9 +217,7 @@ def test_valid_token_nonexistent_file_returns_404(self, test_client, tmp_path):
176217
}
177218
token = _make_token(payload)
178219

179-
with patch(
180-
"app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path
181-
):
220+
with patch(_SNIPPETS_DIR_TARGET, tmp_path):
182221
response = test_client.get(
183222
f"/artifacts/download/{token}", follow_redirects=False
184223
)
@@ -194,9 +233,7 @@ def test_path_traversal_in_filename_returns_400(self, test_client, tmp_path):
194233
}
195234
token = _make_token(payload)
196235

197-
with patch(
198-
"app.sep.apps.snippets.app.snippets_settings.SNIPPETS_DIR", tmp_path
199-
):
236+
with patch(_SNIPPETS_DIR_TARGET, tmp_path):
200237
response = test_client.get(
201238
f"/artifacts/download/{token}", follow_redirects=False
202239
)

tests/app/sep/test_main.py

Lines changed: 38 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,39 @@ 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+
(tmp_path / "collect.sh").write_text("#!/bin/bash\necho hello")
312+
token = crypto_timestamp_serializer.dumps(
313+
{"type": ARTIFACT_TYPE_SNIPPET, "filename": "collect.sh", "md5": "abc123"},
314+
salt=ARTIFACT_DOWNLOAD_SALT,
315+
)
316+
317+
mocker.patch.object(sep_settings, "APPS", REDUCED_ACTIVATION)
318+
get_app_registry.cache_clear()
319+
try:
320+
importlib.reload(artifacts_module)
321+
importlib.reload(main_module)
322+
323+
with patch("app.sep.snippets.config.snippets_settings.SNIPPETS_DIR", tmp_path):
324+
client = TestClient(main_module.sep_app, raise_server_exceptions=False)
325+
response = client.get(f"/artifacts/download/{token}")
326+
327+
assert response.status_code == status.HTTP_200_OK
328+
finally:
329+
sep_settings.APPS = original_apps
330+
get_app_registry.cache_clear()
331+
importlib.reload(artifacts_module)
332+
_reload_restoring_identity()
333+
334+
298335
async def _refresher_proxy_map(mocker) -> dict[SettingClassEnum, ProxyEntry]:
299336
"""Return the proxy map ``sep_overrides_lifespan`` hands to the refresher.
300337

tests/sidecar/test_embedded_settings.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@
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
37+
from tests.app.sep.conftest import REDUCED_ACTIVATION
3538
from tests.sidecar.conftest import (
3639
EMBEDDED_PROFILE,
3740
read_allowlist,
@@ -267,6 +270,35 @@ def test_activation_list_builds_an_app_registry():
267270
assert "snippets" not in activated
268271

269272

273+
@pytest.mark.usefixtures("embedded_profile_cwd")
274+
def test_activation_list_resolves_the_snippet_artifact_type(mocker):
275+
"""Resolve the snippet artifact type from the baked profile's activation list.
276+
277+
The profile activates atw and no artifact-declaring app, so the type has to
278+
come from the static map rather than the registry; without it the signed URL
279+
ATW emits is rejected as an invalid artifact type.
280+
"""
281+
registry = build_app_registry(SEPSettings().APPS)
282+
mocker.patch("app.sep.routes.artifacts.get_app_registry", return_value=registry)
283+
284+
assert ARTIFACT_TYPE_SNIPPET in collect_base_dirs()
285+
286+
287+
@pytest.mark.usefixtures("embedded_profile_cwd")
288+
def test_reduced_activation_mirrors_the_baked_profile():
289+
"""Pin the shared activation constant to the profile it claims to mirror.
290+
291+
``REDUCED_ACTIVATION`` stands in for this profile everywhere in the SEP
292+
subtree, so a divergence makes those tests assert against a deployment that
293+
does not exist — which is how an activation-gated artifact-download failure
294+
stayed invisible to the whole suite while carrying a ``snippets`` entry the
295+
profile never had.
296+
"""
297+
assert [app.module_name for app in REDUCED_ACTIVATION] == [
298+
app.module_name for app in SEPSettings().APPS
299+
]
300+
301+
270302
@pytest.mark.usefixtures("embedded_profile_cwd")
271303
def test_uvicorn_ports_match_the_healthcheck_probe():
272304
"""Assert the profile follows the probe's hardcoded ports, which are contract."""

0 commit comments

Comments
 (0)