Add signed runtime distribution pipeline
This commit is contained in:
@@ -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
|
||||
@@ -4,6 +4,11 @@
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
runtime/
|
||||
!tools/release/runtime/
|
||||
tools/release/runtime/*
|
||||
!tools/release/runtime/Dockerfile.api
|
||||
!tools/release/runtime/Dockerfile.web
|
||||
!tools/release/runtime/nginx.conf
|
||||
__pycache__/
|
||||
audit-reports/
|
||||
coverage/
|
||||
|
||||
@@ -93,6 +93,8 @@ The private installation directory contains:
|
||||
| `load-balancer.cfg` | Non-secret HAProxy WebUI/API discovery configuration |
|
||||
| `plan.json` | Latest desired-state diff and readiness findings |
|
||||
| `receipt.json` | Last successfully applied immutable identities |
|
||||
| `distribution-manifest.json` | Canonical signed runtime/image selection adopted by the installer |
|
||||
| `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases |
|
||||
| `applied-state/` | Checksum-verified snapshot of the last healthy deployment bundle |
|
||||
| `operations/<id>/` | Private hash-chained deployment progress and recovery evidence |
|
||||
| `kubernetes.json` | Optional stateless multi-host Kubernetes export |
|
||||
@@ -128,20 +130,56 @@ Those images must already contain the selected module set. The override exists
|
||||
only to exercise local orchestration before release artifacts exist; it is
|
||||
rejected for `self-hosted`.
|
||||
|
||||
## Runtime Distribution Boundary
|
||||
|
||||
The protected `Runtime Distribution` workflow builds GovOPlaN wheels first,
|
||||
resolves architecture-specific third-party wheels into offline wheelhouses, and
|
||||
then assembles the API images with `pip --no-index`. The target host never
|
||||
clones Git repositories and neither runtime image performs network package
|
||||
installation. Separate amd64/arm64 API and WebUI images are joined into OCI
|
||||
indexes and run as non-root identities. The release assets include CycloneDX
|
||||
application SBOMs, SLSA-style provenance, exact composition evidence, the
|
||||
single-file deployer, its detached Ed25519 signature, and a signed, expiring
|
||||
distribution manifest.
|
||||
|
||||
The manifest contract is
|
||||
[`runtime-distribution-manifest.schema.json`](runtime-distribution-manifest.schema.json),
|
||||
and its separately distributed trust-anchor contract is
|
||||
[`runtime-distribution-keyring.schema.json`](runtime-distribution-keyring.schema.json).
|
||||
Publication is immutable: an existing Gitea release asset must have the same
|
||||
size and SHA-256 digest or publication fails.
|
||||
|
||||
Adopt a downloaded or prefetched release only after obtaining the manifest
|
||||
digest and trusted keyring through the documented independent channel:
|
||||
|
||||
```sh
|
||||
python3 govoplan-deploy.pyz verify-release \
|
||||
--directory /srv/govoplan/installation \
|
||||
--manifest ./distribution-manifest.json \
|
||||
--manifest-sha256 "$(cut -d' ' -f1 distribution-manifest.json.sha256)" \
|
||||
--trusted-keyring ./distribution-keyring.json \
|
||||
--adopt
|
||||
```
|
||||
|
||||
`doctor` and `apply` rehash both stored files, re-run OpenSSL Ed25519
|
||||
verification, enforce channel/expiry/revocation, compare every selected image,
|
||||
and prove that all enabled module ids occur in the signed image composition.
|
||||
An offline image index can bind prefetched OCI archives to the same exact image
|
||||
references and archive hashes; mutable tags or incomplete bundles are rejected.
|
||||
|
||||
## Current Production Gates
|
||||
|
||||
The tool deliberately reports blockers instead of pretending the source tree is
|
||||
a production distribution:
|
||||
|
||||
1. **OCI release artifacts.** The release pipeline does not yet publish pinned
|
||||
multi-architecture API and WebUI images.
|
||||
2. **Signed distribution manifest.** A channel manifest must bind exact image
|
||||
digests, Compose compatibility, SBOM/provenance references, and revocation
|
||||
state. Recording a URL and checksum is not signature verification.
|
||||
1. **First publication.** The protected workflow and fail-closed artifact
|
||||
contracts are implemented, but a release operator must configure the Gitea
|
||||
registry/release tokens and runtime Ed25519 key, publish the first pinned
|
||||
release, and retain its amd64/arm64 readiness evidence.
|
||||
3. **First administrator.** Production needs a one-time, restricted enrollment
|
||||
identity. The development bootstrap must not be enabled in production.
|
||||
4. **Image/module composition.** The selected module set must be proven present
|
||||
in the exact image or installed from verified offline artifacts before it is
|
||||
4. **Image/module composition.** The deployer now enforces the signed
|
||||
composition. A selected module not shipped by that release cannot be
|
||||
enabled.
|
||||
5. **Deployment agent.** Web updates need a separate privileged reconciler with
|
||||
a typed command allowlist. The API and browser must never receive the Docker
|
||||
@@ -353,22 +391,25 @@ of the reviewed update recipe instead of a non-functional update button.
|
||||
|
||||
## Distribution Workflow
|
||||
|
||||
The downloadable entry point should eventually be:
|
||||
The downloadable entry point is a release asset. Obtain the zipapp, detached
|
||||
signature, checksum, and trusted public keyring through independently
|
||||
authenticated paths before execution:
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 --fail --location \
|
||||
https://govoplan.add-ideas.de/install/v1/bootstrap.pyz \
|
||||
--output govoplan-bootstrap.pyz
|
||||
python3 govoplan-bootstrap.pyz init
|
||||
https://git.add-ideas.de/GovOPlaN/govoplan/releases/download/vX.Y.Z/govoplan-deploy.pyz \
|
||||
--output govoplan-deploy.pyz
|
||||
sha256sum --check govoplan-deploy.pyz.sha256
|
||||
openssl pkeyutl -verify -pubin -inkey runtime-release-public.pem -rawin \
|
||||
-in govoplan-deploy.pyz -sigfile govoplan-deploy.pyz.sig
|
||||
python3 govoplan-deploy.pyz init
|
||||
```
|
||||
|
||||
The published documentation must include an independent checksum/signature
|
||||
verification command before execution. The zipapp then downloads only a signed
|
||||
distribution manifest, verifies it against an embedded or explicitly installed
|
||||
keyring, and renders the same installation contract implemented here.
|
||||
|
||||
The source-tree script is the test harness for that future zipapp. It is not yet
|
||||
the internet bootstrap artifact.
|
||||
The zipapp has no GovOPlaN package dependency. It accepts a bounded HTTPS
|
||||
manifest or a prefetched file, requires an independently supplied SHA-256
|
||||
digest and explicit trusted keyring, and executes OpenSSL with a fixed argument
|
||||
vector for Ed25519 verification. It never evaluates downloaded shell text or
|
||||
accepts an arbitrary command string.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -378,7 +419,9 @@ Run the focused tests:
|
||||
./.venv/bin/python -m unittest -v tests.test_deployment_installer
|
||||
```
|
||||
|
||||
The tests cover profile restrictions, secret persistence, external endpoint
|
||||
The tests cover signed release adoption, tamper/expiry/revocation/unknown-key
|
||||
rejection, architecture composition, offline image integrity, profile
|
||||
restrictions, secret persistence, external endpoint
|
||||
requirements, managed Garage bootstrap, S3 policy, replica validation, HAProxy
|
||||
discovery configuration, Compose service selection, secret non-disclosure,
|
||||
service-specific environment isolation, private file modes, external endpoint
|
||||
|
||||
@@ -141,6 +141,12 @@ The canonical backlog item is
|
||||
|
||||
Implementation status as of the current source tree:
|
||||
|
||||
- Slice 1 now has the source-controlled production artifact boundary: offline
|
||||
per-architecture wheel resolution, non-root API/Web image definitions,
|
||||
multi-architecture OCI publication, signed composition/SBOM/provenance,
|
||||
immutable Gitea assets, a signed one-file deployer, and fail-closed manifest
|
||||
adoption. The first real published release and cross-architecture runtime
|
||||
evidence remain release-operator work rather than source-code claims.
|
||||
- Slice 6 has a working application-tier foundation: state profiles, shared
|
||||
object storage, runtime node registration/heartbeats/drain, fenced scheduler,
|
||||
migration serialization, exact-head startup waiting, Ops visibility, and a
|
||||
|
||||
@@ -83,6 +83,18 @@
|
||||
"type": "string",
|
||||
"pattern": "^$|^[0-9a-f]{64}$"
|
||||
},
|
||||
"manifest_keyring_sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^$|^[0-9a-f]{64}$"
|
||||
},
|
||||
"manifest_signature_key_id": {
|
||||
"type": "string",
|
||||
"pattern": "^$|^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"composition_sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^$|^[0-9a-f]{64}$"
|
||||
},
|
||||
"api_image": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
|
||||
|
||||
from govoplan_deploy.bundle import bundle_paths # noqa: E402
|
||||
from govoplan_deploy.cli import main # noqa: E402
|
||||
from govoplan_deploy.distribution import ( # noqa: E402
|
||||
canonical_json,
|
||||
canonical_signed_payload,
|
||||
)
|
||||
from govoplan_deploy.model import load_spec # noqa: E402
|
||||
from govoplan_deploy.planning import static_checks # noqa: E402
|
||||
|
||||
|
||||
class DeploymentReleaseAdoptionTests(unittest.TestCase):
|
||||
def test_adopts_verified_manifest_and_makes_release_checks_pass(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-release-adopt-") as value:
|
||||
root = Path(value)
|
||||
self.assertEqual(
|
||||
0,
|
||||
main(
|
||||
[
|
||||
"init",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--non-interactive",
|
||||
"--module-set",
|
||||
"core",
|
||||
]
|
||||
),
|
||||
)
|
||||
manifest, keyring = self._signed_distribution()
|
||||
manifest_path = root / "source-manifest.json"
|
||||
keyring_path = root / "source-keyring.json"
|
||||
encoded_manifest = canonical_json(manifest)
|
||||
manifest_path.write_bytes(encoded_manifest)
|
||||
keyring_path.write_bytes(canonical_json(keyring))
|
||||
|
||||
result = main(
|
||||
[
|
||||
"verify-release",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--manifest-sha256",
|
||||
hashlib.sha256(encoded_manifest).hexdigest(),
|
||||
"--trusted-keyring",
|
||||
str(keyring_path),
|
||||
"--adopt",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(0, result)
|
||||
paths = bundle_paths(root)
|
||||
spec = load_spec(paths.spec)
|
||||
self.assertEqual("1.2.3", spec.release.version)
|
||||
self.assertEqual("release-1", spec.release.manifest_signature_key_id)
|
||||
self.assertTrue(spec.release.api_image.endswith("a" * 64))
|
||||
release_checks = {
|
||||
item.id: item for item in static_checks(spec, paths)
|
||||
if item.id.startswith("release.") or item.id == "modules.image_composition"
|
||||
}
|
||||
self.assertEqual("ok", release_checks["release.manifest"].level)
|
||||
self.assertEqual(
|
||||
"ok", release_checks["release.signature_verification"].level
|
||||
)
|
||||
self.assertEqual("ok", release_checks["modules.image_composition"].level)
|
||||
|
||||
def test_rejects_manifest_whose_independent_digest_does_not_match(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-release-adopt-") as value:
|
||||
root = Path(value)
|
||||
main(
|
||||
[
|
||||
"init",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--non-interactive",
|
||||
"--module-set",
|
||||
"core",
|
||||
]
|
||||
)
|
||||
manifest, keyring = self._signed_distribution()
|
||||
manifest_path = root / "source-manifest.json"
|
||||
keyring_path = root / "source-keyring.json"
|
||||
manifest_path.write_bytes(canonical_json(manifest))
|
||||
keyring_path.write_bytes(canonical_json(keyring))
|
||||
|
||||
self.assertEqual(
|
||||
1,
|
||||
main(
|
||||
[
|
||||
"verify-release",
|
||||
"--directory",
|
||||
str(root),
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--manifest-sha256",
|
||||
"0" * 64,
|
||||
"--trusted-keyring",
|
||||
str(keyring_path),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _signed_distribution() -> tuple[dict[str, object], dict[str, object]]:
|
||||
now = datetime.now(UTC)
|
||||
private = Ed25519PrivateKey.generate()
|
||||
public = private.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
artifact = {
|
||||
"url": "https://downloads.example.test/artifact.json",
|
||||
"sha256": "f" * 64,
|
||||
}
|
||||
payload: dict[str, object] = {
|
||||
"schema_version": "1",
|
||||
"channel": "stable",
|
||||
"sequence": 1,
|
||||
"version": "1.2.3",
|
||||
"issued_at": (now - timedelta(minutes=1)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=30)).isoformat(),
|
||||
"revoked": False,
|
||||
"deployer": {
|
||||
"url": "https://downloads.example.test/govoplan-deploy.pyz",
|
||||
"sha256": "e" * 64,
|
||||
},
|
||||
"images": {
|
||||
"api": {
|
||||
"index": "registry.example/govoplan/api@sha256:" + "a" * 64,
|
||||
"platforms": {
|
||||
"linux/amd64": "registry.example/govoplan/api@sha256:" + "1" * 64,
|
||||
"linux/arm64": "registry.example/govoplan/api@sha256:" + "2" * 64,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
"web": {
|
||||
"index": "registry.example/govoplan/web@sha256:" + "b" * 64,
|
||||
"platforms": {
|
||||
"linux/amd64": "registry.example/govoplan/web@sha256:" + "3" * 64,
|
||||
"linux/arm64": "registry.example/govoplan/web@sha256:" + "4" * 64,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
},
|
||||
"dependencies": {
|
||||
"postgres": "docker.io/library/postgres@sha256:" + "5" * 64,
|
||||
"redis": "docker.io/library/redis@sha256:" + "6" * 64,
|
||||
"load_balancer": "docker.io/library/haproxy@sha256:" + "7" * 64,
|
||||
},
|
||||
"composition": {
|
||||
"sha256": "c" * 64,
|
||||
"module_ids": [],
|
||||
"packages": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"version": "1.2.3",
|
||||
"wheel_sha256": "8" * 64,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
payload["signatures"] = [
|
||||
{
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"value": base64.b64encode(
|
||||
private.sign(canonical_signed_payload(payload))
|
||||
).decode("ascii"),
|
||||
}
|
||||
]
|
||||
keyring = {
|
||||
"schema_version": "1",
|
||||
"purpose": "govoplan-runtime-distribution",
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"status": "active",
|
||||
"public_key_pem": public,
|
||||
"not_before": (now - timedelta(days=1)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=365)).isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
return payload, keyring
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||
|
||||
from govoplan_deploy.distribution import ( # noqa: E402
|
||||
DistributionError,
|
||||
canonical_signed_payload,
|
||||
verify_manifest,
|
||||
verify_manifest_binding,
|
||||
verify_offline_image_index,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeDistributionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.now = datetime(2026, 8, 3, tzinfo=UTC)
|
||||
self.private = Ed25519PrivateKey.generate()
|
||||
public = self.private.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
self.keyring = {
|
||||
"schema_version": "1",
|
||||
"purpose": "govoplan-runtime-distribution",
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"status": "active",
|
||||
"public_key_pem": public,
|
||||
"not_before": (self.now - timedelta(days=1)).isoformat(),
|
||||
"expires_at": (self.now + timedelta(days=365)).isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_verifies_signature_and_exact_runtime_binding(self) -> None:
|
||||
payload = self._manifest()
|
||||
|
||||
key_id = verify_manifest(
|
||||
payload,
|
||||
self.keyring,
|
||||
expected_channel="stable",
|
||||
now=self.now,
|
||||
)
|
||||
verify_manifest_binding(
|
||||
payload,
|
||||
channel="stable",
|
||||
version="1.2.3",
|
||||
api_image=payload["images"]["api"]["index"],
|
||||
web_image=payload["images"]["web"]["index"],
|
||||
enabled_modules=("access", "files"),
|
||||
composition_sha256="c" * 64,
|
||||
dependencies=payload["dependencies"],
|
||||
)
|
||||
|
||||
self.assertEqual("release-1", key_id)
|
||||
|
||||
def test_tamper_expiry_revocation_and_unknown_key_fail_closed(self) -> None:
|
||||
payload = self._manifest()
|
||||
payload["composition"]["module_ids"].append("mail")
|
||||
with self.assertRaisesRegex(DistributionError, "signature verification"):
|
||||
verify_manifest(payload, self.keyring, now=self.now)
|
||||
|
||||
expired = self._manifest()
|
||||
expired["expires_at"] = (self.now - timedelta(seconds=1)).isoformat()
|
||||
expired["signatures"] = [self._signature(expired)]
|
||||
with self.assertRaisesRegex(DistributionError, "expired"):
|
||||
verify_manifest(expired, self.keyring, now=self.now)
|
||||
|
||||
revoked = self._manifest()
|
||||
revoked["revoked"] = True
|
||||
revoked["signatures"] = [self._signature(revoked)]
|
||||
with self.assertRaisesRegex(DistributionError, "revoked"):
|
||||
verify_manifest(revoked, self.keyring, now=self.now)
|
||||
|
||||
unknown = self._manifest()
|
||||
unknown["signatures"][0]["key_id"] = "other-key"
|
||||
with self.assertRaisesRegex(DistributionError, "active trusted key"):
|
||||
verify_manifest(unknown, self.keyring, now=self.now)
|
||||
|
||||
def test_offline_image_index_is_complete_and_digest_bound(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-offline-images-") as value:
|
||||
root = Path(value)
|
||||
api = root / "api.oci.tar"
|
||||
web = root / "web.oci.tar"
|
||||
api.write_bytes(b"api archive")
|
||||
web.write_bytes(b"web archive")
|
||||
api_ref = "registry.example/govoplan/api@sha256:" + "a" * 64
|
||||
web_ref = "registry.example/govoplan/web@sha256:" + "b" * 64
|
||||
index = {
|
||||
"schema_version": "1",
|
||||
"images": [
|
||||
{
|
||||
"reference": api_ref,
|
||||
"archive": api.name,
|
||||
"sha256": hashlib.sha256(api.read_bytes()).hexdigest(),
|
||||
},
|
||||
{
|
||||
"reference": web_ref,
|
||||
"archive": web.name,
|
||||
"sha256": hashlib.sha256(web.read_bytes()).hexdigest(),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
paths = verify_offline_image_index(
|
||||
index,
|
||||
root=root,
|
||||
expected_references=(api_ref, web_ref),
|
||||
)
|
||||
self.assertEqual((api, web), paths)
|
||||
|
||||
index["images"][1]["sha256"] = "0" * 64
|
||||
with self.assertRaisesRegex(DistributionError, "digest mismatch"):
|
||||
verify_offline_image_index(
|
||||
index,
|
||||
root=root,
|
||||
expected_references=(api_ref, web_ref),
|
||||
)
|
||||
|
||||
def _manifest(self) -> dict[str, object]:
|
||||
artifact = {"url": "https://downloads.example.test/artifact.json", "sha256": "d" * 64}
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": "1",
|
||||
"channel": "stable",
|
||||
"sequence": 1,
|
||||
"version": "1.2.3",
|
||||
"issued_at": (self.now - timedelta(minutes=1)).isoformat(),
|
||||
"expires_at": (self.now + timedelta(days=30)).isoformat(),
|
||||
"revoked": False,
|
||||
"deployer": {
|
||||
"url": "https://downloads.example.test/govoplan-deploy.pyz",
|
||||
"sha256": "e" * 64,
|
||||
},
|
||||
"images": {
|
||||
"api": {
|
||||
"index": "registry.example/govoplan/api@sha256:" + "a" * 64,
|
||||
"platforms": {
|
||||
"linux/amd64": "registry.example/govoplan/api@sha256:" + "1" * 64,
|
||||
"linux/arm64": "registry.example/govoplan/api@sha256:" + "2" * 64,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
"web": {
|
||||
"index": "registry.example/govoplan/web@sha256:" + "b" * 64,
|
||||
"platforms": {
|
||||
"linux/amd64": "registry.example/govoplan/web@sha256:" + "3" * 64,
|
||||
"linux/arm64": "registry.example/govoplan/web@sha256:" + "4" * 64,
|
||||
},
|
||||
"sbom": dict(artifact),
|
||||
"provenance": dict(artifact),
|
||||
},
|
||||
},
|
||||
"dependencies": {
|
||||
"postgres": "docker.io/library/postgres@sha256:" + "5" * 64,
|
||||
"redis": "docker.io/library/redis@sha256:" + "6" * 64,
|
||||
"load_balancer": "docker.io/library/haproxy@sha256:" + "7" * 64,
|
||||
},
|
||||
"composition": {
|
||||
"sha256": "c" * 64,
|
||||
"module_ids": ["access", "files"],
|
||||
"packages": [
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"version": "1.2.3",
|
||||
"wheel_sha256": "8" * 64,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
manifest["signatures"] = [self._signature(manifest)]
|
||||
return manifest
|
||||
|
||||
def _signature(self, payload: dict[str, object]) -> dict[str, str]:
|
||||
return {
|
||||
"key_id": "release-1",
|
||||
"algorithm": "ed25519",
|
||||
"value": base64.b64encode(
|
||||
self.private.sign(canonical_signed_payload(payload))
|
||||
).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
OCI = _load("resolve_oci_platforms", ROOT / "tools/release/resolve-oci-platforms.py")
|
||||
FINALIZE = _load(
|
||||
"finalize_runtime_distribution",
|
||||
ROOT / "tools/release/finalize-runtime-distribution.py",
|
||||
)
|
||||
|
||||
|
||||
class RuntimeDistributionBuildTests(unittest.TestCase):
|
||||
def test_resolves_platforms_and_builds_evidence_descriptor(self) -> None:
|
||||
index = {
|
||||
"schemaVersion": 2,
|
||||
"manifests": [
|
||||
{
|
||||
"digest": "sha256:" + "1" * 64,
|
||||
"platform": {"os": "linux", "architecture": "amd64"},
|
||||
},
|
||||
{
|
||||
"digest": "sha256:" + "2" * 64,
|
||||
"platform": {"os": "linux", "architecture": "arm64"},
|
||||
},
|
||||
],
|
||||
}
|
||||
metadata = OCI.resolve_platforms(
|
||||
index,
|
||||
repository="registry.example/govoplan/api",
|
||||
index_digest="sha256:" + "a" * 64,
|
||||
)
|
||||
self.assertEqual(
|
||||
"registry.example/govoplan/api@sha256:" + "1" * 64,
|
||||
metadata["platforms"]["linux/amd64"],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-finalize-") as value:
|
||||
root = Path(value)
|
||||
composition = {
|
||||
"schema_version": "1",
|
||||
"python": {
|
||||
"packages": [
|
||||
{
|
||||
"package": "govoplan-core",
|
||||
"version": "1.2.3",
|
||||
"sha256": "8" * 64,
|
||||
}
|
||||
],
|
||||
"module_ids": ["access"],
|
||||
"wheelhouse_sha256": "9" * 64,
|
||||
"wheel_count": 1,
|
||||
},
|
||||
"web": {"sha256": "7" * 64, "file_count": 4},
|
||||
}
|
||||
(root / "composition.json").write_text(json.dumps(composition))
|
||||
(root / "api.json").write_text(json.dumps(metadata))
|
||||
web_metadata = {
|
||||
"index": "registry.example/govoplan/web@sha256:" + "b" * 64,
|
||||
"platforms": {
|
||||
"linux/amd64": "registry.example/govoplan/web@sha256:" + "3" * 64,
|
||||
"linux/arm64": "registry.example/govoplan/web@sha256:" + "4" * 64,
|
||||
},
|
||||
}
|
||||
(root / "web.json").write_text(json.dumps(web_metadata))
|
||||
deployer = root / "govoplan-deploy.pyz"
|
||||
deployer.write_bytes(b"zipapp")
|
||||
args = argparse.Namespace(
|
||||
composition=root / "composition.json",
|
||||
api_metadata=root / "api.json",
|
||||
web_metadata=root / "web.json",
|
||||
deployer=deployer,
|
||||
deployer_url="https://downloads.example/govoplan-deploy.pyz",
|
||||
artifact_base_url="https://downloads.example/runtime/v1.2.3",
|
||||
source_commit="f" * 40,
|
||||
version="1.2.3",
|
||||
channel="stable",
|
||||
sequence=1,
|
||||
expires_days=30,
|
||||
dependency=[
|
||||
"postgres=docker.io/library/postgres@sha256:" + "5" * 64,
|
||||
"redis=docker.io/library/redis@sha256:" + "6" * 64,
|
||||
],
|
||||
output_directory=root / "evidence",
|
||||
descriptor=root / "descriptor.json",
|
||||
)
|
||||
|
||||
descriptor = FINALIZE.finalize(args)
|
||||
|
||||
self.assertEqual(["access"], descriptor["composition"]["module_ids"])
|
||||
self.assertEqual(
|
||||
"registry.example/govoplan/api@sha256:" + "a" * 64,
|
||||
descriptor["images"]["api"]["index"],
|
||||
)
|
||||
self.assertTrue((root / "evidence/api-sbom.cdx.json").is_file())
|
||||
self.assertTrue((root / "evidence/web-provenance.json").is_file())
|
||||
|
||||
def test_rejects_incomplete_oci_index(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "linux/amd64 and linux/arm64"):
|
||||
OCI.resolve_platforms(
|
||||
{
|
||||
"manifests": [
|
||||
{
|
||||
"digest": "sha256:" + "1" * 64,
|
||||
"platform": {"os": "linux", "architecture": "amd64"},
|
||||
}
|
||||
]
|
||||
},
|
||||
repository="registry.example/govoplan/api",
|
||||
index_digest="sha256:" + "a" * 64,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "tools"
|
||||
/ "release"
|
||||
/ "prepare-runtime-context.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location("prepare_runtime_context", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class RuntimeImageContextTests(unittest.TestCase):
|
||||
def test_builds_deterministic_network_free_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
|
||||
root = Path(value)
|
||||
wheelhouse = root / "input-wheels"
|
||||
web = root / "web"
|
||||
wheelhouse.mkdir()
|
||||
web.mkdir()
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_core-1.2.3-py3-none-any.whl",
|
||||
package="govoplan-core",
|
||||
version="1.2.3",
|
||||
module_ids=(),
|
||||
)
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_files-1.2.3-py3-none-any.whl",
|
||||
package="govoplan-files",
|
||||
version="1.2.3",
|
||||
module_ids=("files",),
|
||||
)
|
||||
self._wheel(
|
||||
wheelhouse / "sqlalchemy-2.0.0-py3-none-any.whl",
|
||||
package="SQLAlchemy",
|
||||
version="2.0.0",
|
||||
module_ids=(),
|
||||
)
|
||||
(web / "index.html").write_text("<main>GovOPlaN</main>\n", encoding="utf-8")
|
||||
|
||||
composition = MODULE.prepare_context(
|
||||
wheelhouse=wheelhouse,
|
||||
web_dist=web,
|
||||
output=root / "context",
|
||||
required_modules=("files",),
|
||||
source_date_epoch=1_700_000_000,
|
||||
)
|
||||
|
||||
self.assertEqual(["files"], composition["python"]["module_ids"])
|
||||
self.assertEqual(2, composition["python"]["wheel_count"])
|
||||
requirements = (root / "context" / "requirements-runtime.txt").read_text()
|
||||
self.assertEqual(
|
||||
"govoplan-core[server]==1.2.3\ngovoplan-files==1.2.3\n",
|
||||
requirements,
|
||||
)
|
||||
published = json.loads(
|
||||
(
|
||||
root
|
||||
/ "context"
|
||||
/ "web-dist"
|
||||
/ ".well-known"
|
||||
/ "govoplan-composition.json"
|
||||
).read_text()
|
||||
)
|
||||
self.assertEqual(composition, published)
|
||||
|
||||
def test_rejects_missing_required_module(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
|
||||
root = Path(value)
|
||||
wheelhouse = root / "wheels"
|
||||
web = root / "web"
|
||||
wheelhouse.mkdir()
|
||||
web.mkdir()
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
|
||||
package="govoplan-core",
|
||||
version="1.0.0",
|
||||
module_ids=(),
|
||||
)
|
||||
(web / "index.html").write_text("ok", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ContextError, "missing required"):
|
||||
MODULE.prepare_context(
|
||||
wheelhouse=wheelhouse,
|
||||
web_dist=web,
|
||||
output=root / "context",
|
||||
required_modules=("mail",),
|
||||
)
|
||||
|
||||
def test_rejects_symlinked_web_payload(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-runtime-context-") as value:
|
||||
root = Path(value)
|
||||
wheelhouse = root / "wheels"
|
||||
web = root / "web"
|
||||
wheelhouse.mkdir()
|
||||
web.mkdir()
|
||||
self._wheel(
|
||||
wheelhouse / "govoplan_core-1.0.0-py3-none-any.whl",
|
||||
package="govoplan-core",
|
||||
version="1.0.0",
|
||||
module_ids=(),
|
||||
)
|
||||
outside = root / "outside"
|
||||
outside.write_text("not part of dist", encoding="utf-8")
|
||||
(web / "index.html").symlink_to(outside)
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ContextError, "symlink"):
|
||||
MODULE.prepare_context(
|
||||
wheelhouse=wheelhouse,
|
||||
web_dist=web,
|
||||
output=root / "context",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wheel(
|
||||
path: Path,
|
||||
*,
|
||||
package: str,
|
||||
version: str,
|
||||
module_ids: tuple[str, ...],
|
||||
) -> None:
|
||||
dist_info = package.replace("-", "_") + f"-{version}.dist-info"
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr(
|
||||
f"{dist_info}/METADATA",
|
||||
f"Metadata-Version: 2.1\nName: {package}\nVersion: {version}\n",
|
||||
)
|
||||
if module_ids:
|
||||
rows = "\n".join(
|
||||
f"{module_id} = example.module:manifest"
|
||||
for module_id in module_ids
|
||||
)
|
||||
archive.writestr(
|
||||
f"{dist_info}/entry_points.txt",
|
||||
f"[govoplan.modules]\n{rows}\n",
|
||||
)
|
||||
archive.writestr(f"{package.replace('-', '_')}/__init__.py", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -24,6 +24,8 @@ GARAGE_CONFIG_FILENAME = "garage.toml"
|
||||
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
|
||||
PLAN_FILENAME = "plan.json"
|
||||
RECEIPT_FILENAME = "receipt.json"
|
||||
MANIFEST_FILENAME = "distribution-manifest.json"
|
||||
KEYRING_FILENAME = "distribution-keyring.json"
|
||||
LOCK_FILENAME = ".deployment.lock"
|
||||
RUNTIME_ENV_KEYS = (
|
||||
"APP_ENV",
|
||||
@@ -87,6 +89,8 @@ class BundlePaths:
|
||||
load_balancer_config: Path
|
||||
plan: Path
|
||||
receipt: Path
|
||||
manifest: Path
|
||||
keyring: Path
|
||||
lock: Path
|
||||
|
||||
|
||||
@@ -104,6 +108,8 @@ def bundle_paths(root: Path) -> BundlePaths:
|
||||
load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME,
|
||||
plan=resolved / PLAN_FILENAME,
|
||||
receipt=resolved / RECEIPT_FILENAME,
|
||||
manifest=resolved / MANIFEST_FILENAME,
|
||||
keyring=resolved / KEYRING_FILENAME,
|
||||
lock=resolved / LOCK_FILENAME,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
import fcntl
|
||||
import getpass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,20 @@ from .bundle import (
|
||||
write_env,
|
||||
)
|
||||
from .cluster_evidence import collect_kubernetes_evidence
|
||||
from .distribution import (
|
||||
MAX_KEYRING_BYTES,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MAX_OFFLINE_INDEX_BYTES,
|
||||
DistributionError,
|
||||
canonical_json as canonical_distribution_json,
|
||||
decode_json_bytes,
|
||||
fetch_bounded_https,
|
||||
load_bounded_json,
|
||||
read_bounded_bytes,
|
||||
verify_offline_image_index,
|
||||
verify_manifest,
|
||||
verify_manifest_binding,
|
||||
)
|
||||
from .model import (
|
||||
ComponentConfig,
|
||||
DEFAULT_GARAGE_IMAGE,
|
||||
@@ -127,6 +142,43 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
_directory_argument(status)
|
||||
status.add_argument("--json", action="store_true", help="Print JSON.")
|
||||
|
||||
verify_release = subparsers.add_parser(
|
||||
"verify-release",
|
||||
help="Verify and optionally adopt a signed runtime distribution.",
|
||||
)
|
||||
_directory_argument(verify_release)
|
||||
manifest_source = verify_release.add_mutually_exclusive_group(required=True)
|
||||
manifest_source.add_argument("--manifest", type=Path)
|
||||
manifest_source.add_argument("--manifest-url")
|
||||
verify_release.add_argument(
|
||||
"--manifest-sha256",
|
||||
required=True,
|
||||
help="Independently obtained SHA-256 digest of the signed manifest.",
|
||||
)
|
||||
verify_release.add_argument("--trusted-keyring", type=Path, required=True)
|
||||
verify_release.add_argument(
|
||||
"--allow-private-release-host",
|
||||
action="store_true",
|
||||
help="Allow an explicitly selected private HTTPS release mirror.",
|
||||
)
|
||||
verify_release.add_argument(
|
||||
"--adopt",
|
||||
action="store_true",
|
||||
help="Store the verified trust material and select its pinned images.",
|
||||
)
|
||||
|
||||
offline_images = subparsers.add_parser(
|
||||
"verify-offline-images",
|
||||
help="Verify prefetched OCI archives against the adopted distribution.",
|
||||
)
|
||||
_directory_argument(offline_images)
|
||||
offline_images.add_argument("--index", type=Path, required=True)
|
||||
offline_images.add_argument(
|
||||
"--load",
|
||||
action="store_true",
|
||||
help="Load verified archives into Docker using fixed image-load commands.",
|
||||
)
|
||||
|
||||
kubernetes = subparsers.add_parser(
|
||||
"render-kubernetes",
|
||||
help="Export the stateless multi-host runtime for Kubernetes.",
|
||||
@@ -312,6 +364,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return _apply(args)
|
||||
if args.command == "status":
|
||||
return _status(args)
|
||||
if args.command == "verify-release":
|
||||
return _verify_release(args)
|
||||
if args.command == "verify-offline-images":
|
||||
return _verify_offline_images(args)
|
||||
if args.command == "render-kubernetes":
|
||||
return _render_kubernetes(args)
|
||||
if args.command == "verify-kubernetes":
|
||||
@@ -320,7 +376,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return _operations(args)
|
||||
if args.command == "recover":
|
||||
return _recover(args)
|
||||
except (SpecError, ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
except (
|
||||
DistributionError,
|
||||
SpecError,
|
||||
ValueError,
|
||||
OSError,
|
||||
subprocess.SubprocessError,
|
||||
) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
raise RuntimeError(f"unsupported command: {args.command}")
|
||||
@@ -617,6 +679,198 @@ def _status(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _verify_release(args: argparse.Namespace) -> int:
|
||||
paths = bundle_paths(args.directory)
|
||||
spec = load_spec(paths.spec)
|
||||
expected_digest = str(args.manifest_sha256 or "").strip().lower()
|
||||
if len(expected_digest) != 64 or any(
|
||||
character not in "0123456789abcdef" for character in expected_digest
|
||||
):
|
||||
raise ValueError("--manifest-sha256 must be a lowercase SHA-256 digest")
|
||||
manifest_url = str(args.manifest_url or "").strip()
|
||||
if manifest_url:
|
||||
encoded_manifest = fetch_bounded_https(
|
||||
manifest_url,
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
allow_private_host=args.allow_private_release_host,
|
||||
)
|
||||
manifest = decode_json_bytes(
|
||||
encoded_manifest,
|
||||
label="distribution manifest",
|
||||
)
|
||||
actual_digest = hashlib.sha256(encoded_manifest).hexdigest()
|
||||
else:
|
||||
manifest_path = args.manifest.expanduser().resolve()
|
||||
encoded_manifest = read_bounded_bytes(
|
||||
manifest_path,
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
)
|
||||
manifest = load_bounded_json(
|
||||
manifest_path,
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
)
|
||||
actual_digest = hashlib.sha256(encoded_manifest).hexdigest()
|
||||
if encoded_manifest != canonical_distribution_json(manifest):
|
||||
raise DistributionError("distribution manifest is not canonical JSON")
|
||||
if actual_digest != expected_digest:
|
||||
raise DistributionError("distribution manifest SHA-256 does not match")
|
||||
keyring_path = args.trusted_keyring.expanduser().resolve()
|
||||
keyring = load_bounded_json(keyring_path, maximum_bytes=MAX_KEYRING_BYTES)
|
||||
encoded_keyring = canonical_distribution_json(keyring)
|
||||
keyring_digest = hashlib.sha256(encoded_keyring).hexdigest()
|
||||
key_id = verify_manifest(
|
||||
manifest,
|
||||
keyring,
|
||||
expected_channel=spec.release.channel,
|
||||
)
|
||||
dependencies = _selected_dependency_images(spec, manifest=manifest)
|
||||
verify_manifest_binding(
|
||||
manifest,
|
||||
channel=str(manifest["channel"]),
|
||||
version=str(manifest["version"]),
|
||||
api_image=str(manifest["images"]["api"]["index"]),
|
||||
web_image=str(manifest["images"]["web"]["index"]),
|
||||
enabled_modules=spec.enabled_modules,
|
||||
composition_sha256=str(manifest["composition"]["sha256"]),
|
||||
dependencies=dependencies,
|
||||
)
|
||||
print(
|
||||
f"Verified GovOPlaN {manifest['version']} ({manifest['channel']}) "
|
||||
f"with trusted key {key_id}."
|
||||
)
|
||||
if not args.adopt:
|
||||
return 0
|
||||
|
||||
images = manifest["images"]
|
||||
dependency_images = manifest["dependencies"]
|
||||
release = replace(
|
||||
spec.release,
|
||||
channel=str(manifest["channel"]),
|
||||
version=str(manifest["version"]),
|
||||
manifest_url=manifest_url,
|
||||
manifest_sha256=expected_digest,
|
||||
manifest_keyring_sha256=keyring_digest,
|
||||
manifest_signature_key_id=key_id,
|
||||
composition_sha256=str(manifest["composition"]["sha256"]),
|
||||
api_image=str(images["api"]["index"]),
|
||||
web_image=str(images["web"]["index"]),
|
||||
)
|
||||
components = replace(
|
||||
spec.components,
|
||||
postgres=replace(
|
||||
spec.components.postgres,
|
||||
image=(
|
||||
str(dependency_images["postgres"])
|
||||
if spec.components.postgres.mode == "managed"
|
||||
else spec.components.postgres.image
|
||||
),
|
||||
),
|
||||
redis=replace(
|
||||
spec.components.redis,
|
||||
image=(
|
||||
str(dependency_images["redis"])
|
||||
if spec.components.redis.mode == "managed"
|
||||
else spec.components.redis.image
|
||||
),
|
||||
),
|
||||
mail=replace(
|
||||
spec.components.mail,
|
||||
image=(
|
||||
str(dependency_images["test_mail"])
|
||||
if spec.components.mail.mode == "test-mail"
|
||||
else spec.components.mail.image
|
||||
),
|
||||
),
|
||||
storage=replace(
|
||||
spec.components.storage,
|
||||
image=(
|
||||
str(dependency_images["garage"])
|
||||
if spec.components.storage.mode == "garage"
|
||||
else spec.components.storage.image
|
||||
),
|
||||
),
|
||||
load_balancer=replace(
|
||||
spec.components.load_balancer,
|
||||
image=str(dependency_images["load_balancer"]),
|
||||
),
|
||||
)
|
||||
adopted = parse_spec(replace(spec, release=release, components=components).to_dict())
|
||||
ensure_private_directory(paths.root)
|
||||
atomic_write(paths.manifest, encoded_manifest, mode=0o644)
|
||||
atomic_write(
|
||||
paths.keyring,
|
||||
encoded_keyring,
|
||||
mode=0o644,
|
||||
)
|
||||
secrets = reconcile_runtime_environment(adopted, read_env(paths.env))
|
||||
_write_bundle(adopted, paths, secrets)
|
||||
print(f"Adopted immutable runtime distribution in {paths.root}.")
|
||||
return 0
|
||||
|
||||
|
||||
def _verify_offline_images(args: argparse.Namespace) -> int:
|
||||
paths = bundle_paths(args.directory)
|
||||
spec = load_spec(paths.spec)
|
||||
manifest = load_bounded_json(
|
||||
paths.manifest,
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
)
|
||||
index_path = args.index.expanduser().resolve()
|
||||
index = load_bounded_json(
|
||||
index_path,
|
||||
maximum_bytes=MAX_OFFLINE_INDEX_BYTES,
|
||||
)
|
||||
selected_dependencies = _selected_dependency_images(spec, manifest=manifest)
|
||||
expected = (
|
||||
str(manifest["images"]["api"]["index"]),
|
||||
str(manifest["images"]["web"]["index"]),
|
||||
*tuple(selected_dependencies.values()),
|
||||
)
|
||||
archives = verify_offline_image_index(
|
||||
index,
|
||||
root=index_path.parent,
|
||||
expected_references=expected,
|
||||
)
|
||||
print(f"Verified {len(archives)} prefetched OCI image archive(s).")
|
||||
if not args.load:
|
||||
return 0
|
||||
docker = shutil.which("docker")
|
||||
if docker is None:
|
||||
raise ValueError("Docker CLI is required to load offline images")
|
||||
for archive in archives:
|
||||
_run([docker, "image", "load", "--input", str(archive)], cwd=paths.root)
|
||||
for reference in expected:
|
||||
_run([docker, "image", "inspect", reference], cwd=paths.root)
|
||||
print("Loaded and inspected every adopted offline image identity.")
|
||||
return 0
|
||||
|
||||
|
||||
def _selected_dependency_images(
|
||||
spec: InstallationSpec,
|
||||
*,
|
||||
manifest: Mapping[str, object],
|
||||
) -> dict[str, str]:
|
||||
available = manifest.get("dependencies")
|
||||
if not isinstance(available, dict):
|
||||
raise DistributionError("distribution dependencies are invalid")
|
||||
names = ["load_balancer"]
|
||||
if spec.components.postgres.mode == "managed":
|
||||
names.append("postgres")
|
||||
if spec.components.redis.mode == "managed":
|
||||
names.append("redis")
|
||||
if spec.components.mail.mode == "test-mail":
|
||||
names.append("test_mail")
|
||||
if spec.components.storage.mode == "garage":
|
||||
names.append("garage")
|
||||
missing = [name for name in names if not isinstance(available.get(name), str)]
|
||||
if missing:
|
||||
raise DistributionError(
|
||||
"distribution is missing selected dependency images: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return {name: str(available[name]) for name in names}
|
||||
|
||||
|
||||
def _render_kubernetes(args: argparse.Namespace) -> int:
|
||||
paths = bundle_paths(args.directory)
|
||||
spec = load_spec(paths.spec)
|
||||
@@ -727,6 +981,9 @@ def _deployment_receipt(
|
||||
"channel": spec.release.channel,
|
||||
"version": spec.release.version,
|
||||
"manifest_sha256": spec.release.manifest_sha256,
|
||||
"manifest_keyring_sha256": spec.release.manifest_keyring_sha256,
|
||||
"manifest_signature_key_id": spec.release.manifest_signature_key_id,
|
||||
"composition_sha256": spec.release.composition_sha256,
|
||||
"api_image": spec.release.api_image,
|
||||
"web_image": spec.release.web_image,
|
||||
},
|
||||
@@ -770,6 +1027,9 @@ def _updated_spec(
|
||||
if args.manifest_sha256 is not None
|
||||
else current.release.manifest_sha256
|
||||
),
|
||||
manifest_keyring_sha256=current.release.manifest_keyring_sha256,
|
||||
manifest_signature_key_id=current.release.manifest_signature_key_id,
|
||||
composition_sha256=current.release.composition_sha256,
|
||||
api_image=args.api_image or current.release.api_image,
|
||||
web_image=args.web_image or current.release.web_image,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -77,6 +77,9 @@ class ReleaseConfig:
|
||||
version: str
|
||||
manifest_url: str
|
||||
manifest_sha256: str
|
||||
manifest_keyring_sha256: str
|
||||
manifest_signature_key_id: str
|
||||
composition_sha256: str
|
||||
api_image: str
|
||||
web_image: str
|
||||
|
||||
@@ -178,6 +181,9 @@ def default_spec(
|
||||
"version": version,
|
||||
"manifest_url": manifest_url,
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"manifest_keyring_sha256": "",
|
||||
"manifest_signature_key_id": "",
|
||||
"composition_sha256": "",
|
||||
"api_image": api_image,
|
||||
"web_image": web_image,
|
||||
},
|
||||
@@ -314,6 +320,9 @@ def _release(raw: object) -> ReleaseConfig:
|
||||
"version",
|
||||
"manifest_url",
|
||||
"manifest_sha256",
|
||||
"manifest_keyring_sha256",
|
||||
"manifest_signature_key_id",
|
||||
"composition_sha256",
|
||||
"api_image",
|
||||
"web_image",
|
||||
},
|
||||
@@ -335,6 +344,27 @@ def _release(raw: object) -> ReleaseConfig:
|
||||
raise SpecError(
|
||||
"release.manifest_sha256 must be a lowercase SHA-256 hex digest"
|
||||
)
|
||||
manifest_keyring_sha256 = _optional_string(
|
||||
value, "manifest_keyring_sha256"
|
||||
).lower()
|
||||
if manifest_keyring_sha256 and not SHA256_PATTERN.fullmatch(
|
||||
manifest_keyring_sha256
|
||||
):
|
||||
raise SpecError(
|
||||
"release.manifest_keyring_sha256 must be a lowercase SHA-256 hex digest"
|
||||
)
|
||||
manifest_signature_key_id = _optional_string(
|
||||
value, "manifest_signature_key_id"
|
||||
)
|
||||
if manifest_signature_key_id and not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", manifest_signature_key_id
|
||||
):
|
||||
raise SpecError("release.manifest_signature_key_id is invalid")
|
||||
composition_sha256 = _optional_string(value, "composition_sha256").lower()
|
||||
if composition_sha256 and not SHA256_PATTERN.fullmatch(composition_sha256):
|
||||
raise SpecError(
|
||||
"release.composition_sha256 must be a lowercase SHA-256 hex digest"
|
||||
)
|
||||
api_image = _image(_string(value, "api_image"), "release.api_image")
|
||||
web_image = _image(_string(value, "web_image"), "release.web_image")
|
||||
return ReleaseConfig(
|
||||
@@ -342,6 +372,9 @@ def _release(raw: object) -> ReleaseConfig:
|
||||
version=version,
|
||||
manifest_url=manifest_url,
|
||||
manifest_sha256=manifest_sha256,
|
||||
manifest_keyring_sha256=manifest_keyring_sha256,
|
||||
manifest_signature_key_id=manifest_signature_key_id,
|
||||
composition_sha256=composition_sha256,
|
||||
api_image=api_image,
|
||||
web_image=web_image,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,15 @@ from .bundle import (
|
||||
render_compose,
|
||||
service_names,
|
||||
)
|
||||
from .distribution import (
|
||||
MAX_KEYRING_BYTES,
|
||||
MAX_MANIFEST_BYTES,
|
||||
DistributionError,
|
||||
file_sha256,
|
||||
load_bounded_json,
|
||||
verify_manifest,
|
||||
verify_manifest_binding,
|
||||
)
|
||||
from .model import (
|
||||
InstallationSpec,
|
||||
image_is_digest_pinned,
|
||||
@@ -212,40 +221,7 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
|
||||
)
|
||||
)
|
||||
|
||||
if spec.release.manifest_url and spec.release.manifest_sha256:
|
||||
checks.append(
|
||||
Check(
|
||||
"release.manifest",
|
||||
"ok",
|
||||
"A distribution manifest URL and expected digest are recorded.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
Check(
|
||||
"release.manifest",
|
||||
"error" if spec.profile == "self-hosted" else "warning",
|
||||
"No verified distribution manifest is recorded.",
|
||||
"Use a published signed distribution manifest for self-hosted apply.",
|
||||
)
|
||||
)
|
||||
if spec.profile == "self-hosted":
|
||||
checks.append(
|
||||
Check(
|
||||
"release.signature_verification",
|
||||
"error",
|
||||
"Signed distribution-manifest verification is not implemented in the deployer yet.",
|
||||
"Use the published verifier/bootstrap slice before a production apply.",
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
Check(
|
||||
"modules.image_composition",
|
||||
"error" if spec.enabled_modules else "warning",
|
||||
"The selected module set is not yet verified against image package contents.",
|
||||
"Use the signed distribution composition evidence before production apply.",
|
||||
)
|
||||
)
|
||||
checks.extend(_distribution_checks(spec, paths))
|
||||
|
||||
values = read_env(paths.env)
|
||||
required = {"MASTER_KEY_B64", "DATABASE_URL"}
|
||||
@@ -364,6 +340,130 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
|
||||
return tuple(checks)
|
||||
|
||||
|
||||
def _distribution_checks(
|
||||
spec: InstallationSpec,
|
||||
paths: BundlePaths,
|
||||
) -> tuple[Check, ...]:
|
||||
blocking_level = "error" if spec.profile == "self-hosted" else "warning"
|
||||
if not (
|
||||
spec.release.manifest_sha256
|
||||
and spec.release.manifest_keyring_sha256
|
||||
and spec.release.manifest_signature_key_id
|
||||
and spec.release.composition_sha256
|
||||
and paths.manifest.exists()
|
||||
and paths.keyring.exists()
|
||||
):
|
||||
return (
|
||||
Check(
|
||||
"release.manifest",
|
||||
blocking_level,
|
||||
"No locally verified runtime distribution is recorded.",
|
||||
"Run govoplan-deploy verify-release --adopt with an independently trusted keyring.",
|
||||
),
|
||||
Check(
|
||||
"release.signature_verification",
|
||||
blocking_level,
|
||||
"Runtime distribution signature evidence is unavailable.",
|
||||
"Install and verify the signed distribution before apply.",
|
||||
),
|
||||
Check(
|
||||
"modules.image_composition",
|
||||
blocking_level,
|
||||
"Enabled modules are not bound to image composition evidence.",
|
||||
"Adopt a distribution whose composition contains every enabled module.",
|
||||
),
|
||||
)
|
||||
try:
|
||||
manifest_digest = file_sha256(
|
||||
paths.manifest,
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
)
|
||||
if manifest_digest != spec.release.manifest_sha256:
|
||||
raise DistributionError("stored manifest digest does not match installation")
|
||||
keyring_digest = file_sha256(
|
||||
paths.keyring,
|
||||
maximum_bytes=MAX_KEYRING_BYTES,
|
||||
)
|
||||
if keyring_digest != spec.release.manifest_keyring_sha256:
|
||||
raise DistributionError("stored keyring digest does not match installation")
|
||||
manifest = load_bounded_json(
|
||||
paths.manifest,
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
)
|
||||
keyring = load_bounded_json(
|
||||
paths.keyring,
|
||||
maximum_bytes=MAX_KEYRING_BYTES,
|
||||
)
|
||||
key_id = verify_manifest(
|
||||
manifest,
|
||||
keyring,
|
||||
expected_channel=spec.release.channel,
|
||||
)
|
||||
if key_id != spec.release.manifest_signature_key_id:
|
||||
raise DistributionError("verified signature key does not match installation")
|
||||
verify_manifest_binding(
|
||||
manifest,
|
||||
channel=spec.release.channel,
|
||||
version=spec.release.version,
|
||||
api_image=spec.release.api_image,
|
||||
web_image=spec.release.web_image,
|
||||
enabled_modules=spec.enabled_modules,
|
||||
composition_sha256=spec.release.composition_sha256,
|
||||
dependencies=_selected_dependency_images(spec),
|
||||
)
|
||||
except (DistributionError, OSError) as exc:
|
||||
return (
|
||||
Check(
|
||||
"release.manifest",
|
||||
blocking_level,
|
||||
f"Runtime distribution verification failed: {exc}",
|
||||
"Re-adopt an unexpired, non-revoked manifest from a trusted release key.",
|
||||
),
|
||||
Check(
|
||||
"release.signature_verification",
|
||||
blocking_level,
|
||||
"Runtime distribution signature is not trusted.",
|
||||
"Correct the manifest/keyring binding before apply.",
|
||||
),
|
||||
Check(
|
||||
"modules.image_composition",
|
||||
blocking_level,
|
||||
"Runtime image composition is not trusted.",
|
||||
"Correct the signed composition binding before apply.",
|
||||
),
|
||||
)
|
||||
return (
|
||||
Check(
|
||||
"release.manifest",
|
||||
"ok",
|
||||
"Stored runtime distribution matches its independently pinned digest.",
|
||||
),
|
||||
Check(
|
||||
"release.signature_verification",
|
||||
"ok",
|
||||
f"Runtime distribution is signed by trusted key {key_id}.",
|
||||
),
|
||||
Check(
|
||||
"modules.image_composition",
|
||||
"ok",
|
||||
"Every enabled module is present in signed image composition evidence.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _selected_dependency_images(spec: InstallationSpec) -> dict[str, str]:
|
||||
values = {"load_balancer": spec.components.load_balancer.image}
|
||||
if spec.components.postgres.mode == "managed":
|
||||
values["postgres"] = spec.components.postgres.image
|
||||
if spec.components.redis.mode == "managed":
|
||||
values["redis"] = spec.components.redis.image
|
||||
if spec.components.mail.mode == "test-mail":
|
||||
values["test_mail"] = spec.components.mail.image
|
||||
if spec.components.storage.mode == "garage":
|
||||
values["garage"] = spec.components.storage.image
|
||||
return values
|
||||
|
||||
|
||||
def host_checks(
|
||||
spec: InstallationSpec,
|
||||
paths: BundlePaths,
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user