diff --git a/.gitea/workflows/runtime-distribution.yml b/.gitea/workflows/runtime-distribution.yml new file mode 100644 index 0000000..89c6734 --- /dev/null +++ b/.gitea/workflows/runtime-distribution.yml @@ -0,0 +1,243 @@ +name: Runtime Distribution + +on: + workflow_dispatch: + inputs: + version: + description: Release version without leading v + required: true + type: string + python_image: + description: Digest-pinned multi-architecture Python 3.12 slim image + required: true + type: string + nginx_image: + description: Digest-pinned multi-architecture nginx-unprivileged image + required: true + type: string + postgres_image: + description: Digest-pinned PostgreSQL image + required: true + type: string + redis_image: + description: Digest-pinned Redis image + required: true + type: string + load_balancer_image: + description: Digest-pinned HAProxy image + required: true + type: string + garage_image: + description: Digest-pinned Garage image + required: true + type: string + test_mail_image: + description: Digest-pinned GreenMail image + required: true + type: string + +jobs: + publish-runtime: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + path: govoplan + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - name: Use HTTPS for GovOPlaN repositories + run: | + git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan" + git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan" + - name: Bootstrap release sources + working-directory: govoplan + run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website + - name: Build release wheel roots and WebUI + working-directory: govoplan + run: | + python -m venv .runtime-build + .runtime-build/bin/python -m pip install --upgrade pip wheel cryptography + mkdir -p runtime-output/local-wheels + .runtime-build/bin/python -m pip wheel --no-deps --wheel-dir runtime-output/local-wheels --requirement requirements-release.txt + bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui + npm --prefix ../govoplan-core/webui run build + .runtime-build/bin/python tools/release/prepare-runtime-context.py \ + --wheelhouse runtime-output/local-wheels \ + --web-dist ../govoplan-core/webui/dist \ + --output runtime-output/common \ + --required-module tenancy \ + --required-module organizations \ + --required-module identity \ + --required-module idm \ + --required-module access \ + --required-module admin \ + --required-module dashboard \ + --required-module policy \ + --required-module audit \ + --required-module docs \ + --required-module ops + - name: Resolve architecture-specific offline wheelhouses + working-directory: govoplan + run: | + mkdir -p runtime-output/wheels-amd64 runtime-output/wheels-arm64 + cp runtime-output/local-wheels/*.whl runtime-output/wheels-amd64/ + cp runtime-output/local-wheels/*.whl runtime-output/wheels-arm64/ + .runtime-build/bin/python -m pip download --only-binary=:all: \ + --platform manylinux_2_17_x86_64 --platform manylinux2014_x86_64 \ + --implementation cp --python-version 3.12 --abi cp312 \ + --find-links runtime-output/local-wheels \ + --dest runtime-output/wheels-amd64 \ + --requirement runtime-output/common/requirements-runtime.txt + .runtime-build/bin/python -m pip download --only-binary=:all: \ + --platform manylinux_2_17_aarch64 --platform manylinux2014_aarch64 \ + --implementation cp --python-version 3.12 --abi cp312 \ + --find-links runtime-output/local-wheels \ + --dest runtime-output/wheels-arm64 \ + --requirement runtime-output/common/requirements-runtime.txt + .runtime-build/bin/python tools/release/prepare-runtime-context.py \ + --wheelhouse runtime-output/wheels-amd64 \ + --web-dist ../govoplan-core/webui/dist \ + --output runtime-output/context-amd64 + .runtime-build/bin/python tools/release/prepare-runtime-context.py \ + --wheelhouse runtime-output/wheels-arm64 \ + --web-dist ../govoplan-core/webui/dist \ + --output runtime-output/context-arm64 + cmp runtime-output/context-amd64/composition.json runtime-output/context-arm64/composition.json + - name: Build one-file deployer + working-directory: govoplan + run: python tools/deployment/build-deployer-zipapp.py --output runtime-output/govoplan-deploy.pyz + - name: Authenticate OCI publication + working-directory: govoplan + env: + REGISTRY_USERNAME: ${{ secrets.GOVOPLAN_REGISTRY_USERNAME }} + REGISTRY_TOKEN: ${{ secrets.GOVOPLAN_REGISTRY_TOKEN }} + run: | + test -n "$REGISTRY_USERNAME" + test -n "$REGISTRY_TOKEN" + printf '%s' "$REGISTRY_TOKEN" | docker login git.add-ideas.de --username "$REGISTRY_USERNAME" --password-stdin + docker buildx create --name govoplan-runtime --use + - name: Build and publish architecture images + working-directory: govoplan + env: + VERSION: ${{ inputs.version }} + PYTHON_IMAGE: ${{ inputs.python_image }} + NGINX_IMAGE: ${{ inputs.nginx_image }} + run: | + COMPOSITION_SHA256="$(sha256sum runtime-output/context-amd64/composition.json | cut -d' ' -f1)" + for ARCH in amd64 arm64; do + docker buildx build --platform "linux/$ARCH" --push \ + --file tools/release/runtime/Dockerfile.api \ + --build-arg "PYTHON_IMAGE=$PYTHON_IMAGE" \ + --build-arg "GOVOPLAN_RELEASE_VERSION=$VERSION" \ + --build-arg "GOVOPLAN_COMPOSITION_SHA256=$COMPOSITION_SHA256" \ + --tag "git.add-ideas.de/govoplan/runtime-api:$VERSION-$ARCH" \ + "runtime-output/context-$ARCH" + docker buildx build --platform "linux/$ARCH" --push \ + --file tools/release/runtime/Dockerfile.web \ + --build-arg "NGINX_IMAGE=$NGINX_IMAGE" \ + --build-arg "GOVOPLAN_RELEASE_VERSION=$VERSION" \ + --build-arg "GOVOPLAN_COMPOSITION_SHA256=$COMPOSITION_SHA256" \ + --tag "git.add-ideas.de/govoplan/runtime-web:$VERSION-$ARCH" \ + "runtime-output/context-$ARCH" + done + docker buildx imagetools create \ + --tag "git.add-ideas.de/govoplan/runtime-api:$VERSION" \ + "git.add-ideas.de/govoplan/runtime-api:$VERSION-amd64" \ + "git.add-ideas.de/govoplan/runtime-api:$VERSION-arm64" + docker buildx imagetools create \ + --tag "git.add-ideas.de/govoplan/runtime-web:$VERSION" \ + "git.add-ideas.de/govoplan/runtime-web:$VERSION-amd64" \ + "git.add-ideas.de/govoplan/runtime-web:$VERSION-arm64" + docker buildx imagetools inspect "git.add-ideas.de/govoplan/runtime-api:$VERSION" --raw > runtime-output/api-index.json + docker buildx imagetools inspect "git.add-ideas.de/govoplan/runtime-web:$VERSION" --raw > runtime-output/web-index.json + API_DIGEST="sha256:$(sha256sum runtime-output/api-index.json | cut -d' ' -f1)" + WEB_DIGEST="sha256:$(sha256sum runtime-output/web-index.json | cut -d' ' -f1)" + python tools/release/resolve-oci-platforms.py --repository git.add-ideas.de/govoplan/runtime-api --index-digest "$API_DIGEST" --index runtime-output/api-index.json --output runtime-output/api-metadata.json + python tools/release/resolve-oci-platforms.py --repository git.add-ideas.de/govoplan/runtime-web --index-digest "$WEB_DIGEST" --index runtime-output/web-index.json --output runtime-output/web-metadata.json + - name: Generate and sign distribution evidence + working-directory: govoplan + env: + VERSION: ${{ inputs.version }} + SOURCE_COMMIT: ${{ gitea.sha }} + SIGNING_KEY: ${{ secrets.RUNTIME_DISTRIBUTION_SIGNING_KEY }} + SIGNING_KEY_ID: ${{ secrets.RUNTIME_DISTRIBUTION_SIGNING_KEY_ID }} + TRUSTED_KEYRING: ${{ secrets.RUNTIME_DISTRIBUTION_KEYRING }} + POSTGRES_IMAGE: ${{ inputs.postgres_image }} + REDIS_IMAGE: ${{ inputs.redis_image }} + LOAD_BALANCER_IMAGE: ${{ inputs.load_balancer_image }} + GARAGE_IMAGE: ${{ inputs.garage_image }} + TEST_MAIL_IMAGE: ${{ inputs.test_mail_image }} + run: | + test -n "$SIGNING_KEY" + test -n "$SIGNING_KEY_ID" + test -n "$TRUSTED_KEYRING" + printf '%s\n' "$SIGNING_KEY" > runtime-output/signing-key.pem + printf '%s\n' "$TRUSTED_KEYRING" > runtime-output/distribution-keyring.json + chmod 600 runtime-output/signing-key.pem + ARTIFACT_BASE="https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/v$VERSION" + python tools/release/finalize-runtime-distribution.py \ + --composition runtime-output/context-amd64/composition.json \ + --api-metadata runtime-output/api-metadata.json \ + --web-metadata runtime-output/web-metadata.json \ + --deployer runtime-output/govoplan-deploy.pyz \ + --deployer-url "$ARTIFACT_BASE/govoplan-deploy.pyz" \ + --artifact-base-url "$ARTIFACT_BASE" \ + --source-commit "$SOURCE_COMMIT" \ + --version "$VERSION" \ + --sequence "$(date -u +%Y%m%d%H%M)" \ + --dependency "postgres=$POSTGRES_IMAGE" \ + --dependency "redis=$REDIS_IMAGE" \ + --dependency "load_balancer=$LOAD_BALANCER_IMAGE" \ + --dependency "garage=$GARAGE_IMAGE" \ + --dependency "test_mail=$TEST_MAIL_IMAGE" \ + --output-directory runtime-output/evidence \ + --descriptor runtime-output/distribution-descriptor.json + python tools/release/generate-runtime-distribution.py \ + --descriptor runtime-output/distribution-descriptor.json \ + --signing-key "$SIGNING_KEY_ID=runtime-output/signing-key.pem" \ + --output runtime-output/distribution-manifest.json + openssl pkeyutl -sign -inkey runtime-output/signing-key.pem -rawin \ + -in runtime-output/govoplan-deploy.pyz \ + -out runtime-output/govoplan-deploy.pyz.sig + sha256sum runtime-output/govoplan-deploy.pyz > runtime-output/govoplan-deploy.pyz.sha256 + sha256sum runtime-output/distribution-manifest.json > runtime-output/distribution-manifest.json.sha256 + rm runtime-output/signing-key.pem + - name: Verify the published bundle contract with the zipapp + working-directory: govoplan + env: + VERSION: ${{ inputs.version }} + run: | + MANIFEST_SHA256="$(cut -d' ' -f1 runtime-output/distribution-manifest.json.sha256)" + python runtime-output/govoplan-deploy.pyz init \ + --directory runtime-output/acceptance-install \ + --non-interactive --module-set base + python runtime-output/govoplan-deploy.pyz verify-release \ + --directory runtime-output/acceptance-install \ + --manifest runtime-output/distribution-manifest.json \ + --manifest-sha256 "$MANIFEST_SHA256" \ + --trusted-keyring runtime-output/distribution-keyring.json \ + --adopt + - name: Publish immutable Gitea release assets + working-directory: govoplan + env: + VERSION: ${{ inputs.version }} + GITEA_RELEASE_TOKEN: ${{ secrets.GOVOPLAN_RELEASE_TOKEN }} + run: | + python tools/release/publish-runtime-release.py \ + --tag "v$VERSION" \ + --title "GovOPlaN v$VERSION runtime distribution" \ + --asset runtime-output/govoplan-deploy.pyz \ + --asset runtime-output/govoplan-deploy.pyz.sig \ + --asset runtime-output/govoplan-deploy.pyz.sha256 \ + --asset runtime-output/distribution-manifest.json \ + --asset runtime-output/distribution-manifest.json.sha256 \ + --asset runtime-output/distribution-keyring.json \ + --asset runtime-output/context-amd64/composition.json \ + --asset runtime-output/evidence/api-sbom.cdx.json \ + --asset runtime-output/evidence/web-sbom.cdx.json \ + --asset runtime-output/evidence/api-provenance.json \ + --asset runtime-output/evidence/web-provenance.json diff --git a/.gitignore b/.gitignore index eb7c51a..8c5260a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ .ruff_cache/ .venv/ runtime/ +!tools/release/runtime/ +tools/release/runtime/* +!tools/release/runtime/Dockerfile.api +!tools/release/runtime/Dockerfile.web +!tools/release/runtime/nginx.conf __pycache__/ audit-reports/ coverage/ diff --git a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md index d694503..81e073c 100644 --- a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md +++ b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md @@ -93,6 +93,8 @@ The private installation directory contains: | `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery configuration | | `plan.json` | Latest desired-state diff and readiness findings | | `receipt.json` | Last successfully applied immutable identities | +| `distribution-manifest.json` | Canonical signed runtime/image selection adopted by the installer | +| `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases | | `applied-state/` | Checksum-verified snapshot of the last healthy deployment bundle | | `operations//` | Private hash-chained deployment progress and recovery evidence | | `kubernetes.json` | Optional stateless multi-host Kubernetes export | @@ -128,20 +130,56 @@ Those images must already contain the selected module set. The override exists only to exercise local orchestration before release artifacts exist; it is rejected for `self-hosted`. +## Runtime Distribution Boundary + +The protected `Runtime Distribution` workflow builds GovOPlaN wheels first, +resolves architecture-specific third-party wheels into offline wheelhouses, and +then assembles the API images with `pip --no-index`. The target host never +clones Git repositories and neither runtime image performs network package +installation. Separate amd64/arm64 API and WebUI images are joined into OCI +indexes and run as non-root identities. The release assets include CycloneDX +application SBOMs, SLSA-style provenance, exact composition evidence, the +single-file deployer, its detached Ed25519 signature, and a signed, expiring +distribution manifest. + +The manifest contract is +[`runtime-distribution-manifest.schema.json`](runtime-distribution-manifest.schema.json), +and its separately distributed trust-anchor contract is +[`runtime-distribution-keyring.schema.json`](runtime-distribution-keyring.schema.json). +Publication is immutable: an existing Gitea release asset must have the same +size and SHA-256 digest or publication fails. + +Adopt a downloaded or prefetched release only after obtaining the manifest +digest and trusted keyring through the documented independent channel: + +```sh +python3 govoplan-deploy.pyz verify-release \ + --directory /srv/govoplan/installation \ + --manifest ./distribution-manifest.json \ + --manifest-sha256 "$(cut -d' ' -f1 distribution-manifest.json.sha256)" \ + --trusted-keyring ./distribution-keyring.json \ + --adopt +``` + +`doctor` and `apply` rehash both stored files, re-run OpenSSL Ed25519 +verification, enforce channel/expiry/revocation, compare every selected image, +and prove that all enabled module ids occur in the signed image composition. +An offline image index can bind prefetched OCI archives to the same exact image +references and archive hashes; mutable tags or incomplete bundles are rejected. + ## Current Production Gates The tool deliberately reports blockers instead of pretending the source tree is a production distribution: -1. **OCI release artifacts.** The release pipeline does not yet publish pinned - multi-architecture API and WebUI images. -2. **Signed distribution manifest.** A channel manifest must bind exact image - digests, Compose compatibility, SBOM/provenance references, and revocation - state. Recording a URL and checksum is not signature verification. +1. **First publication.** The protected workflow and fail-closed artifact + contracts are implemented, but a release operator must configure the Gitea + registry/release tokens and runtime Ed25519 key, publish the first pinned + release, and retain its amd64/arm64 readiness evidence. 3. **First administrator.** Production needs a one-time, restricted enrollment identity. The development bootstrap must not be enabled in production. -4. **Image/module composition.** The selected module set must be proven present - in the exact image or installed from verified offline artifacts before it is +4. **Image/module composition.** The deployer now enforces the signed + composition. A selected module not shipped by that release cannot be enabled. 5. **Deployment agent.** Web updates need a separate privileged reconciler with a typed command allowlist. The API and browser must never receive the Docker @@ -353,22 +391,25 @@ of the reviewed update recipe instead of a non-functional update button. ## Distribution Workflow -The downloadable entry point should eventually be: +The downloadable entry point is a release asset. Obtain the zipapp, detached +signature, checksum, and trusted public keyring through independently +authenticated paths before execution: ```sh curl --proto '=https' --tlsv1.2 --fail --location \ - https://govoplan.add-ideas.de/install/v1/bootstrap.pyz \ - --output govoplan-bootstrap.pyz -python3 govoplan-bootstrap.pyz init + https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/vX.Y.Z/govoplan-deploy.pyz \ + --output govoplan-deploy.pyz +sha256sum --check govoplan-deploy.pyz.sha256 +openssl pkeyutl -verify -pubin -inkey runtime-release-public.pem -rawin \ + -in govoplan-deploy.pyz -sigfile govoplan-deploy.pyz.sig +python3 govoplan-deploy.pyz init ``` -The published documentation must include an independent checksum/signature -verification command before execution. The zipapp then downloads only a signed -distribution manifest, verifies it against an embedded or explicitly installed -keyring, and renders the same installation contract implemented here. - -The source-tree script is the test harness for that future zipapp. It is not yet -the internet bootstrap artifact. +The zipapp has no GovOPlaN package dependency. It accepts a bounded HTTPS +manifest or a prefetched file, requires an independently supplied SHA-256 +digest and explicit trusted keyring, and executes OpenSSL with a fixed argument +vector for Ed25519 verification. It never evaluates downloaded shell text or +accepts an arbitrary command string. ## Verification @@ -378,7 +419,9 @@ Run the focused tests: ./.venv/bin/python -m unittest -v tests.test_deployment_installer ``` -The tests cover profile restrictions, secret persistence, external endpoint +The tests cover signed release adoption, tamper/expiry/revocation/unknown-key +rejection, architecture composition, offline image integrity, profile +restrictions, secret persistence, external endpoint requirements, managed Garage bootstrap, S3 policy, replica validation, HAProxy discovery configuration, Compose service selection, secret non-disclosure, service-specific environment isolation, private file modes, external endpoint diff --git a/docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md b/docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md index 5da86ac..f763215 100644 --- a/docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md +++ b/docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md @@ -141,6 +141,12 @@ The canonical backlog item is Implementation status as of the current source tree: +- Slice 1 now has the source-controlled production artifact boundary: offline + per-architecture wheel resolution, non-root API/Web image definitions, + multi-architecture OCI publication, signed composition/SBOM/provenance, + immutable Gitea assets, a signed one-file deployer, and fail-closed manifest + adoption. The first real published release and cross-architecture runtime + evidence remain release-operator work rather than source-code claims. - Slice 6 has a working application-tier foundation: state profiles, shared object storage, runtime node registration/heartbeats/drain, fenced scheduler, migration serialization, exact-head startup waiting, Ops visibility, and a diff --git a/docs/installation-spec.schema.json b/docs/installation-spec.schema.json index 0314ead..0cff46d 100644 --- a/docs/installation-spec.schema.json +++ b/docs/installation-spec.schema.json @@ -83,6 +83,18 @@ "type": "string", "pattern": "^$|^[0-9a-f]{64}$" }, + "manifest_keyring_sha256": { + "type": "string", + "pattern": "^$|^[0-9a-f]{64}$" + }, + "manifest_signature_key_id": { + "type": "string", + "pattern": "^$|^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "composition_sha256": { + "type": "string", + "pattern": "^$|^[0-9a-f]{64}$" + }, "api_image": { "type": "string", "minLength": 1, diff --git a/docs/runtime-distribution-keyring.schema.json b/docs/runtime-distribution-keyring.schema.json new file mode 100644 index 0000000..88c50ec --- /dev/null +++ b/docs/runtime-distribution-keyring.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://govoplan.add-ideas.de/schemas/runtime-distribution-keyring-v1.json", + "title": "GovOPlaN runtime distribution trust keyring", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "purpose", "keys"], + "properties": { + "schema_version": { "const": "1" }, + "purpose": { "const": "govoplan-runtime-distribution" }, + "keys": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "key_id", + "algorithm", + "status", + "public_key_pem", + "not_before", + "expires_at" + ], + "properties": { + "key_id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "algorithm": { "const": "ed25519" }, + "status": { "enum": ["active", "retired", "revoked"] }, + "public_key_pem": { "type": "string", "minLength": 1, "maxLength": 8192 }, + "not_before": { "type": "string", "format": "date-time" }, + "expires_at": { "type": "string", "format": "date-time" } + } + } + } + } +} diff --git a/docs/runtime-distribution-manifest.schema.json b/docs/runtime-distribution-manifest.schema.json new file mode 100644 index 0000000..d6451c1 --- /dev/null +++ b/docs/runtime-distribution-manifest.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://govoplan.add-ideas.de/schemas/runtime-distribution-manifest-v1.json", + "title": "GovOPlaN runtime distribution manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "channel", + "sequence", + "version", + "issued_at", + "expires_at", + "revoked", + "deployer", + "images", + "dependencies", + "composition", + "signatures" + ], + "properties": { + "schema_version": { "const": "1" }, + "channel": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" }, + "sequence": { "type": "integer", "minimum": 1 }, + "version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "issued_at": { "type": "string", "format": "date-time" }, + "expires_at": { "type": "string", "format": "date-time" }, + "revoked": { "const": false }, + "deployer": { "$ref": "#/$defs/artifact" }, + "images": { + "type": "object", + "additionalProperties": false, + "required": ["api", "web"], + "properties": { + "api": { "$ref": "#/$defs/image" }, + "web": { "$ref": "#/$defs/image" } + } + }, + "dependencies": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z][a-z0-9_]{1,63}$" }, + "additionalProperties": { "$ref": "#/$defs/imageReference" } + }, + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["sha256", "module_ids", "packages"], + "properties": { + "sha256": { "$ref": "#/$defs/sha256" }, + "module_ids": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,63}$" } + }, + "packages": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version", "wheel_sha256"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "wheel_sha256": { "$ref": "#/$defs/sha256" } + } + } + } + } + }, + "signatures": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key_id", "algorithm", "value"], + "properties": { + "key_id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "algorithm": { "const": "ed25519" }, + "value": { "type": "string", "minLength": 1, "maxLength": 256 } + } + } + } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "imageReference": { + "type": "string", + "pattern": "^[^@\\s]+@sha256:[0-9a-f]{64}$", + "maxLength": 300 + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["url", "sha256"], + "properties": { + "url": { "type": "string", "format": "uri", "pattern": "^https://" }, + "sha256": { "$ref": "#/$defs/sha256" } + } + }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["index", "platforms", "sbom", "provenance"], + "properties": { + "index": { "$ref": "#/$defs/imageReference" }, + "platforms": { + "type": "object", + "additionalProperties": false, + "required": ["linux/amd64", "linux/arm64"], + "properties": { + "linux/amd64": { "$ref": "#/$defs/imageReference" }, + "linux/arm64": { "$ref": "#/$defs/imageReference" } + } + }, + "sbom": { "$ref": "#/$defs/artifact" }, + "provenance": { "$ref": "#/$defs/artifact" } + } + } + } +} diff --git a/tests/test_deployment_release_adoption.py b/tests/test_deployment_release_adoption.py new file mode 100644 index 0000000..8e485c0 --- /dev/null +++ b/tests/test_deployment_release_adoption.py @@ -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() diff --git a/tests/test_runtime_distribution.py b/tests/test_runtime_distribution.py new file mode 100644 index 0000000..def7d0a --- /dev/null +++ b/tests/test_runtime_distribution.py @@ -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() diff --git a/tests/test_runtime_distribution_build.py b/tests/test_runtime_distribution_build.py new file mode 100644 index 0000000..e7b4397 --- /dev/null +++ b/tests/test_runtime_distribution_build.py @@ -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() diff --git a/tests/test_runtime_image_context.py b/tests/test_runtime_image_context.py new file mode 100644 index 0000000..ab04568 --- /dev/null +++ b/tests/test_runtime_image_context.py @@ -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("
GovOPlaN
\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() diff --git a/tools/deployment/govoplan_deploy/bundle.py b/tools/deployment/govoplan_deploy/bundle.py index 3032cd4..afbbad3 100644 --- a/tools/deployment/govoplan_deploy/bundle.py +++ b/tools/deployment/govoplan_deploy/bundle.py @@ -24,6 +24,8 @@ GARAGE_CONFIG_FILENAME = "garage.toml" LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg" PLAN_FILENAME = "plan.json" RECEIPT_FILENAME = "receipt.json" +MANIFEST_FILENAME = "distribution-manifest.json" +KEYRING_FILENAME = "distribution-keyring.json" LOCK_FILENAME = ".deployment.lock" RUNTIME_ENV_KEYS = ( "APP_ENV", @@ -87,6 +89,8 @@ class BundlePaths: load_balancer_config: Path plan: Path receipt: Path + manifest: Path + keyring: Path lock: Path @@ -104,6 +108,8 @@ def bundle_paths(root: Path) -> BundlePaths: load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME, plan=resolved / PLAN_FILENAME, receipt=resolved / RECEIPT_FILENAME, + manifest=resolved / MANIFEST_FILENAME, + keyring=resolved / KEYRING_FILENAME, lock=resolved / LOCK_FILENAME, ) diff --git a/tools/deployment/govoplan_deploy/cli.py b/tools/deployment/govoplan_deploy/cli.py index 0df22b5..2727504 100644 --- a/tools/deployment/govoplan_deploy/cli.py +++ b/tools/deployment/govoplan_deploy/cli.py @@ -8,6 +8,7 @@ from dataclasses import replace from datetime import UTC, datetime import fcntl import getpass +import hashlib import json import os from pathlib import Path @@ -36,6 +37,20 @@ from .bundle import ( write_env, ) from .cluster_evidence import collect_kubernetes_evidence +from .distribution import ( + MAX_KEYRING_BYTES, + MAX_MANIFEST_BYTES, + MAX_OFFLINE_INDEX_BYTES, + DistributionError, + canonical_json as canonical_distribution_json, + decode_json_bytes, + fetch_bounded_https, + load_bounded_json, + read_bounded_bytes, + verify_offline_image_index, + verify_manifest, + verify_manifest_binding, +) from .model import ( ComponentConfig, DEFAULT_GARAGE_IMAGE, @@ -127,6 +142,43 @@ def build_parser() -> argparse.ArgumentParser: _directory_argument(status) status.add_argument("--json", action="store_true", help="Print JSON.") + verify_release = subparsers.add_parser( + "verify-release", + help="Verify and optionally adopt a signed runtime distribution.", + ) + _directory_argument(verify_release) + manifest_source = verify_release.add_mutually_exclusive_group(required=True) + manifest_source.add_argument("--manifest", type=Path) + manifest_source.add_argument("--manifest-url") + verify_release.add_argument( + "--manifest-sha256", + required=True, + help="Independently obtained SHA-256 digest of the signed manifest.", + ) + verify_release.add_argument("--trusted-keyring", type=Path, required=True) + verify_release.add_argument( + "--allow-private-release-host", + action="store_true", + help="Allow an explicitly selected private HTTPS release mirror.", + ) + verify_release.add_argument( + "--adopt", + action="store_true", + help="Store the verified trust material and select its pinned images.", + ) + + offline_images = subparsers.add_parser( + "verify-offline-images", + help="Verify prefetched OCI archives against the adopted distribution.", + ) + _directory_argument(offline_images) + offline_images.add_argument("--index", type=Path, required=True) + offline_images.add_argument( + "--load", + action="store_true", + help="Load verified archives into Docker using fixed image-load commands.", + ) + kubernetes = subparsers.add_parser( "render-kubernetes", help="Export the stateless multi-host runtime for Kubernetes.", @@ -312,6 +364,10 @@ def main(argv: Sequence[str] | None = None) -> int: return _apply(args) if args.command == "status": return _status(args) + if args.command == "verify-release": + return _verify_release(args) + if args.command == "verify-offline-images": + return _verify_offline_images(args) if args.command == "render-kubernetes": return _render_kubernetes(args) if args.command == "verify-kubernetes": @@ -320,7 +376,13 @@ def main(argv: Sequence[str] | None = None) -> int: return _operations(args) if args.command == "recover": return _recover(args) - except (SpecError, ValueError, OSError, subprocess.SubprocessError) as exc: + except ( + DistributionError, + SpecError, + ValueError, + OSError, + subprocess.SubprocessError, + ) as exc: print(f"error: {exc}", file=sys.stderr) return 1 raise RuntimeError(f"unsupported command: {args.command}") @@ -617,6 +679,198 @@ def _status(args: argparse.Namespace) -> int: return 0 +def _verify_release(args: argparse.Namespace) -> int: + paths = bundle_paths(args.directory) + spec = load_spec(paths.spec) + expected_digest = str(args.manifest_sha256 or "").strip().lower() + if len(expected_digest) != 64 or any( + character not in "0123456789abcdef" for character in expected_digest + ): + raise ValueError("--manifest-sha256 must be a lowercase SHA-256 digest") + manifest_url = str(args.manifest_url or "").strip() + if manifest_url: + encoded_manifest = fetch_bounded_https( + manifest_url, + maximum_bytes=MAX_MANIFEST_BYTES, + allow_private_host=args.allow_private_release_host, + ) + manifest = decode_json_bytes( + encoded_manifest, + label="distribution manifest", + ) + actual_digest = hashlib.sha256(encoded_manifest).hexdigest() + else: + manifest_path = args.manifest.expanduser().resolve() + encoded_manifest = read_bounded_bytes( + manifest_path, + maximum_bytes=MAX_MANIFEST_BYTES, + ) + manifest = load_bounded_json( + manifest_path, + maximum_bytes=MAX_MANIFEST_BYTES, + ) + actual_digest = hashlib.sha256(encoded_manifest).hexdigest() + if encoded_manifest != canonical_distribution_json(manifest): + raise DistributionError("distribution manifest is not canonical JSON") + if actual_digest != expected_digest: + raise DistributionError("distribution manifest SHA-256 does not match") + keyring_path = args.trusted_keyring.expanduser().resolve() + keyring = load_bounded_json(keyring_path, maximum_bytes=MAX_KEYRING_BYTES) + encoded_keyring = canonical_distribution_json(keyring) + keyring_digest = hashlib.sha256(encoded_keyring).hexdigest() + key_id = verify_manifest( + manifest, + keyring, + expected_channel=spec.release.channel, + ) + dependencies = _selected_dependency_images(spec, manifest=manifest) + verify_manifest_binding( + manifest, + channel=str(manifest["channel"]), + version=str(manifest["version"]), + api_image=str(manifest["images"]["api"]["index"]), + web_image=str(manifest["images"]["web"]["index"]), + enabled_modules=spec.enabled_modules, + composition_sha256=str(manifest["composition"]["sha256"]), + dependencies=dependencies, + ) + print( + f"Verified GovOPlaN {manifest['version']} ({manifest['channel']}) " + f"with trusted key {key_id}." + ) + if not args.adopt: + return 0 + + images = manifest["images"] + dependency_images = manifest["dependencies"] + release = replace( + spec.release, + channel=str(manifest["channel"]), + version=str(manifest["version"]), + manifest_url=manifest_url, + manifest_sha256=expected_digest, + manifest_keyring_sha256=keyring_digest, + manifest_signature_key_id=key_id, + composition_sha256=str(manifest["composition"]["sha256"]), + api_image=str(images["api"]["index"]), + web_image=str(images["web"]["index"]), + ) + components = replace( + spec.components, + postgres=replace( + spec.components.postgres, + image=( + str(dependency_images["postgres"]) + if spec.components.postgres.mode == "managed" + else spec.components.postgres.image + ), + ), + redis=replace( + spec.components.redis, + image=( + str(dependency_images["redis"]) + if spec.components.redis.mode == "managed" + else spec.components.redis.image + ), + ), + mail=replace( + spec.components.mail, + image=( + str(dependency_images["test_mail"]) + if spec.components.mail.mode == "test-mail" + else spec.components.mail.image + ), + ), + storage=replace( + spec.components.storage, + image=( + str(dependency_images["garage"]) + if spec.components.storage.mode == "garage" + else spec.components.storage.image + ), + ), + load_balancer=replace( + spec.components.load_balancer, + image=str(dependency_images["load_balancer"]), + ), + ) + adopted = parse_spec(replace(spec, release=release, components=components).to_dict()) + ensure_private_directory(paths.root) + atomic_write(paths.manifest, encoded_manifest, mode=0o644) + atomic_write( + paths.keyring, + encoded_keyring, + mode=0o644, + ) + secrets = reconcile_runtime_environment(adopted, read_env(paths.env)) + _write_bundle(adopted, paths, secrets) + print(f"Adopted immutable runtime distribution in {paths.root}.") + return 0 + + +def _verify_offline_images(args: argparse.Namespace) -> int: + paths = bundle_paths(args.directory) + spec = load_spec(paths.spec) + manifest = load_bounded_json( + paths.manifest, + maximum_bytes=MAX_MANIFEST_BYTES, + ) + index_path = args.index.expanduser().resolve() + index = load_bounded_json( + index_path, + maximum_bytes=MAX_OFFLINE_INDEX_BYTES, + ) + selected_dependencies = _selected_dependency_images(spec, manifest=manifest) + expected = ( + str(manifest["images"]["api"]["index"]), + str(manifest["images"]["web"]["index"]), + *tuple(selected_dependencies.values()), + ) + archives = verify_offline_image_index( + index, + root=index_path.parent, + expected_references=expected, + ) + print(f"Verified {len(archives)} prefetched OCI image archive(s).") + if not args.load: + return 0 + docker = shutil.which("docker") + if docker is None: + raise ValueError("Docker CLI is required to load offline images") + for archive in archives: + _run([docker, "image", "load", "--input", str(archive)], cwd=paths.root) + for reference in expected: + _run([docker, "image", "inspect", reference], cwd=paths.root) + print("Loaded and inspected every adopted offline image identity.") + return 0 + + +def _selected_dependency_images( + spec: InstallationSpec, + *, + manifest: Mapping[str, object], +) -> dict[str, str]: + available = manifest.get("dependencies") + if not isinstance(available, dict): + raise DistributionError("distribution dependencies are invalid") + names = ["load_balancer"] + if spec.components.postgres.mode == "managed": + names.append("postgres") + if spec.components.redis.mode == "managed": + names.append("redis") + if spec.components.mail.mode == "test-mail": + names.append("test_mail") + if spec.components.storage.mode == "garage": + names.append("garage") + missing = [name for name in names if not isinstance(available.get(name), str)] + if missing: + raise DistributionError( + "distribution is missing selected dependency images: " + + ", ".join(missing) + ) + return {name: str(available[name]) for name in names} + + def _render_kubernetes(args: argparse.Namespace) -> int: paths = bundle_paths(args.directory) spec = load_spec(paths.spec) @@ -727,6 +981,9 @@ def _deployment_receipt( "channel": spec.release.channel, "version": spec.release.version, "manifest_sha256": spec.release.manifest_sha256, + "manifest_keyring_sha256": spec.release.manifest_keyring_sha256, + "manifest_signature_key_id": spec.release.manifest_signature_key_id, + "composition_sha256": spec.release.composition_sha256, "api_image": spec.release.api_image, "web_image": spec.release.web_image, }, @@ -770,6 +1027,9 @@ def _updated_spec( if args.manifest_sha256 is not None else current.release.manifest_sha256 ), + manifest_keyring_sha256=current.release.manifest_keyring_sha256, + manifest_signature_key_id=current.release.manifest_signature_key_id, + composition_sha256=current.release.composition_sha256, api_image=args.api_image or current.release.api_image, web_image=args.web_image or current.release.web_image, ) diff --git a/tools/deployment/govoplan_deploy/distribution.py b/tools/deployment/govoplan_deploy/distribution.py new file mode 100644 index 0000000..98d79ce --- /dev/null +++ b/tools/deployment/govoplan_deploy/distribution.py @@ -0,0 +1,651 @@ +"""Bounded verification for signed GovOPlaN runtime distributions.""" + +from __future__ import annotations + +import base64 +from datetime import UTC, datetime +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import re +import socket +import stat +import subprocess +import tempfile +from typing import Any, Mapping +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + + +MAX_MANIFEST_BYTES = 4 * 1024 * 1024 +MAX_KEYRING_BYTES = 1024 * 1024 +MAX_OFFLINE_INDEX_BYTES = 4 * 1024 * 1024 +MAX_OFFLINE_IMAGE_BYTES = 16 * 1024 * 1024 * 1024 +SHA256 = re.compile(r"^[0-9a-f]{64}$") +DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$") +TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$") +MODULE_ID = re.compile(r"^[a-z][a-z0-9_]{1,63}$") +KEY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +PLATFORMS = ("linux/amd64", "linux/arm64") +MANIFEST_FILENAME = "distribution-manifest.json" +KEYRING_FILENAME = "distribution-keyring.json" + + +class DistributionError(ValueError): + """Distribution evidence is absent, malformed, or untrusted.""" + + +def canonical_signed_payload(payload: Mapping[str, Any]) -> bytes: + unsigned = dict(payload) + unsigned.pop("signatures", None) + return json.dumps( + unsigned, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def canonical_json(payload: Mapping[str, Any]) -> bytes: + return ( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + + +def load_bounded_json(path: Path, *, maximum_bytes: int) -> dict[str, Any]: + encoded = read_bounded_bytes(path, maximum_bytes=maximum_bytes) + try: + value = json.loads(encoded) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DistributionError(f"trusted JSON file is malformed: {path}") from exc + if not isinstance(value, dict): + raise DistributionError(f"trusted JSON root must be an object: {path}") + return value + + +def read_bounded_bytes(path: Path, *, maximum_bytes: int) -> bytes: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise DistributionError(f"cannot open trusted JSON file: {path}") from exc + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_size > maximum_bytes: + raise DistributionError(f"trusted JSON file is invalid or too large: {path}") + chunks: list[bytes] = [] + total = 0 + while True: + chunk = os.read(descriptor, min(64 * 1024, maximum_bytes + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > maximum_bytes: + raise DistributionError(f"trusted JSON file is too large: {path}") + 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 DistributionError(f"trusted JSON file changed while read: {path}") + finally: + os.close(descriptor) + return b"".join(chunks) + + +def fetch_bounded_https( + url: str, + *, + maximum_bytes: int, + timeout_seconds: float = 15.0, + allow_private_host: bool = False, +) -> bytes: + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname: + raise DistributionError("distribution downloads require an absolute HTTPS URL") + if parsed.username or parsed.password or parsed.fragment: + raise DistributionError("distribution URL must not contain credentials or a fragment") + if not allow_private_host: + _require_public_host(parsed.hostname) + request = Request(url, headers={"Accept": "application/json"}) + try: + with urlopen(request, timeout=timeout_seconds) as response: # noqa: S310 + final = urlsplit(response.geturl()) + if final.scheme != "https": + raise DistributionError("distribution redirect left HTTPS") + declared = response.headers.get("Content-Length") + if declared and int(declared) > maximum_bytes: + raise DistributionError("distribution download exceeds its size limit") + value = response.read(maximum_bytes + 1) + except DistributionError: + raise + except (OSError, ValueError) as exc: + raise DistributionError(f"distribution download failed: {exc}") from exc + if len(value) > maximum_bytes: + raise DistributionError("distribution download exceeds its size limit") + return value + + +def decode_json_bytes(value: bytes, *, label: str) -> dict[str, Any]: + try: + payload = json.loads(value) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DistributionError(f"{label} is not valid JSON") from exc + if not isinstance(payload, dict): + raise DistributionError(f"{label} root must be an object") + return payload + + +def validate_manifest( + payload: Mapping[str, Any], + *, + expected_channel: str | None = None, + now: datetime | None = None, +) -> None: + _exact_keys( + payload, + required={ + "schema_version", + "channel", + "sequence", + "version", + "issued_at", + "expires_at", + "revoked", + "deployer", + "images", + "dependencies", + "composition", + "signatures", + }, + label="distribution manifest", + ) + if payload.get("schema_version") != "1": + raise DistributionError("unsupported distribution manifest schema_version") + channel = _token(payload.get("channel"), "channel", maximum=32, pattern=MODULE_ID) + if expected_channel is not None and channel != expected_channel: + raise DistributionError( + f"distribution channel is {channel!r}, expected {expected_channel!r}" + ) + if isinstance(payload.get("sequence"), bool) or not isinstance( + payload.get("sequence"), int + ) or int(payload["sequence"]) < 1: + raise DistributionError("distribution sequence must be a positive integer") + _token(payload.get("version"), "version", maximum=128, pattern=TOKEN) + issued = _datetime(payload.get("issued_at"), "issued_at") + expires = _datetime(payload.get("expires_at"), "expires_at") + current = (now or datetime.now(UTC)).astimezone(UTC) + if expires <= issued: + raise DistributionError("distribution expiry must be after issuance") + if issued > current: + raise DistributionError("distribution is not valid yet") + if expires <= current: + raise DistributionError("distribution manifest has expired") + if payload.get("revoked") is not False: + raise DistributionError("distribution manifest is revoked") + + deployer = _object(payload.get("deployer"), "deployer") + _exact_keys(deployer, required={"url", "sha256"}, label="deployer") + _https_url(deployer.get("url"), "deployer.url") + _sha256(deployer.get("sha256"), "deployer.sha256") + + images = _object(payload.get("images"), "images") + if set(images) != {"api", "web"}: + raise DistributionError("images must contain exactly api and web") + for name in ("api", "web"): + _validate_image(_object(images[name], f"images.{name}"), f"images.{name}") + + dependencies = _object(payload.get("dependencies"), "dependencies") + if not dependencies: + raise DistributionError("dependencies must not be empty") + for name, reference in dependencies.items(): + if MODULE_ID.fullmatch(str(name)) is None: + raise DistributionError(f"invalid dependency name: {name!r}") + _digest_image(reference, f"dependencies.{name}") + + composition = _object(payload.get("composition"), "composition") + _exact_keys( + composition, + required={"sha256", "module_ids", "packages"}, + label="composition", + ) + _sha256(composition.get("sha256"), "composition.sha256") + module_ids = _string_array(composition.get("module_ids"), "module_ids") + if any(MODULE_ID.fullmatch(item) is None for item in module_ids): + raise DistributionError("composition.module_ids contains an invalid id") + packages = composition.get("packages") + if not isinstance(packages, list) or not packages: + raise DistributionError("composition.packages must be a non-empty array") + seen_packages: set[str] = set() + for index, item in enumerate(packages): + package = _object(item, f"composition.packages[{index}]") + _exact_keys( + package, + required={"name", "version", "wheel_sha256"}, + label=f"composition.packages[{index}]", + ) + name = _token( + package.get("name"), + f"composition.packages[{index}].name", + maximum=128, + pattern=re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$"), + ) + if name in seen_packages: + raise DistributionError("composition contains duplicate packages") + seen_packages.add(name) + _token( + package.get("version"), + f"composition.packages[{index}].version", + maximum=128, + pattern=TOKEN, + ) + _sha256( + package.get("wheel_sha256"), + f"composition.packages[{index}].wheel_sha256", + ) + signatures = payload.get("signatures") + if not isinstance(signatures, list) or not signatures: + raise DistributionError("distribution manifest has no signatures") + seen_signatures: set[str] = set() + for index, item in enumerate(signatures): + signature = _object(item, f"signatures[{index}]") + _exact_keys( + signature, + required={"key_id", "algorithm", "value"}, + label=f"signatures[{index}]", + ) + key_id = _token( + signature.get("key_id"), + f"signatures[{index}].key_id", + maximum=128, + pattern=KEY_ID, + ) + if key_id in seen_signatures: + raise DistributionError("distribution contains duplicate signatures") + seen_signatures.add(key_id) + if signature.get("algorithm") != "ed25519": + raise DistributionError("distribution signature algorithm must be ed25519") + _signature_bytes(signature.get("value"), f"signatures[{index}].value") + + +def verify_manifest( + payload: Mapping[str, Any], + keyring: Mapping[str, Any], + *, + expected_channel: str | None = None, + now: datetime | None = None, + openssl: str = "openssl", +) -> str: + current = (now or datetime.now(UTC)).astimezone(UTC) + validate_manifest(payload, expected_channel=expected_channel, now=current) + keys = _trusted_keys(keyring, now=current) + signed = canonical_signed_payload(payload) + failures: list[str] = [] + for item in payload["signatures"]: + key_id = str(item["key_id"]) + public_key = keys.get(key_id) + if public_key is None: + continue + signature = _signature_bytes(item["value"], "signature.value") + try: + _openssl_verify( + signed, + signature, + public_key, + openssl=openssl, + ) + except DistributionError as exc: + failures.append(f"{key_id}: {exc}") + continue + return key_id + detail = "; ".join(failures) if failures else "no signature used an active trusted key" + raise DistributionError(f"distribution signature verification failed: {detail}") + + +def verify_manifest_binding( + payload: Mapping[str, Any], + *, + channel: str, + version: str, + api_image: str, + web_image: str, + enabled_modules: tuple[str, ...], + composition_sha256: str, + dependencies: Mapping[str, str], +) -> None: + if payload.get("channel") != channel or payload.get("version") != version: + raise DistributionError("stored manifest does not match release channel/version") + images = _object(payload.get("images"), "images") + if _object(images.get("api"), "images.api").get("index") != api_image: + raise DistributionError("stored manifest does not match API image") + if _object(images.get("web"), "images.web").get("index") != web_image: + raise DistributionError("stored manifest does not match Web image") + composition = _object(payload.get("composition"), "composition") + if composition.get("sha256") != composition_sha256: + raise DistributionError("stored manifest composition digest does not match") + available_modules = set(_string_array(composition.get("module_ids"), "module_ids")) + missing = sorted(set(enabled_modules) - available_modules) + if missing: + raise DistributionError( + "enabled modules are absent from runtime composition: " + ", ".join(missing) + ) + manifest_dependencies = _object(payload.get("dependencies"), "dependencies") + for name, reference in dependencies.items(): + if manifest_dependencies.get(name) != reference: + raise DistributionError( + f"stored manifest does not match dependency image {name!r}" + ) + + +def verify_offline_image_index( + index: Mapping[str, Any], + *, + root: Path, + expected_references: tuple[str, ...], +) -> tuple[Path, ...]: + _exact_keys(index, required={"schema_version", "images"}, label="offline index") + if index.get("schema_version") != "1": + raise DistributionError("unsupported offline image index schema") + images = index.get("images") + if not isinstance(images, list): + raise DistributionError("offline image index images must be an array") + references: dict[str, Path] = {} + for item in images: + value = _object(item, "offline image") + _exact_keys( + value, + required={"reference", "archive", "sha256"}, + label="offline image", + ) + reference = _digest_image(value.get("reference"), "offline image reference") + archive_value = value.get("archive") + if not isinstance(archive_value, str) or not archive_value: + raise DistributionError("offline image archive must be a relative path") + archive_relative = Path(archive_value) + if archive_relative.is_absolute() or ".." in archive_relative.parts: + raise DistributionError("offline image archive must stay inside its bundle") + archive = root / archive_relative + if reference in references: + raise DistributionError("offline image index contains duplicate references") + if _sha256_regular_file(archive, maximum_bytes=MAX_OFFLINE_IMAGE_BYTES) != _sha256( + value.get("sha256"), "offline image sha256" + ): + raise DistributionError(f"offline image archive digest mismatch: {archive}") + references[reference] = archive + missing = sorted(set(expected_references) - set(references)) + if missing: + raise DistributionError( + "offline image bundle is incomplete: " + ", ".join(missing) + ) + return tuple(references[item] for item in expected_references) + + +def file_sha256(path: Path, *, maximum_bytes: int = MAX_MANIFEST_BYTES) -> str: + return _sha256_regular_file(path, maximum_bytes=maximum_bytes) + + +def _validate_image(value: Mapping[str, Any], label: str) -> None: + _exact_keys( + value, + required={"index", "platforms", "sbom", "provenance"}, + label=label, + ) + _digest_image(value.get("index"), f"{label}.index") + platforms = _object(value.get("platforms"), f"{label}.platforms") + if set(platforms) != set(PLATFORMS): + raise DistributionError(f"{label}.platforms must cover amd64 and arm64") + for platform, reference in platforms.items(): + _digest_image(reference, f"{label}.platforms.{platform}") + _validate_artifact(_object(value.get("sbom"), f"{label}.sbom"), f"{label}.sbom") + _validate_artifact( + _object(value.get("provenance"), f"{label}.provenance"), + f"{label}.provenance", + ) + + +def _validate_artifact(value: Mapping[str, Any], label: str) -> None: + _exact_keys(value, required={"url", "sha256"}, label=label) + _https_url(value.get("url"), f"{label}.url") + _sha256(value.get("sha256"), f"{label}.sha256") + + +def _trusted_keys( + keyring: Mapping[str, Any], + *, + now: datetime, +) -> dict[str, str]: + _exact_keys( + keyring, + required={"schema_version", "purpose", "keys"}, + label="distribution keyring", + ) + if keyring.get("schema_version") != "1": + raise DistributionError("unsupported distribution keyring schema_version") + if keyring.get("purpose") != "govoplan-runtime-distribution": + raise DistributionError("distribution keyring has the wrong purpose") + values = keyring.get("keys") + if not isinstance(values, list) or not values: + raise DistributionError("distribution keyring contains no keys") + trusted: dict[str, str] = {} + for index, item in enumerate(values): + key = _object(item, f"keyring.keys[{index}]") + _exact_keys( + key, + required={ + "key_id", + "algorithm", + "status", + "public_key_pem", + "not_before", + "expires_at", + }, + label=f"keyring.keys[{index}]", + ) + key_id = _token( + key.get("key_id"), + f"keyring.keys[{index}].key_id", + maximum=128, + pattern=KEY_ID, + ) + if key_id in trusted: + raise DistributionError("distribution keyring contains duplicate key ids") + if key.get("algorithm") != "ed25519": + raise DistributionError("distribution key must use ed25519") + if key.get("status") not in {"active", "retired", "revoked"}: + raise DistributionError("distribution key has an invalid status") + not_before = _datetime(key.get("not_before"), "key.not_before") + expires = _datetime(key.get("expires_at"), "key.expires_at") + public_key = key.get("public_key_pem") + if ( + not isinstance(public_key, str) + or len(public_key.encode("utf-8")) > 8192 + or "BEGIN PUBLIC KEY" not in public_key + ): + raise DistributionError("distribution key has an invalid public key") + if key.get("status") == "active" and not_before <= now < expires: + trusted[key_id] = public_key + if not trusted: + raise DistributionError("distribution keyring has no currently active keys") + return trusted + + +def _openssl_verify( + payload: bytes, + signature: bytes, + public_key: str, + *, + openssl: str, +) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-distribution-verify-") as value: + root = Path(value) + payload_path = root / "payload.json" + signature_path = root / "signature.bin" + key_path = root / "public.pem" + payload_path.write_bytes(payload) + signature_path.write_bytes(signature) + key_path.write_text(public_key, encoding="utf-8") + try: + completed = subprocess.run( + [ + openssl, + "pkeyutl", + "-verify", + "-pubin", + "-inkey", + str(key_path), + "-rawin", + "-in", + str(payload_path), + "-sigfile", + str(signature_path), + ], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise DistributionError("OpenSSL Ed25519 verifier is unavailable") from exc + if completed.returncode != 0: + raise DistributionError("Ed25519 signature is invalid") + + +def _signature_bytes(value: object, label: str) -> bytes: + if not isinstance(value, str) or len(value) > 256: + raise DistributionError(f"{label} is invalid") + try: + decoded = base64.b64decode(value, validate=True) + except (ValueError, base64.binascii.Error) as exc: + raise DistributionError(f"{label} is not valid base64") from exc + if len(decoded) != 64: + raise DistributionError(f"{label} is not an Ed25519 signature") + return decoded + + +def _require_public_host(hostname: str) -> None: + try: + addresses = { + value[4][0] + for value in socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM) + } + except OSError as exc: + raise DistributionError(f"distribution host cannot be resolved: {hostname}") from exc + if not addresses: + raise DistributionError("distribution host resolved to no addresses") + for value in addresses: + address = ipaddress.ip_address(value) + if not address.is_global: + raise DistributionError("distribution host resolves to a non-public address") + + +def _sha256_regular_file(path: Path, *, maximum_bytes: int) -> str: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise DistributionError(f"cannot open immutable artifact: {path}") from exc + digest = hashlib.sha256() + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_size > maximum_bytes: + raise DistributionError(f"immutable artifact is invalid or too large: {path}") + while True: + chunk = os.read(descriptor, 1024 * 1024) + if not chunk: + break + digest.update(chunk) + 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 DistributionError(f"immutable artifact changed while read: {path}") + finally: + os.close(descriptor) + return digest.hexdigest() + + +def _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise DistributionError(f"{label} must be an object") + return value + + +def _exact_keys( + value: Mapping[str, Any], + *, + required: set[str], + label: str, +) -> None: + missing = sorted(required - set(value)) + extra = sorted(set(value) - required) + if missing or extra: + detail = [] + if missing: + detail.append("missing " + ", ".join(missing)) + if extra: + detail.append("unknown " + ", ".join(extra)) + raise DistributionError(f"{label} has invalid fields: {'; '.join(detail)}") + + +def _token( + value: object, + label: str, + *, + maximum: int, + pattern: re.Pattern[str], +) -> str: + if not isinstance(value, str) or len(value) > maximum or pattern.fullmatch(value) is None: + raise DistributionError(f"{label} is invalid") + return value + + +def _datetime(value: object, label: str) -> datetime: + if not isinstance(value, str) or len(value) > 64: + raise DistributionError(f"{label} must be an RFC3339 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise DistributionError(f"{label} must be an RFC3339 timestamp") from exc + if parsed.tzinfo is None: + raise DistributionError(f"{label} must include a timezone") + return parsed.astimezone(UTC) + + +def _sha256(value: object, label: str) -> str: + if not isinstance(value, str) or SHA256.fullmatch(value) is None: + raise DistributionError(f"{label} must be a lowercase SHA-256 digest") + return value + + +def _digest_image(value: object, label: str) -> str: + if not isinstance(value, str) or len(value) > 300 or DIGEST_IMAGE.fullmatch(value) is None: + raise DistributionError(f"{label} must be an OCI image pinned by sha256") + return value + + +def _https_url(value: object, label: str) -> str: + if not isinstance(value, str) or len(value) > 2048: + raise DistributionError(f"{label} must be an HTTPS URL") + parsed = urlsplit(value) + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: + raise DistributionError(f"{label} must be an HTTPS URL without credentials") + return value + + +def _string_array(value: object, label: str) -> tuple[str, ...]: + if ( + not isinstance(value, list) + or len(value) > 1024 + or any(not isinstance(item, str) for item in value) + or len(set(value)) != len(value) + ): + raise DistributionError(f"{label} must be an array of unique strings") + return tuple(value) diff --git a/tools/deployment/govoplan_deploy/model.py b/tools/deployment/govoplan_deploy/model.py index d10136f..3ad70d7 100644 --- a/tools/deployment/govoplan_deploy/model.py +++ b/tools/deployment/govoplan_deploy/model.py @@ -77,6 +77,9 @@ class ReleaseConfig: version: str manifest_url: str manifest_sha256: str + manifest_keyring_sha256: str + manifest_signature_key_id: str + composition_sha256: str api_image: str web_image: str @@ -178,6 +181,9 @@ def default_spec( "version": version, "manifest_url": manifest_url, "manifest_sha256": manifest_sha256, + "manifest_keyring_sha256": "", + "manifest_signature_key_id": "", + "composition_sha256": "", "api_image": api_image, "web_image": web_image, }, @@ -314,6 +320,9 @@ def _release(raw: object) -> ReleaseConfig: "version", "manifest_url", "manifest_sha256", + "manifest_keyring_sha256", + "manifest_signature_key_id", + "composition_sha256", "api_image", "web_image", }, @@ -335,6 +344,27 @@ def _release(raw: object) -> ReleaseConfig: raise SpecError( "release.manifest_sha256 must be a lowercase SHA-256 hex digest" ) + manifest_keyring_sha256 = _optional_string( + value, "manifest_keyring_sha256" + ).lower() + if manifest_keyring_sha256 and not SHA256_PATTERN.fullmatch( + manifest_keyring_sha256 + ): + raise SpecError( + "release.manifest_keyring_sha256 must be a lowercase SHA-256 hex digest" + ) + manifest_signature_key_id = _optional_string( + value, "manifest_signature_key_id" + ) + if manifest_signature_key_id and not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", manifest_signature_key_id + ): + raise SpecError("release.manifest_signature_key_id is invalid") + composition_sha256 = _optional_string(value, "composition_sha256").lower() + if composition_sha256 and not SHA256_PATTERN.fullmatch(composition_sha256): + raise SpecError( + "release.composition_sha256 must be a lowercase SHA-256 hex digest" + ) api_image = _image(_string(value, "api_image"), "release.api_image") web_image = _image(_string(value, "web_image"), "release.web_image") return ReleaseConfig( @@ -342,6 +372,9 @@ def _release(raw: object) -> ReleaseConfig: version=version, manifest_url=manifest_url, manifest_sha256=manifest_sha256, + manifest_keyring_sha256=manifest_keyring_sha256, + manifest_signature_key_id=manifest_signature_key_id, + composition_sha256=composition_sha256, api_image=api_image, web_image=web_image, ) diff --git a/tools/deployment/govoplan_deploy/planning.py b/tools/deployment/govoplan_deploy/planning.py index 744ee0b..5db2ff1 100644 --- a/tools/deployment/govoplan_deploy/planning.py +++ b/tools/deployment/govoplan_deploy/planning.py @@ -22,6 +22,15 @@ from .bundle import ( render_compose, service_names, ) +from .distribution import ( + MAX_KEYRING_BYTES, + MAX_MANIFEST_BYTES, + DistributionError, + file_sha256, + load_bounded_json, + verify_manifest, + verify_manifest_binding, +) from .model import ( InstallationSpec, image_is_digest_pinned, @@ -212,40 +221,7 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, .. ) ) - if spec.release.manifest_url and spec.release.manifest_sha256: - checks.append( - Check( - "release.manifest", - "ok", - "A distribution manifest URL and expected digest are recorded.", - ) - ) - else: - checks.append( - Check( - "release.manifest", - "error" if spec.profile == "self-hosted" else "warning", - "No verified distribution manifest is recorded.", - "Use a published signed distribution manifest for self-hosted apply.", - ) - ) - if spec.profile == "self-hosted": - checks.append( - Check( - "release.signature_verification", - "error", - "Signed distribution-manifest verification is not implemented in the deployer yet.", - "Use the published verifier/bootstrap slice before a production apply.", - ) - ) - checks.append( - Check( - "modules.image_composition", - "error" if spec.enabled_modules else "warning", - "The selected module set is not yet verified against image package contents.", - "Use the signed distribution composition evidence before production apply.", - ) - ) + checks.extend(_distribution_checks(spec, paths)) values = read_env(paths.env) required = {"MASTER_KEY_B64", "DATABASE_URL"} @@ -364,6 +340,130 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, .. return tuple(checks) +def _distribution_checks( + spec: InstallationSpec, + paths: BundlePaths, +) -> tuple[Check, ...]: + blocking_level = "error" if spec.profile == "self-hosted" else "warning" + if not ( + spec.release.manifest_sha256 + and spec.release.manifest_keyring_sha256 + and spec.release.manifest_signature_key_id + and spec.release.composition_sha256 + and paths.manifest.exists() + and paths.keyring.exists() + ): + return ( + Check( + "release.manifest", + blocking_level, + "No locally verified runtime distribution is recorded.", + "Run govoplan-deploy verify-release --adopt with an independently trusted keyring.", + ), + Check( + "release.signature_verification", + blocking_level, + "Runtime distribution signature evidence is unavailable.", + "Install and verify the signed distribution before apply.", + ), + Check( + "modules.image_composition", + blocking_level, + "Enabled modules are not bound to image composition evidence.", + "Adopt a distribution whose composition contains every enabled module.", + ), + ) + try: + manifest_digest = file_sha256( + paths.manifest, + maximum_bytes=MAX_MANIFEST_BYTES, + ) + if manifest_digest != spec.release.manifest_sha256: + raise DistributionError("stored manifest digest does not match installation") + keyring_digest = file_sha256( + paths.keyring, + maximum_bytes=MAX_KEYRING_BYTES, + ) + if keyring_digest != spec.release.manifest_keyring_sha256: + raise DistributionError("stored keyring digest does not match installation") + manifest = load_bounded_json( + paths.manifest, + maximum_bytes=MAX_MANIFEST_BYTES, + ) + keyring = load_bounded_json( + paths.keyring, + maximum_bytes=MAX_KEYRING_BYTES, + ) + key_id = verify_manifest( + manifest, + keyring, + expected_channel=spec.release.channel, + ) + if key_id != spec.release.manifest_signature_key_id: + raise DistributionError("verified signature key does not match installation") + verify_manifest_binding( + manifest, + channel=spec.release.channel, + version=spec.release.version, + api_image=spec.release.api_image, + web_image=spec.release.web_image, + enabled_modules=spec.enabled_modules, + composition_sha256=spec.release.composition_sha256, + dependencies=_selected_dependency_images(spec), + ) + except (DistributionError, OSError) as exc: + return ( + Check( + "release.manifest", + blocking_level, + f"Runtime distribution verification failed: {exc}", + "Re-adopt an unexpired, non-revoked manifest from a trusted release key.", + ), + Check( + "release.signature_verification", + blocking_level, + "Runtime distribution signature is not trusted.", + "Correct the manifest/keyring binding before apply.", + ), + Check( + "modules.image_composition", + blocking_level, + "Runtime image composition is not trusted.", + "Correct the signed composition binding before apply.", + ), + ) + return ( + Check( + "release.manifest", + "ok", + "Stored runtime distribution matches its independently pinned digest.", + ), + Check( + "release.signature_verification", + "ok", + f"Runtime distribution is signed by trusted key {key_id}.", + ), + Check( + "modules.image_composition", + "ok", + "Every enabled module is present in signed image composition evidence.", + ), + ) + + +def _selected_dependency_images(spec: InstallationSpec) -> dict[str, str]: + values = {"load_balancer": spec.components.load_balancer.image} + if spec.components.postgres.mode == "managed": + values["postgres"] = spec.components.postgres.image + if spec.components.redis.mode == "managed": + values["redis"] = spec.components.redis.image + if spec.components.mail.mode == "test-mail": + values["test_mail"] = spec.components.mail.image + if spec.components.storage.mode == "garage": + values["garage"] = spec.components.storage.image + return values + + def host_checks( spec: InstallationSpec, paths: BundlePaths, diff --git a/tools/release/finalize-runtime-distribution.py b/tools/release/finalize-runtime-distribution.py new file mode 100644 index 0000000..b2fbe42 --- /dev/null +++ b/tools/release/finalize-runtime-distribution.py @@ -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()) diff --git a/tools/release/generate-runtime-distribution.py b/tools/release/generate-runtime-distribution.py new file mode 100644 index 0000000..e6de0ae --- /dev/null +++ b/tools/release/generate-runtime-distribution.py @@ -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()) diff --git a/tools/release/prepare-runtime-context.py b/tools/release/prepare-runtime-context.py new file mode 100644 index 0000000..54cfe68 --- /dev/null +++ b/tools/release/prepare-runtime-context.py @@ -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()) diff --git a/tools/release/publish-runtime-release.py b/tools/release/publish-runtime-release.py new file mode 100644 index 0000000..1ea099a --- /dev/null +++ b/tools/release/publish-runtime-release.py @@ -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()) diff --git a/tools/release/resolve-oci-platforms.py b/tools/release/resolve-oci-platforms.py new file mode 100644 index 0000000..635d9cb --- /dev/null +++ b/tools/release/resolve-oci-platforms.py @@ -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:") + 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()) diff --git a/tools/release/runtime/Dockerfile.api b/tools/release/runtime/Dockerfile.api new file mode 100644 index 0000000..81453ab --- /dev/null +++ b/tools/release/runtime/Dockerfile.api @@ -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"] diff --git a/tools/release/runtime/Dockerfile.web b/tools/release/runtime/Dockerfile.web new file mode 100644 index 0000000..f41cf51 --- /dev/null +++ b/tools/release/runtime/Dockerfile.web @@ -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;"] diff --git a/tools/release/runtime/nginx.conf b/tools/release/runtime/nginx.conf new file mode 100644 index 0000000..e0821fc --- /dev/null +++ b/tools/release/runtime/nginx.conf @@ -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; + } + } +}