Add signed runtime distribution pipeline
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
|
||||
|
||||
from govoplan_deploy.bundle import bundle_paths # noqa: E402
|
||||
from govoplan_deploy.cli import main # noqa: E402
|
||||
from govoplan_deploy.distribution import ( # noqa: E402
|
||||
canonical_json,
|
||||
canonical_signed_payload,
|
||||
)
|
||||
from govoplan_deploy.model import load_spec # noqa: E402
|
||||
from govoplan_deploy.planning import static_checks # noqa: E402
|
||||
|
||||
|
||||
class DeploymentReleaseAdoptionTests(unittest.TestCase):
|
||||
def test_adopts_verified_manifest_and_makes_release_checks_pass(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-release-adopt-") as value:
|
||||
root = Path(value)
|
||||
self.assertEqual(
|
||||
0,
|
||||
main(
|
||||
[
|
||||
"init",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--non-interactive",
|
||||
"--module-set",
|
||||
"core",
|
||||
]
|
||||
),
|
||||
)
|
||||
manifest, keyring = self._signed_distribution()
|
||||
manifest_path = root / "source-manifest.json"
|
||||
keyring_path = root / "source-keyring.json"
|
||||
encoded_manifest = canonical_json(manifest)
|
||||
manifest_path.write_bytes(encoded_manifest)
|
||||
keyring_path.write_bytes(canonical_json(keyring))
|
||||
|
||||
result = main(
|
||||
[
|
||||
"verify-release",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--manifest-sha256",
|
||||
hashlib.sha256(encoded_manifest).hexdigest(),
|
||||
"--trusted-keyring",
|
||||
str(keyring_path),
|
||||
"--adopt",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(0, result)
|
||||
paths = bundle_paths(root)
|
||||
spec = load_spec(paths.spec)
|
||||
self.assertEqual("1.2.3", spec.release.version)
|
||||
self.assertEqual("release-1", spec.release.manifest_signature_key_id)
|
||||
self.assertTrue(spec.release.api_image.endswith("a" * 64))
|
||||
release_checks = {
|
||||
item.id: item for item in static_checks(spec, paths)
|
||||
if item.id.startswith("release.") or item.id == "modules.image_composition"
|
||||
}
|
||||
self.assertEqual("ok", release_checks["release.manifest"].level)
|
||||
self.assertEqual(
|
||||
"ok", release_checks["release.signature_verification"].level
|
||||
)
|
||||
self.assertEqual("ok", release_checks["modules.image_composition"].level)
|
||||
|
||||
def test_rejects_manifest_whose_independent_digest_does_not_match(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-release-adopt-") as value:
|
||||
root = Path(value)
|
||||
main(
|
||||
[
|
||||
"init",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--non-interactive",
|
||||
"--module-set",
|
||||
"core",
|
||||
]
|
||||
)
|
||||
manifest, keyring = self._signed_distribution()
|
||||
manifest_path = root / "source-manifest.json"
|
||||
keyring_path = root / "source-keyring.json"
|
||||
manifest_path.write_bytes(canonical_json(manifest))
|
||||
keyring_path.write_bytes(canonical_json(keyring))
|
||||
|
||||
self.assertEqual(
|
||||
1,
|
||||
main(
|
||||
[
|
||||
"verify-release",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--manifest-sha256",
|
||||
"0" * 64,
|
||||
"--trusted-keyring",
|
||||
str(keyring_path),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _signed_distribution() -> tuple[dict[str, object], dict[str, object]]:
|
||||
now = datetime.now(UTC)
|
||||
private = Ed25519PrivateKey.generate()
|
||||
public = private.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
artifact = {
|
||||
"url": "https://downloads.example.test/artifact.json",
|
||||
"sha256": "f" * 64,
|
||||
}
|
||||
payload: dict[str, object] = {
|
||||
"schema_version": "1",
|
||||
"channel": "stable",
|
||||
"sequence": 1,
|
||||
"version": "1.2.3",
|
||||
"issued_at": (now - timedelta(minutes=1)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=30)).isoformat(),
|
||||
"revoked": False,
|
||||
"deployer": {
|
||||
"url": "https://downloads.example.test/govoplan-deploy.pyz",
|
||||
"sha256": "e" * 64,
|
||||
},
|
||||
"images": {
|
||||
"api": {
|
||||
"index": "registry.example/govoplan/api@sha256:" + "a" * 64,
|
||||
"platforms": {
|
||||
"linux/amd64": "registry.example/govoplan/api@sha256:" + "1" * 64,
|
||||
"linux/arm64": "registry.example/govoplan/api@sha256:" + "2" * 64,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
"web": {
|
||||
"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,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
},
|
||||
"dependencies": {
|
||||
"postgres": "docker.io/library/postgres@sha256:" + "5" * 64,
|
||||
"redis": "docker.io/library/redis@sha256:" + "6" * 64,
|
||||
"load_balancer": "docker.io/library/haproxy@sha256:" + "7" * 64,
|
||||
},
|
||||
"composition": {
|
||||
"sha256": "c" * 64,
|
||||
"module_ids": [],
|
||||
"packages": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"version": "1.2.3",
|
||||
"wheel_sha256": "8" * 64,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
payload["signatures"] = [
|
||||
{
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"value": base64.b64encode(
|
||||
private.sign(canonical_signed_payload(payload))
|
||||
).decode("ascii"),
|
||||
}
|
||||
]
|
||||
keyring = {
|
||||
"schema_version": "1",
|
||||
"purpose": "govoplan-runtime-distribution",
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"status": "active",
|
||||
"public_key_pem": public,
|
||||
"not_before": (now - timedelta(days=1)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=365)).isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
return payload, keyring
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||
|
||||
from govoplan_deploy.distribution import ( # noqa: E402
|
||||
DistributionError,
|
||||
canonical_signed_payload,
|
||||
verify_manifest,
|
||||
verify_manifest_binding,
|
||||
verify_offline_image_index,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeDistributionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.now = datetime(2026, 8, 3, tzinfo=UTC)
|
||||
self.private = Ed25519PrivateKey.generate()
|
||||
public = self.private.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
self.keyring = {
|
||||
"schema_version": "1",
|
||||
"purpose": "govoplan-runtime-distribution",
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"status": "active",
|
||||
"public_key_pem": public,
|
||||
"not_before": (self.now - timedelta(days=1)).isoformat(),
|
||||
"expires_at": (self.now + timedelta(days=365)).isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_verifies_signature_and_exact_runtime_binding(self) -> None:
|
||||
payload = self._manifest()
|
||||
|
||||
key_id = verify_manifest(
|
||||
payload,
|
||||
self.keyring,
|
||||
expected_channel="stable",
|
||||
now=self.now,
|
||||
)
|
||||
verify_manifest_binding(
|
||||
payload,
|
||||
channel="stable",
|
||||
version="1.2.3",
|
||||
api_image=payload["images"]["api"]["index"],
|
||||
web_image=payload["images"]["web"]["index"],
|
||||
enabled_modules=("access", "files"),
|
||||
composition_sha256="c" * 64,
|
||||
dependencies=payload["dependencies"],
|
||||
)
|
||||
|
||||
self.assertEqual("release-1", key_id)
|
||||
|
||||
def test_tamper_expiry_revocation_and_unknown_key_fail_closed(self) -> None:
|
||||
payload = self._manifest()
|
||||
payload["composition"]["module_ids"].append("mail")
|
||||
with self.assertRaisesRegex(DistributionError, "signature verification"):
|
||||
verify_manifest(payload, self.keyring, now=self.now)
|
||||
|
||||
expired = self._manifest()
|
||||
expired["expires_at"] = (self.now - timedelta(seconds=1)).isoformat()
|
||||
expired["signatures"] = [self._signature(expired)]
|
||||
with self.assertRaisesRegex(DistributionError, "expired"):
|
||||
verify_manifest(expired, self.keyring, now=self.now)
|
||||
|
||||
revoked = self._manifest()
|
||||
revoked["revoked"] = True
|
||||
revoked["signatures"] = [self._signature(revoked)]
|
||||
with self.assertRaisesRegex(DistributionError, "revoked"):
|
||||
verify_manifest(revoked, self.keyring, now=self.now)
|
||||
|
||||
unknown = self._manifest()
|
||||
unknown["signatures"][0]["key_id"] = "other-key"
|
||||
with self.assertRaisesRegex(DistributionError, "active trusted key"):
|
||||
verify_manifest(unknown, self.keyring, now=self.now)
|
||||
|
||||
def test_offline_image_index_is_complete_and_digest_bound(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-offline-images-") as value:
|
||||
root = Path(value)
|
||||
api = root / "api.oci.tar"
|
||||
web = root / "web.oci.tar"
|
||||
api.write_bytes(b"api archive")
|
||||
web.write_bytes(b"web archive")
|
||||
api_ref = "registry.example/govoplan/api@sha256:" + "a" * 64
|
||||
web_ref = "registry.example/govoplan/web@sha256:" + "b" * 64
|
||||
index = {
|
||||
"schema_version": "1",
|
||||
"images": [
|
||||
{
|
||||
"reference": api_ref,
|
||||
"archive": api.name,
|
||||
"sha256": hashlib.sha256(api.read_bytes()).hexdigest(),
|
||||
},
|
||||
{
|
||||
"reference": web_ref,
|
||||
"archive": web.name,
|
||||
"sha256": hashlib.sha256(web.read_bytes()).hexdigest(),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
paths = verify_offline_image_index(
|
||||
index,
|
||||
root=root,
|
||||
expected_references=(api_ref, web_ref),
|
||||
)
|
||||
self.assertEqual((api, web), paths)
|
||||
|
||||
index["images"][1]["sha256"] = "0" * 64
|
||||
with self.assertRaisesRegex(DistributionError, "digest mismatch"):
|
||||
verify_offline_image_index(
|
||||
index,
|
||||
root=root,
|
||||
expected_references=(api_ref, web_ref),
|
||||
)
|
||||
|
||||
def _manifest(self) -> dict[str, object]:
|
||||
artifact = {"url": "https://downloads.example.test/artifact.json", "sha256": "d" * 64}
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": "1",
|
||||
"channel": "stable",
|
||||
"sequence": 1,
|
||||
"version": "1.2.3",
|
||||
"issued_at": (self.now - timedelta(minutes=1)).isoformat(),
|
||||
"expires_at": (self.now + timedelta(days=30)).isoformat(),
|
||||
"revoked": False,
|
||||
"deployer": {
|
||||
"url": "https://downloads.example.test/govoplan-deploy.pyz",
|
||||
"sha256": "e" * 64,
|
||||
},
|
||||
"images": {
|
||||
"api": {
|
||||
"index": "registry.example/govoplan/api@sha256:" + "a" * 64,
|
||||
"platforms": {
|
||||
"linux/amd64": "registry.example/govoplan/api@sha256:" + "1" * 64,
|
||||
"linux/arm64": "registry.example/govoplan/api@sha256:" + "2" * 64,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
"web": {
|
||||
"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,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
},
|
||||
"dependencies": {
|
||||
"postgres": "docker.io/library/postgres@sha256:" + "5" * 64,
|
||||
"redis": "docker.io/library/redis@sha256:" + "6" * 64,
|
||||
"load_balancer": "docker.io/library/haproxy@sha256:" + "7" * 64,
|
||||
},
|
||||
"composition": {
|
||||
"sha256": "c" * 64,
|
||||
"module_ids": ["access", "files"],
|
||||
"packages": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"version": "1.2.3",
|
||||
"wheel_sha256": "8" * 64,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
manifest["signatures"] = [self._signature(manifest)]
|
||||
return manifest
|
||||
|
||||
def _signature(self, payload: dict[str, object]) -> dict[str, str]:
|
||||
return {
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"value": base64.b64encode(
|
||||
self.private.sign(canonical_signed_payload(payload))
|
||||
).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
class RuntimeDistributionBuildTests(unittest.TestCase):
|
||||
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"],
|
||||
)
|
||||
|
||||
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")
|
||||
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",
|
||||
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())
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "tools"
|
||||
/ "release"
|
||||
/ "prepare-runtime-context.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location("prepare_runtime_context", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class RuntimeImageContextTests(unittest.TestCase):
|
||||
def test_builds_deterministic_network_free_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
|
||||
root = Path(value)
|
||||
wheelhouse = root / "input-wheels"
|
||||
web = root / "web"
|
||||
wheelhouse.mkdir()
|
||||
web.mkdir()
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_core-1.2.3-py3-none-any.whl",
|
||||
package="govoplan-core",
|
||||
version="1.2.3",
|
||||
module_ids=(),
|
||||
)
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_files-1.2.3-py3-none-any.whl",
|
||||
package="govoplan-files",
|
||||
version="1.2.3",
|
||||
module_ids=("files",),
|
||||
)
|
||||
self._wheel(
|
||||
wheelhouse / "sqlalchemy-2.0.0-py3-none-any.whl",
|
||||
package="SQLAlchemy",
|
||||
version="2.0.0",
|
||||
module_ids=(),
|
||||
)
|
||||
(web / "index.html").write_text("<main>GovOPlaN</main>\n", encoding="utf-8")
|
||||
|
||||
composition = MODULE.prepare_context(
|
||||
wheelhouse=wheelhouse,
|
||||
web_dist=web,
|
||||
output=root / "context",
|
||||
required_modules=("files",),
|
||||
source_date_epoch=1_700_000_000,
|
||||
)
|
||||
|
||||
self.assertEqual(["files"], composition["python"]["module_ids"])
|
||||
self.assertEqual(2, composition["python"]["wheel_count"])
|
||||
requirements = (root / "context" / "requirements-runtime.txt").read_text()
|
||||
self.assertEqual(
|
||||
"govoplan-core[server]==1.2.3\ngovoplan-files==1.2.3\n",
|
||||
requirements,
|
||||
)
|
||||
published = json.loads(
|
||||
(
|
||||
root
|
||||
/ "context"
|
||||
/ "web-dist"
|
||||
/ ".well-known"
|
||||
/ "govoplan-composition.json"
|
||||
).read_text()
|
||||
)
|
||||
self.assertEqual(composition, published)
|
||||
|
||||
def test_rejects_missing_required_module(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
|
||||
root = Path(value)
|
||||
wheelhouse = root / "wheels"
|
||||
web = root / "web"
|
||||
wheelhouse.mkdir()
|
||||
web.mkdir()
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
|
||||
package="govoplan-core",
|
||||
version="1.0.0",
|
||||
module_ids=(),
|
||||
)
|
||||
(web / "index.html").write_text("ok", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ContextError, "missing required"):
|
||||
MODULE.prepare_context(
|
||||
wheelhouse=wheelhouse,
|
||||
web_dist=web,
|
||||
output=root / "context",
|
||||
required_modules=("mail",),
|
||||
)
|
||||
|
||||
def test_rejects_symlinked_web_payload(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
|
||||
root = Path(value)
|
||||
wheelhouse = root / "wheels"
|
||||
web = root / "web"
|
||||
wheelhouse.mkdir()
|
||||
web.mkdir()
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
|
||||
package="govoplan-core",
|
||||
version="1.0.0",
|
||||
module_ids=(),
|
||||
)
|
||||
outside = root / "outside"
|
||||
outside.write_text("not part of dist", encoding="utf-8")
|
||||
(web / "index.html").symlink_to(outside)
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ContextError, "symlink"):
|
||||
MODULE.prepare_context(
|
||||
wheelhouse=wheelhouse,
|
||||
web_dist=web,
|
||||
output=root / "context",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wheel(
|
||||
path: Path,
|
||||
*,
|
||||
package: str,
|
||||
version: str,
|
||||
module_ids: tuple[str, ...],
|
||||
) -> None:
|
||||
dist_info = package.replace("-", "_") + f"-{version}.dist-info"
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr(
|
||||
f"{dist_info}/METADATA",
|
||||
f"Metadata-Version: 2.1\nName: {package}\nVersion: {version}\n",
|
||||
)
|
||||
if module_ids:
|
||||
rows = "\n".join(
|
||||
f"{module_id} = example.module:manifest"
|
||||
for module_id in module_ids
|
||||
)
|
||||
archive.writestr(
|
||||
f"{dist_info}/entry_points.txt",
|
||||
f"[govoplan.modules]\n{rows}\n",
|
||||
)
|
||||
archive.writestr(f"{package.replace('-', '_')}/__init__.py", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user