From 37050b1458f4f722344d43dfe72d36c35a6b72c2 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 18 Oct 2026 22:02:09 +0300 Subject: [PATCH 1/2] Add validated release manifest contract --- .gitea/workflows/ci.yaml | 3 +- docs/operations/CURRENT_STATE.md | 4 + docs/operations/DEPLOYMENT.md | 39 ++++++++ infra/deploy/test_release_manifest.py | 131 ++++++++++++++++++++++++++ infra/deploy/validate_release.py | 106 +++++++++++++++++++++ 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 infra/deploy/test_release_manifest.py create mode 100644 infra/deploy/validate_release.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 4382f2f..2bd50f4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -51,7 +51,8 @@ jobs: infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh \ infra/runner/test-ci-container-builds.sh python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())" - - name: Run rollback helper tests + python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/validate_release.py').read_text())" + - name: Run deployment contract and rollback tests run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v - name: Validate production Compose and Caddyfile run: | diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index a15d875..66b05ce 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -84,6 +84,10 @@ UFW rule details, and protected file metadata remain unverified. `status` helper, guarded rollback helper, and installer with exact sudoers command rules. None is installed on the VPS. CI contains no production deployment job; starting/stopping services remains owner-run. +- This branch adds an offline release-manifest validator and tests for the + repository allowlist intended for a future root-owned host integration. The + manifest format does not prove source provenance. No registry, host policy, + publish credential, or deployment integration is configured or installed. - A repository-scoped official `act_runner` `0.2.13` installation is configured in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB ext4 loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU, diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index 167947c..a96e1eb 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -73,6 +73,45 @@ no image publishing or production deployment step. The installed sudo helpers support fixed verification, read-only Compose `config`/`status`, and guarded rollback. They do not start or stop services. +## Release image contract (preparation only) + +`infra/deploy/validate_release.py` checks an offline JSON release manifest +against an explicitly supplied repository allowlist. Schema version 1 requires +exactly `schema_version`, `source_ref`, `commit`, and `images`; the source ref +must be `refs/heads/main`, the commit must be a 40-character lowercase Git +SHA-1, and the images must contain exactly `backend` and `opensearch` as +`repository@sha256:<64 lowercase hex>` references. The policy has schema +version 1 and one non-empty repository allowlist for each service. Unknown or +duplicate JSON keys are rejected. + +The validator takes the manifest path and an explicit policy path: + +```bash +python3 infra/deploy/validate_release.py release.json --policy /etc/akyldash/release-policy.json +``` + +The policy shape is `{"schema_version":1,"repositories":{"backend":["registry.invalid/akyldash/backend"],"opensearch":["registry.invalid/akyldash/opensearch"]}}`. +These `registry.invalid` names are format examples only; this change does not +create a policy file or approve a registry. + +The repository name in the policy must be approved for the registry. No real +registry namespace or root-owned host policy is configured by this change; do +not use the synthetic `registry.invalid` names in tests as production values. +The host integration must load policy only from its fixed root-owned path. +This validator checks format and allowlist only: a manifest can claim a main +ref and commit, but is not proof of source, build provenance, signature, or +trusted publication. CI still has no publish credentials, image publication, +host helper, or deploy trigger. + +Compose merges an image override with the existing service `build` entries. +The regression test renders fake digest references and confirms both entries +remain; any future deploy command must use `docker compose up --no-build` so +the host cannot silently build a different image. This does not make digest +rollback available: the current rollback helper expects local tags. A separate +host-side deploy/rollback design, owner-approved registry permissions and +retention, and manual backup/health/rollback end-to-end validation are still +required before publishing or enabling CD. + ## CI `.gitea/workflows/ci.yaml` runs the Python unit suites and builds both diff --git a/infra/deploy/test_release_manifest.py b/infra/deploy/test_release_manifest.py new file mode 100644 index 0000000..d924a88 --- /dev/null +++ b/infra/deploy/test_release_manifest.py @@ -0,0 +1,131 @@ +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from validate_release import validate_manifest + +ROOT = Path(__file__).resolve().parents[2] +REPOSITORIES = { + "backend": ["registry.invalid/akyldash/backend"], + "opensearch": ["registry.invalid/akyldash/opensearch"], +} + + +class ReleaseManifestTests(unittest.TestCase): + def setUp(self): + self.policy = {"schema_version": 1, "repositories": REPOSITORIES} + self.manifest = { + "schema_version": 1, + "source_ref": "refs/heads/main", + "commit": "a" * 40, + "images": { + "backend": "registry.invalid/akyldash/backend@sha256:" + "b" * 64, + "opensearch": "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64, + }, + } + + def test_accepts_both_policy_pinned_images(self): + self.assertEqual(validate_manifest(self.manifest, self.policy), self.manifest) + + def test_rejects_untrusted_ref(self): + self.manifest["source_ref"] = "refs/pull/12/head" + with self.assertRaisesRegex(ValueError, "source_ref"): + validate_manifest(self.manifest, self.policy) + + def test_rejects_tag_instead_of_digest(self): + self.manifest["images"]["backend"] = "registry.invalid/akyldash/backend:latest" + with self.assertRaisesRegex(ValueError, "sha256"): + validate_manifest(self.manifest, self.policy) + + def test_rejects_empty_image_reference(self): + self.manifest["images"]["backend"] = "" + with self.assertRaisesRegex(ValueError, "sha256"): + validate_manifest(self.manifest, self.policy) + + def test_rejects_invalid_commit(self): + self.manifest["commit"] = "not-a-commit" + with self.assertRaisesRegex(ValueError, "commit"): + validate_manifest(self.manifest, self.policy) + + def test_rejects_repository_outside_policy(self): + self.manifest["images"]["backend"] = "registry.invalid/other/backend@sha256:" + "b" * 64 + with self.assertRaisesRegex(ValueError, "not allowed"): + validate_manifest(self.manifest, self.policy) + + def test_rejects_missing_or_extra_images(self): + self.manifest["images"].pop("opensearch") + with self.assertRaisesRegex(ValueError, "exactly backend and opensearch"): + validate_manifest(self.manifest, self.policy) + self.manifest["images"]["opensearch"] = "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64 + self.manifest["images"]["extra"] = "registry.invalid/akyldash/extra@sha256:" + "d" * 64 + with self.assertRaisesRegex(ValueError, "exactly backend and opensearch"): + validate_manifest(self.manifest, self.policy) + + def test_rejects_duplicate_json_keys(self): + with tempfile.TemporaryDirectory() as directory: + manifest_path = Path(directory) / "manifest.json" + policy_path = Path(directory) / "policy.json" + manifest_path.write_text( + '{"schema_version":1,"source_ref":"refs/heads/main","commit":"' + "a" * 40 + + '","images":{"backend":"first","backend":"second","opensearch":"third"}}', + encoding="utf-8", + ) + policy_path.write_text(json.dumps(self.policy), encoding="utf-8") + command = [ + "python3", str(ROOT / "infra/deploy/validate_release.py"), str(manifest_path), + "--policy", str(policy_path), + ] + result = subprocess.run(command, text=True, capture_output=True) + self.assertEqual(result.returncode, 1) + self.assertIn("duplicate JSON key: backend", result.stderr) + + def test_rejects_malformed_json(self): + with tempfile.TemporaryDirectory() as directory: + manifest_path = Path(directory) / "manifest.json" + policy_path = Path(directory) / "policy.json" + manifest_path.write_text('{"schema_version":', encoding="utf-8") + policy_path.write_text(json.dumps(self.policy), encoding="utf-8") + result = subprocess.run( + ["python3", str(ROOT / "infra/deploy/validate_release.py"), str(manifest_path), "--policy", str(policy_path)], + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("cannot read valid JSON", result.stderr) + + @unittest.skipUnless(shutil.which("docker"), "Docker CLI is unavailable") + def test_compose_override_renders_digest_images_and_preserves_build(self): + backend = "registry.invalid/akyldash/backend@sha256:" + "b" * 64 + opensearch = "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64 + with tempfile.TemporaryDirectory() as directory: + temp = Path(directory) + secret = temp / "review_secret" + secret.touch() + override = temp / "release-override.json" + override.write_text(json.dumps({"services": { + "backend": {"image": backend}, + "opensearch": {"image": opensearch}, + }}), encoding="utf-8") + command = [ + "docker", "compose", "--env-file", str(ROOT / "deploy/production/.env.example"), + "-f", str(ROOT / "deploy/production/compose.yaml"), "-f", str(override), + "config", "--format", "json", + ] + result = subprocess.run( + command, text=True, capture_output=True, + env={**os.environ, "REVIEW_SECRET_FILE": str(secret)}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + services = json.loads(result.stdout)["services"] + self.assertEqual(services["backend"]["image"], backend) + self.assertEqual(services["opensearch"]["image"], opensearch) + self.assertIn("build", services["backend"]) + self.assertIn("build", services["opensearch"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/infra/deploy/validate_release.py b/infra/deploy/validate_release.py new file mode 100644 index 0000000..b3eccdf --- /dev/null +++ b/infra/deploy/validate_release.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Validate the shape and repository allowlist of a prepared release manifest.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +SERVICES = {"backend", "opensearch"} +COMMIT_RE = re.compile(r"[0-9a-f]{40}\Z") +REPOSITORY_RE = re.compile( + r"[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?" + r"(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*\Z" +) +IMAGE_RE = re.compile(r"(.+)@sha256:([0-9a-f]{64})\Z") + + +def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def read_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError(f"cannot read valid JSON from {path}: {error}") from error + + +def validate_policy(policy: object) -> dict[str, list[str]]: + if not isinstance(policy, dict) or set(policy) != {"schema_version", "repositories"}: + raise ValueError("policy must contain exactly schema_version and repositories") + if type(policy["schema_version"]) is not int or policy["schema_version"] != 1: + raise ValueError("unsupported policy schema_version") + repositories = policy["repositories"] + if not isinstance(repositories, dict) or set(repositories) != SERVICES: + raise ValueError("policy repositories must contain exactly backend and opensearch") + result: dict[str, list[str]] = {} + for service in sorted(SERVICES): + allowed = repositories[service] + if not isinstance(allowed, list) or not allowed: + raise ValueError(f"policy repository allowlist for {service} must be a non-empty list") + if any(not isinstance(repository, str) or not REPOSITORY_RE.fullmatch(repository) for repository in allowed): + raise ValueError(f"policy contains an invalid repository for {service}") + if len(set(allowed)) != len(allowed): + raise ValueError(f"policy repository allowlist for {service} contains duplicates") + result[service] = allowed + return result + + +def validate_manifest(manifest: object, policy: object) -> dict[str, object]: + allowed_repositories = validate_policy(policy) + if not isinstance(manifest, dict) or set(manifest) != { + "schema_version", "source_ref", "commit", "images" + }: + raise ValueError("manifest must contain exactly schema_version, source_ref, commit, and images") + if type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1: + raise ValueError("unsupported manifest schema_version") + if manifest["source_ref"] != "refs/heads/main": + raise ValueError("manifest source_ref must be refs/heads/main") + commit = manifest["commit"] + if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit): + raise ValueError("manifest commit must be a 40-character lowercase Git SHA-1") + images = manifest["images"] + if not isinstance(images, dict) or set(images) != SERVICES: + raise ValueError("manifest images must contain exactly backend and opensearch") + for service in sorted(SERVICES): + image = images[service] + match = IMAGE_RE.fullmatch(image) if isinstance(image, str) else None + if not match: + raise ValueError(f"{service} image must use repository@sha256:<64 lowercase hex>") + repository = match.group(1) + if not REPOSITORY_RE.fullmatch(repository): + raise ValueError(f"{service} image has an invalid OCI repository") + if repository not in allowed_repositories[service]: + raise ValueError(f"{service} image repository is not allowed by policy") + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("--policy", required=True, type=Path) + args = parser.parse_args() + try: + manifest = validate_manifest(read_json(args.manifest), read_json(args.policy)) + except ValueError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + images = manifest["images"] + print( + "PASS release manifest " + f"commit={manifest['commit']} backend={images['backend']} opensearch={images['opensearch']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) -- 2.54.0 From e12a8a919cf122902c7d39edac7fb5b0af85ba26 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 18 Oct 2026 22:07:45 +0300 Subject: [PATCH 2/2] Document release manifest preparation scope --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bac0d9..071f50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Не выпущено +- Infrastructure: добавлен офлайн-контракт release manifest с точной схемой, + digest-ссылками и repository allowlist; проверен Compose override с + `--no-build` как требованием будущего deploy. Публикация, provenance и + production-деплой не включены. - CI actions теперь используют workspace, переданный Gitea, для Caddyfile и Kaniko build context; CI run 69 и smoke run 70 прошли на self-hosted runner. - Runner installer сохраняет `HOME` и XDG-пути в user-unit `docker.service`, перезапускает активный daemon и проверяет окружение его процесса. - Runner installer защищает image и mountpoint root-owned home-каталогом, оставляет регистрацию и workspace в runner-owned каталоге, безопасно форматирует образ по открытому FD и проверяет `nosuid,nodev`. -- 2.54.0