Separate Builder readiness (infrastructure-only) from build (recipe-evidence
gated). Add Evidence Materializer (validator/projector, never invents release
evidence). Unify Source Admission policy digest to exact policy file SHA-256
through one helper/constructor. Add HMAC-audited isolated-peer policy rebind.
Base: 2852c2a403 (exact)
Files: 10 (6 modified + 4 added), +1513/-40
Patch SHA-256: 85be2b4e369a7d39eba0f7b7f1c2641a9eabdf0276d4d48d371cfc348465ed51
319 lines
12 KiB
Python
319 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
|
|
|
from aios_trusted_candidate_builder import ( # noqa: E402
|
|
BuilderError,
|
|
TrustedCandidateBuilderConfig,
|
|
load_builder_policy,
|
|
)
|
|
from release_evidence_materializer import ( # noqa: E402
|
|
EvidenceMaterializationError,
|
|
TrustedBuilderEvidenceMaterializer,
|
|
)
|
|
from silent_release_controller import ReleaseCandidate, ReleaseState # noqa: E402
|
|
from silent_release_hardening import ( # noqa: E402
|
|
DeploymentPlan,
|
|
FinalizationPlan,
|
|
ReleasePlan,
|
|
TagPlan,
|
|
TrustedCandidateBundle,
|
|
)
|
|
|
|
|
|
POLICY_PATH = ROOT / "config" / "gitops" / "trusted-candidate-builder-policy.json"
|
|
|
|
|
|
def canonical(value: dict) -> bytes:
|
|
return json.dumps(
|
|
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
|
).encode()
|
|
|
|
|
|
def digest(label: str) -> str:
|
|
return hashlib.sha256(label.encode()).hexdigest()
|
|
|
|
|
|
def write_bytes(path: Path, value: bytes) -> str:
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
path.write_bytes(value)
|
|
path.chmod(0o600)
|
|
return hashlib.sha256(value).hexdigest()
|
|
|
|
|
|
def write_json(path: Path, value: dict) -> str:
|
|
return write_bytes(path, canonical(value))
|
|
|
|
|
|
def build_valid_source(root: Path) -> tuple[dict, dict, dict]:
|
|
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
|
recipe = config.fixed_recipe("V2R2C2")
|
|
version = recipe["version"]
|
|
version_root = root / version
|
|
version_root.mkdir(parents=True, mode=0o700)
|
|
|
|
tree_oid = "a" * 40
|
|
artifact_sha = write_bytes(version_root / "artifact.tar.gz", b"artifact-bytes")
|
|
manifest_sha = write_bytes(version_root / "manifest.json", b'{"manifest":"v1"}\n')
|
|
closure_sha = write_bytes(version_root / "checksums.sha256", b"checksums-v1\n")
|
|
policy_sha = write_bytes(
|
|
version_root / "trusted-release-policy.json",
|
|
b'{"trusted":"release-policy"}\n',
|
|
)
|
|
|
|
tests = {"passed": True, "receipt": "real-tests"}
|
|
canary = {"passed": True, "receipt": "real-canary"}
|
|
gitea = {
|
|
"consistent": True,
|
|
"remote_ref_oid": recipe["source_commit"],
|
|
"repository": config.repository,
|
|
"source_commit": recipe["source_commit"],
|
|
"tree_oid": tree_oid,
|
|
}
|
|
rollback_contract_sha = digest("rollback-contract")
|
|
rollback = {
|
|
"ready": True,
|
|
"rollback_sha256": rollback_contract_sha,
|
|
"source_commit": recipe["source_commit"],
|
|
}
|
|
secret_scan = {"artifact_sha256": artifact_sha, "passed": True}
|
|
write_json(version_root / "tests.json", tests)
|
|
write_json(version_root / "canary.json", canary)
|
|
write_json(version_root / "gitea.json", gitea)
|
|
write_json(version_root / "rollback.json", rollback)
|
|
write_json(version_root / "secret-scan.json", secret_scan)
|
|
|
|
candidate = ReleaseCandidate(
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
tree_oid=tree_oid,
|
|
artifact_sha256=artifact_sha,
|
|
manifest_sha256=manifest_sha,
|
|
tests=tests,
|
|
canary=canary,
|
|
rollback_ready=True,
|
|
git_consistency={
|
|
"consistent": True,
|
|
"repository": config.repository,
|
|
"source_commit": recipe["source_commit"],
|
|
"tree_oid": tree_oid,
|
|
},
|
|
gitea_consistency=gitea,
|
|
state=ReleaseState.READY_FOR_APPROVAL,
|
|
)
|
|
plan = ReleasePlan(
|
|
deployment=DeploymentPlan(
|
|
service="aios-silent-release-controller",
|
|
release_path="/srv/release/r1",
|
|
release_root="/srv/release",
|
|
cutover_link="/srv/release/current",
|
|
dropin_path="/srv/release/r1/deploy/systemd/controller.service",
|
|
dropin_sha256=digest("dropin"),
|
|
systemd_unit_sha256=digest("unit"),
|
|
rollback_path="/srv/release/rollback-r1",
|
|
rollback_sha256=rollback_contract_sha,
|
|
source_commit=recipe["source_commit"],
|
|
tree_oid=tree_oid,
|
|
artifact_sha256=artifact_sha,
|
|
manifest_sha256=manifest_sha,
|
|
package_closure_sha256=closure_sha,
|
|
authoritative_git_dir="/data/aios-gitops/repo.git",
|
|
repository_identity=config.repository,
|
|
baseline_before_sha256=digest("baseline"),
|
|
registry_before_sha256=digest("registry"),
|
|
production_tag_refset_before_sha256=digest("tagset"),
|
|
health_contract_version="trusted-builder/v1",
|
|
timeout_policy={"seconds": 10},
|
|
retry_policy={"attempts": 1},
|
|
policy_sha256=policy_sha,
|
|
health_contract={"checked": True},
|
|
),
|
|
tag=TagPlan(
|
|
version="trusted-v2r2c2-001",
|
|
tag_type="annotated",
|
|
expected_tag_oid="b" * 40,
|
|
expected_target_oid=recipe["source_commit"],
|
|
evidence_closure_oid="c" * 40,
|
|
approval_plan_id="owner-plan-trusted-v2r2c2",
|
|
repository_identity=config.repository,
|
|
gitea_owner="aios",
|
|
gitea_repository="aios",
|
|
current_remote_tag_oid=None,
|
|
remote_absent_evidence_sha256=digest("tag-absence"),
|
|
verification_contract="trusted-builder/v1",
|
|
),
|
|
finalization=FinalizationPlan(
|
|
version="trusted-v2r2c2-001",
|
|
expected_tag_oid="b" * 40,
|
|
expected_target_oid=recipe["source_commit"],
|
|
expected_previous_baseline_sha256=digest("baseline"),
|
|
registry_expected_record={"base": recipe["source_commit"]},
|
|
baseline_expected_record={"base": recipe["source_commit"]},
|
|
final_consistency_contract="trusted-builder/v1",
|
|
),
|
|
policy_sha256=policy_sha,
|
|
)
|
|
checks = {name: True for name in config.required_checks}
|
|
TrustedCandidateBundle(candidate, plan, checks).validate()
|
|
receipt = {
|
|
"schema": "aios.trusted-candidate-builder.evidence.v1",
|
|
"version": version,
|
|
"candidate": candidate.to_dict(),
|
|
"release_plan": plan.to_dict(),
|
|
}
|
|
write_json(version_root / "receipt.json", receipt)
|
|
return recipe, candidate.to_dict(), plan.to_dict()
|
|
|
|
|
|
def fake_git(tree_oid: str):
|
|
def runner(argv, **_kwargs):
|
|
if "rev-parse" in argv:
|
|
return SimpleNamespace(stdout=tree_oid + "\n", returncode=0)
|
|
return SimpleNamespace(stdout="", returncode=0)
|
|
return runner
|
|
|
|
|
|
def make_materializer(tmp_path: Path, source_root: Path, output_root: Path, tree_oid: str):
|
|
authority = tmp_path / "repo.git"
|
|
authority.mkdir(mode=0o700)
|
|
output_root.mkdir(mode=0o700)
|
|
return TrustedBuilderEvidenceMaterializer(
|
|
builder_policy=POLICY_PATH,
|
|
authority_git_dir=authority,
|
|
source_root=source_root,
|
|
output_root=output_root,
|
|
expected_owner_uid=None,
|
|
git_runner=fake_git(tree_oid),
|
|
)
|
|
|
|
|
|
def test_materializer_projects_only_a_fully_validated_closure_and_is_idempotent(tmp_path: Path) -> None:
|
|
source_root = tmp_path / "source"
|
|
source_root.mkdir(mode=0o700)
|
|
recipe, candidate, _plan = build_valid_source(source_root)
|
|
output_root = tmp_path / "builder-evidence"
|
|
materializer = make_materializer(
|
|
tmp_path, source_root, output_root, candidate["tree_oid"]
|
|
)
|
|
|
|
result = materializer.materialize(
|
|
version=recipe["version"],
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
)
|
|
assert result["status"] == "EVIDENCE_MATERIALIZED"
|
|
assert result["idempotent"] is False
|
|
target = output_root / recipe["version"]
|
|
assert (target / "receipt.json").is_file()
|
|
assert (target / "materialization.json").is_file()
|
|
assert (target / "artifact.tar.gz").read_bytes() == b"artifact-bytes"
|
|
|
|
replay = materializer.materialize(
|
|
version=recipe["version"],
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
)
|
|
assert replay["status"] == "EVIDENCE_MATERIALIZED"
|
|
assert replay["idempotent"] is True
|
|
assert replay["materialization_id"] == result["materialization_id"]
|
|
|
|
|
|
def test_materializer_refuses_tampered_or_incomplete_source_without_writing_output(tmp_path: Path) -> None:
|
|
source_root = tmp_path / "source"
|
|
source_root.mkdir(mode=0o700)
|
|
recipe, candidate, _plan = build_valid_source(source_root)
|
|
(source_root / recipe["version"] / "manifest.json").write_bytes(b"tampered")
|
|
(source_root / recipe["version"] / "manifest.json").chmod(0o600)
|
|
output_root = tmp_path / "builder-evidence"
|
|
materializer = make_materializer(
|
|
tmp_path, source_root, output_root, candidate["tree_oid"]
|
|
)
|
|
|
|
with pytest.raises(EvidenceMaterializationError, match="COLLECTOR_DIGEST_MISMATCH"):
|
|
materializer.materialize(
|
|
version=recipe["version"],
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
)
|
|
assert not (output_root / recipe["version"]).exists()
|
|
|
|
|
|
def test_materializer_denies_symlinked_evidence(tmp_path: Path) -> None:
|
|
source_root = tmp_path / "source"
|
|
source_root.mkdir(mode=0o700)
|
|
recipe, candidate, _plan = build_valid_source(source_root)
|
|
manifest = source_root / recipe["version"] / "manifest.json"
|
|
real = source_root / recipe["version"] / "manifest.real"
|
|
manifest.rename(real)
|
|
manifest.symlink_to(real.name)
|
|
output_root = tmp_path / "builder-evidence"
|
|
materializer = make_materializer(
|
|
tmp_path, source_root, output_root, candidate["tree_oid"]
|
|
)
|
|
|
|
with pytest.raises(EvidenceMaterializationError):
|
|
materializer.materialize(
|
|
version=recipe["version"],
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
)
|
|
assert not (output_root / recipe["version"]).exists()
|
|
|
|
|
|
def test_materializer_rejects_conflicting_existing_destination(tmp_path: Path) -> None:
|
|
source_root = tmp_path / "source"
|
|
source_root.mkdir(mode=0o700)
|
|
recipe, candidate, _plan = build_valid_source(source_root)
|
|
output_root = tmp_path / "builder-evidence"
|
|
materializer = make_materializer(
|
|
tmp_path, source_root, output_root, candidate["tree_oid"]
|
|
)
|
|
materializer.materialize(
|
|
version=recipe["version"],
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
)
|
|
target = output_root / recipe["version"] / "artifact.tar.gz"
|
|
target.chmod(0o600)
|
|
target.write_bytes(b"conflict")
|
|
target.chmod(0o600)
|
|
|
|
with pytest.raises(
|
|
EvidenceMaterializationError, match="EVIDENCE_DESTINATION_CONFLICT"
|
|
):
|
|
materializer.materialize(
|
|
version=recipe["version"],
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
)
|
|
|
|
|
|
def test_materializer_does_not_create_receipt_when_source_receipt_is_missing(tmp_path: Path) -> None:
|
|
source_root = tmp_path / "source"
|
|
source_root.mkdir(mode=0o700)
|
|
recipe, candidate, _plan = build_valid_source(source_root)
|
|
(source_root / recipe["version"] / "receipt.json").unlink()
|
|
output_root = tmp_path / "builder-evidence"
|
|
materializer = make_materializer(
|
|
tmp_path, source_root, output_root, candidate["tree_oid"]
|
|
)
|
|
|
|
with pytest.raises(EvidenceMaterializationError):
|
|
materializer.materialize(
|
|
version=recipe["version"],
|
|
candidate_id=recipe["candidate_id"],
|
|
source_commit=recipe["source_commit"],
|
|
)
|
|
assert not (output_root / recipe["version"] / "receipt.json").exists()
|