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

This commit is contained in:
2026-08-03 00:54:06 +02:00
parent 29acb55b7c
commit 43380eb068
24 changed files with 3371 additions and 54 deletions
+243
View File
@@ -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
+5
View File
@@ -4,6 +4,11 @@
.ruff_cache/ .ruff_cache/
.venv/ .venv/
runtime/ runtime/
!tools/release/runtime/
tools/release/runtime/*
!tools/release/runtime/Dockerfile.api
!tools/release/runtime/Dockerfile.web
!tools/release/runtime/nginx.conf
__pycache__/ __pycache__/
audit-reports/ audit-reports/
coverage/ coverage/
@@ -93,6 +93,8 @@ The private installation directory contains:
| `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery configuration | | `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery configuration |
| `plan.json` | Latest desired-state diff and readiness findings | | `plan.json` | Latest desired-state diff and readiness findings |
| `receipt.json` | Last successfully applied immutable identities | | `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 | | `applied-state/` | Checksum-verified snapshot of the last healthy deployment bundle |
| `operations/<id>/` | Private hash-chained deployment progress and recovery evidence | | `operations/<id>/` | Private hash-chained deployment progress and recovery evidence |
| `kubernetes.json` | Optional stateless multi-host Kubernetes export | | `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 only to exercise local orchestration before release artifacts exist; it is
rejected for `self-hosted`. 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 ## Current Production Gates
The tool deliberately reports blockers instead of pretending the source tree is The tool deliberately reports blockers instead of pretending the source tree is
a production distribution: a production distribution:
1. **OCI release artifacts.** The release pipeline does not yet publish pinned 1. **First publication.** The protected workflow and fail-closed artifact
multi-architecture API and WebUI images. contracts are implemented, but a release operator must configure the Gitea
2. **Signed distribution manifest.** A channel manifest must bind exact image registry/release tokens and runtime Ed25519 key, publish the first pinned
digests, Compose compatibility, SBOM/provenance references, and revocation release, and retain its amd64/arm64 readiness evidence.
state. Recording a URL and checksum is not signature verification.
3. **First administrator.** Production needs a one-time, restricted enrollment 3. **First administrator.** Production needs a one-time, restricted enrollment
identity. The development bootstrap must not be enabled in production. identity. The development bootstrap must not be enabled in production.
4. **Image/module composition.** The selected module set must be proven present 4. **Image/module composition.** The deployer now enforces the signed
in the exact image or installed from verified offline artifacts before it is composition. A selected module not shipped by that release cannot be
enabled. enabled.
5. **Deployment agent.** Web updates need a separate privileged reconciler with 5. **Deployment agent.** Web updates need a separate privileged reconciler with
a typed command allowlist. The API and browser must never receive the Docker 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 ## 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 ```sh
curl --proto '=https' --tlsv1.2 --fail --location \ curl --proto '=https' --tlsv1.2 --fail --location \
https://govoplan.add-ideas.de/install/v1/bootstrap.pyz \ https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/vX.Y.Z/govoplan-deploy.pyz \
--output govoplan-bootstrap.pyz --output govoplan-deploy.pyz
python3 govoplan-bootstrap.pyz init 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 The zipapp has no GovOPlaN package dependency. It accepts a bounded HTTPS
verification command before execution. The zipapp then downloads only a signed manifest or a prefetched file, requires an independently supplied SHA-256
distribution manifest, verifies it against an embedded or explicitly installed digest and explicit trusted keyring, and executes OpenSSL with a fixed argument
keyring, and renders the same installation contract implemented here. vector for Ed25519 verification. It never evaluates downloaded shell text or
accepts an arbitrary command string.
The source-tree script is the test harness for that future zipapp. It is not yet
the internet bootstrap artifact.
## Verification ## Verification
@@ -378,7 +419,9 @@ Run the focused tests:
./.venv/bin/python -m unittest -v tests.test_deployment_installer ./.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 requirements, managed Garage bootstrap, S3 policy, replica validation, HAProxy
discovery configuration, Compose service selection, secret non-disclosure, discovery configuration, Compose service selection, secret non-disclosure,
service-specific environment isolation, private file modes, external endpoint service-specific environment isolation, private file modes, external endpoint
@@ -141,6 +141,12 @@ The canonical backlog item is
Implementation status as of the current source tree: 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 - Slice 6 has a working application-tier foundation: state profiles, shared
object storage, runtime node registration/heartbeats/drain, fenced scheduler, object storage, runtime node registration/heartbeats/drain, fenced scheduler,
migration serialization, exact-head startup waiting, Ops visibility, and a migration serialization, exact-head startup waiting, Ops visibility, and a
+12
View File
@@ -83,6 +83,18 @@
"type": "string", "type": "string",
"pattern": "^$|^[0-9a-f]{64}$" "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": { "api_image": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
@@ -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" }
}
}
}
}
}
@@ -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" }
}
}
}
}
+207
View File
@@ -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()
+201
View File
@@ -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()
+133
View File
@@ -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()
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
import zipfile
SCRIPT = (
Path(__file__).resolve().parents[1]
/ "tools"
/ "release"
/ "prepare-runtime-context.py"
)
SPEC = importlib.util.spec_from_file_location("prepare_runtime_context", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class RuntimeImageContextTests(unittest.TestCase):
def test_builds_deterministic_network_free_context(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
root = Path(value)
wheelhouse = root / "input-wheels"
web = root / "web"
wheelhouse.mkdir()
web.mkdir()
self._wheel(
wheelhouse / "govoplan_core-1.2.3-py3-none-any.whl",
package="govoplan-core",
version="1.2.3",
module_ids=(),
)
self._wheel(
wheelhouse / "govoplan_files-1.2.3-py3-none-any.whl",
package="govoplan-files",
version="1.2.3",
module_ids=("files",),
)
self._wheel(
wheelhouse / "sqlalchemy-2.0.0-py3-none-any.whl",
package="SQLAlchemy",
version="2.0.0",
module_ids=(),
)
(web / "index.html").write_text("<main>GovOPlaN</main>\n", encoding="utf-8")
composition = MODULE.prepare_context(
wheelhouse=wheelhouse,
web_dist=web,
output=root / "context",
required_modules=("files",),
source_date_epoch=1_700_000_000,
)
self.assertEqual(["files"], composition["python"]["module_ids"])
self.assertEqual(2, composition["python"]["wheel_count"])
requirements = (root / "context" / "requirements-runtime.txt").read_text()
self.assertEqual(
"govoplan-core[server]==1.2.3\ngovoplan-files==1.2.3\n",
requirements,
)
published = json.loads(
(
root
/ "context"
/ "web-dist"
/ ".well-known"
/ "govoplan-composition.json"
).read_text()
)
self.assertEqual(composition, published)
def test_rejects_missing_required_module(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
root = Path(value)
wheelhouse = root / "wheels"
web = root / "web"
wheelhouse.mkdir()
web.mkdir()
self._wheel(
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
package="govoplan-core",
version="1.0.0",
module_ids=(),
)
(web / "index.html").write_text("ok", encoding="utf-8")
with self.assertRaisesRegex(MODULE.ContextError, "missing required"):
MODULE.prepare_context(
wheelhouse=wheelhouse,
web_dist=web,
output=root / "context",
required_modules=("mail",),
)
def test_rejects_symlinked_web_payload(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
root = Path(value)
wheelhouse = root / "wheels"
web = root / "web"
wheelhouse.mkdir()
web.mkdir()
self._wheel(
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
package="govoplan-core",
version="1.0.0",
module_ids=(),
)
outside = root / "outside"
outside.write_text("not part of dist", encoding="utf-8")
(web / "index.html").symlink_to(outside)
with self.assertRaisesRegex(MODULE.ContextError, "symlink"):
MODULE.prepare_context(
wheelhouse=wheelhouse,
web_dist=web,
output=root / "context",
)
@staticmethod
def _wheel(
path: Path,
*,
package: str,
version: str,
module_ids: tuple[str, ...],
) -> None:
dist_info = package.replace("-", "_") + f"-{version}.dist-info"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(
f"{dist_info}/METADATA",
f"Metadata-Version: 2.1\nName: {package}\nVersion: {version}\n",
)
if module_ids:
rows = "\n".join(
f"{module_id} = example.module:manifest"
for module_id in module_ids
)
archive.writestr(
f"{dist_info}/entry_points.txt",
f"[govoplan.modules]\n{rows}\n",
)
archive.writestr(f"{package.replace('-', '_')}/__init__.py", "")
if __name__ == "__main__":
unittest.main()
@@ -24,6 +24,8 @@ GARAGE_CONFIG_FILENAME = "garage.toml"
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg" LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
PLAN_FILENAME = "plan.json" PLAN_FILENAME = "plan.json"
RECEIPT_FILENAME = "receipt.json" RECEIPT_FILENAME = "receipt.json"
MANIFEST_FILENAME = "distribution-manifest.json"
KEYRING_FILENAME = "distribution-keyring.json"
LOCK_FILENAME = ".deployment.lock" LOCK_FILENAME = ".deployment.lock"
RUNTIME_ENV_KEYS = ( RUNTIME_ENV_KEYS = (
"APP_ENV", "APP_ENV",
@@ -87,6 +89,8 @@ class BundlePaths:
load_balancer_config: Path load_balancer_config: Path
plan: Path plan: Path
receipt: Path receipt: Path
manifest: Path
keyring: Path
lock: Path lock: Path
@@ -104,6 +108,8 @@ def bundle_paths(root: Path) -> BundlePaths:
load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME, load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME,
plan=resolved / PLAN_FILENAME, plan=resolved / PLAN_FILENAME,
receipt=resolved / RECEIPT_FILENAME, receipt=resolved / RECEIPT_FILENAME,
manifest=resolved / MANIFEST_FILENAME,
keyring=resolved / KEYRING_FILENAME,
lock=resolved / LOCK_FILENAME, lock=resolved / LOCK_FILENAME,
) )
+261 -1
View File
@@ -8,6 +8,7 @@ from dataclasses import replace
from datetime import UTC, datetime from datetime import UTC, datetime
import fcntl import fcntl
import getpass import getpass
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
@@ -36,6 +37,20 @@ from .bundle import (
write_env, write_env,
) )
from .cluster_evidence import collect_kubernetes_evidence 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 ( from .model import (
ComponentConfig, ComponentConfig,
DEFAULT_GARAGE_IMAGE, DEFAULT_GARAGE_IMAGE,
@@ -127,6 +142,43 @@ def build_parser() -> argparse.ArgumentParser:
_directory_argument(status) _directory_argument(status)
status.add_argument("--json", action="store_true", help="Print JSON.") 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( kubernetes = subparsers.add_parser(
"render-kubernetes", "render-kubernetes",
help="Export the stateless multi-host runtime for 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) return _apply(args)
if args.command == "status": if args.command == "status":
return _status(args) 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": if args.command == "render-kubernetes":
return _render_kubernetes(args) return _render_kubernetes(args)
if args.command == "verify-kubernetes": if args.command == "verify-kubernetes":
@@ -320,7 +376,13 @@ def main(argv: Sequence[str] | None = None) -> int:
return _operations(args) return _operations(args)
if args.command == "recover": if args.command == "recover":
return _recover(args) 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) print(f"error: {exc}", file=sys.stderr)
return 1 return 1
raise RuntimeError(f"unsupported command: {args.command}") raise RuntimeError(f"unsupported command: {args.command}")
@@ -617,6 +679,198 @@ def _status(args: argparse.Namespace) -> int:
return 0 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: def _render_kubernetes(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory) paths = bundle_paths(args.directory)
spec = load_spec(paths.spec) spec = load_spec(paths.spec)
@@ -727,6 +981,9 @@ def _deployment_receipt(
"channel": spec.release.channel, "channel": spec.release.channel,
"version": spec.release.version, "version": spec.release.version,
"manifest_sha256": spec.release.manifest_sha256, "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, "api_image": spec.release.api_image,
"web_image": spec.release.web_image, "web_image": spec.release.web_image,
}, },
@@ -770,6 +1027,9 @@ def _updated_spec(
if args.manifest_sha256 is not None if args.manifest_sha256 is not None
else current.release.manifest_sha256 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, api_image=args.api_image or current.release.api_image,
web_image=args.web_image or current.release.web_image, web_image=args.web_image or current.release.web_image,
) )
@@ -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)
+33
View File
@@ -77,6 +77,9 @@ class ReleaseConfig:
version: str version: str
manifest_url: str manifest_url: str
manifest_sha256: str manifest_sha256: str
manifest_keyring_sha256: str
manifest_signature_key_id: str
composition_sha256: str
api_image: str api_image: str
web_image: str web_image: str
@@ -178,6 +181,9 @@ def default_spec(
"version": version, "version": version,
"manifest_url": manifest_url, "manifest_url": manifest_url,
"manifest_sha256": manifest_sha256, "manifest_sha256": manifest_sha256,
"manifest_keyring_sha256": "",
"manifest_signature_key_id": "",
"composition_sha256": "",
"api_image": api_image, "api_image": api_image,
"web_image": web_image, "web_image": web_image,
}, },
@@ -314,6 +320,9 @@ def _release(raw: object) -> ReleaseConfig:
"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",
}, },
@@ -335,6 +344,27 @@ def _release(raw: object) -> ReleaseConfig:
raise SpecError( raise SpecError(
"release.manifest_sha256 must be a lowercase SHA-256 hex digest" "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") api_image = _image(_string(value, "api_image"), "release.api_image")
web_image = _image(_string(value, "web_image"), "release.web_image") web_image = _image(_string(value, "web_image"), "release.web_image")
return ReleaseConfig( return ReleaseConfig(
@@ -342,6 +372,9 @@ def _release(raw: object) -> ReleaseConfig:
version=version, version=version,
manifest_url=manifest_url, manifest_url=manifest_url,
manifest_sha256=manifest_sha256, 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, api_image=api_image,
web_image=web_image, web_image=web_image,
) )
+134 -34
View File
@@ -22,6 +22,15 @@ from .bundle import (
render_compose, render_compose,
service_names, 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 ( from .model import (
InstallationSpec, InstallationSpec,
image_is_digest_pinned, 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.extend(_distribution_checks(spec, paths))
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.",
)
)
values = read_env(paths.env) values = read_env(paths.env)
required = {"MASTER_KEY_B64", "DATABASE_URL"} required = {"MASTER_KEY_B64", "DATABASE_URL"}
@@ -364,6 +340,130 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
return tuple(checks) 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( def host_checks(
spec: InstallationSpec, spec: InstallationSpec,
paths: BundlePaths, paths: BundlePaths,
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Create runtime SBOM, provenance, and an unsigned distribution descriptor."""
from __future__ import annotations
import argparse
from datetime import UTC, datetime, timedelta
import hashlib
import json
from pathlib import Path
import re
import sys
from typing import Any
from urllib.parse import urlsplit
import uuid
SHA256 = re.compile(r"^[0-9a-f]{64}$")
DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--composition", type=Path, required=True)
parser.add_argument("--api-metadata", type=Path, required=True)
parser.add_argument("--web-metadata", type=Path, required=True)
parser.add_argument("--deployer", type=Path, required=True)
parser.add_argument("--deployer-url", required=True)
parser.add_argument("--artifact-base-url", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--channel", default="stable")
parser.add_argument("--sequence", type=int, required=True)
parser.add_argument("--expires-days", type=int, default=90)
parser.add_argument(
"--dependency",
action="append",
default=[],
metavar="NAME=IMAGE@SHA256",
)
parser.add_argument("--output-directory", type=Path, required=True)
parser.add_argument("--descriptor", type=Path, required=True)
return parser
def finalize(args: argparse.Namespace) -> dict[str, Any]:
composition = _json_object(args.composition)
api = _image_metadata(_json_object(args.api_metadata), "api")
web = _image_metadata(_json_object(args.web_metadata), "web")
dependencies = dict(_dependency(value) for value in args.dependency)
if not dependencies:
raise ValueError("at least one --dependency is required")
_https_url(args.deployer_url, "deployer URL")
artifact_base = _https_url(args.artifact_base_url, "artifact base URL").rstrip("/")
if args.sequence < 1 or not 1 <= args.expires_days <= 365:
raise ValueError("sequence and expiry window are out of bounds")
output = args.output_directory.expanduser().resolve()
output.mkdir(parents=True, exist_ok=True)
api_sbom = _api_sbom(composition, version=args.version)
web_sbom = _web_sbom(composition, version=args.version)
api_provenance = _provenance(
subject=api["index"],
source_commit=args.source_commit,
composition=composition,
)
web_provenance = _provenance(
subject=web["index"],
source_commit=args.source_commit,
composition=composition,
)
artifact_values = {
"api-sbom.cdx.json": api_sbom,
"web-sbom.cdx.json": web_sbom,
"api-provenance.json": api_provenance,
"web-provenance.json": web_provenance,
}
artifacts: dict[str, dict[str, str]] = {}
for filename, value in artifact_values.items():
path = output / filename
encoded = _canonical_json(value)
path.write_bytes(encoded)
artifacts[filename] = {
"url": f"{artifact_base}/{filename}",
"sha256": hashlib.sha256(encoded).hexdigest(),
}
composition_encoded = _canonical_json(composition)
packages = _manifest_packages(composition)
issued = datetime.now(UTC).replace(microsecond=0)
descriptor: dict[str, Any] = {
"schema_version": "1",
"channel": args.channel,
"sequence": args.sequence,
"version": args.version,
"issued_at": issued.isoformat(),
"expires_at": (issued + timedelta(days=args.expires_days)).isoformat(),
"revoked": False,
"deployer": {
"url": args.deployer_url,
"sha256": _sha256_file(args.deployer),
},
"images": {
"api": {
**api,
"sbom": artifacts["api-sbom.cdx.json"],
"provenance": artifacts["api-provenance.json"],
},
"web": {
**web,
"sbom": artifacts["web-sbom.cdx.json"],
"provenance": artifacts["web-provenance.json"],
},
},
"dependencies": dict(sorted(dependencies.items())),
"composition": {
"sha256": hashlib.sha256(composition_encoded).hexdigest(),
"module_ids": list(composition["python"]["module_ids"]),
"packages": packages,
},
}
args.descriptor.parent.mkdir(parents=True, exist_ok=True)
args.descriptor.write_bytes(_canonical_json(descriptor))
return descriptor
def _api_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
components = []
for package in composition["python"]["packages"]:
components.append(
{
"type": "library",
"name": package["package"],
"version": package["version"],
"hashes": [{"alg": "SHA-256", "content": package["sha256"]}],
"purl": f"pkg:pypi/{package['package']}@{package['version']}",
}
)
return {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-api:' + version)}",
"version": 1,
"metadata": {"component": {"type": "application", "name": "govoplan-api", "version": version}},
"components": components,
}
def _web_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
return {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-web:' + version)}",
"version": 1,
"metadata": {"component": {"type": "application", "name": "govoplan-web", "version": version}},
"components": [
{
"type": "file",
"name": "govoplan-web-dist",
"version": version,
"hashes": [
{
"alg": "SHA-256",
"content": composition["web"]["sha256"],
}
],
}
],
}
def _provenance(
*,
subject: str,
source_commit: str,
composition: dict[str, Any],
) -> dict[str, Any]:
digest = subject.rsplit("@sha256:", 1)[1]
return {
"_type": "https://in-toto.io/Statement/v1",
"subject": [{"name": subject.split("@", 1)[0], "digest": {"sha256": digest}}],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://govoplan.add-ideas.de/build/runtime-oci/v1",
"externalParameters": {
"source_commit": source_commit,
"network_free_image_assembly": True,
},
"resolvedDependencies": [
{
"uri": "govoplan:runtime-composition",
"digest": {
"sha256": hashlib.sha256(_canonical_json(composition)).hexdigest()
},
}
],
},
"runDetails": {
"builder": {"id": "https://git.add-ideas.de/GovOPlaN/govoplan/actions"},
"metadata": {"invocationId": source_commit},
},
},
}
def _manifest_packages(composition: dict[str, Any]) -> list[dict[str, str]]:
values = []
for package in composition["python"]["packages"]:
values.append(
{
"name": str(package["package"]),
"version": str(package["version"]),
"wheel_sha256": str(package["sha256"]),
}
)
return sorted(values, key=lambda item: item["name"])
def _image_metadata(value: dict[str, Any], label: str) -> dict[str, Any]:
if set(value) != {"index", "platforms"}:
raise ValueError(f"{label} image metadata has invalid fields")
if not isinstance(value["index"], str) or DIGEST_IMAGE.fullmatch(value["index"]) is None:
raise ValueError(f"{label} index is not digest-pinned")
platforms = value["platforms"]
if not isinstance(platforms, dict) or set(platforms) != {"linux/amd64", "linux/arm64"}:
raise ValueError(f"{label} image does not cover amd64 and arm64")
if any(not isinstance(item, str) or DIGEST_IMAGE.fullmatch(item) is None for item in platforms.values()):
raise ValueError(f"{label} platform image is not digest-pinned")
return {"index": value["index"], "platforms": dict(sorted(platforms.items()))}
def _dependency(value: str) -> tuple[str, str]:
if "=" not in value:
raise ValueError("--dependency must use NAME=IMAGE@SHA256")
name, reference = value.split("=", 1)
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", name) is None:
raise ValueError(f"invalid dependency name: {name!r}")
if DIGEST_IMAGE.fullmatch(reference) is None:
raise ValueError(f"dependency {name!r} is not digest-pinned")
return name, reference
def _json_object(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"JSON root must be an object: {path}")
return value
def _https_url(value: str, label: str) -> str:
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValueError(f"{label} must be an HTTPS URL without credentials")
return value
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _canonical_json(value: object) -> bytes:
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
def main() -> int:
args = build_parser().parse_args()
try:
finalize(args)
except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Runtime release evidence written below {args.output_directory}")
print(f"Unsigned descriptor written to {args.descriptor}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Validate and sign a GovOPlaN OCI runtime distribution manifest."""
from __future__ import annotations
import argparse
import base64
import json
from pathlib import Path
import sys
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
from govoplan_deploy.distribution import ( # noqa: E402
DistributionError,
canonical_json,
canonical_signed_payload,
validate_manifest,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--descriptor", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--signing-key",
action="append",
default=[],
metavar="KEY_ID=PRIVATE_PEM",
required=True,
)
return parser
def sign_manifest(
descriptor: dict[str, object],
signing_keys: tuple[tuple[str, Path], ...],
) -> dict[str, object]:
payload = dict(descriptor)
payload["signatures"] = [
_signature(payload, key_id=key_id, path=path)
for key_id, path in signing_keys
]
validate_manifest(payload)
return payload
def _signature(
payload: dict[str, object],
*,
key_id: str,
path: Path,
) -> dict[str, str]:
try:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
except (OSError, ValueError, TypeError) as exc:
raise DistributionError(f"cannot load signing key {key_id!r}") from exc
if not isinstance(key, Ed25519PrivateKey):
raise DistributionError(f"signing key {key_id!r} is not Ed25519")
return {
"key_id": key_id,
"algorithm": "ed25519",
"value": base64.b64encode(key.sign(canonical_signed_payload(payload))).decode(
"ascii"
),
}
def _parse_signing_key(value: str) -> tuple[str, Path]:
if "=" not in value:
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
key_id, raw_path = value.split("=", 1)
if not key_id or not raw_path:
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
return key_id, Path(raw_path).expanduser().resolve()
def main() -> int:
args = build_parser().parse_args()
try:
descriptor = json.loads(args.descriptor.read_text(encoding="utf-8"))
if not isinstance(descriptor, dict):
raise DistributionError("descriptor root must be an object")
if "signatures" in descriptor:
raise DistributionError("descriptor must not contain signatures")
payload = sign_manifest(
descriptor,
tuple(_parse_signing_key(value) for value in args.signing_key),
)
encoded = canonical_json(payload)
args.output.parent.mkdir(parents=True, exist_ok=True)
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
temporary.write_bytes(encoded)
temporary.chmod(0o644)
temporary.replace(args.output)
except (DistributionError, OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Runtime distribution manifest written to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Assemble a deterministic, network-free GovOPlaN OCI build context."""
from __future__ import annotations
import argparse
import configparser
from email.parser import BytesParser
from email.policy import compat32
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import zipfile
NORMALIZED_PACKAGE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
MAX_WHEELS = 512
MAX_WHEEL_BYTES = 512 * 1024 * 1024
MAX_WEB_FILES = 100_000
MAX_WEB_BYTES = 2 * 1024 * 1024 * 1024
class ContextError(ValueError):
"""The release inputs cannot form an immutable runtime context."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wheelhouse", type=Path, required=True)
parser.add_argument("--web-dist", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--required-module", action="append", default=[])
parser.add_argument(
"--source-date-epoch",
type=int,
default=int(os.environ.get("SOURCE_DATE_EPOCH", "0") or 0),
)
return parser
def prepare_context(
*,
wheelhouse: Path,
web_dist: Path,
output: Path,
required_modules: tuple[str, ...] = (),
source_date_epoch: int = 0,
) -> dict[str, object]:
source_wheels = _regular_files(wheelhouse, suffix=".whl", maximum=MAX_WHEELS)
if not source_wheels:
raise ContextError("wheelhouse contains no wheel artifacts")
if output.exists() and any(output.iterdir()):
raise ContextError("output directory must be absent or empty")
output.mkdir(parents=True, exist_ok=True)
target_wheels = output / "wheelhouse"
target_web = output / "web-dist"
target_wheels.mkdir(mode=0o755)
packages: list[dict[str, object]] = []
govoplan_wheel_rows: list[dict[str, object]] = []
roots: list[tuple[str, str]] = []
module_ids: set[str] = set()
seen_packages: set[str] = set()
wheel_rows: list[dict[str, object]] = []
for source in source_wheels:
if source.stat().st_size > MAX_WHEEL_BYTES:
raise ContextError(f"wheel exceeds size limit: {source.name}")
identity = inspect_wheel(source)
package_name = str(identity["package"])
if package_name in seen_packages:
raise ContextError(f"duplicate wheel distribution: {package_name}")
seen_packages.add(package_name)
target = target_wheels / source.name
_copy_regular(source, target, source_date_epoch=source_date_epoch)
row = {
"filename": source.name,
"sha256": _sha256_file(target),
"size": target.stat().st_size,
}
wheel_rows.append(row)
if package_name.startswith("govoplan-"):
package_modules = tuple(str(item) for item in identity["module_ids"])
module_ids.update(package_modules)
package = {
**row,
"package": package_name,
"version": identity["version"],
"module_ids": list(package_modules),
}
packages.append(package)
govoplan_wheel_rows.append(row)
root = (
f"{package_name}[server]"
if package_name == "govoplan-core"
else package_name
)
roots.append((root, str(identity["version"])))
if not any(package["package"] == "govoplan-core" for package in packages):
raise ContextError("wheelhouse does not contain govoplan-core")
missing_modules = sorted(set(required_modules) - module_ids)
if missing_modules:
raise ContextError(
"runtime composition is missing required modules: "
+ ", ".join(missing_modules)
)
requirements = "".join(
f"{package}=={version}\n" for package, version in sorted(roots)
)
_write_regular(
output / "requirements-runtime.txt",
requirements.encode("utf-8"),
source_date_epoch=source_date_epoch,
)
web_rows = _copy_web_tree(
web_dist,
target_web,
source_date_epoch=source_date_epoch,
)
composition: dict[str, object] = {
"schema_version": "1",
"python": {
"packages": sorted(packages, key=lambda item: str(item["package"])),
"module_ids": sorted(module_ids),
"wheelhouse_sha256": _rows_digest(govoplan_wheel_rows),
"wheel_count": len(govoplan_wheel_rows),
},
"web": {
"sha256": _rows_digest(web_rows),
"file_count": len(web_rows),
},
}
encoded = (json.dumps(composition, indent=2, sort_keys=True) + "\n").encode(
"utf-8"
)
_write_regular(
output / "composition.json",
encoded,
source_date_epoch=source_date_epoch,
)
_write_regular(
target_web / ".well-known" / "govoplan-composition.json",
encoded,
source_date_epoch=source_date_epoch,
)
_copy_regular(
Path(__file__).resolve().parent / "runtime" / "nginx.conf",
output / "nginx.conf",
source_date_epoch=source_date_epoch,
)
return composition
def inspect_wheel(path: Path) -> dict[str, object]:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ContextError(f"wheel cannot be opened safely: {path.name}") from exc
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode):
raise ContextError(f"wheel is not a regular file: {path.name}")
with os.fdopen(os.dup(descriptor), "rb") as handle:
with zipfile.ZipFile(handle) as archive:
metadata = [
member
for member in archive.infolist()
if PurePosixPath(member.filename).name == "METADATA"
and PurePosixPath(member.filename).parent.name.endswith(
".dist-info"
)
]
if len(metadata) != 1:
raise ContextError(
f"wheel must contain one METADATA file: {path.name}"
)
parsed = BytesParser(policy=compat32).parsebytes(
archive.read(metadata[0])
)
package = _normalize_package(str(parsed.get("Name") or ""))
version = str(parsed.get("Version") or "").strip()
if VERSION.fullmatch(version) is None:
raise ContextError(f"wheel has invalid version: {path.name}")
entry_points_name = (
PurePosixPath(metadata[0].filename).parent / "entry_points.txt"
).as_posix()
module_ids: tuple[str, ...] = ()
if entry_points_name in archive.namelist():
module_ids = _module_entry_points(
archive.read(entry_points_name).decode("utf-8")
)
final = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
final.st_dev,
final.st_ino,
final.st_size,
final.st_mtime_ns,
):
raise ContextError(f"wheel changed while inspected: {path.name}")
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
if isinstance(exc, ContextError):
raise
raise ContextError(f"wheel is not a readable archive: {path.name}") from exc
finally:
os.close(descriptor)
return {"package": package, "version": version, "module_ids": module_ids}
def _module_entry_points(value: str) -> tuple[str, ...]:
parser = configparser.ConfigParser(interpolation=None, strict=True)
try:
parser.read_string(value)
except configparser.Error as exc:
raise ContextError("wheel entry_points.txt is malformed") from exc
if not parser.has_section("govoplan.modules"):
return ()
values = tuple(sorted(parser.options("govoplan.modules")))
for item in values:
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", item) is None:
raise ContextError(f"wheel has invalid module entry point: {item!r}")
return values
def _normalize_package(value: str) -> str:
normalized = re.sub(r"[-_.]+", "-", value.strip().lower())
if NORMALIZED_PACKAGE.fullmatch(normalized) is None:
raise ContextError("wheel has invalid package name")
return normalized
def _regular_files(root: Path, *, suffix: str, maximum: int) -> list[Path]:
if root.is_symlink() or not root.is_dir():
raise ContextError(f"input directory is not a real directory: {root}")
values = sorted(path for path in root.iterdir() if path.name.endswith(suffix))
if len(values) > maximum:
raise ContextError(f"input directory exceeds {maximum} files")
for path in values:
if path.is_symlink() or not path.is_file():
raise ContextError(f"input artifact is not a regular file: {path.name}")
return values
def _copy_web_tree(
source: Path,
target: Path,
*,
source_date_epoch: int,
) -> list[dict[str, object]]:
if source.is_symlink() or not source.is_dir():
raise ContextError("WebUI dist must be a real directory")
rows: list[dict[str, object]] = []
total = 0
for path in sorted(source.rglob("*")):
relative = path.relative_to(source)
if path.is_symlink():
raise ContextError(f"WebUI dist contains a symlink: {relative}")
if path.is_dir():
continue
if not path.is_file():
raise ContextError(f"WebUI dist contains a special file: {relative}")
if len(rows) >= MAX_WEB_FILES:
raise ContextError("WebUI dist exceeds its file-count limit")
total += path.stat().st_size
if total > MAX_WEB_BYTES:
raise ContextError("WebUI dist exceeds its total-size limit")
destination = target / relative
_copy_regular(path, destination, source_date_epoch=source_date_epoch)
rows.append(
{
"path": relative.as_posix(),
"sha256": _sha256_file(destination),
"size": destination.stat().st_size,
}
)
if not rows:
raise ContextError("WebUI dist contains no files")
return rows
def _copy_regular(source: Path, target: Path, *, source_date_epoch: int) -> None:
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
with source.open("rb") as source_handle, target.open("xb") as target_handle:
shutil.copyfileobj(source_handle, target_handle)
target_handle.flush()
os.fsync(target_handle.fileno())
target.chmod(0o644)
os.utime(target, (source_date_epoch, source_date_epoch))
def _write_regular(path: Path, value: bytes, *, source_date_epoch: int) -> None:
path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
path.write_bytes(value)
path.chmod(0o644)
os.utime(path, (source_date_epoch, source_date_epoch))
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _rows_digest(rows: list[dict[str, object]]) -> str:
encoded = json.dumps(rows, separators=(",", ":"), sort_keys=True).encode(
"utf-8"
)
return hashlib.sha256(encoded).hexdigest()
def main() -> int:
args = build_parser().parse_args()
try:
composition = prepare_context(
wheelhouse=args.wheelhouse.expanduser().resolve(),
web_dist=args.web_dist.expanduser().resolve(),
output=args.output.expanduser().resolve(),
required_modules=tuple(args.required_module),
source_date_epoch=args.source_date_epoch,
)
except (ContextError, OSError) as exc:
print(f"error: {exc}", file=os.sys.stderr)
return 1
print(json.dumps(composition, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Publish immutable GovOPlaN runtime evidence as Gitea release assets."""
from __future__ import annotations
import argparse
import hashlib
import json
import mimetypes
import os
from pathlib import Path
import secrets
import sys
from typing import Any
from urllib.error import HTTPError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
MAX_ASSET_BYTES = 256 * 1024 * 1024
class PublishError(RuntimeError):
"""A release asset cannot be published immutably."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="https://git.add-ideas.de")
parser.add_argument("--owner", default="GovOPlaN")
parser.add_argument("--repo", default="govoplan")
parser.add_argument("--tag", required=True)
parser.add_argument("--title", required=True)
parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.")
parser.add_argument("--asset", type=Path, action="append", default=[], required=True)
parser.add_argument("--token-env", default="GITEA_RELEASE_TOKEN")
return parser
class GiteaReleasePublisher:
def __init__(self, *, base_url: str, owner: str, repo: str, token: str) -> None:
if not base_url.startswith("https://"):
raise PublishError("Gitea release publication requires HTTPS")
if not token:
raise PublishError("Gitea release token is empty")
self.base_url = base_url.rstrip("/")
self.owner = owner
self.repo = repo
self.token = token
def release(self, *, tag: str, title: str, body: str) -> dict[str, Any]:
path = self._repo_path(f"/releases/tags/{quote(tag, safe='')}")
try:
return self._json("GET", path)
except HTTPError as exc:
if exc.code != 404:
raise
return self._json(
"POST",
self._repo_path("/releases"),
payload={
"tag_name": tag,
"name": title,
"body": body,
"draft": False,
"prerelease": False,
},
expected=201,
)
def upload_assets(self, release: dict[str, Any], assets: tuple[Path, ...]) -> None:
release_id = release.get("id")
if isinstance(release_id, bool) or not isinstance(release_id, int):
raise PublishError("Gitea release response has no numeric id")
existing = self._json(
"GET",
self._repo_path(f"/releases/{release_id}/assets"),
)
if not isinstance(existing, list):
raise PublishError("Gitea release assets response is invalid")
existing_by_name = {
str(item.get("name")): item for item in existing if isinstance(item, dict)
}
for asset in assets:
path = asset.expanduser().resolve()
if path.is_symlink() or not path.is_file():
raise PublishError(f"release asset is not a regular file: {path}")
size = path.stat().st_size
if size > MAX_ASSET_BYTES:
raise PublishError(f"release asset exceeds size limit: {path.name}")
prior = existing_by_name.get(path.name)
if prior is not None:
self._require_same_existing_asset(prior, path)
continue
self._upload(release_id, path)
def _require_same_existing_asset(self, prior: dict[str, Any], path: Path) -> None:
url = prior.get("browser_download_url")
size = prior.get("size")
if not isinstance(url, str) or not url.startswith("https://") or size != path.stat().st_size:
raise PublishError(f"release asset already exists with another identity: {path.name}")
request = Request(url, headers=self._headers())
digest = hashlib.sha256()
total = 0
with urlopen(request, timeout=30) as response: # noqa: S310
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_ASSET_BYTES:
raise PublishError("existing release asset exceeds size limit")
digest.update(chunk)
if digest.hexdigest() != _sha256_file(path):
raise PublishError(f"release asset already exists with another digest: {path.name}")
def _upload(self, release_id: int, path: Path) -> None:
boundary = "govoplan-" + secrets.token_hex(16)
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
prefix = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
suffix = f"\r\n--{boundary}--\r\n".encode("ascii")
data = prefix + path.read_bytes() + suffix
query = urlencode({"name": path.name})
request = Request(
self._repo_path(f"/releases/{release_id}/assets") + "?" + query,
data=data,
method="POST",
headers={
**self._headers(),
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
)
try:
with urlopen(request, timeout=120) as response: # noqa: S310
if response.status != 201:
raise PublishError(
f"Gitea asset upload returned HTTP {response.status}"
)
except HTTPError as exc:
raise PublishError(f"Gitea asset upload failed with HTTP {exc.code}") from exc
def _json(
self,
method: str,
url: str,
*,
payload: dict[str, Any] | None = None,
expected: int = 200,
) -> Any:
data = None
headers = self._headers()
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, method=method, headers=headers)
with urlopen(request, timeout=30) as response: # noqa: S310
if response.status != expected:
raise PublishError(f"Gitea API returned HTTP {response.status}")
return json.load(response)
def _headers(self) -> dict[str, str]:
return {"Authorization": f"token {self.token}", "Accept": "application/json"}
def _repo_path(self, suffix: str) -> str:
return (
f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/"
f"{quote(self.repo, safe='')}{suffix}"
)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
args = build_parser().parse_args()
try:
publisher = GiteaReleasePublisher(
base_url=args.base_url,
owner=args.owner,
repo=args.repo,
token=os.environ.get(args.token_env, ""),
)
release = publisher.release(tag=args.tag, title=args.title, body=args.body)
publisher.upload_assets(release, tuple(args.asset))
except (HTTPError, OSError, PublishError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Published {len(args.asset)} immutable asset(s) to {args.owner}/{args.repo} {args.tag}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Resolve amd64/arm64 child digests from an OCI image index."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
def resolve_platforms(
payload: object,
*,
repository: str,
index_digest: str,
) -> dict[str, object]:
if not repository or "@" in repository or any(value.isspace() for value in repository):
raise ValueError("repository must be an unpinned OCI repository name")
if DIGEST.fullmatch(index_digest) is None:
raise ValueError("index digest must be sha256:<hex>")
if not isinstance(payload, dict) or not isinstance(payload.get("manifests"), list):
raise ValueError("OCI index must contain manifests")
platforms: dict[str, str] = {}
for item in payload["manifests"]:
if not isinstance(item, dict) or not isinstance(item.get("platform"), dict):
continue
platform = item["platform"]
key = f"{platform.get('os')}/{platform.get('architecture')}"
if key not in {"linux/amd64", "linux/arm64"}:
continue
digest = item.get("digest")
if not isinstance(digest, str) or DIGEST.fullmatch(digest) is None:
raise ValueError(f"OCI index has an invalid {key} digest")
if key in platforms:
raise ValueError(f"OCI index has duplicate {key} manifests")
platforms[key] = f"{repository}@{digest}"
if set(platforms) != {"linux/amd64", "linux/arm64"}:
raise ValueError("OCI index must contain linux/amd64 and linux/arm64")
return {
"index": f"{repository}@{index_digest}",
"platforms": dict(sorted(platforms.items())),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", required=True)
parser.add_argument("--index-digest", required=True)
parser.add_argument("--index", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
try:
payload = json.loads(args.index.read_text(encoding="utf-8"))
result = resolve_platforms(
payload,
repository=args.repository,
index_digest=args.index_digest,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(result, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_IMAGE
FROM ${PYTHON_IMAGE}
ARG GOVOPLAN_RELEASE_VERSION
ARG GOVOPLAN_COMPOSITION_SHA256
LABEL org.opencontainers.image.title="GovOPlaN API runtime" \
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/opt/govoplan/runtime \
PATH=/opt/govoplan/runtime/bin:${PATH} \
HOME=/var/lib/govoplan
COPY wheelhouse/ /opt/govoplan/wheels/
COPY requirements-runtime.txt composition.json /opt/govoplan/
RUN python -m pip install --disable-pip-version-check --no-cache-dir \
--no-index --find-links=/opt/govoplan/wheels \
--target=/opt/govoplan/runtime \
--requirement=/opt/govoplan/requirements-runtime.txt \
&& rm -rf /opt/govoplan/wheels \
&& groupadd --gid 10001 govoplan \
&& useradd --uid 10001 --gid 10001 --home-dir /var/lib/govoplan \
--create-home --shell /usr/sbin/nologin govoplan \
&& mkdir -p /var/lib/govoplan /tmp/govoplan \
&& chown -R 10001:10001 /var/lib/govoplan /tmp/govoplan \
&& chmod -R a-w /opt/govoplan
USER 10001:10001
WORKDIR /var/lib/govoplan
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=12 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
CMD ["python", "-m", "uvicorn", "govoplan_core.server.app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.7
ARG NGINX_IMAGE
FROM ${NGINX_IMAGE}
ARG GOVOPLAN_RELEASE_VERSION
ARG GOVOPLAN_COMPOSITION_SHA256
LABEL org.opencontainers.image.title="GovOPlaN WebUI runtime" \
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
USER 0
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/*
COPY web-dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
RUN chown -R 101:101 /usr/share/nginx/html \
&& chmod -R a-w /usr/share/nginx/html /etc/nginx/nginx.conf
USER 101:101
EXPOSE 8080
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
+41
View File
@@ -0,0 +1,41 @@
pid /tmp/nginx.pid;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
error_log /dev/stderr warn;
sendfile on;
server_tokens off;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
server {
listen 8080;
root /usr/share/nginx/html;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://load-balancer:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}