Skip to content

Commit 9eb80f2

Browse files
Merge issue-91-static-manifest: build runtime static manifest (#91)
2 parents 80eea47 + cfcdde7 commit 9eb80f2

6 files changed

Lines changed: 212 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,49 @@ jobs:
419419
test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image")" = "$RELEASE_SHA"
420420
test "$(docker run --rm --entrypoint id "$image" -u)" = "10001"
421421
test "$(docker run --rm --entrypoint id "$image" -g)" = "10001"
422+
- name: Verify the built runtime static manifest
423+
if: github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false
424+
env:
425+
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
426+
run: |
427+
set -euo pipefail
428+
image="dtc-website:$RELEASE_SHA"
429+
fixtures="$PWD/.tmp/static-manifest-fixtures"
430+
mkdir -p "$fixtures/absent"
431+
printf '{\n' > "$fixtures/malformed.json"
432+
printf '{"paths": {}, "version": "1.1", "hash": "fixture"}\n' > "$fixtures/missing-entry.json"
433+
434+
verify_manifest() {
435+
settings_module="$1"
436+
shift
437+
docker run --rm \
438+
--entrypoint sh \
439+
--env "DJANGO_SETTINGS_MODULE=$settings_module" \
440+
"$@" \
441+
"$image" -c 'uv run --no-sync python -m scripts.verify_static_manifest'
442+
}
443+
444+
verify_manifest website.settings.collectstatic
445+
446+
if verify_manifest website.settings.collectstatic \
447+
--mount "type=bind,source=$fixtures/absent,target=/app/staticfiles,readonly"; then
448+
echo "Static verification accepted an absent manifest." >&2
449+
exit 1
450+
fi
451+
if verify_manifest website.settings.collectstatic \
452+
--mount "type=bind,source=$fixtures/malformed.json,target=/app/staticfiles/staticfiles.json,readonly"; then
453+
echo "Static verification accepted a malformed manifest." >&2
454+
exit 1
455+
fi
456+
if verify_manifest website.settings.collectstatic \
457+
--mount "type=bind,source=$fixtures/missing-entry.json,target=/app/staticfiles/staticfiles.json,readonly"; then
458+
echo "Static verification accepted a manifest without courses.css." >&2
459+
exit 1
460+
fi
461+
if verify_manifest website.settings.test; then
462+
echo "Static verification accepted an incompatible storage backend." >&2
463+
exit 1
464+
fi
422465
- name: Smoke-test liveness without publishing
423466
if: github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false
424467
env:
@@ -433,6 +476,7 @@ jobs:
433476
trap 'docker logs dtc-web; docker rm --force dtc-web' EXIT
434477
for attempt in $(seq 1 30); do
435478
if test "$(curl --fail --silent http://127.0.0.1:8000/health/live)" = "{\"status\": \"ok\", \"version\": \"$RELEASE_SHA\"}"; then
479+
curl --fail --silent --output /dev/null http://127.0.0.1:8000/unified/
436480
exit 0
437481
fi
438482
sleep 1

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ ENV PATH="/app/.venv/bin:$PATH" \
2323
PYTHONUNBUFFERED=1 \
2424
DJANGO_SETTINGS_MODULE=website.settings.production
2525
RUN mkdir -p /app/.cache/uv \
26-
&& DJANGO_SETTINGS_MODULE=website.settings.test uv run --no-sync python manage.py collectstatic --noinput \
26+
&& DJANGO_SETTINGS_MODULE=website.settings.collectstatic uv run --no-sync python manage.py collectstatic --noinput \
2727
&& chown -R dtc:dtc /app/.cache
2828
USER 10001:10001
2929
LABEL org.opencontainers.image.revision=$SOURCE_SHA

core/tests/test_deployment_workflow.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,7 @@ def test_gate_b_evidence_contract_is_atomic_offline_and_workflow_isolated(self)
730730

731731
unchanged_hashes = {
732732
".github/workflows/ci.yml": (
733-
"6932845a0f919c816a086bdcf5976d38bb1195ee75af4d118e6fd4b962e11ac9"
733+
"0a2d97843ad8045375258af0a96464e32aba1feb19f83c3b20f61f415bb8211c"
734734
),
735735
"deploy/oidc_probe.py": (
736736
"10f38b3c3df04c763f0e09ffe6128fc9d9fe174c4f3f7f161600992fcd84e2ff"
@@ -897,7 +897,7 @@ def test_gate_b_operator_contract_is_exact_and_workflow_isolated(self) -> None:
897897
"4dd65a576f3bd3d3bd2dff41170ee161f45ffe5362a4e4e1f8af0feabc081027"
898898
),
899899
".github/workflows/ci.yml": (
900-
"6932845a0f919c816a086bdcf5976d38bb1195ee75af4d118e6fd4b962e11ac9"
900+
"0a2d97843ad8045375258af0a96464e32aba1feb19f83c3b20f61f415bb8211c"
901901
),
902902
"deploy/oidc_probe.py": (
903903
"10f38b3c3df04c763f0e09ffe6128fc9d9fe174c4f3f7f161600992fcd84e2ff"
@@ -1000,6 +1000,34 @@ def test_one_image_is_built_tested_and_published_only_after_all_gates(self) -> N
10001000
self.assertIn("published-image record independently of deployment", workflow)
10011001
self.assertNotIn("terraform apply", workflow)
10021002

1003+
def test_release_image_builds_and_verifies_the_runtime_static_manifest(self) -> None:
1004+
dockerfile = (ROOT / "Dockerfile").read_text()
1005+
workflow = (ROOT / ".github/workflows/ci.yml").read_text()
1006+
manifest_gate = workflow.split(
1007+
"- name: Verify the built runtime static manifest", maxsplit=1
1008+
)[1].split("- name: Smoke-test liveness without publishing", maxsplit=1)[0]
1009+
1010+
self.assertIn("DJANGO_SETTINGS_MODULE=website.settings.collectstatic", dockerfile)
1011+
self.assertNotIn(
1012+
"DJANGO_SETTINGS_MODULE=website.settings.test "
1013+
"uv run --no-sync python manage.py collectstatic",
1014+
dockerfile,
1015+
)
1016+
self.assertIn("python -m scripts.verify_static_manifest", manifest_gate)
1017+
self.assertIn("website.settings.collectstatic", manifest_gate)
1018+
self.assertIn("website.settings.test", manifest_gate)
1019+
self.assertIn("malformed.json", manifest_gate)
1020+
self.assertIn("missing-entry.json", manifest_gate)
1021+
self.assertIn("target=/app/staticfiles,readonly", manifest_gate)
1022+
self.assertLess(
1023+
workflow.index("- name: Verify the built runtime static manifest"),
1024+
workflow.index("- name: Preserve the one tested image"),
1025+
)
1026+
self.assertIn(
1027+
"curl --fail --silent --output /dev/null http://127.0.0.1:8000/unified/",
1028+
workflow,
1029+
)
1030+
10031031
def test_serving_entrypoint_never_runs_migrations(self) -> None:
10041032
entrypoint = (ROOT / "entrypoint.sh").read_text()
10051033
dockerfile = (ROOT / "Dockerfile").read_text()

core/tests/test_settings.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,58 @@ def test_database_url_selects_postgresql(self) -> None:
187187
def test_sqlite_is_not_an_implicit_local_fallback(self) -> None:
188188
with patch.dict(os.environ, {}, clear=True), self.assertRaises(ImproperlyConfigured):
189189
database_from_environment(allow_sqlite=True)
190+
191+
192+
class CollectstaticSettingsTests(SimpleTestCase):
193+
def import_collectstatic_settings(
194+
self, *, environment_name: str | None = None
195+
) -> subprocess.CompletedProcess[str]:
196+
environment = os.environ.copy()
197+
for name in (
198+
"DATABASE_URL",
199+
"DJANGO_ALLOWED_HOSTS",
200+
"DJANGO_CSRF_TRUSTED_ORIGINS",
201+
"DJANGO_SECRET_KEY",
202+
"DJANGO_SETTINGS_MODULE",
203+
"DTC_ENVIRONMENT",
204+
):
205+
environment.pop(name, None)
206+
if environment_name is not None:
207+
environment["DTC_ENVIRONMENT"] = environment_name
208+
command = (
209+
"import json; import website.settings.collectstatic as s; "
210+
"print(json.dumps({"
211+
"'backend': s.STORAGES['staticfiles']['BACKEND'], "
212+
"'database': s.DATABASES['default']['ENGINE']"
213+
"}))"
214+
)
215+
return subprocess.run(
216+
[sys.executable, "-c", command],
217+
cwd=os.getcwd(),
218+
env=environment,
219+
capture_output=True,
220+
text=True,
221+
check=False,
222+
)
223+
224+
def test_collectstatic_settings_are_self_contained_and_use_runtime_manifest_storage(
225+
self,
226+
) -> None:
227+
result = self.import_collectstatic_settings()
228+
229+
self.assertEqual(result.returncode, 0, result.stderr)
230+
self.assertEqual(
231+
json.loads(result.stdout),
232+
{
233+
"backend": "whitenoise.storage.CompressedManifestStaticFilesStorage",
234+
"database": "django.db.backends.sqlite3",
235+
},
236+
)
237+
238+
def test_collectstatic_settings_reject_deployed_environments(self) -> None:
239+
for environment_name in ("development", "production"):
240+
with self.subTest(environment_name=environment_name):
241+
result = self.import_collectstatic_settings(environment_name=environment_name)
242+
243+
self.assertNotEqual(result.returncode, 0)
244+
self.assertIn("Collectstatic settings are build-only", result.stderr)

scripts/verify_static_manifest.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Verify that a built image contains the static manifest required at runtime."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
from pathlib import Path
8+
from urllib.parse import urlsplit
9+
10+
import django
11+
from django.conf import settings
12+
from django.contrib.staticfiles.storage import staticfiles_storage
13+
14+
EXPECTED_BACKEND = "whitenoise.storage.CompressedManifestStaticFilesStorage"
15+
REQUIRED_ASSET = "courses.css"
16+
17+
18+
def _fail(message: str) -> int:
19+
print(f"Static manifest verification failed: {message}", file=sys.stderr)
20+
return 1
21+
22+
23+
def main() -> int:
24+
try:
25+
django.setup()
26+
backend = settings.STORAGES["staticfiles"]["BACKEND"]
27+
if backend != EXPECTED_BACKEND:
28+
return _fail("staticfiles storage does not use the runtime manifest backend")
29+
30+
static_root = Path(settings.STATIC_ROOT).resolve()
31+
manifest_path = static_root / "staticfiles.json"
32+
try:
33+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
34+
except (OSError, UnicodeError, json.JSONDecodeError):
35+
return _fail("staticfiles.json is absent or malformed")
36+
37+
paths = manifest.get("paths")
38+
if not isinstance(paths, dict) or not isinstance(paths.get(REQUIRED_ASSET), str):
39+
return _fail(f"staticfiles.json has no {REQUIRED_ASSET} entry")
40+
41+
resolved_url = staticfiles_storage.url(REQUIRED_ASSET)
42+
resolved_path = urlsplit(resolved_url).path
43+
if not resolved_path.startswith(settings.STATIC_URL):
44+
return _fail(f"resolved {REQUIRED_ASSET} URL is outside STATIC_URL")
45+
46+
relative_path = resolved_path.removeprefix(settings.STATIC_URL)
47+
if paths[REQUIRED_ASSET] != relative_path:
48+
return _fail(f"manifest and storage disagree about {REQUIRED_ASSET}")
49+
50+
asset_path = (static_root / relative_path).resolve()
51+
if not asset_path.is_relative_to(static_root) or not asset_path.is_file():
52+
return _fail(f"resolved {REQUIRED_ASSET} file is absent")
53+
except Exception as error:
54+
return _fail(f"{type(error).__name__} while loading the runtime storage contract")
55+
56+
print(f"Static manifest verified: {REQUIRED_ASSET} -> {relative_path}")
57+
return 0
58+
59+
60+
if __name__ == "__main__":
61+
raise SystemExit(main())

website/settings/collectstatic.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Build-only settings for generating release static assets.
2+
3+
This module deliberately reuses the self-contained test bootstrap while restoring
4+
the manifest storage contract used by deployed settings. It must never be selected
5+
by a deployed process.
6+
"""
7+
8+
from django.core.exceptions import ImproperlyConfigured
9+
10+
from .test import * # noqa: F403
11+
12+
if RUNTIME_ENVIRONMENT in { # noqa: F405
13+
RuntimeEnvironment.DEVELOPMENT, # noqa: F405
14+
RuntimeEnvironment.PRODUCTION, # noqa: F405
15+
}:
16+
raise ImproperlyConfigured("Collectstatic settings are build-only")
17+
18+
STORAGES = {
19+
**STORAGES, # noqa: F405
20+
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
21+
}

0 commit comments

Comments
 (0)