diff --git a/.dagger/src/ci/main.py b/.dagger/src/ci/main.py index 0c11639..245d56c 100644 --- a/.dagger/src/ci/main.py +++ b/.dagger/src/ci/main.py @@ -2,6 +2,9 @@ from __future__ import annotations +import asyncio +from collections.abc import Mapping +from types import MappingProxyType from typing import Final, Self import dagger @@ -14,6 +17,38 @@ "ghcr.io/astral-sh/uv:0.11.32@sha256:" "df4cae8f3a96d175e2e5f992e597550000edbe78fdc2594d5cd8de1a217f504c" ) +# Module gates need git, uvx, and a Dagger CLI that reaches the engine through nesting. +MODULE_IMAGE: Final = ( + "python:3.13.14-bookworm@sha256:" + "8b9a8b28d9cc221c6ab5d40e9cfcd99429959f6a8f5171612a99147975ab043f" +) +ENGINE_IMAGE: Final = ( + "registry.dagger.io/engine:v0.21.8@sha256:" + "c9c1a0a6546380983d42e8d75adde070a2a0935c54b498d8bc9045d9cb2ee336" +) +MODULE_GATES: Final = ( + "modules/cloudflare-pages", + "modules/portfolio-foundation", + "modules/python-package", +) +# Hosted module gates run inside Dagger. These tests need host Docker (gitleaks via +# `docker run`) or a host Dagger CLI working in a temp directory; a nested CLI resolves +# paths against the container workdir instead. Hosted CI still runs gitleaks through +# `foundation.guard`; run these locally with each module's `poe gate`. +HOSTED_SKIPPED_TESTS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType( + { + "modules/portfolio-foundation": ( + "tests/test_guard_integration.py", + "tests/test_artifact.py::test_should_envelope_nested_directory_and_reject_unexpected_input", + "tests/test_artifact.py::test_should_reject_symlink_and_preserve_control_filename_in_evidence", + "tests/test_bootstrap.py::test_should_bootstrap_clean_module_before_frozen_sync", + "tests/test_source_integration.py::test_should_bind_exact_nested_public_tree_and_reject_tampering", + ), + "modules/cloudflare-pages": ( + "tests/test_deploy_contract.py::test_should_run_real_dagger_mock_provider_contract", + ), + } +) REPOSITORY_URL: Final = "https://github.com/hseshadr/ci.git" REPOSITORY: Final = "hseshadr/ci" SHA_LENGTH: Final = 40 @@ -58,6 +93,7 @@ def create(cls, workspace: dagger.Workspace) -> Self: async def ci(self, github_token: dagger.Secret, commit_sha: str = "") -> str: """Run canonical quality, security, and composition gates.""" await self._quality().sync() + await self._module_gates() await self._security(commit_sha, github_token) await self._module_fixtures() return "central Dagger gate passed" @@ -94,6 +130,41 @@ async def _module_fixture(self, path: str) -> None: if not await fixture.passed(): raise RuntimeError(f"Dagger module fixture failed: {path}") + async def _module_gates(self) -> None: + gates = (self._module_gate(path).sync() for path in MODULE_GATES) + results = await asyncio.gather(*gates, return_exceptions=True) + failed = [ + path + for path, result in zip(MODULE_GATES, results, strict=True) + if isinstance(result, BaseException) + ] + if failed: + raise RuntimeError(f"module gates failed: {', '.join(failed)}") + + def _module_gate(self, path: str) -> dagger.Container: + project = f"{path}/.dagger" + skipped = " ".join(f"--deselect={test}" for test in HOSTED_SKIPPED_TESTS.get(path, ())) + generated = self.source.as_module_source(source_root_path=path) + base = self._module_tools().with_directory("/src", self.source) + base = base.with_directory("/src", generated.generated_context_directory()) + base = base.with_env_variable("PYTEST_ADDOPTS", skipped) + # Nested Dagger takes its context from the nearest .git and resolves paths from the + # workdir, so `dagger -m ..` in a module's gate loads that module and its siblings. + base = base.with_exec(["git", "init", "--quiet", "/src"]).with_workdir(f"/src/{project}") + base = base.with_exec(["uv", "sync", "--frozen", "--all-groups"]) + gate = ["uv", "run", "poe", "gate"] + return base.with_exec(gate, experimental_privileged_nesting=True) + + def _module_tools(self) -> dagger.Container: + uv = dag.container().from_(UV_IMAGE) + engine = dag.container().from_(ENGINE_IMAGE) + base = dag.container(platform=dagger.Platform("linux/amd64")).from_(MODULE_IMAGE) + base = base.with_file("/usr/local/bin/uv", uv.file("/uv")) + base = base.with_file("/usr/local/bin/uvx", uv.file("/uvx")) + base = base.with_file("/usr/local/bin/dagger", engine.file("/usr/local/bin/dagger")) + base = base.with_mounted_cache("/root/.cache/uv", dag.cache_volume("ci-module-uv")) + return base.with_workdir("/src") + async def _security(self, commit_sha: str, github_token: dagger.Secret) -> None: await self._dependency_audit().sync() await (await self._repository_guard(commit_sha)).sync() diff --git a/.dagger/tests/test_dagger_contract.py b/.dagger/tests/test_dagger_contract.py index 07a1956..1c152dd 100644 --- a/.dagger/tests/test_dagger_contract.py +++ b/.dagger/tests/test_dagger_contract.py @@ -266,6 +266,7 @@ def test_should_run_public_ci_in_protected_order(monkeypatch: pytest.MonkeyPatch central = Ci.__new__(Ci) monkeypatch.setattr(central, "_quality", lambda: FakeSync(events, "quality")) monkeypatch.setattr(central, "_security", _security_recorder(events)) + monkeypatch.setattr(central, "_module_gates", _gates_recorder(events)) monkeypatch.setattr(central, "_module_fixtures", _fixture_recorder(events)) # When @@ -273,7 +274,7 @@ def test_should_run_public_ci_in_protected_order(monkeypatch: pytest.MonkeyPatch # Then assert result == "central Dagger gate passed" - assert events == ["quality", "security:" + "a" * 40, "module-fixtures"] + assert events == ["quality", "module-gates", "security:" + "a" * 40, "module-fixtures"] def test_should_run_public_security_without_quality(monkeypatch: pytest.MonkeyPatch) -> None: @@ -423,6 +424,13 @@ async def record(commit_sha: str, _: dagger.Secret) -> None: return record +def _gates_recorder(events: list[str]) -> Callable[[], Awaitable[None]]: + async def record() -> None: + events.append("module-gates") + + return record + + def _fixture_recorder(events: list[str]) -> Callable[[], Awaitable[str]]: async def record() -> str: events.append("module-fixtures") diff --git a/.dagger/tests/test_module_gates.py b/.dagger/tests/test_module_gates.py new file mode 100644 index 0000000..b3555a6 --- /dev/null +++ b/.dagger/tests/test_module_gates.py @@ -0,0 +1,235 @@ +"""Hosted central CI runs every shared module's own quality gate.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import cast + +import dagger +import pytest + +from ci import main as main_module +from ci.main import ENGINE_IMAGE, HOSTED_SKIPPED_TESTS, MODULE_GATES, MODULE_IMAGE, UV_IMAGE, Ci + +ROOT = Path(__file__).parents[2] + + +class FakeGenerated: + def __init__(self, path: str) -> None: + self.path = path + + def generated_context_directory(self) -> dagger.Directory: + return cast(dagger.Directory, f"generated:{self.path}") + + +class FakeSource: + def as_module_source(self, *, source_root_path: str) -> FakeGenerated: + return FakeGenerated(source_root_path) + + +class FakeContainer: + def __init__(self, operations: list[tuple[object, ...]]) -> None: + self.operations = operations + + def from_(self, image: str) -> FakeContainer: + self.operations.append(("from", image)) + return self + + def file(self, path: str) -> dagger.File: + return cast(dagger.File, f"file:{path}") + + def with_file(self, path: str, file: dagger.File) -> FakeContainer: + self.operations.append(("with-file", path, file)) + return self + + def with_directory(self, path: str, directory: dagger.Directory) -> FakeContainer: + self.operations.append(("directory", path, directory)) + return self + + def with_workdir(self, path: str) -> FakeContainer: + self.operations.append(("workdir", path)) + return self + + def with_mounted_cache(self, path: str, cache: object) -> FakeContainer: + return self + + def with_env_variable(self, name: str, value: str) -> FakeContainer: + self.operations.append(("env", name, value)) + return self + + def with_exec( + self, command: list[str], *, experimental_privileged_nesting: bool = False + ) -> FakeContainer: + self.operations.append(("exec", tuple(command), experimental_privileged_nesting)) + return self + + +class FakeDag: + def __init__(self, operations: list[tuple[object, ...]]) -> None: + self.operations = operations + + def container(self, platform: dagger.Platform | None = None) -> FakeContainer: + return FakeContainer(self.operations) + + def cache_volume(self, name: str) -> object: + return name + + +class FailingGate: + async def sync(self) -> None: + raise RuntimeError("module gate failed") + + +class PassingGate: + def __init__(self, events: list[str], path: str) -> None: + self.events = events + self.path = path + + async def sync(self) -> PassingGate: + # Like dagger.Container.sync, a passing gate returns the (truthy) synced object. + self.events.append(self.path) + return self + + +def _gate_operations(monkeypatch: pytest.MonkeyPatch, path: str) -> list[tuple[object, ...]]: + operations: list[tuple[object, ...]] = [] + central = Ci.__new__(Ci) + central.source = cast(dagger.Directory, FakeSource()) + monkeypatch.setattr(main_module, "dag", FakeDag(operations)) + central._module_gate(path) + return operations + + +def _execs(operations: list[tuple[object, ...]]) -> list[tuple[object, ...]]: + return [item for item in operations if item[0] == "exec"] + + +def test_should_gate_every_central_module_in_the_repository() -> None: + # Given every Dagger module shipped under modules/ + shipped = tuple( + sorted(str(path.parent.relative_to(ROOT)) for path in ROOT.glob("modules/*/dagger.json")) + ) + + # Then hosted CI gates each one, so a new module cannot skip its gate + assert shipped == ( + "modules/cloudflare-pages", + "modules/portfolio-foundation", + "modules/python-package", + ) + assert shipped == MODULE_GATES + + +def test_should_run_the_module_poe_gate_with_nested_engine_access( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given the foundation module + operations = _gate_operations(monkeypatch, "modules/portfolio-foundation") + + # Then /src is marked as the repository root, so nested Dagger resolves sibling + # modules, and the module's own frozen environment runs its own `poe gate` + assert _execs(operations) == [ + ("exec", ("git", "init", "--quiet", "/src"), False), + ("exec", ("uv", "sync", "--frozen", "--all-groups"), False), + ("exec", ("uv", "run", "poe", "gate"), True), + ] + assert ("workdir", "/src/modules/portfolio-foundation/.dagger") in operations + + +def test_should_overlay_the_module_generated_sdk_on_the_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given the cloudflare-pages module + operations = _gate_operations(monkeypatch, "modules/cloudflare-pages") + + # Then the engine-generated client for that module lands on top of the explicit source + directories = [item for item in operations if item[0] == "directory"] + assert directories[-1] == ("directory", "/src", "generated:modules/cloudflare-pages") + + +def test_should_provide_git_uvx_and_the_pinned_dagger_cli(monkeypatch: pytest.MonkeyPatch) -> None: + # Given any module gate + operations = _gate_operations(monkeypatch, "modules/python-package") + + # Then the tools its integration tests need come from digest-pinned images + assert ("from", MODULE_IMAGE) in operations + assert ("with-file", "/usr/local/bin/uvx", "file:/uvx") in operations + assert ("with-file", "/usr/local/bin/dagger", "file:/usr/local/bin/dagger") in operations + assert UV_IMAGE in {item[1] for item in operations if item[0] == "from"} + assert ENGINE_IMAGE in {item[1] for item in operations if item[0] == "from"} + assert "@sha256:" in MODULE_IMAGE and "@sha256:" in ENGINE_IMAGE + + +FOUNDATION_HOST_TESTS = ( + "tests/test_guard_integration.py", + "tests/test_artifact.py::test_should_envelope_nested_directory_and_reject_unexpected_input", + "tests/test_artifact.py::test_should_reject_symlink_and_preserve_control_filename_in_evidence", + "tests/test_bootstrap.py::test_should_bootstrap_clean_module_before_frozen_sync", + "tests/test_source_integration.py::test_should_bind_exact_nested_public_tree_and_reject_tampering", +) +PAGES_HOST_TESTS = ( + "tests/test_deploy_contract.py::test_should_run_real_dagger_mock_provider_contract", +) + + +def test_should_deselect_only_the_named_host_engine_tests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given every module gate + foundation = _gate_operations(monkeypatch, "modules/portfolio-foundation") + pages = _gate_operations(monkeypatch, "modules/cloudflare-pages") + package = _gate_operations(monkeypatch, "modules/python-package") + + # Then only tests that need host Docker or a host Dagger CLI working in a temp + # directory are left out, each by exact node id, and python-package keeps every test + assert HOSTED_SKIPPED_TESTS == { + "modules/portfolio-foundation": FOUNDATION_HOST_TESTS, + "modules/cloudflare-pages": PAGES_HOST_TESTS, + } + expected = " ".join(f"--deselect={test}" for test in FOUNDATION_HOST_TESTS) + assert ("env", "PYTEST_ADDOPTS", expected) in foundation + assert ("env", "PYTEST_ADDOPTS", f"--deselect={PAGES_HOST_TESTS[0]}") in pages + assert ("env", "PYTEST_ADDOPTS", "") in package + + +def test_should_name_only_tests_that_exist(monkeypatch: pytest.MonkeyPatch) -> None: + # Given each deselected node id + for path, tests in HOSTED_SKIPPED_TESTS.items(): + for test in tests: + file_name, _, name = test.partition("::") + source = (ROOT / path / ".dagger" / file_name).read_text(encoding="utf-8") + + # Then it names a real test, so a rename cannot silently widen the skip + assert not name or f"def {name}(" in source + + +def test_should_run_every_module_gate(monkeypatch: pytest.MonkeyPatch) -> None: + # Given passing gates + events: list[str] = [] + central = Ci.__new__(Ci) + monkeypatch.setattr(central, "_module_gate", lambda path: PassingGate(events, path)) + + # When + asyncio.run(central._module_gates()) + + # Then each module was gated + assert sorted(events) == list(MODULE_GATES) + + +def test_should_run_every_gate_and_name_each_failed_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given the foundation gate fails and the other gates pass + events: list[str] = [] + central = Ci.__new__(Ci) + failing = "modules/portfolio-foundation" + monkeypatch.setattr( + central, + "_module_gate", + lambda path: FailingGate() if path == failing else PassingGate(events, path), + ) + + # When / Then the central gate fails and names the failed module + with pytest.raises(RuntimeError, match=r"module gates failed: modules/portfolio-foundation"): + asyncio.run(central._module_gates()) + assert sorted(events) == [path for path in MODULE_GATES if path != failing]