Add signed runtime distribution pipeline
Dependency Audit / dependency-audit (push) Successful in 1m39s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m3s

This commit is contained in:
2026-08-03 00:54:06 +02:00
parent 29acb55b7c
commit 43380eb068
24 changed files with 3371 additions and 54 deletions
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Create runtime SBOM, provenance, and an unsigned distribution descriptor."""
from __future__ import annotations
import argparse
from datetime import UTC, datetime, timedelta
import hashlib
import json
from pathlib import Path
import re
import sys
from typing import Any
from urllib.parse import urlsplit
import uuid
SHA256 = re.compile(r"^[0-9a-f]{64}$")
DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--composition", type=Path, required=True)
parser.add_argument("--api-metadata", type=Path, required=True)
parser.add_argument("--web-metadata", type=Path, required=True)
parser.add_argument("--deployer", type=Path, required=True)
parser.add_argument("--deployer-url", required=True)
parser.add_argument("--artifact-base-url", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--channel", default="stable")
parser.add_argument("--sequence", type=int, required=True)
parser.add_argument("--expires-days", type=int, default=90)
parser.add_argument(
"--dependency",
action="append",
default=[],
metavar="NAME=IMAGE@SHA256",
)
parser.add_argument("--output-directory", type=Path, required=True)
parser.add_argument("--descriptor", type=Path, required=True)
return parser
def finalize(args: argparse.Namespace) -> dict[str, Any]:
composition = _json_object(args.composition)
api = _image_metadata(_json_object(args.api_metadata), "api")
web = _image_metadata(_json_object(args.web_metadata), "web")
dependencies = dict(_dependency(value) for value in args.dependency)
if not dependencies:
raise ValueError("at least one --dependency is required")
_https_url(args.deployer_url, "deployer URL")
artifact_base = _https_url(args.artifact_base_url, "artifact base URL").rstrip("/")
if args.sequence < 1 or not 1 <= args.expires_days <= 365:
raise ValueError("sequence and expiry window are out of bounds")
output = args.output_directory.expanduser().resolve()
output.mkdir(parents=True, exist_ok=True)
api_sbom = _api_sbom(composition, version=args.version)
web_sbom = _web_sbom(composition, version=args.version)
api_provenance = _provenance(
subject=api["index"],
source_commit=args.source_commit,
composition=composition,
)
web_provenance = _provenance(
subject=web["index"],
source_commit=args.source_commit,
composition=composition,
)
artifact_values = {
"api-sbom.cdx.json": api_sbom,
"web-sbom.cdx.json": web_sbom,
"api-provenance.json": api_provenance,
"web-provenance.json": web_provenance,
}
artifacts: dict[str, dict[str, str]] = {}
for filename, value in artifact_values.items():
path = output / filename
encoded = _canonical_json(value)
path.write_bytes(encoded)
artifacts[filename] = {
"url": f"{artifact_base}/{filename}",
"sha256": hashlib.sha256(encoded).hexdigest(),
}
composition_encoded = _canonical_json(composition)
packages = _manifest_packages(composition)
issued = datetime.now(UTC).replace(microsecond=0)
descriptor: dict[str, Any] = {
"schema_version": "1",
"channel": args.channel,
"sequence": args.sequence,
"version": args.version,
"issued_at": issued.isoformat(),
"expires_at": (issued + timedelta(days=args.expires_days)).isoformat(),
"revoked": False,
"deployer": {
"url": args.deployer_url,
"sha256": _sha256_file(args.deployer),
},
"images": {
"api": {
**api,
"sbom": artifacts["api-sbom.cdx.json"],
"provenance": artifacts["api-provenance.json"],
},
"web": {
**web,
"sbom": artifacts["web-sbom.cdx.json"],
"provenance": artifacts["web-provenance.json"],
},
},
"dependencies": dict(sorted(dependencies.items())),
"composition": {
"sha256": hashlib.sha256(composition_encoded).hexdigest(),
"module_ids": list(composition["python"]["module_ids"]),
"packages": packages,
},
}
args.descriptor.parent.mkdir(parents=True, exist_ok=True)
args.descriptor.write_bytes(_canonical_json(descriptor))
return descriptor
def _api_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
components = []
for package in composition["python"]["packages"]:
components.append(
{
"type": "library",
"name": package["package"],
"version": package["version"],
"hashes": [{"alg": "SHA-256", "content": package["sha256"]}],
"purl": f"pkg:pypi/{package['package']}@{package['version']}",
}
)
return {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-api:' + version)}",
"version": 1,
"metadata": {"component": {"type": "application", "name": "govoplan-api", "version": version}},
"components": components,
}
def _web_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
return {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-web:' + version)}",
"version": 1,
"metadata": {"component": {"type": "application", "name": "govoplan-web", "version": version}},
"components": [
{
"type": "file",
"name": "govoplan-web-dist",
"version": version,
"hashes": [
{
"alg": "SHA-256",
"content": composition["web"]["sha256"],
}
],
}
],
}
def _provenance(
*,
subject: str,
source_commit: str,
composition: dict[str, Any],
) -> dict[str, Any]:
digest = subject.rsplit("@sha256:", 1)[1]
return {
"_type": "https://in-toto.io/Statement/v1",
"subject": [{"name": subject.split("@", 1)[0], "digest": {"sha256": digest}}],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://govoplan.add-ideas.de/build/runtime-oci/v1",
"externalParameters": {
"source_commit": source_commit,
"network_free_image_assembly": True,
},
"resolvedDependencies": [
{
"uri": "govoplan:runtime-composition",
"digest": {
"sha256": hashlib.sha256(_canonical_json(composition)).hexdigest()
},
}
],
},
"runDetails": {
"builder": {"id": "https://git.add-ideas.de/GovOPlaN/govoplan/actions"},
"metadata": {"invocationId": source_commit},
},
},
}
def _manifest_packages(composition: dict[str, Any]) -> list[dict[str, str]]:
values = []
for package in composition["python"]["packages"]:
values.append(
{
"name": str(package["package"]),
"version": str(package["version"]),
"wheel_sha256": str(package["sha256"]),
}
)
return sorted(values, key=lambda item: item["name"])
def _image_metadata(value: dict[str, Any], label: str) -> dict[str, Any]:
if set(value) != {"index", "platforms"}:
raise ValueError(f"{label} image metadata has invalid fields")
if not isinstance(value["index"], str) or DIGEST_IMAGE.fullmatch(value["index"]) is None:
raise ValueError(f"{label} index is not digest-pinned")
platforms = value["platforms"]
if not isinstance(platforms, dict) or set(platforms) != {"linux/amd64", "linux/arm64"}:
raise ValueError(f"{label} image does not cover amd64 and arm64")
if any(not isinstance(item, str) or DIGEST_IMAGE.fullmatch(item) is None for item in platforms.values()):
raise ValueError(f"{label} platform image is not digest-pinned")
return {"index": value["index"], "platforms": dict(sorted(platforms.items()))}
def _dependency(value: str) -> tuple[str, str]:
if "=" not in value:
raise ValueError("--dependency must use NAME=IMAGE@SHA256")
name, reference = value.split("=", 1)
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", name) is None:
raise ValueError(f"invalid dependency name: {name!r}")
if DIGEST_IMAGE.fullmatch(reference) is None:
raise ValueError(f"dependency {name!r} is not digest-pinned")
return name, reference
def _json_object(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"JSON root must be an object: {path}")
return value
def _https_url(value: str, label: str) -> str:
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValueError(f"{label} must be an HTTPS URL without credentials")
return value
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _canonical_json(value: object) -> bytes:
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
def main() -> int:
args = build_parser().parse_args()
try:
finalize(args)
except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Runtime release evidence written below {args.output_directory}")
print(f"Unsigned descriptor written to {args.descriptor}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Validate and sign a GovOPlaN OCI runtime distribution manifest."""
from __future__ import annotations
import argparse
import base64
import json
from pathlib import Path
import sys
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
from govoplan_deploy.distribution import ( # noqa: E402
DistributionError,
canonical_json,
canonical_signed_payload,
validate_manifest,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--descriptor", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--signing-key",
action="append",
default=[],
metavar="KEY_ID=PRIVATE_PEM",
required=True,
)
return parser
def sign_manifest(
descriptor: dict[str, object],
signing_keys: tuple[tuple[str, Path], ...],
) -> dict[str, object]:
payload = dict(descriptor)
payload["signatures"] = [
_signature(payload, key_id=key_id, path=path)
for key_id, path in signing_keys
]
validate_manifest(payload)
return payload
def _signature(
payload: dict[str, object],
*,
key_id: str,
path: Path,
) -> dict[str, str]:
try:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
except (OSError, ValueError, TypeError) as exc:
raise DistributionError(f"cannot load signing key {key_id!r}") from exc
if not isinstance(key, Ed25519PrivateKey):
raise DistributionError(f"signing key {key_id!r} is not Ed25519")
return {
"key_id": key_id,
"algorithm": "ed25519",
"value": base64.b64encode(key.sign(canonical_signed_payload(payload))).decode(
"ascii"
),
}
def _parse_signing_key(value: str) -> tuple[str, Path]:
if "=" not in value:
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
key_id, raw_path = value.split("=", 1)
if not key_id or not raw_path:
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
return key_id, Path(raw_path).expanduser().resolve()
def main() -> int:
args = build_parser().parse_args()
try:
descriptor = json.loads(args.descriptor.read_text(encoding="utf-8"))
if not isinstance(descriptor, dict):
raise DistributionError("descriptor root must be an object")
if "signatures" in descriptor:
raise DistributionError("descriptor must not contain signatures")
payload = sign_manifest(
descriptor,
tuple(_parse_signing_key(value) for value in args.signing_key),
)
encoded = canonical_json(payload)
args.output.parent.mkdir(parents=True, exist_ok=True)
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
temporary.write_bytes(encoded)
temporary.chmod(0o644)
temporary.replace(args.output)
except (DistributionError, OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Runtime distribution manifest written to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Assemble a deterministic, network-free GovOPlaN OCI build context."""
from __future__ import annotations
import argparse
import configparser
from email.parser import BytesParser
from email.policy import compat32
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import zipfile
NORMALIZED_PACKAGE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
MAX_WHEELS = 512
MAX_WHEEL_BYTES = 512 * 1024 * 1024
MAX_WEB_FILES = 100_000
MAX_WEB_BYTES = 2 * 1024 * 1024 * 1024
class ContextError(ValueError):
"""The release inputs cannot form an immutable runtime context."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wheelhouse", type=Path, required=True)
parser.add_argument("--web-dist", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--required-module", action="append", default=[])
parser.add_argument(
"--source-date-epoch",
type=int,
default=int(os.environ.get("SOURCE_DATE_EPOCH", "0") or 0),
)
return parser
def prepare_context(
*,
wheelhouse: Path,
web_dist: Path,
output: Path,
required_modules: tuple[str, ...] = (),
source_date_epoch: int = 0,
) -> dict[str, object]:
source_wheels = _regular_files(wheelhouse, suffix=".whl", maximum=MAX_WHEELS)
if not source_wheels:
raise ContextError("wheelhouse contains no wheel artifacts")
if output.exists() and any(output.iterdir()):
raise ContextError("output directory must be absent or empty")
output.mkdir(parents=True, exist_ok=True)
target_wheels = output / "wheelhouse"
target_web = output / "web-dist"
target_wheels.mkdir(mode=0o755)
packages: list[dict[str, object]] = []
govoplan_wheel_rows: list[dict[str, object]] = []
roots: list[tuple[str, str]] = []
module_ids: set[str] = set()
seen_packages: set[str] = set()
wheel_rows: list[dict[str, object]] = []
for source in source_wheels:
if source.stat().st_size > MAX_WHEEL_BYTES:
raise ContextError(f"wheel exceeds size limit: {source.name}")
identity = inspect_wheel(source)
package_name = str(identity["package"])
if package_name in seen_packages:
raise ContextError(f"duplicate wheel distribution: {package_name}")
seen_packages.add(package_name)
target = target_wheels / source.name
_copy_regular(source, target, source_date_epoch=source_date_epoch)
row = {
"filename": source.name,
"sha256": _sha256_file(target),
"size": target.stat().st_size,
}
wheel_rows.append(row)
if package_name.startswith("govoplan-"):
package_modules = tuple(str(item) for item in identity["module_ids"])
module_ids.update(package_modules)
package = {
**row,
"package": package_name,
"version": identity["version"],
"module_ids": list(package_modules),
}
packages.append(package)
govoplan_wheel_rows.append(row)
root = (
f"{package_name}[server]"
if package_name == "govoplan-core"
else package_name
)
roots.append((root, str(identity["version"])))
if not any(package["package"] == "govoplan-core" for package in packages):
raise ContextError("wheelhouse does not contain govoplan-core")
missing_modules = sorted(set(required_modules) - module_ids)
if missing_modules:
raise ContextError(
"runtime composition is missing required modules: "
+ ", ".join(missing_modules)
)
requirements = "".join(
f"{package}=={version}\n" for package, version in sorted(roots)
)
_write_regular(
output / "requirements-runtime.txt",
requirements.encode("utf-8"),
source_date_epoch=source_date_epoch,
)
web_rows = _copy_web_tree(
web_dist,
target_web,
source_date_epoch=source_date_epoch,
)
composition: dict[str, object] = {
"schema_version": "1",
"python": {
"packages": sorted(packages, key=lambda item: str(item["package"])),
"module_ids": sorted(module_ids),
"wheelhouse_sha256": _rows_digest(govoplan_wheel_rows),
"wheel_count": len(govoplan_wheel_rows),
},
"web": {
"sha256": _rows_digest(web_rows),
"file_count": len(web_rows),
},
}
encoded = (json.dumps(composition, indent=2, sort_keys=True) + "\n").encode(
"utf-8"
)
_write_regular(
output / "composition.json",
encoded,
source_date_epoch=source_date_epoch,
)
_write_regular(
target_web / ".well-known" / "govoplan-composition.json",
encoded,
source_date_epoch=source_date_epoch,
)
_copy_regular(
Path(__file__).resolve().parent / "runtime" / "nginx.conf",
output / "nginx.conf",
source_date_epoch=source_date_epoch,
)
return composition
def inspect_wheel(path: Path) -> dict[str, object]:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ContextError(f"wheel cannot be opened safely: {path.name}") from exc
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode):
raise ContextError(f"wheel is not a regular file: {path.name}")
with os.fdopen(os.dup(descriptor), "rb") as handle:
with zipfile.ZipFile(handle) as archive:
metadata = [
member
for member in archive.infolist()
if PurePosixPath(member.filename).name == "METADATA"
and PurePosixPath(member.filename).parent.name.endswith(
".dist-info"
)
]
if len(metadata) != 1:
raise ContextError(
f"wheel must contain one METADATA file: {path.name}"
)
parsed = BytesParser(policy=compat32).parsebytes(
archive.read(metadata[0])
)
package = _normalize_package(str(parsed.get("Name") or ""))
version = str(parsed.get("Version") or "").strip()
if VERSION.fullmatch(version) is None:
raise ContextError(f"wheel has invalid version: {path.name}")
entry_points_name = (
PurePosixPath(metadata[0].filename).parent / "entry_points.txt"
).as_posix()
module_ids: tuple[str, ...] = ()
if entry_points_name in archive.namelist():
module_ids = _module_entry_points(
archive.read(entry_points_name).decode("utf-8")
)
final = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
final.st_dev,
final.st_ino,
final.st_size,
final.st_mtime_ns,
):
raise ContextError(f"wheel changed while inspected: {path.name}")
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
if isinstance(exc, ContextError):
raise
raise ContextError(f"wheel is not a readable archive: {path.name}") from exc
finally:
os.close(descriptor)
return {"package": package, "version": version, "module_ids": module_ids}
def _module_entry_points(value: str) -> tuple[str, ...]:
parser = configparser.ConfigParser(interpolation=None, strict=True)
try:
parser.read_string(value)
except configparser.Error as exc:
raise ContextError("wheel entry_points.txt is malformed") from exc
if not parser.has_section("govoplan.modules"):
return ()
values = tuple(sorted(parser.options("govoplan.modules")))
for item in values:
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", item) is None:
raise ContextError(f"wheel has invalid module entry point: {item!r}")
return values
def _normalize_package(value: str) -> str:
normalized = re.sub(r"[-_.]+", "-", value.strip().lower())
if NORMALIZED_PACKAGE.fullmatch(normalized) is None:
raise ContextError("wheel has invalid package name")
return normalized
def _regular_files(root: Path, *, suffix: str, maximum: int) -> list[Path]:
if root.is_symlink() or not root.is_dir():
raise ContextError(f"input directory is not a real directory: {root}")
values = sorted(path for path in root.iterdir() if path.name.endswith(suffix))
if len(values) > maximum:
raise ContextError(f"input directory exceeds {maximum} files")
for path in values:
if path.is_symlink() or not path.is_file():
raise ContextError(f"input artifact is not a regular file: {path.name}")
return values
def _copy_web_tree(
source: Path,
target: Path,
*,
source_date_epoch: int,
) -> list[dict[str, object]]:
if source.is_symlink() or not source.is_dir():
raise ContextError("WebUI dist must be a real directory")
rows: list[dict[str, object]] = []
total = 0
for path in sorted(source.rglob("*")):
relative = path.relative_to(source)
if path.is_symlink():
raise ContextError(f"WebUI dist contains a symlink: {relative}")
if path.is_dir():
continue
if not path.is_file():
raise ContextError(f"WebUI dist contains a special file: {relative}")
if len(rows) >= MAX_WEB_FILES:
raise ContextError("WebUI dist exceeds its file-count limit")
total += path.stat().st_size
if total > MAX_WEB_BYTES:
raise ContextError("WebUI dist exceeds its total-size limit")
destination = target / relative
_copy_regular(path, destination, source_date_epoch=source_date_epoch)
rows.append(
{
"path": relative.as_posix(),
"sha256": _sha256_file(destination),
"size": destination.stat().st_size,
}
)
if not rows:
raise ContextError("WebUI dist contains no files")
return rows
def _copy_regular(source: Path, target: Path, *, source_date_epoch: int) -> None:
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
with source.open("rb") as source_handle, target.open("xb") as target_handle:
shutil.copyfileobj(source_handle, target_handle)
target_handle.flush()
os.fsync(target_handle.fileno())
target.chmod(0o644)
os.utime(target, (source_date_epoch, source_date_epoch))
def _write_regular(path: Path, value: bytes, *, source_date_epoch: int) -> None:
path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
path.write_bytes(value)
path.chmod(0o644)
os.utime(path, (source_date_epoch, source_date_epoch))
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _rows_digest(rows: list[dict[str, object]]) -> str:
encoded = json.dumps(rows, separators=(",", ":"), sort_keys=True).encode(
"utf-8"
)
return hashlib.sha256(encoded).hexdigest()
def main() -> int:
args = build_parser().parse_args()
try:
composition = prepare_context(
wheelhouse=args.wheelhouse.expanduser().resolve(),
web_dist=args.web_dist.expanduser().resolve(),
output=args.output.expanduser().resolve(),
required_modules=tuple(args.required_module),
source_date_epoch=args.source_date_epoch,
)
except (ContextError, OSError) as exc:
print(f"error: {exc}", file=os.sys.stderr)
return 1
print(json.dumps(composition, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Publish immutable GovOPlaN runtime evidence as Gitea release assets."""
from __future__ import annotations
import argparse
import hashlib
import json
import mimetypes
import os
from pathlib import Path
import secrets
import sys
from typing import Any
from urllib.error import HTTPError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
MAX_ASSET_BYTES = 256 * 1024 * 1024
class PublishError(RuntimeError):
"""A release asset cannot be published immutably."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="https://git.add-ideas.de")
parser.add_argument("--owner", default="GovOPlaN")
parser.add_argument("--repo", default="govoplan")
parser.add_argument("--tag", required=True)
parser.add_argument("--title", required=True)
parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.")
parser.add_argument("--asset", type=Path, action="append", default=[], required=True)
parser.add_argument("--token-env", default="GITEA_RELEASE_TOKEN")
return parser
class GiteaReleasePublisher:
def __init__(self, *, base_url: str, owner: str, repo: str, token: str) -> None:
if not base_url.startswith("https://"):
raise PublishError("Gitea release publication requires HTTPS")
if not token:
raise PublishError("Gitea release token is empty")
self.base_url = base_url.rstrip("/")
self.owner = owner
self.repo = repo
self.token = token
def release(self, *, tag: str, title: str, body: str) -> dict[str, Any]:
path = self._repo_path(f"/releases/tags/{quote(tag, safe='')}")
try:
return self._json("GET", path)
except HTTPError as exc:
if exc.code != 404:
raise
return self._json(
"POST",
self._repo_path("/releases"),
payload={
"tag_name": tag,
"name": title,
"body": body,
"draft": False,
"prerelease": False,
},
expected=201,
)
def upload_assets(self, release: dict[str, Any], assets: tuple[Path, ...]) -> None:
release_id = release.get("id")
if isinstance(release_id, bool) or not isinstance(release_id, int):
raise PublishError("Gitea release response has no numeric id")
existing = self._json(
"GET",
self._repo_path(f"/releases/{release_id}/assets"),
)
if not isinstance(existing, list):
raise PublishError("Gitea release assets response is invalid")
existing_by_name = {
str(item.get("name")): item for item in existing if isinstance(item, dict)
}
for asset in assets:
path = asset.expanduser().resolve()
if path.is_symlink() or not path.is_file():
raise PublishError(f"release asset is not a regular file: {path}")
size = path.stat().st_size
if size > MAX_ASSET_BYTES:
raise PublishError(f"release asset exceeds size limit: {path.name}")
prior = existing_by_name.get(path.name)
if prior is not None:
self._require_same_existing_asset(prior, path)
continue
self._upload(release_id, path)
def _require_same_existing_asset(self, prior: dict[str, Any], path: Path) -> None:
url = prior.get("browser_download_url")
size = prior.get("size")
if not isinstance(url, str) or not url.startswith("https://") or size != path.stat().st_size:
raise PublishError(f"release asset already exists with another identity: {path.name}")
request = Request(url, headers=self._headers())
digest = hashlib.sha256()
total = 0
with urlopen(request, timeout=30) as response: # noqa: S310
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_ASSET_BYTES:
raise PublishError("existing release asset exceeds size limit")
digest.update(chunk)
if digest.hexdigest() != _sha256_file(path):
raise PublishError(f"release asset already exists with another digest: {path.name}")
def _upload(self, release_id: int, path: Path) -> None:
boundary = "govoplan-" + secrets.token_hex(16)
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
prefix = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
suffix = f"\r\n--{boundary}--\r\n".encode("ascii")
data = prefix + path.read_bytes() + suffix
query = urlencode({"name": path.name})
request = Request(
self._repo_path(f"/releases/{release_id}/assets") + "?" + query,
data=data,
method="POST",
headers={
**self._headers(),
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
)
try:
with urlopen(request, timeout=120) as response: # noqa: S310
if response.status != 201:
raise PublishError(
f"Gitea asset upload returned HTTP {response.status}"
)
except HTTPError as exc:
raise PublishError(f"Gitea asset upload failed with HTTP {exc.code}") from exc
def _json(
self,
method: str,
url: str,
*,
payload: dict[str, Any] | None = None,
expected: int = 200,
) -> Any:
data = None
headers = self._headers()
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, method=method, headers=headers)
with urlopen(request, timeout=30) as response: # noqa: S310
if response.status != expected:
raise PublishError(f"Gitea API returned HTTP {response.status}")
return json.load(response)
def _headers(self) -> dict[str, str]:
return {"Authorization": f"token {self.token}", "Accept": "application/json"}
def _repo_path(self, suffix: str) -> str:
return (
f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/"
f"{quote(self.repo, safe='')}{suffix}"
)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
args = build_parser().parse_args()
try:
publisher = GiteaReleasePublisher(
base_url=args.base_url,
owner=args.owner,
repo=args.repo,
token=os.environ.get(args.token_env, ""),
)
release = publisher.release(tag=args.tag, title=args.title, body=args.body)
publisher.upload_assets(release, tuple(args.asset))
except (HTTPError, OSError, PublishError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Published {len(args.asset)} immutable asset(s) to {args.owner}/{args.repo} {args.tag}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Resolve amd64/arm64 child digests from an OCI image index."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
def resolve_platforms(
payload: object,
*,
repository: str,
index_digest: str,
) -> dict[str, object]:
if not repository or "@" in repository or any(value.isspace() for value in repository):
raise ValueError("repository must be an unpinned OCI repository name")
if DIGEST.fullmatch(index_digest) is None:
raise ValueError("index digest must be sha256:<hex>")
if not isinstance(payload, dict) or not isinstance(payload.get("manifests"), list):
raise ValueError("OCI index must contain manifests")
platforms: dict[str, str] = {}
for item in payload["manifests"]:
if not isinstance(item, dict) or not isinstance(item.get("platform"), dict):
continue
platform = item["platform"]
key = f"{platform.get('os')}/{platform.get('architecture')}"
if key not in {"linux/amd64", "linux/arm64"}:
continue
digest = item.get("digest")
if not isinstance(digest, str) or DIGEST.fullmatch(digest) is None:
raise ValueError(f"OCI index has an invalid {key} digest")
if key in platforms:
raise ValueError(f"OCI index has duplicate {key} manifests")
platforms[key] = f"{repository}@{digest}"
if set(platforms) != {"linux/amd64", "linux/arm64"}:
raise ValueError("OCI index must contain linux/amd64 and linux/arm64")
return {
"index": f"{repository}@{index_digest}",
"platforms": dict(sorted(platforms.items())),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", required=True)
parser.add_argument("--index-digest", required=True)
parser.add_argument("--index", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
try:
payload = json.loads(args.index.read_text(encoding="utf-8"))
result = resolve_platforms(
payload,
repository=args.repository,
index_digest=args.index_digest,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(result, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_IMAGE
FROM ${PYTHON_IMAGE}
ARG GOVOPLAN_RELEASE_VERSION
ARG GOVOPLAN_COMPOSITION_SHA256
LABEL org.opencontainers.image.title="GovOPlaN API runtime" \
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/opt/govoplan/runtime \
PATH=/opt/govoplan/runtime/bin:${PATH} \
HOME=/var/lib/govoplan
COPY wheelhouse/ /opt/govoplan/wheels/
COPY requirements-runtime.txt composition.json /opt/govoplan/
RUN python -m pip install --disable-pip-version-check --no-cache-dir \
--no-index --find-links=/opt/govoplan/wheels \
--target=/opt/govoplan/runtime \
--requirement=/opt/govoplan/requirements-runtime.txt \
&& rm -rf /opt/govoplan/wheels \
&& groupadd --gid 10001 govoplan \
&& useradd --uid 10001 --gid 10001 --home-dir /var/lib/govoplan \
--create-home --shell /usr/sbin/nologin govoplan \
&& mkdir -p /var/lib/govoplan /tmp/govoplan \
&& chown -R 10001:10001 /var/lib/govoplan /tmp/govoplan \
&& chmod -R a-w /opt/govoplan
USER 10001:10001
WORKDIR /var/lib/govoplan
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=12 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
CMD ["python", "-m", "uvicorn", "govoplan_core.server.app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.7
ARG NGINX_IMAGE
FROM ${NGINX_IMAGE}
ARG GOVOPLAN_RELEASE_VERSION
ARG GOVOPLAN_COMPOSITION_SHA256
LABEL org.opencontainers.image.title="GovOPlaN WebUI runtime" \
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
USER 0
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/*
COPY web-dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
RUN chown -R 101:101 /usr/share/nginx/html \
&& chmod -R a-w /usr/share/nginx/html /etc/nginx/nginx.conf
USER 101:101
EXPOSE 8080
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
+41
View File
@@ -0,0 +1,41 @@
pid /tmp/nginx.pid;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
error_log /dev/stderr warn;
sendfile on;
server_tokens off;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
server {
listen 8080;
root /usr/share/nginx/html;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://load-balancer:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}