379 lines
14 KiB
Python
379 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from urllib.error import HTTPError
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _load(name: str, path: Path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
OCI = _load("resolve_oci_platforms", ROOT / "tools/release/resolve-oci-platforms.py")
|
|
FINALIZE = _load(
|
|
"finalize_runtime_distribution",
|
|
ROOT / "tools/release/finalize-runtime-distribution.py",
|
|
)
|
|
DEPLOYER_BUILD = _load(
|
|
"build_deployer_zipapp",
|
|
ROOT / "tools/deployment/build-deployer-zipapp.py",
|
|
)
|
|
PUBLISH = _load(
|
|
"publish_runtime_release",
|
|
ROOT / "tools/release/publish-runtime-release.py",
|
|
)
|
|
|
|
|
|
class RuntimeDistributionBuildTests(unittest.TestCase):
|
|
def test_deployment_zipapp_is_reproducible_across_source_mtimes(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="govoplan-reproducible-zipapp-") as value:
|
|
root = Path(value)
|
|
source = root / "source"
|
|
shutil.copytree(ROOT / "tools/deployment", source)
|
|
first = root / "first.pyz"
|
|
second = root / "second.pyz"
|
|
original_root = DEPLOYER_BUILD.ROOT
|
|
try:
|
|
DEPLOYER_BUILD.ROOT = source
|
|
self.assertEqual(0, DEPLOYER_BUILD.main(["--output", str(first)]))
|
|
for path in source.rglob("*.py"):
|
|
os.utime(path, (2_000_000_000, 2_000_000_000))
|
|
self.assertEqual(0, DEPLOYER_BUILD.main(["--output", str(second)]))
|
|
finally:
|
|
DEPLOYER_BUILD.ROOT = original_root
|
|
|
|
self.assertEqual(first.read_bytes(), second.read_bytes())
|
|
|
|
def test_workflow_signs_with_the_release_environment(self) -> None:
|
|
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertIn(
|
|
".runtime-build/bin/python tools/release/generate-runtime-distribution.py",
|
|
workflow,
|
|
)
|
|
self.assertNotIn(
|
|
"\n python tools/release/generate-runtime-distribution.py",
|
|
workflow,
|
|
)
|
|
|
|
def test_workflow_rejects_missing_or_mutable_image_inputs_before_build(self) -> None:
|
|
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
validation = workflow.index("- name: Validate immutable release inputs")
|
|
bootstrap = workflow.index("- name: Bootstrap release sources")
|
|
self.assertLess(validation, bootstrap)
|
|
self.assertIn('image_pattern = re.compile(r"^[^@\\s]+@sha256:', workflow)
|
|
for input_name in (
|
|
"python_image",
|
|
"nginx_image",
|
|
"postgres_image",
|
|
"redis_image",
|
|
"load_balancer_image",
|
|
"managed_ingress_image",
|
|
"garage_image",
|
|
"test_mail_image",
|
|
"binfmt_image",
|
|
):
|
|
self.assertIn(f"inputs.{input_name}", workflow)
|
|
|
|
def test_api_runtime_points_core_at_packaged_migration_scripts(self) -> None:
|
|
dockerfile = (ROOT / "tools/release/runtime/Dockerfile.api").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertIn(
|
|
"GOVOPLAN_CORE_SOURCE_ROOT=/opt/govoplan/runtime/govoplan_core_runtime",
|
|
dockerfile,
|
|
)
|
|
|
|
def test_web_runtime_uses_only_writable_tmpfs_for_nginx_temp_files(self) -> None:
|
|
nginx = (ROOT / "tools/release/runtime/nginx.conf").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
for temporary_path in (
|
|
"client_body_temp_path /tmp/client_temp;",
|
|
"fastcgi_temp_path /tmp/fastcgi_temp;",
|
|
"proxy_temp_path /tmp/proxy_temp;",
|
|
"scgi_temp_path /tmp/scgi_temp;",
|
|
"uwsgi_temp_path /tmp/uwsgi_temp;",
|
|
):
|
|
self.assertIn(temporary_path, nginx)
|
|
|
|
def test_workflow_verifies_portable_bootstrap_artifacts_before_execution(
|
|
self,
|
|
) -> None:
|
|
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertIn(
|
|
"(cd runtime-output && sha256sum govoplan-deploy.pyz > "
|
|
"govoplan-deploy.pyz.sha256)",
|
|
workflow,
|
|
)
|
|
self.assertIn("openssl pkeyutl -verify -pubin", workflow)
|
|
self.assertIn("govoplan-deploy.tampered.pyz", workflow)
|
|
self.assertLess(
|
|
workflow.index("openssl pkeyutl -verify -pubin"),
|
|
workflow.index("python runtime-output/govoplan-deploy.pyz init"),
|
|
)
|
|
|
|
def test_workflow_retains_both_platform_runtime_smoke_receipts(self) -> None:
|
|
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertIn('for ARCH in amd64 arm64; do', workflow)
|
|
self.assertIn("tools/checks/runtime-image-smoke.py", workflow)
|
|
self.assertIn("Resolve managed dependency platform images", workflow)
|
|
self.assertIn("--postgres-metadata", workflow)
|
|
self.assertIn("--redis-metadata", workflow)
|
|
self.assertIn("Register arm64 execution for runtime smoke", workflow)
|
|
self.assertIn(
|
|
'docker run --privileged --rm "$BINFMT_IMAGE" --install arm64',
|
|
workflow,
|
|
)
|
|
self.assertIn("runtime-smoke-amd64.json", workflow)
|
|
self.assertIn("runtime-smoke-arm64.json", workflow)
|
|
self.assertIn(
|
|
"jq -r '.platforms[\"linux/amd64\"]' runtime-output/api-metadata.json",
|
|
workflow,
|
|
)
|
|
self.assertNotIn(".platforms[\\\"linux/amd64\\\"]", workflow)
|
|
|
|
def test_workflow_binds_the_release_tag_to_the_workflow_commit(self) -> None:
|
|
workflow = (ROOT / ".gitea/workflows/runtime-distribution.yml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
publisher = (ROOT / "tools/release/publish-runtime-release.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertIn("SOURCE_COMMIT: ${{ gitea.sha }}", workflow)
|
|
self.assertIn('--target-commit "$SOURCE_COMMIT"', workflow)
|
|
self.assertIn('"target_commitish": target_commit', publisher)
|
|
self.assertIn("self._resolve_commit(tag) != target_commit", publisher)
|
|
|
|
def test_runtime_publisher_rejects_a_tag_on_another_commit(self) -> None:
|
|
publisher = PUBLISH.GiteaReleasePublisher(
|
|
base_url="https://git.example.test",
|
|
owner="GovOPlaN",
|
|
repo="govoplan",
|
|
token="secret",
|
|
)
|
|
target = "1" * 40
|
|
with (
|
|
patch.object(
|
|
publisher,
|
|
"_resolve_commit",
|
|
side_effect=(target, "2" * 40),
|
|
),
|
|
self.assertRaisesRegex(PUBLISH.PublishError, "another commit"),
|
|
):
|
|
publisher.release(
|
|
tag="v1.2.3",
|
|
target_commit=target,
|
|
title="Release",
|
|
body="Body",
|
|
)
|
|
|
|
def test_runtime_publisher_creates_the_tag_at_the_exact_commit(self) -> None:
|
|
publisher = PUBLISH.GiteaReleasePublisher(
|
|
base_url="https://git.example.test",
|
|
owner="GovOPlaN",
|
|
repo="govoplan",
|
|
token="secret",
|
|
)
|
|
target = "1" * 40
|
|
requests: list[tuple[str, dict[str, object] | None]] = []
|
|
|
|
def request(method: str, _url: str, **kwargs):
|
|
payload = kwargs.get("payload")
|
|
requests.append((method, payload))
|
|
if method == "GET":
|
|
raise HTTPError(_url, 404, "not found", {}, None)
|
|
return {"id": 1}
|
|
|
|
with (
|
|
patch.object(
|
|
publisher,
|
|
"_resolve_commit",
|
|
side_effect=(target, None, target),
|
|
),
|
|
patch.object(publisher, "_json", side_effect=request),
|
|
):
|
|
release = publisher.release(
|
|
tag="v1.2.3",
|
|
target_commit=target,
|
|
title="Release",
|
|
body="Body",
|
|
)
|
|
|
|
self.assertEqual({"id": 1}, release)
|
|
self.assertEqual("POST", requests[-1][0])
|
|
assert requests[-1][1] is not None
|
|
self.assertEqual(target, requests[-1][1]["target_commitish"])
|
|
|
|
def test_resolves_platforms_and_builds_evidence_descriptor(self) -> None:
|
|
index = {
|
|
"schemaVersion": 2,
|
|
"manifests": [
|
|
{
|
|
"digest": "sha256:" + "1" * 64,
|
|
"platform": {"os": "linux", "architecture": "amd64"},
|
|
},
|
|
{
|
|
"digest": "sha256:" + "2" * 64,
|
|
"platform": {"os": "linux", "architecture": "arm64"},
|
|
},
|
|
],
|
|
}
|
|
metadata = OCI.resolve_platforms(
|
|
index,
|
|
repository="registry.example/govoplan/api",
|
|
index_digest="sha256:" + "a" * 64,
|
|
)
|
|
self.assertEqual(
|
|
"registry.example/govoplan/api@sha256:" + "1" * 64,
|
|
metadata["platforms"]["linux/amd64"],
|
|
)
|
|
dependency_metadata = OCI.resolve_platforms(
|
|
index,
|
|
repository="registry.example:5000/library/postgres:16-alpine",
|
|
index_digest="sha256:" + "a" * 64,
|
|
)
|
|
self.assertEqual(
|
|
"registry.example:5000/library/postgres@sha256:" + "2" * 64,
|
|
dependency_metadata["platforms"]["linux/arm64"],
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-finalize-") as value:
|
|
root = Path(value)
|
|
composition = {
|
|
"schema_version": "1",
|
|
"python": {
|
|
"packages": [
|
|
{
|
|
"package": "govoplan-core",
|
|
"version": "1.2.3",
|
|
"sha256": "8" * 64,
|
|
}
|
|
],
|
|
"module_ids": ["access"],
|
|
"wheelhouse_sha256": "9" * 64,
|
|
"wheel_count": 1,
|
|
},
|
|
"web": {"sha256": "7" * 64, "file_count": 4},
|
|
}
|
|
(root / "composition.json").write_text(json.dumps(composition))
|
|
(root / "api.json").write_text(json.dumps(metadata))
|
|
web_metadata = {
|
|
"index": "registry.example/govoplan/web@sha256:" + "b" * 64,
|
|
"platforms": {
|
|
"linux/amd64": "registry.example/govoplan/web@sha256:" + "3" * 64,
|
|
"linux/arm64": "registry.example/govoplan/web@sha256:" + "4" * 64,
|
|
},
|
|
}
|
|
(root / "web.json").write_text(json.dumps(web_metadata))
|
|
deployer = root / "govoplan-deploy.pyz"
|
|
deployer.write_bytes(b"zipapp")
|
|
package_lock = root / "package-artifacts.lock.json"
|
|
package_lock_value = {
|
|
"schema_version": "1",
|
|
"release_version": "1.2.3",
|
|
"python": [
|
|
{
|
|
"name": "govoplan-core",
|
|
"version": "1.2.3",
|
|
"sha256": "8" * 64,
|
|
}
|
|
],
|
|
"webui": [],
|
|
}
|
|
package_lock_value["lock_sha256"] = hashlib.sha256(
|
|
json.dumps(
|
|
package_lock_value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
).hexdigest()
|
|
package_lock.write_text(
|
|
json.dumps(package_lock_value) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
args = argparse.Namespace(
|
|
composition=root / "composition.json",
|
|
api_metadata=root / "api.json",
|
|
web_metadata=root / "web.json",
|
|
deployer=deployer,
|
|
deployer_url="https://downloads.example/govoplan-deploy.pyz",
|
|
package_lock=package_lock,
|
|
artifact_base_url="https://downloads.example/runtime/v1.2.3",
|
|
source_commit="f" * 40,
|
|
version="1.2.3",
|
|
channel="stable",
|
|
sequence=1,
|
|
expires_days=30,
|
|
dependency=[
|
|
"postgres=docker.io/library/postgres@sha256:" + "5" * 64,
|
|
"redis=docker.io/library/redis@sha256:" + "6" * 64,
|
|
],
|
|
output_directory=root / "evidence",
|
|
descriptor=root / "descriptor.json",
|
|
)
|
|
|
|
descriptor = FINALIZE.finalize(args)
|
|
|
|
self.assertEqual(["access"], descriptor["composition"]["module_ids"])
|
|
self.assertEqual(
|
|
"registry.example/govoplan/api@sha256:" + "a" * 64,
|
|
descriptor["images"]["api"]["index"],
|
|
)
|
|
self.assertTrue((root / "evidence/api-sbom.cdx.json").is_file())
|
|
self.assertTrue((root / "evidence/web-provenance.json").is_file())
|
|
self.assertEqual(
|
|
hashlib.sha256(package_lock.read_bytes()).hexdigest(),
|
|
descriptor["package_lock"]["sha256"],
|
|
)
|
|
|
|
def test_rejects_incomplete_oci_index(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "linux/amd64 and linux/arm64"):
|
|
OCI.resolve_platforms(
|
|
{
|
|
"manifests": [
|
|
{
|
|
"digest": "sha256:" + "1" * 64,
|
|
"platform": {"os": "linux", "architecture": "amd64"},
|
|
}
|
|
]
|
|
},
|
|
repository="registry.example/govoplan/api",
|
|
index_digest="sha256:" + "a" * 64,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|