Add signed runtime distribution pipeline
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user