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