diff --git a/Dockerfile b/Dockerfile index c4bc75b..3b2787a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ COPY .forgejo/workflows/image.yaml ./.forgejo/workflows/image.yaml COPY schemas/ ./schemas/ COPY k8s/ ./k8s/ COPY scripts/render_gitops.py scripts/check_gitops_promotion.py ./scripts/ -RUN uv sync --frozen --extra dev && uv run --no-sync pytest -q -p no:cacheprovider tests/test_frontend_patterns.py tests/test_instruction_evaluation.py tests/test_admin_sync_api.py tests/test_gitops_release.py +RUN uv sync --frozen --extra dev && uv run --no-sync python scripts/render_gitops.py --check --verify-frontend && uv run --no-sync pytest -q -p no:cacheprovider tests/test_frontend_patterns.py tests/test_instruction_evaluation.py tests/test_admin_sync_api.py tests/test_gitops_release.py # Stage 2 — runtime image FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS runtime diff --git a/k8s/gitops/frontend-source.json b/k8s/gitops/frontend-source.json new file mode 100644 index 0000000..d789616 --- /dev/null +++ b/k8s/gitops/frontend-source.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "repository": "coulomb/frontend-patterns", + "revision": "10f0131f67daac14b14d48735f71db65366ebde5", + "files": { + "frontend-patterns-daily.md": "b604873587003c96a7015d705936a5aaf865123e34b4be3afe928c459a02c7d1", + "frontend-patterns-monthly.md": "52755a99c02e6ed4ef1f99261bbb5ce59214b6761b360aa467b405e4932c9b98", + "frontend-patterns-weekly.md": "5437dc5fbcec8fecbbca0e0c53531750c90a9aa5c044df5b1b40e4b96af9c186" + } +} diff --git a/scripts/render_gitops.py b/scripts/render_gitops.py index 5caa793..9a312bd 100644 --- a/scripts/render_gitops.py +++ b/scripts/render_gitops.py @@ -1,5 +1,9 @@ """Render only the reviewed application resource set, excluding jobs and custody.""" import argparse +import hashlib +import json +import re +import urllib.request from pathlib import Path import yaml ROOT=Path(__file__).resolve().parents[1] @@ -22,9 +26,25 @@ def render(): if d['metadata'].get('namespace')!='activity-core': raise ValueError('namespace outside grant') selected.append(d) return '# Generated by scripts/render_gitops.py; do not edit directly.\n'+'---\n'.join(yaml.dump(d,Dumper=Dumper,sort_keys=False) for d in selected) +def verify_frontend(source_dir=None): + pin=json.loads((ROOT/'k8s/gitops/frontend-source.json').read_text()) + names={f'frontend-patterns-{mode}.md' for mode in ('daily','weekly','monthly')} + if pin.get('schema_version')!=1 or pin.get('repository')!='coulomb/frontend-patterns' or set(pin['files'])!=names or not re.fullmatch('[0-9a-f]{40}',pin['revision']): + raise ValueError('invalid frontend source pin') + cm=next(d for d in yaml.safe_load_all(render()) if d['metadata']['name']=='actcore-external-activity-definitions') + for name,digest in pin['files'].items(): + if source_dir is None: + url=f"https://forgejo.coulomb.social/coulomb/frontend-patterns/raw/commit/{pin['revision']}/activity-definitions/{name}" + with urllib.request.urlopen(url,timeout=10) as response: body=response.read(32769) + else: body=(Path(source_dir)/name).read_bytes() + if len(body)>32768 or hashlib.sha256(body).hexdigest()!=digest or cm['data'][name].encode()!=body: + raise ValueError('frontend definition projection mismatch: '+name) + if __name__=='__main__': - parser=argparse.ArgumentParser(); parser.add_argument('--check',action='store_true'); args=parser.parse_args() + parser=argparse.ArgumentParser(); parser.add_argument('--check',action='store_true'); parser.add_argument('--verify-frontend',action='store_true'); args=parser.parse_args() path=ROOT/'k8s/gitops/runtime.yaml'; text=render() if args.check: if path.read_text()!=text: raise SystemExit('GitOps projection stale; run scripts/render_gitops.py') else: path.write_text(text) + + if args.verify_frontend: verify_frontend() diff --git a/tests/test_gitops_release.py b/tests/test_gitops_release.py index 6c74ecb..c6ce51e 100644 --- a/tests/test_gitops_release.py +++ b/tests/test_gitops_release.py @@ -62,3 +62,13 @@ def test_refuses_authority_expansion(change): elif change=='tag':c['image']='forgejo.coulomb.social/coulomb/activity-core:latest' else:after.pop() with pytest.raises(ValueError):load('check_gitops_promotion').validate(before,after,evidence,now) + + +def test_frontend_projection_checks_content_and_pin(tmp_path): + renderer=load('render_gitops') + cm=next(d for d in yaml.safe_load_all(renderer.render()) if d['metadata']['name']=='actcore-external-activity-definitions') + for name,body in cm['data'].items(): + if name.startswith('frontend-patterns-'): (tmp_path/name).write_text(body) + renderer.verify_frontend(tmp_path) + (tmp_path/'frontend-patterns-daily.md').write_text('tampered') + with pytest.raises(ValueError): renderer.verify_frontend(tmp_path)