Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c647ad1d85 | ||
|
|
869cc00764 | ||
|
|
a5f6dba1e6 | ||
|
|
ce88505a9e | ||
|
|
6b7b4c8b8d | ||
|
|
d7aa3a5e01 | ||
|
|
35ae3d5716 | ||
|
|
52b74ba610 | ||
|
|
51b5f0e53e | ||
|
|
b660e2cc32 | ||
|
|
6ece3525a7 | ||
|
|
2253b83ee4 | ||
|
|
cef2a3e1e5 |
@@ -27,3 +27,36 @@ docs/CODEX_FULL_EXECUTION_PROMPT_V1R1C0.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
本包是 V1R1C0 全量工程部署包,包含文档、脚本、迁移、接口适配器、测试用例。部署时应由 Codex 在服务器上定位当前生产包和运行目录,按增量方式叠加,不得破坏现有 WebOps/NPM/Casdoor/OpenClaw/Hermes。
|
本包是 V1R1C0 全量工程部署包,包含文档、脚本、迁移、接口适配器、测试用例。部署时应由 Codex 在服务器上定位当前生产包和运行目录,按增量方式叠加,不得破坏现有 WebOps/NPM/Casdoor/OpenClaw/Hermes。
|
||||||
|
|
||||||
|
## AIOS V2R2C1 Pre-Push Review 当前状态(语义一致性 follow-up)
|
||||||
|
|
||||||
|
本段针对最新 mechanism branch HEAD 重新核对,不把早期 b660e2c 文档快照当作当前代码状态。文档只写入非 main 的 Owner-review 分支,不能替代 Production Baseline、生产运行时或 main 合并。
|
||||||
|
|
||||||
|
| 项目 | 当前值 | 语义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Repository | aios/aios | 权威仓库身份 |
|
||||||
|
| Authoritative Git | /data/aios-gitops/repo.git | 权威 bare repository |
|
||||||
|
| Authoritative main HEAD | 3244f0f17f8845592ac94d585f5ebd65693c9f29 | 当前工程主线 |
|
||||||
|
| Production Baseline | V2R2C1 / generation 4 | PRODUCTION_DEPLOYED / ACTIVE |
|
||||||
|
| Baseline target | 998d30304787e31e5ae607c0bd86b5edb2b0e5ad | 当前生产发布目标 |
|
||||||
|
| V2R2C1 tag object | 07fc2746b688f5f243f29b39de39f32edc3dd9d2 | annotated tag object |
|
||||||
|
| Latest mechanism HEAD | 52b74ba61040246fffa8b10011c1199830e3d665 | 最新待审机制分支状态 |
|
||||||
|
| Full audit scope | 17 paths, +1403/-79 | b660e2c → 51b5f0e → 52b74ba |
|
||||||
|
| Latest-tip delta | 12 paths, +334/-39 | 51b5f0e → 52b74ba |
|
||||||
|
| Documentation branch | agent/docs/v2r2c1-provenance-mainline-refresh-20260810 | 非 main Owner-review 分支 |
|
||||||
|
| Branch HEAD before semantic repair | 869cc007649a39035f5b74513cecc55bb1ae673f | 本次 follow-up 的父提交 |
|
||||||
|
| Documentation branch write/readback | COMPLETE after this follow-up | 仅针对文档分支 |
|
||||||
|
| PR #65 | open / unmerged | Owner reviewer remains required |
|
||||||
|
| Owner review | REQUIRED | review 后停止,不自动合并 |
|
||||||
|
|
||||||
|
文档分支状态与生产状态分开报告。mechanism/release production write、main merge、Production release、Baseline update、tag rewrite 和 force push 均为 NOT_RUN。legacy_global_writes_enabled 必须保持 false。
|
||||||
|
|
||||||
|
### 最新机制与验收事实
|
||||||
|
|
||||||
|
初始 b660e2c 修复覆盖 14 个文件;完整 b660e2c → 51b5f0e → 52b74ba 审计覆盖 17 个路径,净增量为 +1403/-79;最新 52b74ba 自身覆盖 12 个路径,净增量为 +334/-39。README、requirements、architecture、acceptance 和 version differences 已按这三个范围分别表述。
|
||||||
|
|
||||||
|
当前测试事实为 760 passed、2 skipped、1 failed、154 subtests passed。唯一失败为 tests/test_signer_contract_compatibility.py::test_real_signer_help_and_self_test_are_available。/usr/local/libexec/aios-grant-signer-adapter self-test 返回 42,两个独立重跑仍返回 42。因此 Full external gate 为 PARTIAL,Signer gate 为 FAIL / EXTERNAL,Production ready 保持 false。
|
||||||
|
|
||||||
|
当前架构模型必须同时表达 R、A、T、E、M、C、P:权威仓库身份、annotated tag object、peeled release target、evidence closure、pinned source-registry main commit、当前权威 main/reference commit 和 Owner-approved approval plan。还必须分别说明 source-registry 精确字节与 SHA-256、authority/approval binding、release/general artifact binding、executor 独立复核、runtime projection 与 Git convergence、post-deploy source-registry recheck 以及 fail-closed 状态。
|
||||||
|
|
||||||
|
五个文档均需通过 UTF-8 strict decode、CJK > 0、U+FFFD = 0、异常连续问号 = 0,并在提交后证明 source bytes SHA-256 与 Git blob 内容一致。任何生产 consequential action 仍需要独立 authority mechanism 与 Owner review。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,811 +0,0 @@
|
|||||||
"""Canonical Task projection intent and same-identity recovery primitives.
|
|
||||||
|
|
||||||
This module is intentionally Task-Plane-owned. It does not open a network
|
|
||||||
listener and it does not authenticate callers; existing control-plane services
|
|
||||||
must enforce their normal service-side ``agent-task:control`` boundary before
|
|
||||||
calling it.
|
|
||||||
|
|
||||||
The recovery primitive has one purpose: converge a durable, complete
|
|
||||||
ProjectionIntent to exactly one canonical Task row. It never fabricates task
|
|
||||||
semantics from a Bridge binding and it never generates a replacement task id.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import Enum
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, Mapping, Sequence
|
|
||||||
|
|
||||||
|
|
||||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
|
||||||
JOB_ID_RE = TASK_ID_RE
|
|
||||||
SHA40_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
SHA64_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
|
||||||
SCHEMA = "aios.canonical-task-projection-intent.v1"
|
|
||||||
RECEIPT_SCHEMA = "aios.canonical-task-projection-receipt.v1"
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectionRecoveryError(RuntimeError):
|
|
||||||
def __init__(self, code: str, status: int = 409) -> None:
|
|
||||||
super().__init__(code)
|
|
||||||
self.code = code
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectionState(str, Enum):
|
|
||||||
PENDING = "PENDING"
|
|
||||||
CONVERGED = "CONVERGED"
|
|
||||||
DRIFT = "DRIFT"
|
|
||||||
FAILED = "FAILED"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
BACKFILL_REQUIRED = "BACKFILL_REQUIRED"
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_json(value: object) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
result = value.strip()
|
|
||||||
if pattern is not None and pattern.fullmatch(result) is None:
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _integer(value: object, code: str, *, minimum: int = 0) -> int:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_mapping(value: object, code: str) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return {str(k): v for k, v in value.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_list(value: object, code: str) -> list[Any]:
|
|
||||||
if not isinstance(value, list):
|
|
||||||
raise ProjectionRecoveryError(code, 400)
|
|
||||||
return list(value)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CanonicalTaskPayload:
|
|
||||||
task_id: str
|
|
||||||
goal: str
|
|
||||||
instructions: str
|
|
||||||
success_criteria: tuple[Mapping[str, Any], ...]
|
|
||||||
constraints: Mapping[str, Any]
|
|
||||||
required_capabilities: tuple[str, ...]
|
|
||||||
metadata: Mapping[str, Any]
|
|
||||||
priority: int = 100
|
|
||||||
max_attempts: int = 20
|
|
||||||
deadline: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskPayload":
|
|
||||||
allowed = {
|
|
||||||
"task_id",
|
|
||||||
"goal",
|
|
||||||
"instructions",
|
|
||||||
"success_criteria",
|
|
||||||
"constraints",
|
|
||||||
"required_capabilities",
|
|
||||||
"metadata",
|
|
||||||
"priority",
|
|
||||||
"max_attempts",
|
|
||||||
"deadline",
|
|
||||||
}
|
|
||||||
if set(value) - allowed:
|
|
||||||
raise ProjectionRecoveryError("TASK_PAYLOAD_UNKNOWN_FIELDS", 400)
|
|
||||||
|
|
||||||
task_id = _string(value.get("task_id"), "TASK_ID_INVALID", TASK_ID_RE)
|
|
||||||
goal = _string(value.get("goal"), "TASK_GOAL_INVALID")
|
|
||||||
instructions = _string(value.get("instructions"), "TASK_INSTRUCTIONS_INVALID")
|
|
||||||
if len(goal) > 8192 or len(instructions) > 65536:
|
|
||||||
raise ProjectionRecoveryError("TASK_TEXT_TOO_LARGE", 413)
|
|
||||||
|
|
||||||
criteria_raw = _plain_list(value.get("success_criteria"), "TASK_SUCCESS_CRITERIA_INVALID")
|
|
||||||
criteria: list[Mapping[str, Any]] = []
|
|
||||||
for item in criteria_raw:
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_INVALID", 400)
|
|
||||||
criteria.append(dict(item))
|
|
||||||
if not criteria:
|
|
||||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_REQUIRED", 400)
|
|
||||||
|
|
||||||
constraints = _plain_mapping(value.get("constraints"), "TASK_CONSTRAINTS_INVALID")
|
|
||||||
capabilities_raw = _plain_list(
|
|
||||||
value.get("required_capabilities", []),
|
|
||||||
"TASK_CAPABILITIES_INVALID",
|
|
||||||
)
|
|
||||||
capabilities: list[str] = []
|
|
||||||
for item in capabilities_raw:
|
|
||||||
capabilities.append(_string(item, "TASK_CAPABILITY_INVALID"))
|
|
||||||
|
|
||||||
# Recovery recreates the pristine canonical row. Initial execution
|
|
||||||
# projection remains the sole owner of runtime capabilities.
|
|
||||||
if capabilities:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
|
|
||||||
metadata = _plain_mapping(value.get("metadata", {}), "TASK_METADATA_INVALID")
|
|
||||||
if "execution" in metadata or "execution_projection" in metadata:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
|
|
||||||
priority = _integer(value.get("priority", 100), "TASK_PRIORITY_INVALID", minimum=0)
|
|
||||||
max_attempts = _integer(
|
|
||||||
value.get("max_attempts", 20),
|
|
||||||
"TASK_MAX_ATTEMPTS_INVALID",
|
|
||||||
minimum=1,
|
|
||||||
)
|
|
||||||
deadline = value.get("deadline")
|
|
||||||
if deadline is not None:
|
|
||||||
deadline = _string(deadline, "TASK_DEADLINE_INVALID")
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
task_id=task_id,
|
|
||||||
goal=goal,
|
|
||||||
instructions=instructions,
|
|
||||||
success_criteria=tuple(criteria),
|
|
||||||
constraints=constraints,
|
|
||||||
required_capabilities=tuple(capabilities),
|
|
||||||
metadata=metadata,
|
|
||||||
priority=priority,
|
|
||||||
max_attempts=max_attempts,
|
|
||||||
deadline=deadline,
|
|
||||||
)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"goal": self.goal,
|
|
||||||
"instructions": self.instructions,
|
|
||||||
"success_criteria": [dict(x) for x in self.success_criteria],
|
|
||||||
"constraints": dict(self.constraints),
|
|
||||||
"required_capabilities": list(self.required_capabilities),
|
|
||||||
"metadata": dict(self.metadata),
|
|
||||||
"priority": self.priority,
|
|
||||||
"max_attempts": self.max_attempts,
|
|
||||||
"deadline": self.deadline,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def payload_sha256(self) -> str:
|
|
||||||
return sha256_json(self.public())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class GovernedProjectionBinding:
|
|
||||||
governed_task_id: str
|
|
||||||
bridge_job_id: str
|
|
||||||
repository: str
|
|
||||||
branch: str
|
|
||||||
generation: int
|
|
||||||
base_oid: str
|
|
||||||
expected_branch_oid: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedProjectionBinding":
|
|
||||||
allowed = {
|
|
||||||
"governed_task_id",
|
|
||||||
"bridge_job_id",
|
|
||||||
"repository",
|
|
||||||
"branch",
|
|
||||||
"generation",
|
|
||||||
"base_oid",
|
|
||||||
"expected_branch_oid",
|
|
||||||
}
|
|
||||||
if set(value) != allowed:
|
|
||||||
raise ProjectionRecoveryError("GOVERNED_BINDING_FIELDS_INVALID", 400)
|
|
||||||
repository = _string(value["repository"], "REPOSITORY_INVALID")
|
|
||||||
if repository != "aios/aios":
|
|
||||||
raise ProjectionRecoveryError("REPOSITORY_MISMATCH", 409)
|
|
||||||
return cls(
|
|
||||||
governed_task_id=_string(
|
|
||||||
value["governed_task_id"],
|
|
||||||
"GOVERNED_TASK_ID_INVALID",
|
|
||||||
TASK_ID_RE,
|
|
||||||
),
|
|
||||||
bridge_job_id=_string(
|
|
||||||
value["bridge_job_id"],
|
|
||||||
"BRIDGE_JOB_ID_INVALID",
|
|
||||||
JOB_ID_RE,
|
|
||||||
),
|
|
||||||
repository=repository,
|
|
||||||
branch=_string(value["branch"], "BRANCH_INVALID", BRANCH_RE),
|
|
||||||
generation=_integer(value["generation"], "GENERATION_INVALID", minimum=1),
|
|
||||||
base_oid=_string(value["base_oid"], "BASE_OID_INVALID", SHA40_RE),
|
|
||||||
expected_branch_oid=_string(
|
|
||||||
value["expected_branch_oid"],
|
|
||||||
"EXPECTED_OID_INVALID",
|
|
||||||
SHA40_RE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ProjectionSourceArtifact:
|
|
||||||
ref: str
|
|
||||||
sha256: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ProjectionSourceArtifact":
|
|
||||||
if set(value) != {"ref", "sha256"}:
|
|
||||||
raise ProjectionRecoveryError("SOURCE_ARTIFACT_FIELDS_INVALID", 400)
|
|
||||||
return cls(
|
|
||||||
ref=_string(value["ref"], "SOURCE_ARTIFACT_REF_INVALID"),
|
|
||||||
sha256=_string(value["sha256"], "SOURCE_ARTIFACT_SHA_INVALID", SHA64_RE),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CanonicalTaskProjectionIntent:
|
|
||||||
projection_id: str
|
|
||||||
projection_generation: int
|
|
||||||
idempotency_key: str
|
|
||||||
task: CanonicalTaskPayload
|
|
||||||
binding: GovernedProjectionBinding
|
|
||||||
source_artifact: ProjectionSourceArtifact
|
|
||||||
schema: str = SCHEMA
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskProjectionIntent":
|
|
||||||
allowed = {
|
|
||||||
"schema",
|
|
||||||
"projection_id",
|
|
||||||
"projection_generation",
|
|
||||||
"idempotency_key",
|
|
||||||
"task",
|
|
||||||
"governed_binding",
|
|
||||||
"source_artifact",
|
|
||||||
"task_payload_sha256",
|
|
||||||
}
|
|
||||||
if set(value) != allowed:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_INTENT_FIELDS_INVALID", 400)
|
|
||||||
if value["schema"] != SCHEMA:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_INTENT_SCHEMA_INVALID", 400)
|
|
||||||
|
|
||||||
task = CanonicalTaskPayload.from_mapping(
|
|
||||||
_plain_mapping(value["task"], "TASK_PAYLOAD_INVALID")
|
|
||||||
)
|
|
||||||
binding = GovernedProjectionBinding.from_mapping(
|
|
||||||
_plain_mapping(value["governed_binding"], "GOVERNED_BINDING_INVALID")
|
|
||||||
)
|
|
||||||
source = ProjectionSourceArtifact.from_mapping(
|
|
||||||
_plain_mapping(value["source_artifact"], "SOURCE_ARTIFACT_INVALID")
|
|
||||||
)
|
|
||||||
if binding.governed_task_id != task.task_id:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
|
||||||
|
|
||||||
expected_hash = _string(
|
|
||||||
value["task_payload_sha256"],
|
|
||||||
"TASK_PAYLOAD_SHA_INVALID",
|
|
||||||
SHA64_RE,
|
|
||||||
)
|
|
||||||
if task.payload_sha256 != expected_hash:
|
|
||||||
raise ProjectionRecoveryError("TASK_PAYLOAD_SHA_MISMATCH", 409)
|
|
||||||
|
|
||||||
projection_id = _string(
|
|
||||||
value["projection_id"],
|
|
||||||
"PROJECTION_ID_INVALID",
|
|
||||||
TASK_ID_RE,
|
|
||||||
)
|
|
||||||
idempotency_key = _string(
|
|
||||||
value["idempotency_key"],
|
|
||||||
"IDEMPOTENCY_KEY_INVALID",
|
|
||||||
TASK_ID_RE,
|
|
||||||
)
|
|
||||||
generation = _integer(
|
|
||||||
value["projection_generation"],
|
|
||||||
"PROJECTION_GENERATION_INVALID",
|
|
||||||
minimum=1,
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
projection_id=projection_id,
|
|
||||||
projection_generation=generation,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
task=task,
|
|
||||||
binding=binding,
|
|
||||||
source_artifact=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema": self.schema,
|
|
||||||
"projection_id": self.projection_id,
|
|
||||||
"projection_generation": self.projection_generation,
|
|
||||||
"idempotency_key": self.idempotency_key,
|
|
||||||
"task": self.task.public(),
|
|
||||||
"governed_binding": self.binding.public(),
|
|
||||||
"source_artifact": asdict(self.source_artifact),
|
|
||||||
"task_payload_sha256": self.task.payload_sha256,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def request_sha256(self) -> str:
|
|
||||||
return sha256_json(self.public())
|
|
||||||
|
|
||||||
|
|
||||||
class CanonicalTaskProjectionStore:
|
|
||||||
"""Task-Plane-owned intent/recovery store.
|
|
||||||
|
|
||||||
The caller must already be inside the Task Plane authorization boundary.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_path: Path | str) -> None:
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
|
||||||
connection.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return connection
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
|
||||||
return {
|
|
||||||
str(row["name"]): row
|
|
||||||
for row in connection.execute(f'PRAGMA table_info("{table}")')
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _table_names(connection: sqlite3.Connection) -> set[str]:
|
|
||||||
return {
|
|
||||||
str(row[0])
|
|
||||||
for row in connection.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _event_table(
|
|
||||||
cls,
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
) -> tuple[str, dict[str, sqlite3.Row]]:
|
|
||||||
tables = cls._table_names(connection)
|
|
||||||
for name in ("task_events", "events"):
|
|
||||||
if name in tables:
|
|
||||||
columns = cls._columns(connection, name)
|
|
||||||
if "task_id" in columns and "event_type" in columns:
|
|
||||||
return name, columns
|
|
||||||
raise ProjectionRecoveryError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ensure_control_tables(connection: sqlite3.Connection) -> None:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_projection_intents (
|
|
||||||
projection_id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
bridge_job_id TEXT NOT NULL,
|
|
||||||
projection_generation INTEGER NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
request_sha256 TEXT NOT NULL,
|
|
||||||
task_payload_sha256 TEXT NOT NULL,
|
|
||||||
intent_json TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
UNIQUE(task_id, projection_generation),
|
|
||||||
UNIQUE(task_id, idempotency_key)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_projection_receipts (
|
|
||||||
projection_id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
bridge_job_id TEXT NOT NULL,
|
|
||||||
projection_generation INTEGER NOT NULL,
|
|
||||||
task_payload_sha256 TEXT NOT NULL,
|
|
||||||
canonical_revision INTEGER NOT NULL,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
created_new_row INTEGER NOT NULL,
|
|
||||||
observed_at TEXT NOT NULL,
|
|
||||||
receipt_json TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def persist_intent(
|
|
||||||
self,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
now = _now()
|
|
||||||
request_sha = intent.request_sha256
|
|
||||||
intent_text = _canonical(intent.public()).decode("utf-8")
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
self._ensure_control_tables(connection)
|
|
||||||
existing = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM task_projection_intents
|
|
||||||
WHERE task_id=? AND projection_generation=?
|
|
||||||
""",
|
|
||||||
(intent.task.task_id, intent.projection_generation),
|
|
||||||
).fetchone()
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
str(existing["projection_id"]) != intent.projection_id
|
|
||||||
or str(existing["idempotency_key"]) != intent.idempotency_key
|
|
||||||
or str(existing["request_sha256"]) != request_sha
|
|
||||||
):
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return {
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"state": str(existing["state"]),
|
|
||||||
"idempotent": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO task_projection_intents(
|
|
||||||
projection_id,task_id,bridge_job_id,projection_generation,
|
|
||||||
idempotency_key,request_sha256,task_payload_sha256,intent_json,
|
|
||||||
state,last_error,created_at,updated_at
|
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
intent.projection_id,
|
|
||||||
intent.task.task_id,
|
|
||||||
intent.binding.bridge_job_id,
|
|
||||||
intent.projection_generation,
|
|
||||||
intent.idempotency_key,
|
|
||||||
request_sha,
|
|
||||||
intent.task.payload_sha256,
|
|
||||||
intent_text,
|
|
||||||
ProjectionState.PENDING.value,
|
|
||||||
None,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return {
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"state": ProjectionState.PENDING.value,
|
|
||||||
"idempotent": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _task_row_payload(
|
|
||||||
self,
|
|
||||||
row: sqlite3.Row,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
success = json.loads(row["success_criteria_json"])
|
|
||||||
constraints = json.loads(row["constraints_json"])
|
|
||||||
capabilities = json.loads(row["required_capabilities_json"])
|
|
||||||
metadata = json.loads(row["metadata_json"])
|
|
||||||
except (TypeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ProjectionRecoveryError("CANONICAL_TASK_JSON_INVALID", 409) from exc
|
|
||||||
|
|
||||||
if (
|
|
||||||
not isinstance(success, list)
|
|
||||||
or not isinstance(constraints, Mapping)
|
|
||||||
or not isinstance(capabilities, list)
|
|
||||||
or not isinstance(metadata, Mapping)
|
|
||||||
):
|
|
||||||
raise ProjectionRecoveryError("CANONICAL_TASK_SHAPE_INVALID", 409)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"task_id": str(row["task_id"]),
|
|
||||||
"goal": str(row["goal"]),
|
|
||||||
"instructions": str(row["instructions"]),
|
|
||||||
"success_criteria": success,
|
|
||||||
"constraints": dict(constraints),
|
|
||||||
"required_capabilities": capabilities,
|
|
||||||
"metadata": dict(metadata),
|
|
||||||
"priority": int(row["priority"]),
|
|
||||||
"max_attempts": int(row["max_attempts"]),
|
|
||||||
"deadline": row["deadline"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _verify_existing_task(
|
|
||||||
self,
|
|
||||||
row: sqlite3.Row,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> int:
|
|
||||||
observed = self._task_row_payload(row)
|
|
||||||
observed_hash = sha256_json(observed)
|
|
||||||
if observed_hash != intent.task.payload_sha256:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
return int(row["revision"])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _required_task_columns() -> set[str]:
|
|
||||||
return {
|
|
||||||
"task_id",
|
|
||||||
"goal",
|
|
||||||
"instructions",
|
|
||||||
"success_criteria_json",
|
|
||||||
"required_capabilities_json",
|
|
||||||
"constraints_json",
|
|
||||||
"metadata_json",
|
|
||||||
"priority",
|
|
||||||
"max_attempts",
|
|
||||||
"attempt",
|
|
||||||
"status",
|
|
||||||
"claimed_by",
|
|
||||||
"lease_token_hash",
|
|
||||||
"lease_expires_at",
|
|
||||||
"result_json",
|
|
||||||
"verification_json",
|
|
||||||
"revision",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _insert_task(
|
|
||||||
self,
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> int:
|
|
||||||
columns = self._columns(connection, "tasks")
|
|
||||||
if not self._required_task_columns().issubset(columns):
|
|
||||||
raise ProjectionRecoveryError("TASK_SCHEMA_RECOVERY_UNSUPPORTED", 503)
|
|
||||||
|
|
||||||
now = _now()
|
|
||||||
values: dict[str, Any] = {
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"goal": intent.task.goal,
|
|
||||||
"instructions": intent.task.instructions,
|
|
||||||
"success_criteria_json": _canonical(
|
|
||||||
[dict(x) for x in intent.task.success_criteria]
|
|
||||||
).decode("utf-8"),
|
|
||||||
"required_capabilities_json": "[]",
|
|
||||||
"constraints_json": _canonical(dict(intent.task.constraints)).decode("utf-8"),
|
|
||||||
"metadata_json": _canonical(dict(intent.task.metadata)).decode("utf-8"),
|
|
||||||
"deadline": intent.task.deadline,
|
|
||||||
"priority": intent.task.priority,
|
|
||||||
"max_attempts": intent.task.max_attempts,
|
|
||||||
"attempt": 0,
|
|
||||||
"status": "QUEUED",
|
|
||||||
"claimed_by": None,
|
|
||||||
"lease_token_hash": None,
|
|
||||||
"lease_expires_at": None,
|
|
||||||
"result_json": None,
|
|
||||||
"verification_json": None,
|
|
||||||
"revision": 1,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
for optional in (
|
|
||||||
"result_digest",
|
|
||||||
"result_idempotency_key",
|
|
||||||
):
|
|
||||||
if optional in columns:
|
|
||||||
values[optional] = None
|
|
||||||
|
|
||||||
names = [name for name in values if name in columns]
|
|
||||||
placeholders = ",".join("?" for _ in names)
|
|
||||||
connection.execute(
|
|
||||||
f'INSERT INTO tasks ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
|
||||||
f"VALUES ({placeholders})",
|
|
||||||
tuple(values[name] for name in names),
|
|
||||||
)
|
|
||||||
self._insert_task_created_event(connection, intent)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def _insert_task_created_event(
|
|
||||||
self,
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> None:
|
|
||||||
table, columns = self._event_table(connection)
|
|
||||||
event = {
|
|
||||||
"schema": "aios.task-projection-recovery.event.v1",
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"bridge_job_id": intent.binding.bridge_job_id,
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"projection_generation": intent.projection_generation,
|
|
||||||
"task_payload_sha256": intent.task.payload_sha256,
|
|
||||||
"source_artifact_ref": intent.source_artifact.ref,
|
|
||||||
"source_artifact_sha256": intent.source_artifact.sha256,
|
|
||||||
"recovery": True,
|
|
||||||
}
|
|
||||||
values: dict[str, Any] = {
|
|
||||||
"task_id": intent.task.task_id,
|
|
||||||
"event_type": "TASK_CREATED",
|
|
||||||
}
|
|
||||||
payload_text = _canonical(event).decode("utf-8")
|
|
||||||
if "payload_json" in columns:
|
|
||||||
values["payload_json"] = payload_text
|
|
||||||
elif "payload" in columns:
|
|
||||||
values["payload"] = payload_text
|
|
||||||
else:
|
|
||||||
raise ProjectionRecoveryError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
|
||||||
if "created_at" in columns:
|
|
||||||
values["created_at"] = _now()
|
|
||||||
if "agent_id" in columns:
|
|
||||||
values["agent_id"] = "control-plane-projection-recovery"
|
|
||||||
if "actor_id" in columns:
|
|
||||||
values["actor_id"] = "control-plane-projection-recovery"
|
|
||||||
|
|
||||||
names = list(values)
|
|
||||||
connection.execute(
|
|
||||||
f'INSERT INTO "{table}" ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
|
||||||
f'VALUES ({",".join("?" for _ in names)})',
|
|
||||||
tuple(values[name] for name in names),
|
|
||||||
)
|
|
||||||
|
|
||||||
def reconcile(
|
|
||||||
self,
|
|
||||||
intent: CanonicalTaskProjectionIntent,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
# Persist first so a crash before Task creation leaves a durable PENDING
|
|
||||||
# intent for startup/supervisor reconciliation.
|
|
||||||
self.persist_intent(intent)
|
|
||||||
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
self._ensure_control_tables(connection)
|
|
||||||
existing_receipt = connection.execute(
|
|
||||||
"SELECT * FROM task_projection_receipts WHERE projection_id=?",
|
|
||||||
(intent.projection_id,),
|
|
||||||
).fetchone()
|
|
||||||
if existing_receipt is not None:
|
|
||||||
if (
|
|
||||||
str(existing_receipt["task_id"]) != intent.task.task_id
|
|
||||||
or str(existing_receipt["bridge_job_id"]) != intent.binding.bridge_job_id
|
|
||||||
or str(existing_receipt["task_payload_sha256"])
|
|
||||||
!= intent.task.payload_sha256
|
|
||||||
):
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"PROJECTION_RECEIPT_IDENTITY_CONFLICT",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
receipt = json.loads(existing_receipt["receipt_json"])
|
|
||||||
receipt["idempotent"] = True
|
|
||||||
return receipt
|
|
||||||
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT * FROM tasks WHERE task_id=?",
|
|
||||||
(intent.task.task_id,),
|
|
||||||
).fetchone()
|
|
||||||
created_new = False
|
|
||||||
if row is None:
|
|
||||||
revision = self._insert_task(connection, intent)
|
|
||||||
created_new = True
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT * FROM tasks WHERE task_id=?",
|
|
||||||
(intent.task.task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise ProjectionRecoveryError(
|
|
||||||
"TASK_RECOVERY_OBSERVATION_FAILED",
|
|
||||||
503,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
revision = self._verify_existing_task(row, intent)
|
|
||||||
|
|
||||||
# Observation after mutation is mandatory.
|
|
||||||
revision = self._verify_existing_task(row, intent)
|
|
||||||
observed_at = _now()
|
|
||||||
receipt = {
|
|
||||||
"schema": RECEIPT_SCHEMA,
|
|
||||||
"projection_id": intent.projection_id,
|
|
||||||
"governed_task_id": intent.binding.governed_task_id,
|
|
||||||
"canonical_task_id": intent.task.task_id,
|
|
||||||
"bridge_job_id": intent.binding.bridge_job_id,
|
|
||||||
"projection_generation": intent.projection_generation,
|
|
||||||
"task_payload_sha256": intent.task.payload_sha256,
|
|
||||||
"canonical_revision": revision,
|
|
||||||
"state": ProjectionState.CONVERGED.value,
|
|
||||||
"created_new_row": created_new,
|
|
||||||
"source_artifact": asdict(intent.source_artifact),
|
|
||||||
"observed_at": observed_at,
|
|
||||||
"idempotent": False,
|
|
||||||
}
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO task_projection_receipts(
|
|
||||||
projection_id,task_id,bridge_job_id,projection_generation,
|
|
||||||
task_payload_sha256,canonical_revision,state,created_new_row,
|
|
||||||
observed_at,receipt_json
|
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
intent.projection_id,
|
|
||||||
intent.task.task_id,
|
|
||||||
intent.binding.bridge_job_id,
|
|
||||||
intent.projection_generation,
|
|
||||||
intent.task.payload_sha256,
|
|
||||||
revision,
|
|
||||||
ProjectionState.CONVERGED.value,
|
|
||||||
1 if created_new else 0,
|
|
||||||
observed_at,
|
|
||||||
_canonical(receipt).decode("utf-8"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
UPDATE task_projection_intents
|
|
||||||
SET state=?, last_error=NULL, updated_at=?
|
|
||||||
WHERE projection_id=?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
ProjectionState.CONVERGED.value,
|
|
||||||
observed_at,
|
|
||||||
intent.projection_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return receipt
|
|
||||||
|
|
||||||
def get_projection(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
task_id = _string(task_id, "TASK_ID_INVALID", TASK_ID_RE)
|
|
||||||
with self._connect() as connection:
|
|
||||||
self._ensure_control_tables(connection)
|
|
||||||
intent = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM task_projection_intents
|
|
||||||
WHERE task_id=?
|
|
||||||
ORDER BY projection_generation DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if intent is None:
|
|
||||||
return None
|
|
||||||
receipt = connection.execute(
|
|
||||||
"SELECT receipt_json FROM task_projection_receipts WHERE projection_id=?",
|
|
||||||
(intent["projection_id"],),
|
|
||||||
).fetchone()
|
|
||||||
return {
|
|
||||||
"projection_id": intent["projection_id"],
|
|
||||||
"task_id": task_id,
|
|
||||||
"bridge_job_id": intent["bridge_job_id"],
|
|
||||||
"projection_generation": int(intent["projection_generation"]),
|
|
||||||
"state": intent["state"],
|
|
||||||
"last_error": intent["last_error"],
|
|
||||||
"task_payload_sha256": intent["task_payload_sha256"],
|
|
||||||
"receipt": json.loads(receipt[0]) if receipt else None,
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""HTTP integration helpers for canonical Task projection recovery.
|
|
||||||
|
|
||||||
This module intentionally contains no HTTP server. Existing Task Plane /
|
|
||||||
projection-owner handlers should call ``recover_from_json`` only after their
|
|
||||||
normal service-side ``agent-task:control`` authorization succeeds.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from aios_agent_task_projection_recovery import (
|
|
||||||
CanonicalTaskProjectionIntent,
|
|
||||||
CanonicalTaskProjectionStore,
|
|
||||||
ProjectionRecoveryError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def recover_from_json(
|
|
||||||
db_path: Path | str,
|
|
||||||
task_id: str,
|
|
||||||
body: Mapping[str, Any],
|
|
||||||
) -> tuple[int, dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(body)
|
|
||||||
if intent.task.task_id != task_id:
|
|
||||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
|
||||||
result = CanonicalTaskProjectionStore(db_path).reconcile(intent)
|
|
||||||
return (200 if result.get("idempotent") else 201), result
|
|
||||||
except ProjectionRecoveryError as exc:
|
|
||||||
return exc.status, {"error": exc.code}
|
|
||||||
|
|
||||||
|
|
||||||
def get_projection(
|
|
||||||
db_path: Path | str,
|
|
||||||
task_id: str,
|
|
||||||
) -> tuple[int, dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
result = CanonicalTaskProjectionStore(db_path).get_projection(task_id)
|
|
||||||
except ProjectionRecoveryError as exc:
|
|
||||||
return exc.status, {"error": exc.code}
|
|
||||||
if result is None:
|
|
||||||
return 404, {"error": "TASK_PROJECTION_NOT_FOUND"}
|
|
||||||
return 200, result
|
|
||||||
@@ -94,25 +94,6 @@ class AuthorityGitExecutor:
|
|||||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def create_task_branch(self, branch: str, base_oid: str) -> str:
|
|
||||||
"""Create an isolated agent branch once, using an empty-ref CAS."""
|
|
||||||
ref = branch if branch.startswith("refs/heads/") else "refs/heads/" + branch
|
|
||||||
if not TASK_BRANCH_RE.fullmatch(ref):
|
|
||||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
|
||||||
_oid(base_oid, "base_oid")
|
|
||||||
if not self.commit_exists(base_oid):
|
|
||||||
raise AuthorityGitError("BASE_COMMIT_NOT_FOUND")
|
|
||||||
current = self.read_ref(ref)
|
|
||||||
if current is not None:
|
|
||||||
if current == base_oid:
|
|
||||||
return current
|
|
||||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
|
||||||
self._git(["update-ref", ref, base_oid, "0" * 40])
|
|
||||||
readback = self.read_ref(ref)
|
|
||||||
if readback != base_oid:
|
|
||||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
|
||||||
return readback
|
|
||||||
|
|
||||||
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ from __future__ import annotations
|
|||||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from version_registry import (
|
||||||
|
configured_authoritative_git_dir,
|
||||||
|
configured_repository_identity,
|
||||||
|
list_registry_versions,
|
||||||
|
list_registry_versions_report,
|
||||||
|
)
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -22,7 +28,6 @@ from gitea_tag_publication import (
|
|||||||
TagPublisher,
|
TagPublisher,
|
||||||
redact,
|
redact,
|
||||||
validate_version,
|
validate_version,
|
||||||
validate_gitea_api_url,
|
|
||||||
)
|
)
|
||||||
from orchestrator.action_monitor import ActionMonitor
|
from orchestrator.action_monitor import ActionMonitor
|
||||||
from silent_release_hardening import (
|
from silent_release_hardening import (
|
||||||
@@ -73,8 +78,8 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
|||||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
AUTHORITATIVE_GIT_DIR = configured_authoritative_git_dir()
|
||||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
REPOSITORY_IDENTITY = configured_repository_identity()
|
||||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||||
'/var/lib/aios-gitops/silent-release',
|
'/var/lib/aios-gitops/silent-release',
|
||||||
@@ -309,7 +314,8 @@ def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
|||||||
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
||||||
|
|
||||||
def existing_tag_target(tag: str) -> str | None:
|
def existing_tag_target(tag: str) -> str | None:
|
||||||
validate_version(tag)
|
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||||
|
raise ValueError('invalid release version')
|
||||||
try:
|
try:
|
||||||
result = run_authoritative_git(
|
result = run_authoritative_git(
|
||||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||||
@@ -344,7 +350,7 @@ def required_authoritative_git_dir() -> Path:
|
|||||||
|
|
||||||
def publication_publisher() -> TagPublisher:
|
def publication_publisher() -> TagPublisher:
|
||||||
authoritative_repo = required_authoritative_git_dir()
|
authoritative_repo = required_authoritative_git_dir()
|
||||||
publisher = TagPublisher(
|
return TagPublisher(
|
||||||
WS,
|
WS,
|
||||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||||
GiteaTagClient(
|
GiteaTagClient(
|
||||||
@@ -355,10 +361,6 @@ def publication_publisher() -> TagPublisher:
|
|||||||
),
|
),
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||||
)
|
)
|
||||||
publisher.baseline_path = Path(os.getenv(
|
|
||||||
'AIOS_GITOPS_BASELINE_AUTHORITY', str(WS / 'baseline' / 'current.json')
|
|
||||||
))
|
|
||||||
return publisher
|
|
||||||
|
|
||||||
def commit(paths, msg):
|
def commit(paths, msg):
|
||||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||||
@@ -470,6 +472,14 @@ class H(BaseHTTPRequestHandler):
|
|||||||
))
|
))
|
||||||
return self.sendj(data)
|
return self.sendj(data)
|
||||||
if path in ('/api/ver','/api/versions','/ver'):
|
if path in ('/api/ver','/api/versions','/ver'):
|
||||||
|
registry_report = list_registry_versions_report(
|
||||||
|
WS,
|
||||||
|
baseline_file=Path(os.getenv('AIOS_GITOPS_BASELINE_AUTHORITY', '/var/lib/aios-gitops-runtime-anchor/baseline/current.json')),
|
||||||
|
# Do not invent an authoritative repository when authority
|
||||||
|
# discovery is disabled/omitted. Preserve the unavailable
|
||||||
|
# authority path (503) instead of turning it into a 404.
|
||||||
|
repo_git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
|
)
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
repository_identity=REPOSITORY_IDENTITY,
|
repository_identity=REPOSITORY_IDENTITY,
|
||||||
@@ -478,7 +488,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': snapshot['query_error'],
|
'query_error': snapshot['query_error'],
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
}, 503)
|
}, 503)
|
||||||
try:
|
try:
|
||||||
@@ -487,10 +498,11 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': str(exc),
|
'query_error': str(exc),
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
}, 503)
|
}, 503)
|
||||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
registry = registry_report['registry_versions']
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'registry_versions': registry,
|
'registry_versions': registry,
|
||||||
'local_authoritative_tags': tags,
|
'local_authoritative_tags': tags,
|
||||||
@@ -500,6 +512,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
'versions_dir': registry,
|
'versions_dir': registry,
|
||||||
'head': snapshot['authoritative_head_oid'],
|
'head': snapshot['authoritative_head_oid'],
|
||||||
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
})
|
})
|
||||||
if path in ('/api/diff','/diff'):
|
if path in ('/api/diff','/diff'):
|
||||||
frm=(q.get('from') or [''])[0]
|
frm=(q.get('from') or [''])[0]
|
||||||
@@ -514,15 +527,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({'error':redact(exc)},413)
|
return self.sendj({'error':redact(exc)},413)
|
||||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||||
return self.sendj({'error':redact(exc)},503)
|
return self.sendj({'error':redact(exc)},503)
|
||||||
payload = {'from': frm, 'to': to, 'diff': diff}
|
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||||
payload.update({
|
|
||||||
key: diff[key]
|
|
||||||
for key in (
|
|
||||||
'from_oid', 'to_oid', 'changed_paths', 'stats',
|
|
||||||
'total_size_bytes', 'truncated', 'patch_limit_bytes',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return self.sendj(payload)
|
|
||||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
@@ -634,8 +639,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj(
|
return self.sendj(
|
||||||
service.get_capability_status(candidate_id)
|
service.get_capability_status(candidate_id)
|
||||||
)
|
)
|
||||||
result = service.get_release_saga(candidate_id)
|
return self.sendj(service.get_release_saga(candidate_id))
|
||||||
return self.sendj(result, 404 if result.get('status') == 'NOT_FOUND' else 200)
|
|
||||||
if path == '/getRecoveryStatus':
|
if path == '/getRecoveryStatus':
|
||||||
return self.sendj(
|
return self.sendj(
|
||||||
silent_release_service().get_recovery_status()
|
silent_release_service().get_recovery_status()
|
||||||
@@ -786,10 +790,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
a=self.need('tag:create');
|
a=self.need('tag:create');
|
||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
try:
|
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||||
validate_version(ver)
|
|
||||||
except ValueError:
|
|
||||||
return self.sendj({'error':'version must match VxRxCx'},400)
|
|
||||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'error':'production_tag_evidence_required',
|
'error':'production_tag_evidence_required',
|
||||||
@@ -963,8 +964,6 @@ def api_spec():
|
|||||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
|
||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ BRIDGE_OPERATIONS = (
|
|||||||
"getRuntimeStatus",
|
"getRuntimeStatus",
|
||||||
"getGovernedWriteCapabilities",
|
"getGovernedWriteCapabilities",
|
||||||
"createGovernedTask",
|
"createGovernedTask",
|
||||||
"projectOnlineAgentExecution",
|
|
||||||
"acquireWriterLease",
|
"acquireWriterLease",
|
||||||
"commitFileGoverned",
|
"commitFileGoverned",
|
||||||
"commitFilesGoverned",
|
"commitFilesGoverned",
|
||||||
@@ -31,7 +30,6 @@ BRIDGE_OPERATIONS = (
|
|||||||
|
|
||||||
OPERATION_REQUIRED_FIELDS = {
|
OPERATION_REQUIRED_FIELDS = {
|
||||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||||
"projectOnlineAgentExecution": ("task_id", "required_capabilities"),
|
|
||||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||||
@@ -48,78 +46,6 @@ OPERATION_REQUIRED_FIELDS = {
|
|||||||
"getControlPlaneSnapshot": (),
|
"getControlPlaneSnapshot": (),
|
||||||
}
|
}
|
||||||
|
|
||||||
_OPERATION_PATHS = {
|
|
||||||
"createGovernedTask": "/v2/governed-task",
|
|
||||||
"projectOnlineAgentExecution": "/v2/execution-envelope",
|
|
||||||
"acquireWriterLease": "/v2/writer-lease",
|
|
||||||
"commitFileGoverned": "/v2/commit-file",
|
|
||||||
"commitFilesGoverned": "/v2/commit-files",
|
|
||||||
"getGitOpsJob": "/v2/job",
|
|
||||||
"resumeGitOpsJob": "/v2/job/resume",
|
|
||||||
"reconcileGitOpsJob": "/v2/job/reconcile",
|
|
||||||
"getTagPublicationStatus": "/v2/tag/status",
|
|
||||||
"getReleaseConsistency": "/v2/release/consistency",
|
|
||||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def operation_contracts() -> dict[str, dict[str, object]]:
|
|
||||||
"""Return the runtime contract consumed by adapters and schema tooling."""
|
|
||||||
return {
|
|
||||||
operation: {
|
|
||||||
"method": "POST",
|
|
||||||
"path": _OPERATION_PATHS.get(operation),
|
|
||||||
"required_fields": list(fields),
|
|
||||||
}
|
|
||||||
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_openapi_contract_fragment() -> str:
|
|
||||||
required = "\n".join(
|
|
||||||
f" - {field}"
|
|
||||||
for field in OPERATION_REQUIRED_FIELDS["commitFilesGoverned"]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
" /v2/commit-files:\n"
|
|
||||||
" post:\n"
|
|
||||||
" operationId: commitFilesGoverned\n"
|
|
||||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
|
||||||
" requestBody:\n"
|
|
||||||
" required: true\n"
|
|
||||||
" content:\n"
|
|
||||||
" application/json:\n"
|
|
||||||
" schema:\n"
|
|
||||||
" type: object\n"
|
|
||||||
" additionalProperties: false\n"
|
|
||||||
" required:\n"
|
|
||||||
f"{required}\n"
|
|
||||||
" properties:\n"
|
|
||||||
" task_id: {type: string}\n"
|
|
||||||
" job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}\n"
|
|
||||||
" source_lease_epoch: {type: integer, minimum: 1}\n"
|
|
||||||
" repository: {type: string, example: aios/aios}\n"
|
|
||||||
" expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}\n"
|
|
||||||
" target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}\n"
|
|
||||||
" generation: {type: integer, minimum: 1}\n"
|
|
||||||
" idempotency_key: {type: string}\n"
|
|
||||||
" files:\n"
|
|
||||||
" type: array\n"
|
|
||||||
" minItems: 1\n"
|
|
||||||
" items:\n"
|
|
||||||
" type: object\n"
|
|
||||||
" additionalProperties: false\n"
|
|
||||||
" required: [path, content_base64]\n"
|
|
||||||
" properties:\n"
|
|
||||||
" path: {type: string, minLength: 1, maxLength: 512}\n"
|
|
||||||
" content_base64: {type: string}\n"
|
|
||||||
" message: {type: string, minLength: 1, maxLength: 200}\n"
|
|
||||||
" responses:\n"
|
|
||||||
" '200': {description: Governed commit job and verified branch result}\n"
|
|
||||||
" '400': {description: Contract or policy rejection}\n"
|
|
||||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeDriftError(RuntimeError):
|
class RuntimeDriftError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -196,7 +122,6 @@ class BridgeV2Adapter:
|
|||||||
"legacy_writes_enabled": False,
|
"legacy_writes_enabled": False,
|
||||||
"governed_writes_enabled": True,
|
"governed_writes_enabled": True,
|
||||||
"operations": list(BRIDGE_OPERATIONS),
|
"operations": list(BRIDGE_OPERATIONS),
|
||||||
"operation_contracts": operation_contracts(),
|
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
"main_write": False,
|
"main_write": False,
|
||||||
"tag_write": False,
|
"tag_write": False,
|
||||||
@@ -225,7 +150,6 @@ class BridgeV2Adapter:
|
|||||||
return handler(dict(request))
|
return handler(dict(request))
|
||||||
|
|
||||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
||||||
def projectOnlineAgentExecution(self, request: Mapping[str, Any]) -> Any: return self._call("projectOnlineAgentExecution", request)
|
|
||||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@@ -15,11 +14,10 @@ from typing import Any, Mapping
|
|||||||
|
|
||||||
from aios_authority_git_executor import AuthorityGitExecutor
|
from aios_authority_git_executor import AuthorityGitExecutor
|
||||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, BridgeV2Error, OPERATION_REQUIRED_FIELDS
|
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, BridgeV2Error, OPERATION_REQUIRED_FIELDS
|
||||||
from gitea_tag_publication import validate_gitea_api_url
|
|
||||||
from aios_continuation_executor import AuthorityGitAdapter, ContinuationCommit, ContinuationExecutor, ContinuationPermissions, ContinuationRuntimeBinding, DetachedGitWorkspace, GiteaContinuationRemote, ServerSideCredentialProjection
|
from aios_continuation_executor import AuthorityGitAdapter, ContinuationCommit, ContinuationExecutor, ContinuationPermissions, ContinuationRuntimeBinding, DetachedGitWorkspace, GiteaContinuationRemote, ServerSideCredentialProjection
|
||||||
from aios_continuation_lease import WriterLeaseRequestV2
|
from aios_continuation_lease import WriterLeaseRequestV2
|
||||||
from aios_control_plane_snapshot import build_control_plane_snapshot
|
from aios_control_plane_snapshot import build_control_plane_snapshot
|
||||||
from aios_gitops_job_controller import GitOpsJobController, JobState, JobStore, _RECOVERABLE, sha256_json
|
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap
|
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap
|
||||||
from governed_production_release import build_executor_config
|
from governed_production_release import build_executor_config
|
||||||
|
|
||||||
@@ -28,7 +26,6 @@ ROUTES = {
|
|||||||
"/v2/runtime": "getRuntimeStatus",
|
"/v2/runtime": "getRuntimeStatus",
|
||||||
"/v2/capabilities": "getGovernedWriteCapabilities",
|
"/v2/capabilities": "getGovernedWriteCapabilities",
|
||||||
"/v2/governed-task": "createGovernedTask",
|
"/v2/governed-task": "createGovernedTask",
|
||||||
"/v2/execution-envelope": "projectOnlineAgentExecution",
|
|
||||||
"/v2/writer-lease": "acquireWriterLease",
|
"/v2/writer-lease": "acquireWriterLease",
|
||||||
"/v2/commit-file": "commitFileGoverned",
|
"/v2/commit-file": "commitFileGoverned",
|
||||||
"/v2/commit-files": "commitFilesGoverned",
|
"/v2/commit-files": "commitFilesGoverned",
|
||||||
@@ -55,14 +52,13 @@ class RuntimeConfig:
|
|||||||
ssh_key_path: Path
|
ssh_key_path: Path
|
||||||
remote_name: str = "gitea-http"
|
remote_name: str = "gitea-http"
|
||||||
repository: str = "aios/aios"
|
repository: str = "aios/aios"
|
||||||
api_base: str = ""
|
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||||
v2_task_id: str = "TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-20260809"
|
v2_task_id: str = "TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-20260809"
|
||||||
v2_branch: str = "agent/p0-chatgpt-bridge-v2-control-plane-convergence-20260809"
|
v2_branch: str = "agent/p0-chatgpt-bridge-v2-control-plane-convergence-20260809"
|
||||||
target_version: str = "V2R2C1"
|
target_version: str = "V2R2C1"
|
||||||
registry_root: Path = Path("/var/lib/aios-gitops/runtime/registry-projection")
|
registry_root: Path = Path("/var/lib/aios-gitops/runtime/registry-projection")
|
||||||
baseline_path: Path = Path("/var/lib/aios-gitops-runtime-anchor/baseline/current.json")
|
baseline_path: Path = Path("/var/lib/aios-gitops-runtime-anchor/baseline/current.json")
|
||||||
health_url: str = "http://127.0.0.1:18927/health"
|
health_url: str = "http://127.0.0.1:18927/health"
|
||||||
release_plan_path: Path | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _safe(value: str) -> str:
|
def _safe(value: str) -> str:
|
||||||
@@ -119,30 +115,6 @@ class GovernedRuntime:
|
|||||||
key = (str(request["task_id"]), int(request["generation"]))
|
key = (str(request["task_id"]), int(request["generation"]))
|
||||||
if key in self.runtimes: return self.runtimes[key]
|
if key in self.runtimes: return self.runtimes[key]
|
||||||
lease = WriterLeaseRequestV2(task_id=str(request["task_id"]), repository=str(request["repository"]), branch_mode=str(request.get("branch_mode", "CONTINUE_EXISTING")), base_oid=str(request["base_oid"]), expected_branch_oid=str(request["expected_branch_oid"]), target_branch=str(request["target_branch"]), generation=int(request["generation"]), lease_seconds=int(request.get("lease_seconds", 900)))
|
lease = WriterLeaseRequestV2(task_id=str(request["task_id"]), repository=str(request["repository"]), branch_mode=str(request.get("branch_mode", "CONTINUE_EXISTING")), base_oid=str(request["base_oid"]), expected_branch_oid=str(request["expected_branch_oid"]), target_branch=str(request["target_branch"]), generation=int(request["generation"]), lease_seconds=int(request.get("lease_seconds", 900)))
|
||||||
original_branch_mode = lease.branch_mode
|
|
||||||
if lease.branch_mode == "CREATE_NEW":
|
|
||||||
try:
|
|
||||||
lease.validate(AuthorityGitAdapter(self.authority))
|
|
||||||
except Exception as exc:
|
|
||||||
# A request replay after the authority CAS may observe the
|
|
||||||
# already-created branch. Only the exact base identity is
|
|
||||||
# idempotent; all other validation failures remain fail-closed.
|
|
||||||
if str(exc) != "TARGET_BRANCH_ALREADY_EXISTS":
|
|
||||||
raise
|
|
||||||
# Bootstrap owns the durable binding; the runtime performs the one-time
|
|
||||||
# branch materialization through the authority CAS before continuation
|
|
||||||
# validation. No direct Git or legacy writer path is used.
|
|
||||||
self.authority.create_task_branch(lease.target_branch, lease.base_oid)
|
|
||||||
lease = WriterLeaseRequestV2(
|
|
||||||
task_id=lease.task_id,
|
|
||||||
repository=lease.repository,
|
|
||||||
branch_mode="CONTINUE_EXISTING",
|
|
||||||
base_oid=lease.base_oid,
|
|
||||||
expected_branch_oid=lease.expected_branch_oid,
|
|
||||||
target_branch=lease.target_branch,
|
|
||||||
generation=lease.generation,
|
|
||||||
lease_seconds=lease.lease_seconds,
|
|
||||||
)
|
|
||||||
lease.validate(AuthorityGitAdapter(self.authority))
|
lease.validate(AuthorityGitAdapter(self.authority))
|
||||||
worktree_path = self.config.state_root / "worktrees" / _safe(lease.task_id)
|
worktree_path = self.config.state_root / "worktrees" / _safe(lease.task_id)
|
||||||
if worktree_path.exists():
|
if worktree_path.exists():
|
||||||
@@ -152,33 +124,13 @@ class GovernedRuntime:
|
|||||||
workspace = DetachedGitWorkspace(worktree_path)
|
workspace = DetachedGitWorkspace(worktree_path)
|
||||||
if workspace.head_oid() != lease.expected_branch_oid or not workspace.is_detached(): raise RuntimeError("WORKTREE_HEAD_MISMATCH")
|
if workspace.head_oid() != lease.expected_branch_oid or not workspace.is_detached(): raise RuntimeError("WORKTREE_HEAD_MISMATCH")
|
||||||
job_root = self._job_root(lease.task_id); controller = GitOpsJobController(JobStore(job_root, payload_root=job_root / "payloads")); credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path); remote = GiteaContinuationRemote(workspace=worktree_path, credential=credential, remote_name=self.config.remote_name, repository=lease.repository, api_base=self.config.api_base); executor = ContinuationExecutor(controller, AuthorityGitAdapter(self.authority), workspace, remote, credential)
|
job_root = self._job_root(lease.task_id); controller = GitOpsJobController(JobStore(job_root, payload_root=job_root / "payloads")); credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path); remote = GiteaContinuationRemote(workspace=worktree_path, credential=credential, remote_name=self.config.remote_name, repository=lease.repository, api_base=self.config.api_base); executor = ContinuationExecutor(controller, AuthorityGitAdapter(self.authority), workspace, remote, credential)
|
||||||
binding = ContinuationRuntimeBinding(request=lease, workspace_path=worktree_path, lease_not_before=datetime.now(timezone.utc) - timedelta(seconds=1), lease_expires_at=datetime.now(timezone.utc) + timedelta(seconds=lease.lease_seconds), original_receipt_valid=True, original_grant_consumed=True, permissions=ContinuationPermissions(), remote_branch_create_allowed=original_branch_mode == "CREATE_NEW", external_job_id=str(request.get("job_id") or "") or None)
|
binding = ContinuationRuntimeBinding(request=lease, workspace_path=worktree_path, lease_not_before=datetime.now(timezone.utc) - timedelta(seconds=1), lease_expires_at=datetime.now(timezone.utc) + timedelta(seconds=lease.lease_seconds), original_receipt_valid=True, original_grant_consumed=True, permissions=ContinuationPermissions())
|
||||||
result = (binding, controller, executor); self.runtimes[key] = result; self.controllers[lease.task_id] = controller; return result
|
result = (binding, controller, executor); self.runtimes[key] = result; self.controllers[lease.task_id] = controller; return result
|
||||||
|
|
||||||
def create_task(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
def create_task(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
||||||
|
|
||||||
def project_online_agent_execution(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
capabilities = request.get("required_capabilities") or []
|
|
||||||
if not isinstance(capabilities, list) or not all(isinstance(item, str) for item in capabilities):
|
|
||||||
raise ValueError("CAPABILITY_SCOPE_INVALID")
|
|
||||||
execution = request.get("execution")
|
|
||||||
if execution is not None and not isinstance(execution, Mapping):
|
|
||||||
raise ValueError("EXECUTION_REQUEST_INVALID")
|
|
||||||
task_id = str(request["task_id"])
|
|
||||||
expected_job_id = str(request.get("job_id") or "") or None
|
|
||||||
return self.bootstrap.execution_envelope(
|
|
||||||
task_id,
|
|
||||||
execution,
|
|
||||||
required_capabilities=tuple(capabilities),
|
|
||||||
expected_job_id=expected_job_id,
|
|
||||||
)
|
|
||||||
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
|
||||||
|
|
||||||
def acquire_lease(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
def acquire_lease(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
replay = self._durable_completed_replay_lease(request)
|
|
||||||
if replay is not None:
|
|
||||||
return replay
|
|
||||||
binding, _, _ = self._runtime(request)
|
binding, _, _ = self._runtime(request)
|
||||||
req = binding.request
|
req = binding.request
|
||||||
return {"schema": WriterLeaseRequestV2.schema, "task_id": req.task_id, "repository": req.repository, "branch_mode": req.branch_mode, "base_oid": req.base_oid, "expected_branch_oid": req.expected_branch_oid, "target_branch": req.target_branch, "generation": req.generation, "lease_seconds": req.lease_seconds, "create_target_branch": req.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if req.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True}
|
return {"schema": WriterLeaseRequestV2.schema, "task_id": req.task_id, "repository": req.repository, "branch_mode": req.branch_mode, "base_oid": req.base_oid, "expected_branch_oid": req.expected_branch_oid, "target_branch": req.target_branch, "generation": req.generation, "lease_seconds": req.lease_seconds, "create_target_branch": req.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if req.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True}
|
||||||
@@ -190,9 +142,6 @@ class GovernedRuntime:
|
|||||||
return files
|
return files
|
||||||
|
|
||||||
def commit_files(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
def commit_files(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
replay = self._durable_completed_replay(request)
|
|
||||||
if replay is not None:
|
|
||||||
return replay
|
|
||||||
runtime = self.runtimes.get((str(request["task_id"]), int(request["generation"])))
|
runtime = self.runtimes.get((str(request["task_id"]), int(request["generation"])))
|
||||||
if runtime is None: raise RuntimeError("LEASE_NOT_FOUND")
|
if runtime is None: raise RuntimeError("LEASE_NOT_FOUND")
|
||||||
binding, _, executor = runtime
|
binding, _, executor = runtime
|
||||||
@@ -201,316 +150,6 @@ class GovernedRuntime:
|
|||||||
executor.workspace.materialize_files(commit.files)
|
executor.workspace.materialize_files(commit.files)
|
||||||
return executor.execute(binding, commit)
|
return executor.execute(binding, commit)
|
||||||
|
|
||||||
def _find_completed_replay_job(self, external_job_id: str) -> tuple[Path, dict[str, Any]] | None:
|
|
||||||
matches: list[tuple[Path, dict[str, Any]]] = []
|
|
||||||
roots = [self.config.state_root / "jobs"]
|
|
||||||
for controller in self.controllers.values():
|
|
||||||
roots.append(controller.store.root.parent)
|
|
||||||
seen: set[str] = set()
|
|
||||||
for jobs_root in roots:
|
|
||||||
if not jobs_root.is_dir():
|
|
||||||
continue
|
|
||||||
candidates = []
|
|
||||||
if (jobs_root / "state.sqlite3").is_file():
|
|
||||||
candidates.append(jobs_root)
|
|
||||||
candidates.extend(root for root in jobs_root.iterdir() if root.is_dir())
|
|
||||||
for root in candidates:
|
|
||||||
db = root / "state.sqlite3"
|
|
||||||
key = str(db.resolve()) if db.exists() else str(db)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
if not db.is_file() or db.is_symlink():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
rows = connection.execute("SELECT * FROM jobs").fetchall()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
for raw in rows:
|
|
||||||
row = dict(raw)
|
|
||||||
durable = json.loads(row["request_json"])
|
|
||||||
binding = durable.get("binding", durable)
|
|
||||||
if str(binding.get("external_job_id") or "") == external_job_id:
|
|
||||||
matches.append((root, row))
|
|
||||||
except (OSError, ValueError, TypeError, sqlite3.Error):
|
|
||||||
continue
|
|
||||||
if not matches:
|
|
||||||
return None
|
|
||||||
if len(matches) != 1:
|
|
||||||
raise RuntimeError("EXTERNAL_JOB_MAPPING_AMBIGUOUS")
|
|
||||||
return matches[0]
|
|
||||||
|
|
||||||
def _durable_completed_replay_lease(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
||||||
"""Admit an exact completed replay before ordinary lease validation."""
|
|
||||||
external_job_id = str(request.get("job_id") or "").strip()
|
|
||||||
if not external_job_id:
|
|
||||||
if request.get("source_lease_epoch") is not None:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_EXTERNAL_JOB_ID_REQUIRED")
|
|
||||||
return None
|
|
||||||
match = self._find_completed_replay_job(external_job_id)
|
|
||||||
if match is None:
|
|
||||||
return None
|
|
||||||
root, row = match
|
|
||||||
if str(row["state"]) != JobState.COMPLETED.value:
|
|
||||||
return None
|
|
||||||
db = root / "state.sqlite3"
|
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
evidence_rows = connection.execute(
|
|
||||||
"SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC",
|
|
||||||
(row["job_id"],),
|
|
||||||
).fetchall()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
if not evidence_rows:
|
|
||||||
return None
|
|
||||||
evidence = dict(evidence_rows[0])
|
|
||||||
immutable = tuple(key for key in evidence if key != "created_at")
|
|
||||||
if any(any(item[key] != evidence[key] for key in immutable) for item in evidence_rows[1:]):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AMBIGUOUS")
|
|
||||||
durable = json.loads(row["request_json"])
|
|
||||||
binding = durable.get("binding", durable)
|
|
||||||
target_branch = str(binding.get("target_branch") or binding.get("branch") or "")
|
|
||||||
for field, incoming in (
|
|
||||||
("task_id", request.get("task_id")),
|
|
||||||
("repository", request.get("repository")),
|
|
||||||
("target_branch", request.get("target_branch")),
|
|
||||||
("generation", request.get("generation")),
|
|
||||||
("branch_mode", request.get("branch_mode")),
|
|
||||||
):
|
|
||||||
durable_value = target_branch if field == "target_branch" else binding.get(field)
|
|
||||||
if str(durable_value) != str(incoming):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT")
|
|
||||||
historical_head = str(evidence.get("historical_lease_head_oid") or "")
|
|
||||||
if str(request.get("base_oid") or "") != historical_head or str(request.get("expected_branch_oid") or "") != historical_head:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_EXPECTED_HEAD_CONFLICT")
|
|
||||||
if request.get("source_lease_epoch") is not None and int(request["source_lease_epoch"]) != int(evidence["source_lease_epoch"]):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_EPOCH_CONFLICT")
|
|
||||||
receipt = json.loads(row["receipt_json"] or "null")
|
|
||||||
if not isinstance(receipt, Mapping) or receipt.get("commit_oid") != receipt.get("authority_oid"):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID")
|
|
||||||
authority_head = self.authority.read_ref("refs/heads/" + target_branch)
|
|
||||||
if authority_head != evidence.get("current_head_oid"):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
|
||||||
if not (
|
|
||||||
evidence.get("commit_oid")
|
|
||||||
== evidence.get("authority_head_oid")
|
|
||||||
== evidence.get("current_head_oid")
|
|
||||||
== receipt.get("commit_oid")
|
|
||||||
and evidence.get("commit_oid") != historical_head
|
|
||||||
):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
|
||||||
return {
|
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
|
||||||
"task_id": request["task_id"],
|
|
||||||
"job_id": external_job_id,
|
|
||||||
"repository": request["repository"],
|
|
||||||
"branch_mode": request["branch_mode"],
|
|
||||||
"base_oid": request["base_oid"],
|
|
||||||
"expected_branch_oid": request["expected_branch_oid"],
|
|
||||||
"target_branch": request["target_branch"],
|
|
||||||
"generation": int(request["generation"]),
|
|
||||||
"lease_seconds": int(request.get("lease_seconds") or 900),
|
|
||||||
"create_target_branch": False,
|
|
||||||
"worktree_mode": "detached_at_expected_head",
|
|
||||||
"cas_ref_update_required": True,
|
|
||||||
"lease_created": False,
|
|
||||||
"completed_replay": True,
|
|
||||||
"current_head_oid": evidence["current_head_oid"],
|
|
||||||
"completed_replay_reconciliation_id": evidence["reconciliation_id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _durable_completed_replay(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
||||||
"""Reconcile an exact completed replay before any worktree mutation."""
|
|
||||||
external_job_id = str(request.get("job_id") or "").strip()
|
|
||||||
if not external_job_id:
|
|
||||||
if request.get("source_lease_epoch") is not None:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_EXTERNAL_JOB_ID_REQUIRED")
|
|
||||||
return None
|
|
||||||
matches: list[tuple[Path, dict[str, Any]]] = []
|
|
||||||
roots = [self.config.state_root / "jobs"]
|
|
||||||
for controller in self.controllers.values():
|
|
||||||
roots.append(controller.store.root.parent)
|
|
||||||
seen: set[str] = set()
|
|
||||||
for jobs_root in roots:
|
|
||||||
if not jobs_root.is_dir():
|
|
||||||
continue
|
|
||||||
candidates = []
|
|
||||||
if (jobs_root / "state.sqlite3").is_file():
|
|
||||||
candidates.append(jobs_root)
|
|
||||||
candidates.extend(root for root in jobs_root.iterdir() if root.is_dir())
|
|
||||||
for root in candidates:
|
|
||||||
db = root / "state.sqlite3"
|
|
||||||
key = str(db.resolve()) if db.exists() else str(db)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
if not db.is_file() or db.is_symlink():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
rows = connection.execute("SELECT * FROM jobs").fetchall()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
for raw in rows:
|
|
||||||
row = dict(raw)
|
|
||||||
durable = json.loads(row["request_json"])
|
|
||||||
binding = durable.get("binding", durable)
|
|
||||||
if str(binding.get("external_job_id") or "") == external_job_id:
|
|
||||||
matches.append((root, row))
|
|
||||||
except (OSError, ValueError, TypeError, sqlite3.Error):
|
|
||||||
continue
|
|
||||||
if not matches:
|
|
||||||
return None
|
|
||||||
if len(matches) != 1:
|
|
||||||
raise RuntimeError("EXTERNAL_JOB_MAPPING_AMBIGUOUS")
|
|
||||||
root, row = matches[0]
|
|
||||||
if str(row["state"]) != JobState.COMPLETED.value:
|
|
||||||
return None
|
|
||||||
durable = json.loads(row["request_json"])
|
|
||||||
binding = durable.get("binding", durable)
|
|
||||||
source_lease_epoch = request.get("source_lease_epoch")
|
|
||||||
if source_lease_epoch is None:
|
|
||||||
db = root / "state.sqlite3"
|
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
evidence = connection.execute(
|
|
||||||
"SELECT source_lease_epoch FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC LIMIT 1",
|
|
||||||
(row["job_id"],),
|
|
||||||
).fetchone()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
if evidence is not None:
|
|
||||||
source_lease_epoch = int(evidence["source_lease_epoch"])
|
|
||||||
files = self._files(request)
|
|
||||||
commit = ContinuationCommit(str(request["idempotency_key"]), files, str(request["message"]))
|
|
||||||
payload = commit.payload()
|
|
||||||
receipt = json.loads(row["receipt_json"] or "null")
|
|
||||||
if not isinstance(receipt, Mapping):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID")
|
|
||||||
incoming = {
|
|
||||||
"task_id": str(request.get("task_id") or ""),
|
|
||||||
"external_job_id": external_job_id,
|
|
||||||
"internal_job_id": str(row["job_id"]),
|
|
||||||
"repository": str(request.get("repository") or ""),
|
|
||||||
"target_branch": str(request.get("target_branch") or ""),
|
|
||||||
"generation": int(request.get("generation") or 0),
|
|
||||||
"branch_mode": str(request.get("branch_mode") or ""),
|
|
||||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
|
||||||
"files_digest": hashlib.sha256(json.dumps({path: hashlib.sha256(content).hexdigest() for path, content in sorted(files.items())}, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(),
|
|
||||||
"message_digest": hashlib.sha256(str(request["message"]).encode("utf-8")).hexdigest(),
|
|
||||||
"worker_principal": str(binding.get("worker_principal") or ""),
|
|
||||||
"worker_lineage": str(binding.get("worker_lineage") or ""),
|
|
||||||
"source_lease_epoch": int(source_lease_epoch or 0),
|
|
||||||
"durable_revision": int(row["revision"]),
|
|
||||||
"completion_identity": sha256_json({"job_id": row["job_id"], "revision": int(row["revision"]), "receipt": receipt}),
|
|
||||||
"reason": "COMPLETED_REPLAY_LEASE_EVIDENCE_RECONCILIATION",
|
|
||||||
"provenance": {"source": "governed-completed-replay", "authority_readback": True},
|
|
||||||
}
|
|
||||||
for field in ("task_id", "repository", "target_branch", "generation", "branch_mode", "payload_digest", "files_digest", "message_digest", "worker_principal", "worker_lineage"):
|
|
||||||
durable_value = binding.get(field)
|
|
||||||
if field == "target_branch":
|
|
||||||
durable_value = binding.get("target_branch") or binding.get("branch")
|
|
||||||
elif field in {"worker_principal", "worker_lineage"}:
|
|
||||||
durable_value = durable_value or ""
|
|
||||||
if durable_value not in (None, "") and str(durable_value) != str(incoming[field]):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT")
|
|
||||||
db = root / "state.sqlite3"
|
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
columns = {str(item[1]) for item in connection.execute("PRAGMA table_info(branch_leases)").fetchall()}
|
|
||||||
if "epoch" in columns:
|
|
||||||
lease = connection.execute("SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? AND epoch=? ORDER BY rowid DESC LIMIT 1", (row["job_id"], incoming["source_lease_epoch"])).fetchone()
|
|
||||||
else:
|
|
||||||
if incoming["source_lease_epoch"] != 1:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_EPOCH_UNAVAILABLE")
|
|
||||||
lease = connection.execute("SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? ORDER BY rowid DESC LIMIT 1", (row["job_id"],)).fetchone()
|
|
||||||
if lease is None:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_MISSING")
|
|
||||||
historical_lease_head = str(lease["current_head_oid"])
|
|
||||||
event_tables = {str(item[0]) for item in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='branch_lease_events'").fetchall()}
|
|
||||||
lease_state = str(lease["state"])
|
|
||||||
lease_generation = int(lease["generation"])
|
|
||||||
if "branch_lease_events" in event_tables:
|
|
||||||
event = connection.execute("SELECT state, current_head_oid, generation FROM branch_lease_events WHERE lease_id=? ORDER BY rowid DESC LIMIT 1", (lease["lease_id"],)).fetchone()
|
|
||||||
if event is not None:
|
|
||||||
lease_state = str(event["state"])
|
|
||||||
historical_lease_head = str(event["current_head_oid"])
|
|
||||||
lease_generation = int(event["generation"])
|
|
||||||
if lease_state not in {"ACTIVE", "USED", "EXPIRED"}:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_STATE_INVALID")
|
|
||||||
if lease_generation != incoming["generation"]:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_LEASE_GENERATION_CONFLICT")
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
incoming["historical_lease_head_oid"] = historical_lease_head
|
|
||||||
authority_head = self.authority.read_ref("refs/heads/" + incoming["target_branch"])
|
|
||||||
if authority_head is None:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_MISSING")
|
|
||||||
if receipt.get("commit_oid") != receipt.get("authority_oid"):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID")
|
|
||||||
incoming["commit_oid"] = str(receipt["commit_oid"])
|
|
||||||
incoming["authority_head_oid"] = authority_head
|
|
||||||
|
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
evidence_rows = connection.execute("SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC", (row["job_id"],)).fetchall()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
if evidence_rows:
|
|
||||||
evidence = dict(evidence_rows[0])
|
|
||||||
immutable = tuple(key for key in evidence if key != "created_at")
|
|
||||||
if any(any(item[key] != evidence[key] for key in immutable) for item in evidence_rows[1:]):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AMBIGUOUS")
|
|
||||||
normalized = JobStore._completed_replay_identity(incoming)
|
|
||||||
columns = {
|
|
||||||
"task_id": "task_id",
|
|
||||||
"external_job_id": "external_job_id",
|
|
||||||
"internal_job_id": "internal_job_id",
|
|
||||||
"repository": "repository",
|
|
||||||
"target_branch": "target_branch",
|
|
||||||
"generation": "generation",
|
|
||||||
"branch_mode": "branch_mode",
|
|
||||||
"payload_digest": "payload_digest",
|
|
||||||
"files_digest": "files_digest",
|
|
||||||
"message_digest": "message_digest",
|
|
||||||
"commit_oid": "commit_oid",
|
|
||||||
"authority_head_oid": "authority_head_oid",
|
|
||||||
"historical_lease_head_oid": "historical_lease_head_oid",
|
|
||||||
"worker_principal": "worker_principal",
|
|
||||||
"worker_lineage": "worker_lineage",
|
|
||||||
"source_lease_epoch": "source_lease_epoch",
|
|
||||||
"durable_revision": "durable_revision",
|
|
||||||
"completion_identity": "completion_identity",
|
|
||||||
"reason": "reason",
|
|
||||||
}
|
|
||||||
if any(str(evidence[column]) != str(normalized[field]) for field, column in columns.items()):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT")
|
|
||||||
if evidence["provenance_json"] != json.dumps(normalized["provenance"], sort_keys=True, separators=(",", ":")):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_PROVENANCE_CONFLICT")
|
|
||||||
if evidence["identity_digest"] != sha256_json(normalized):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_IDENTITY_CONFLICT")
|
|
||||||
if not (evidence["commit_oid"] == evidence["authority_head_oid"] == evidence["current_head_oid"] and evidence["commit_oid"] != evidence["historical_lease_head_oid"]):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
|
||||||
if self.authority.read_ref("refs/heads/" + incoming["target_branch"]) != evidence["current_head_oid"]:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
|
||||||
result = {"job_id": external_job_id, "task_id": incoming["task_id"], "repository": incoming["repository"], "branch_mode": incoming["branch_mode"], "base_oid": request.get("base_oid"), "expected_branch_oid": request.get("expected_branch_oid"), "target_branch": incoming["target_branch"], "generation": incoming["generation"], "commit_oid": evidence["commit_oid"], "idempotency_key": row["idempotency_key"], "state": JobState.COMPLETED.value, "side_effect_state": row["side_effect_state"], "http_status": 200, "revision": int(row["revision"]), "receipt": {**receipt, "commit_oid": evidence["commit_oid"], "authority_oid": evidence["authority_head_oid"], "current_head_oid": evidence["current_head_oid"], "external_job_id": evidence["external_job_id"], "completed_replay_reconciliation_id": evidence["reconciliation_id"]}, "current_head_oid": evidence["current_head_oid"], "completed_replay_reconciliation_id": evidence["reconciliation_id"]}
|
|
||||||
return result
|
|
||||||
|
|
||||||
controller = GitOpsJobController(JobStore.for_existing_append(root))
|
|
||||||
reconciliation = controller.store.append_completed_replay_reconciliation(str(row["job_id"]), incoming, authority_head_oid=authority_head)
|
|
||||||
evidence = {**receipt, "commit_oid": reconciliation["commit_oid"], "authority_oid": reconciliation["authority_head_oid"], "current_head_oid": reconciliation["current_head_oid"], "completed_replay_reconciliation_id": reconciliation["reconciliation_id"], "external_job_id": external_job_id}
|
|
||||||
return {"job_id": external_job_id, "task_id": incoming["task_id"], "repository": incoming["repository"], "branch_mode": incoming["branch_mode"], "base_oid": request.get("base_oid"), "expected_branch_oid": request.get("expected_branch_oid"), "target_branch": incoming["target_branch"], "generation": incoming["generation"], "commit_oid": reconciliation["commit_oid"], "idempotency_key": row["idempotency_key"], "state": JobState.COMPLETED.value, "side_effect_state": row["side_effect_state"], "http_status": 200, "revision": int(row["revision"]), "receipt": evidence, "current_head_oid": reconciliation["current_head_oid"], "completed_replay_reconciliation_id": reconciliation["reconciliation_id"]}
|
|
||||||
|
|
||||||
def _controller_for_job(self, job_id: str) -> GitOpsJobController:
|
def _controller_for_job(self, job_id: str) -> GitOpsJobController:
|
||||||
for controller in self.controllers.values():
|
for controller in self.controllers.values():
|
||||||
try:
|
try:
|
||||||
@@ -523,32 +162,12 @@ class GovernedRuntime:
|
|||||||
for root in jobs_root.iterdir():
|
for root in jobs_root.iterdir():
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
continue
|
continue
|
||||||
db = root / "state.sqlite3"
|
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||||
if not db.is_file() or db.is_symlink():
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
controller.store.read(job_id)
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
rows = connection.execute("SELECT job_id, request_json FROM jobs").fetchall()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
matched = False
|
|
||||||
for row in rows:
|
|
||||||
if str(row["job_id"]) == job_id:
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
durable = json.loads(row["request_json"])
|
|
||||||
binding = durable.get("binding", durable)
|
|
||||||
if str(binding.get("external_job_id") or "") == job_id:
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
if not matched:
|
|
||||||
continue
|
|
||||||
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
|
||||||
self.controllers[str(job_id)] = controller
|
self.controllers[str(job_id)] = controller
|
||||||
return controller
|
return controller
|
||||||
except (OSError, ValueError, TypeError, sqlite3.Error):
|
except Exception:
|
||||||
continue
|
continue
|
||||||
raise RuntimeError("JOB_NOT_FOUND")
|
raise RuntimeError("JOB_NOT_FOUND")
|
||||||
|
|
||||||
@@ -658,30 +277,9 @@ class GovernedRuntime:
|
|||||||
controller = self._controller_for_job(job_id)
|
controller = self._controller_for_job(job_id)
|
||||||
row = controller.store.read(job_id)
|
row = controller.store.read(job_id)
|
||||||
if action == "get":
|
if action == "get":
|
||||||
result = controller.store._receipt(row, duplicate=True).as_dict()
|
return controller.store._receipt(row, duplicate=True).as_dict()
|
||||||
evidence_reader = getattr(controller.store, "completed_replay_evidence", None)
|
|
||||||
evidence = evidence_reader(job_id) if callable(evidence_reader) else None
|
|
||||||
if evidence is not None:
|
|
||||||
branch = str(json.loads(row["request_json"]).get("binding", {}).get("branch") or row["branch"])
|
|
||||||
authority_head = self.authority.read_ref("refs/heads/" + branch)
|
|
||||||
if authority_head != evidence["authority_head_oid"] or evidence["current_head_oid"] != authority_head:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
|
||||||
result["job_id"] = evidence["external_job_id"]
|
|
||||||
result["current_head_oid"] = evidence["current_head_oid"]
|
|
||||||
result["completed_replay_reconciliation_id"] = evidence["reconciliation_id"]
|
|
||||||
result["receipt"] = {
|
|
||||||
**(result.get("receipt") or {}),
|
|
||||||
"commit_oid": evidence["commit_oid"],
|
|
||||||
"authority_oid": evidence["authority_head_oid"],
|
|
||||||
"current_head_oid": evidence["current_head_oid"],
|
|
||||||
"external_job_id": evidence["external_job_id"],
|
|
||||||
"completed_replay_reconciliation_id": evidence["reconciliation_id"],
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
if action == "resume":
|
if action == "resume":
|
||||||
return controller.resume(job_id).as_dict()
|
return controller.resume(job_id).as_dict()
|
||||||
if JobState(row["state"]) not in _RECOVERABLE:
|
|
||||||
return controller.store._receipt(row, duplicate=True).as_dict()
|
|
||||||
return self._executor_from_row(row, controller).reconcile(job_id)
|
return self._executor_from_row(row, controller).reconcile(job_id)
|
||||||
|
|
||||||
def _snapshot_task(self) -> dict[str, Any]:
|
def _snapshot_task(self) -> dict[str, Any]:
|
||||||
@@ -697,38 +295,45 @@ class GovernedRuntime:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
generation = 0
|
generation = 0
|
||||||
reconciliation_state = "OBSERVATION_REQUIRED"
|
reconciliation_state = "OBSERVATION_REQUIRED"
|
||||||
projection: dict[str, Any] = {}
|
|
||||||
db = self.config.state_root / "jobs" / _safe(task_id) / "state.sqlite3"
|
db = self.config.state_root / "jobs" / _safe(task_id) / "state.sqlite3"
|
||||||
if db.is_file() and not db.is_symlink():
|
if db.is_file():
|
||||||
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
try:
|
try:
|
||||||
lease = connection.execute("SELECT MAX(generation) FROM branch_leases WHERE branch=?", (branch,)).fetchone()
|
lease = connection.execute(
|
||||||
|
"SELECT MAX(generation) FROM branch_leases WHERE branch=?",
|
||||||
|
(branch,),
|
||||||
|
).fetchone()
|
||||||
generation = max(generation, int((lease or (0,))[0] or 0))
|
generation = max(generation, int((lease or (0,))[0] or 0))
|
||||||
latest = connection.execute("SELECT job_id, state, side_effect_state FROM jobs WHERE branch=? ORDER BY updated_at DESC LIMIT 1", (branch,)).fetchone()
|
latest = connection.execute(
|
||||||
|
"SELECT state, side_effect_state FROM jobs WHERE branch=? ORDER BY updated_at DESC LIMIT 1",
|
||||||
|
(branch,),
|
||||||
|
).fetchone()
|
||||||
if latest is not None:
|
if latest is not None:
|
||||||
reconciliation_state = "COMPLETED" if latest["state"] == "COMPLETED" else str(latest["side_effect_state"] or latest["state"])
|
reconciliation_state = (
|
||||||
evidence_rows = connection.execute("SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC", (latest["job_id"],)).fetchall()
|
"COMPLETED"
|
||||||
if evidence_rows:
|
if latest[0] == "COMPLETED"
|
||||||
evidence = dict(evidence_rows[0])
|
else str(latest[1] or latest[0])
|
||||||
immutable = tuple(key for key in evidence if key != "created_at")
|
)
|
||||||
if any(any(item[key] != evidence[key] for key in immutable) for item in evidence_rows[1:]):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AMBIGUOUS")
|
|
||||||
if not (evidence["commit_oid"] == evidence["authority_head_oid"] == evidence["current_head_oid"] and evidence["commit_oid"] != evidence["historical_lease_head_oid"]):
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
|
||||||
current = self.authority.read_ref("refs/heads/" + branch)
|
|
||||||
if current != evidence["authority_head_oid"] or current != evidence["current_head_oid"]:
|
|
||||||
raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
|
||||||
reconciliation_state = "COMPLETED_REPLAY_RECONCILED"
|
|
||||||
projection = {"external_job_id": evidence["external_job_id"], "commit_oid": evidence["commit_oid"], "current_head_oid": evidence["current_head_oid"], "completed_replay_reconciliation_id": evidence["reconciliation_id"], "receipt_authority_oid": evidence["authority_head_oid"]}
|
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
candidate = {"task_id": task_id, "branch": branch, "authority_branch_oid": authority, "gitea_git_oid": None, "gitea_api_oid": None, "commit_visible": None, "generation": generation, "reconciliation_state": reconciliation_state}
|
candidates.append(
|
||||||
candidate.update(projection)
|
{
|
||||||
candidates.append(candidate)
|
"task_id": task_id,
|
||||||
|
"branch": branch,
|
||||||
|
"authority_branch_oid": authority,
|
||||||
|
"gitea_git_oid": None,
|
||||||
|
"gitea_api_oid": None,
|
||||||
|
"commit_visible": None,
|
||||||
|
"generation": generation,
|
||||||
|
"reconciliation_state": reconciliation_state,
|
||||||
|
}
|
||||||
|
)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {}
|
return {}
|
||||||
return max(candidates, key=lambda item: (int(item["generation"] or 0), bool(item["authority_branch_oid"])))
|
return max(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (int(item["generation"] or 0), bool(item["authority_branch_oid"])),
|
||||||
|
)
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, Any]:
|
def snapshot(self) -> dict[str, Any]:
|
||||||
task = self._snapshot_task()
|
task = self._snapshot_task()
|
||||||
@@ -736,7 +341,6 @@ class GovernedRuntime:
|
|||||||
|
|
||||||
def handlers(self) -> dict[str, Any]:
|
def handlers(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"projectOnlineAgentExecution": self.project_online_agent_execution,
|
|
||||||
"createGovernedTask": self.create_task,
|
"createGovernedTask": self.create_task,
|
||||||
"acquireWriterLease": self.acquire_lease,
|
"acquireWriterLease": self.acquire_lease,
|
||||||
"commitFileGoverned": lambda request: self.commit_files({**request, "files": {request["path"]: request["content_base64"]}}),
|
"commitFileGoverned": lambda request: self.commit_files({**request, "files": {request["path"]: request["content_base64"]}}),
|
||||||
@@ -776,9 +380,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
if not isinstance(value, dict): raise _HTTPError("JSON_OBJECT_REQUIRED")
|
if not isinstance(value, dict): raise _HTTPError("JSON_OBJECT_REQUIRED")
|
||||||
return value
|
return value
|
||||||
def _allowed(self, operation: str, body: Mapping[str, Any]) -> None:
|
def _allowed(self, operation: str, body: Mapping[str, Any]) -> None:
|
||||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "generation", "branch_mode", "source_lease_epoch"}
|
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "generation", "branch_mode"}
|
||||||
if operation == "commitFilesGoverned": allowed.add("files")
|
if operation == "commitFilesGoverned": allowed.add("files")
|
||||||
allowed.update({"owner_approval", "execute", "job_id", "historical_lease_head_oid"})
|
allowed.update({"owner_approval", "execute"})
|
||||||
if set(body) - allowed: raise _HTTPError("UNKNOWN_FIELDS")
|
if set(body) - allowed: raise _HTTPError("UNKNOWN_FIELDS")
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
if self.adapter is None: self._json({"error": "RUNTIME_UNAVAILABLE"}, 503); return
|
if self.adapter is None: self._json({"error": "RUNTIME_UNAVAILABLE"}, 503); return
|
||||||
@@ -814,12 +418,8 @@ def main() -> None:
|
|||||||
token_path,
|
token_path,
|
||||||
ssh_key_path,
|
ssh_key_path,
|
||||||
remote_name=os.environ.get("AIOS_GITEA_REMOTE", "gitea-http"),
|
remote_name=os.environ.get("AIOS_GITEA_REMOTE", "gitea-http"),
|
||||||
api_base=validate_gitea_api_url(os.environ.get("AIOS_GITEA_API_BASE", "")),
|
api_base=os.environ.get("AIOS_GITEA_API_BASE", "https://g.cnnetmkt.top/git/api/v1"),
|
||||||
target_version=os.environ.get("AIOS_CONTROL_PLANE_TARGET_VERSION", "V2R2C1"),
|
target_version=os.environ.get("AIOS_CONTROL_PLANE_TARGET_VERSION", "V2R2C1"),
|
||||||
baseline_path=Path(os.environ.get(
|
|
||||||
"AIOS_GITOPS_BASELINE_AUTHORITY",
|
|
||||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
runtime = GovernedRuntime(config)
|
runtime = GovernedRuntime(config)
|
||||||
serve(
|
serve(
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class AuthorityPort(Protocol):
|
|||||||
|
|
||||||
class RemotePort(Protocol):
|
class RemotePort(Protocol):
|
||||||
def repository_identity(self) -> str: ...
|
def repository_identity(self) -> str: ...
|
||||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None: ...
|
def push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||||
def read_git_ref(self, branch: str) -> str | None: ...
|
def read_git_ref(self, branch: str) -> str | None: ...
|
||||||
def read_api_branch(self, branch: str) -> str | None: ...
|
def read_api_branch(self, branch: str) -> str | None: ...
|
||||||
def commit_visible(self, oid: str) -> bool: ...
|
def commit_visible(self, oid: str) -> bool: ...
|
||||||
@@ -258,11 +258,10 @@ class GiteaContinuationRemote:
|
|||||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||||
return oid
|
return oid
|
||||||
|
|
||||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None:
|
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||||
self._require_projected()
|
self._require_projected()
|
||||||
self.repository_identity()
|
self.repository_identity()
|
||||||
current = self.read_git_ref(branch)
|
if self.read_git_ref(branch) != expected:
|
||||||
if current != expected and not (allow_absent and current is None):
|
|
||||||
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||||
|
|
||||||
@@ -336,10 +335,6 @@ class ContinuationRuntimeBinding:
|
|||||||
original_receipt_valid: bool
|
original_receipt_valid: bool
|
||||||
original_grant_consumed: bool
|
original_grant_consumed: bool
|
||||||
permissions: ContinuationPermissions
|
permissions: ContinuationPermissions
|
||||||
remote_branch_create_allowed: bool = False
|
|
||||||
external_job_id: str | None = None
|
|
||||||
worker_principal: str = "aios-autonomy-agent"
|
|
||||||
worker_lineage: str = "governed-continuation"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -455,17 +450,6 @@ class ContinuationExecutor:
|
|||||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||||
"lineage_anchor_oid": request.base_oid,
|
"lineage_anchor_oid": request.base_oid,
|
||||||
"generation": request.generation,
|
"generation": request.generation,
|
||||||
"remote_branch_create_allowed": binding.remote_branch_create_allowed,
|
|
||||||
"external_job_id": binding.external_job_id,
|
|
||||||
"files_digest": hashlib.sha256(
|
|
||||||
json.dumps(
|
|
||||||
{path: hashlib.sha256(content).hexdigest() for path, content in sorted(commit.files.items())},
|
|
||||||
sort_keys=True, separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
).hexdigest(),
|
|
||||||
"message_digest": hashlib.sha256(commit.message.encode("utf-8")).hexdigest(),
|
|
||||||
"worker_principal": binding.worker_principal,
|
|
||||||
"worker_lineage": binding.worker_lineage,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
||||||
@@ -515,7 +499,7 @@ class ContinuationExecutor:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid, allow_absent=binding.remote_branch_create_allowed)
|
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
except Exception:
|
except Exception:
|
||||||
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
||||||
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
||||||
@@ -529,7 +513,6 @@ class ContinuationExecutor:
|
|||||||
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||||
receipt = {
|
receipt = {
|
||||||
"authority_oid": authority_oid,
|
"authority_oid": authority_oid,
|
||||||
"commit_oid": new_oid,
|
|
||||||
"desired_remote_oid": new_oid,
|
"desired_remote_oid": new_oid,
|
||||||
"remote_verified": False,
|
"remote_verified": False,
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
@@ -552,7 +535,6 @@ class ContinuationExecutor:
|
|||||||
return self._remote_pending(job_id, authority_oid, new_oid)
|
return self._remote_pending(job_id, authority_oid, new_oid)
|
||||||
receipt = {
|
receipt = {
|
||||||
"authority_oid": authority_oid,
|
"authority_oid": authority_oid,
|
||||||
"commit_oid": new_oid,
|
|
||||||
"git_remote_oid": git_oid,
|
"git_remote_oid": git_oid,
|
||||||
"api_branch_oid": api_oid,
|
"api_branch_oid": api_oid,
|
||||||
"commit_visible": True,
|
"commit_visible": True,
|
||||||
@@ -601,14 +583,11 @@ class ContinuationExecutor:
|
|||||||
and self.authority.is_ancestor(git_oid, new_oid)
|
and self.authority.is_ancestor(git_oid, new_oid)
|
||||||
):
|
):
|
||||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
||||||
self.remote.push_exact(row["branch"], git_oid, new_oid, allow_absent=bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW")
|
self.remote.push_exact(row["branch"], git_oid, new_oid)
|
||||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||||
allow_remote_create = bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW" or (git_oid is None and api_oid is None)
|
if authority_oid == new_oid and git_oid == api_oid == expected_oid:
|
||||||
remote_matches_expected = git_oid == api_oid == expected_oid
|
|
||||||
remote_absent_for_create = git_oid is None and api_oid is None
|
|
||||||
if authority_oid == new_oid and (remote_matches_expected or remote_absent_for_create):
|
|
||||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
||||||
self.remote.push_exact(row["branch"], expected_oid, new_oid, allow_absent=allow_remote_create)
|
self.remote.push_exact(row["branch"], expected_oid, new_oid)
|
||||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, A
|
|||||||
|
|
||||||
|
|
||||||
def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, Any] | None = None, repository: Mapping[str, Any] | None = None, production: Mapping[str, Any] | None = None, task: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None, credential_broker: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, Any] | None = None, repository: Mapping[str, Any] | None = None, production: Mapping[str, Any] | None = None, task: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None, credential_broker: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||||
task_snapshot = _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state"))
|
|
||||||
for key in ("external_job_id", "commit_oid", "current_head_oid", "completed_replay_reconciliation_id", "receipt_authority_oid"):
|
|
||||||
if task is not None and key in task:
|
|
||||||
task_snapshot[key] = task[key]
|
|
||||||
return {
|
return {
|
||||||
"version": {"target_release": target_version},
|
"version": {"target_release": target_version},
|
||||||
"runtime": _copy(runtime, ("bridge_schema_version", "control_plane_release_version", "runtime_release_commit", "runtime_path", "artifact_sha256", "drift_status")),
|
"runtime": _copy(runtime, ("bridge_schema_version", "control_plane_release_version", "runtime_release_commit", "runtime_path", "artifact_sha256", "drift_status")),
|
||||||
"repository": _copy(repository, ("identity", "authority_git_dir", "authority_main_oid")),
|
"repository": _copy(repository, ("identity", "authority_git_dir", "authority_main_oid")),
|
||||||
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
||||||
"task": task_snapshot,
|
"task": _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state")),
|
||||||
"capabilities": _copy(capabilities, ("legacy_writes_enabled", "governed_writes_enabled", "force_push", "main_write", "tag_write", "baseline_write", "production_write")),
|
"capabilities": _copy(capabilities, ("legacy_writes_enabled", "governed_writes_enabled", "force_push", "main_write", "tag_write", "baseline_write", "production_write")),
|
||||||
"credential_broker": _copy(credential_broker, ("status", "provider")),
|
"credential_broker": _copy(credential_broker, ("status", "provider")),
|
||||||
"secret_material_exposed": False,
|
"secret_material_exposed": False,
|
||||||
|
|||||||
@@ -1,478 +0,0 @@
|
|||||||
"""Minimal convergence contracts for AIOS V2R2C2.
|
|
||||||
|
|
||||||
This module deliberately contains no external side effects. It provides the shared
|
|
||||||
data contracts and pure evaluation rules used by domain controllers. Domain
|
|
||||||
controllers remain authoritative for their own final states.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import Enum
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from typing import Any, Iterable, Mapping, Sequence
|
|
||||||
|
|
||||||
|
|
||||||
class ConvergenceContractError(ValueError):
|
|
||||||
"""Raised when an invalid convergence contract is constructed."""
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceState(str, Enum):
|
|
||||||
PASS = "PASS"
|
|
||||||
FAIL = "FAIL"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
NOT_APPLICABLE = "NOT_APPLICABLE"
|
|
||||||
|
|
||||||
|
|
||||||
class AcceptanceDecision(str, Enum):
|
|
||||||
ACCEPT = "ACCEPT"
|
|
||||||
REJECT = "REJECT"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
class ObservedHealth(str, Enum):
|
|
||||||
HEALTHY = "HEALTHY"
|
|
||||||
DEGRADED = "DEGRADED"
|
|
||||||
FAILED = "FAILED"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
class ReconciliationState(str, Enum):
|
|
||||||
PENDING = "PENDING"
|
|
||||||
OBSERVING = "OBSERVING"
|
|
||||||
DIFF_FOUND = "DIFF_FOUND"
|
|
||||||
APPLYING = "APPLYING"
|
|
||||||
VERIFYING = "VERIFYING"
|
|
||||||
CONVERGED = "CONVERGED"
|
|
||||||
DEGRADED = "DEGRADED"
|
|
||||||
BLOCKED = "BLOCKED"
|
|
||||||
|
|
||||||
|
|
||||||
class ContinuationStopReason(str, Enum):
|
|
||||||
CONTEXT_BOUNDARY = "context_boundary"
|
|
||||||
WORKER_RESTART = "worker_restart"
|
|
||||||
HANDOFF = "handoff"
|
|
||||||
AUTHORITY_GATE = "authority_gate"
|
|
||||||
WAIT_EXTERNAL = "wait_external"
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(value: Mapping[str, Any]) -> str:
|
|
||||||
return "sha256:" + hashlib.sha256(_canonical_json(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _nonempty(value: object, field_name: str) -> str:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text:
|
|
||||||
raise ConvergenceContractError(f"{field_name}_required")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _generation(value: object, field_name: str = "generation") -> int:
|
|
||||||
try:
|
|
||||||
parsed = int(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ConvergenceContractError(f"{field_name}_invalid") from exc
|
|
||||||
if parsed < 1:
|
|
||||||
raise ConvergenceContractError(f"{field_name}_invalid")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class StateOwnership:
|
|
||||||
object_type: str
|
|
||||||
authoritative_service: str
|
|
||||||
authoritative_store: str
|
|
||||||
final_writer: str | None = None
|
|
||||||
projections: tuple[str, ...] = ()
|
|
||||||
schema_version: str = "aios.state-ownership.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.object_type, "object_type")
|
|
||||||
_nonempty(self.authoritative_service, "authoritative_service")
|
|
||||||
_nonempty(self.authoritative_store, "authoritative_store")
|
|
||||||
if self.final_writer is not None:
|
|
||||||
_nonempty(self.final_writer, "final_writer")
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_STATE_OWNERSHIP: tuple[StateOwnership, ...] = (
|
|
||||||
StateOwnership(
|
|
||||||
object_type="goal",
|
|
||||||
authoritative_service="goal-kernel",
|
|
||||||
authoritative_store="goal-store",
|
|
||||||
final_writer="goal-kernel",
|
|
||||||
projections=("admin-web", "context-pack"),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="task_run",
|
|
||||||
authoritative_service="task-plane-18940",
|
|
||||||
authoritative_store="durable-task-store",
|
|
||||||
final_writer="task-plane-18940",
|
|
||||||
projections=("admin-web", "context-pack"),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="release",
|
|
||||||
authoritative_service="release-controller",
|
|
||||||
authoritative_store="release-registry",
|
|
||||||
final_writer="release-controller",
|
|
||||||
projections=("baseline-authority", "admin-web"),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="git_branch",
|
|
||||||
authoritative_service="authority-git-executor",
|
|
||||||
authoritative_store="authoritative-git",
|
|
||||||
final_writer="authority-git-executor",
|
|
||||||
projections=("gitea",),
|
|
||||||
),
|
|
||||||
StateOwnership(
|
|
||||||
object_type="external_projection",
|
|
||||||
authoritative_service="projection-controller",
|
|
||||||
authoritative_store="projection-store",
|
|
||||||
final_writer="projection-controller",
|
|
||||||
projections=("openapi", "gpt-action", "nginx"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ownership_index(
|
|
||||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
|
||||||
) -> dict[str, StateOwnership]:
|
|
||||||
result: dict[str, StateOwnership] = {}
|
|
||||||
for row in ownership:
|
|
||||||
if row.object_type in result:
|
|
||||||
raise ConvergenceContractError(f"duplicate_state_owner:{row.object_type}")
|
|
||||||
result[row.object_type] = row
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceCriterion:
|
|
||||||
criterion_id: str
|
|
||||||
required: bool
|
|
||||||
state: EvidenceState
|
|
||||||
verifier_id: str
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.criterion_id, "criterion_id")
|
|
||||||
_nonempty(self.verifier_id, "verifier_id")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceInvariant:
|
|
||||||
invariant_id: str
|
|
||||||
state: EvidenceState
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.invariant_id, "invariant_id")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceVector:
|
|
||||||
subject_type: str
|
|
||||||
subject_id: str
|
|
||||||
subject_revision: int
|
|
||||||
criteria: tuple[AcceptanceCriterion, ...]
|
|
||||||
invariants: tuple[AcceptanceInvariant, ...] = ()
|
|
||||||
unresolved_side_effects: tuple[str, ...] = ()
|
|
||||||
unresolved_blockers: tuple[str, ...] = ()
|
|
||||||
runtime_proofs: tuple[str, ...] = ()
|
|
||||||
schema: str = "aios.acceptance-vector.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.subject_type, "subject_type")
|
|
||||||
_nonempty(self.subject_id, "subject_id")
|
|
||||||
_generation(self.subject_revision, "subject_revision")
|
|
||||||
ids = [row.criterion_id for row in self.criteria]
|
|
||||||
if len(ids) != len(set(ids)):
|
|
||||||
raise ConvergenceContractError("duplicate_acceptance_criterion")
|
|
||||||
invariant_ids = [row.invariant_id for row in self.invariants]
|
|
||||||
if len(invariant_ids) != len(set(invariant_ids)):
|
|
||||||
raise ConvergenceContractError("duplicate_acceptance_invariant")
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
value = asdict(self)
|
|
||||||
for row in value["criteria"]:
|
|
||||||
row["state"] = row["state"].value
|
|
||||||
for row in value["invariants"]:
|
|
||||||
row["state"] = row["state"].value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AcceptanceResult:
|
|
||||||
decision: AcceptanceDecision
|
|
||||||
reasons: tuple[str, ...]
|
|
||||||
vector_hash: str
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"decision": self.decision.value,
|
|
||||||
"reasons": list(self.reasons),
|
|
||||||
"vector_hash": self.vector_hash,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_acceptance(vector: AcceptanceVector) -> AcceptanceResult:
|
|
||||||
"""Pure fail-closed acceptance evaluator.
|
|
||||||
|
|
||||||
The evaluator never writes a domain final state. The domain owner consumes this
|
|
||||||
result and remains the only final writer for Goal/Release/Product state.
|
|
||||||
"""
|
|
||||||
reasons: list[str] = []
|
|
||||||
required = [row for row in vector.criteria if row.required]
|
|
||||||
|
|
||||||
for row in required:
|
|
||||||
if row.state == EvidenceState.FAIL:
|
|
||||||
reasons.append(f"criterion_failed:{row.criterion_id}")
|
|
||||||
elif row.state == EvidenceState.BLOCKED:
|
|
||||||
reasons.append(f"criterion_blocked:{row.criterion_id}")
|
|
||||||
elif row.state == EvidenceState.UNKNOWN:
|
|
||||||
reasons.append(f"criterion_unknown:{row.criterion_id}")
|
|
||||||
|
|
||||||
for row in vector.invariants:
|
|
||||||
if row.state == EvidenceState.FAIL:
|
|
||||||
reasons.append(f"invariant_failed:{row.invariant_id}")
|
|
||||||
elif row.state in {EvidenceState.UNKNOWN, EvidenceState.BLOCKED}:
|
|
||||||
reasons.append(f"invariant_unknown:{row.invariant_id}")
|
|
||||||
|
|
||||||
if vector.unresolved_side_effects:
|
|
||||||
reasons.append("unresolved_side_effects")
|
|
||||||
if vector.unresolved_blockers:
|
|
||||||
reasons.append("unresolved_blockers")
|
|
||||||
|
|
||||||
digest = _sha256(vector.as_dict())
|
|
||||||
|
|
||||||
if any(reason.startswith(("criterion_failed:", "invariant_failed:")) for reason in reasons):
|
|
||||||
decision = AcceptanceDecision.REJECT
|
|
||||||
elif any(reason.startswith("criterion_blocked:") for reason in reasons) or vector.unresolved_blockers:
|
|
||||||
decision = AcceptanceDecision.BLOCKED
|
|
||||||
elif reasons:
|
|
||||||
decision = AcceptanceDecision.UNKNOWN
|
|
||||||
else:
|
|
||||||
decision = AcceptanceDecision.ACCEPT
|
|
||||||
|
|
||||||
return AcceptanceResult(decision=decision, reasons=tuple(reasons), vector_hash=digest)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DesiredState:
|
|
||||||
resource_id: str
|
|
||||||
resource_type: str
|
|
||||||
desired_generation: int
|
|
||||||
desired_spec: Mapping[str, Any]
|
|
||||||
owner_goal_id: str | None = None
|
|
||||||
lifecycle: str = "active"
|
|
||||||
schema: str = "aios.desired-state.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.resource_id, "resource_id")
|
|
||||||
_nonempty(self.resource_type, "resource_type")
|
|
||||||
_generation(self.desired_generation, "desired_generation")
|
|
||||||
if self.lifecycle not in {"active", "suspended", "deleted"}:
|
|
||||||
raise ConvergenceContractError("desired_lifecycle_invalid")
|
|
||||||
|
|
||||||
def identity(self) -> tuple[str, int]:
|
|
||||||
return self.resource_id, self.desired_generation
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ObservedState:
|
|
||||||
resource_id: str
|
|
||||||
observed_generation: int | None
|
|
||||||
observed_spec: Mapping[str, Any]
|
|
||||||
health: ObservedHealth
|
|
||||||
source_identity: str
|
|
||||||
observed_at: str
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
schema: str = "aios.observed-state.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.resource_id, "resource_id")
|
|
||||||
_nonempty(self.source_identity, "source_identity")
|
|
||||||
_nonempty(self.observed_at, "observed_at")
|
|
||||||
if self.observed_generation is not None:
|
|
||||||
_generation(self.observed_generation, "observed_generation")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ReconciliationPlan:
|
|
||||||
resource_id: str
|
|
||||||
desired_generation: int
|
|
||||||
state: ReconciliationState
|
|
||||||
diff: Mapping[str, Any]
|
|
||||||
observation_required: bool
|
|
||||||
idempotency_key: str
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
value = asdict(self)
|
|
||||||
value["state"] = self.state.value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def plan_reconciliation(
|
|
||||||
desired: DesiredState,
|
|
||||||
observed: ObservedState | None,
|
|
||||||
) -> ReconciliationPlan:
|
|
||||||
"""Return a side-effect-free reconciliation decision.
|
|
||||||
|
|
||||||
UNKNOWN/missing observed state always requires observation and never converges.
|
|
||||||
"""
|
|
||||||
identity = {
|
|
||||||
"resource_id": desired.resource_id,
|
|
||||||
"desired_generation": desired.desired_generation,
|
|
||||||
"desired_spec": desired.desired_spec,
|
|
||||||
}
|
|
||||||
key = _sha256(identity)
|
|
||||||
|
|
||||||
if observed is None:
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.OBSERVING,
|
|
||||||
{"observed": None},
|
|
||||||
True,
|
|
||||||
key,
|
|
||||||
"observed_state_missing",
|
|
||||||
)
|
|
||||||
if observed.resource_id != desired.resource_id:
|
|
||||||
raise ConvergenceContractError("resource_identity_mismatch")
|
|
||||||
if observed.health == ObservedHealth.UNKNOWN:
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.OBSERVING,
|
|
||||||
{"health": observed.health.value},
|
|
||||||
True,
|
|
||||||
key,
|
|
||||||
"observed_state_unknown",
|
|
||||||
)
|
|
||||||
|
|
||||||
same_generation = observed.observed_generation == desired.desired_generation
|
|
||||||
same_spec = dict(observed.observed_spec) == dict(desired.desired_spec)
|
|
||||||
if same_generation and same_spec and observed.health == ObservedHealth.HEALTHY:
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.CONVERGED,
|
|
||||||
{},
|
|
||||||
False,
|
|
||||||
key,
|
|
||||||
"desired_equals_observed",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ReconciliationPlan(
|
|
||||||
desired.resource_id,
|
|
||||||
desired.desired_generation,
|
|
||||||
ReconciliationState.DIFF_FOUND,
|
|
||||||
{
|
|
||||||
"desired_generation": desired.desired_generation,
|
|
||||||
"observed_generation": observed.observed_generation,
|
|
||||||
"desired_spec": dict(desired.desired_spec),
|
|
||||||
"observed_spec": dict(observed.observed_spec),
|
|
||||||
"observed_health": observed.health.value,
|
|
||||||
},
|
|
||||||
False,
|
|
||||||
key,
|
|
||||||
"desired_observed_drift",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SideEffectReceipt:
|
|
||||||
mutation_id: str
|
|
||||||
external_system: str
|
|
||||||
state: str
|
|
||||||
observed_at: str
|
|
||||||
external_request_id: str | None = None
|
|
||||||
external_object_id: str | None = None
|
|
||||||
observed_result_hash: str | None = None
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
schema: str = "aios.side-effect-receipt.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_nonempty(self.mutation_id, "mutation_id")
|
|
||||||
_nonempty(self.external_system, "external_system")
|
|
||||||
_nonempty(self.observed_at, "observed_at")
|
|
||||||
if self.state not in {
|
|
||||||
"confirmed_applied",
|
|
||||||
"confirmed_not_applied",
|
|
||||||
"unknown",
|
|
||||||
}:
|
|
||||||
raise ConvergenceContractError("side_effect_receipt_state_invalid")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ContinuationManifest:
|
|
||||||
trace_id: str
|
|
||||||
goal_id: str
|
|
||||||
goal_revision: int
|
|
||||||
task_id: str
|
|
||||||
run_id: str
|
|
||||||
current_node_id: str
|
|
||||||
acceptance_vector_ref: str
|
|
||||||
next_action: str
|
|
||||||
stop_reason: ContinuationStopReason
|
|
||||||
checkpoint_ref: str | None = None
|
|
||||||
unresolved_blocker_refs: tuple[str, ...] = ()
|
|
||||||
authoritative_decision_refs: tuple[str, ...] = ()
|
|
||||||
resource_refs: tuple[str, ...] = ()
|
|
||||||
artifact_refs: tuple[str, ...] = ()
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
created_at: str = field(default_factory=_utc_now)
|
|
||||||
schema: str = "aios.continuation-manifest.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for name in (
|
|
||||||
"trace_id",
|
|
||||||
"goal_id",
|
|
||||||
"task_id",
|
|
||||||
"run_id",
|
|
||||||
"current_node_id",
|
|
||||||
"acceptance_vector_ref",
|
|
||||||
"next_action",
|
|
||||||
"created_at",
|
|
||||||
):
|
|
||||||
_nonempty(getattr(self, name), name)
|
|
||||||
_generation(self.goal_revision, "goal_revision")
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
value = asdict(self)
|
|
||||||
value["stop_reason"] = self.stop_reason.value
|
|
||||||
return value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def content_hash(self) -> str:
|
|
||||||
return _sha256(self.as_dict())
|
|
||||||
|
|
||||||
|
|
||||||
def validate_state_ownership(
|
|
||||||
object_type: str,
|
|
||||||
writer: str,
|
|
||||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
|
||||||
*,
|
|
||||||
final_state: bool = False,
|
|
||||||
) -> None:
|
|
||||||
row = ownership_index(ownership).get(object_type)
|
|
||||||
if row is None:
|
|
||||||
raise ConvergenceContractError(f"state_owner_missing:{object_type}")
|
|
||||||
expected = row.final_writer if final_state and row.final_writer else row.authoritative_service
|
|
||||||
if writer != expected:
|
|
||||||
raise ConvergenceContractError(
|
|
||||||
f"STATE_OWNERSHIP_VIOLATION:{object_type}:{writer}:{expected}"
|
|
||||||
)
|
|
||||||
@@ -41,8 +41,6 @@ class JobState(str, Enum):
|
|||||||
REMOTE_SYNCING = "REMOTE_SYNCING"
|
REMOTE_SYNCING = "REMOTE_SYNCING"
|
||||||
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
FAILED = "FAILED"
|
|
||||||
CANCELLED = "CANCELLED"
|
|
||||||
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
||||||
RECONCILING = "RECONCILING"
|
RECONCILING = "RECONCILING"
|
||||||
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
||||||
@@ -61,12 +59,7 @@ _ORDER = {
|
|||||||
JobState.COMPLETED: 8,
|
JobState.COMPLETED: 8,
|
||||||
}
|
}
|
||||||
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
||||||
_TERMINAL = {
|
_TERMINAL = {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED}
|
||||||
JobState.COMPLETED,
|
|
||||||
JobState.FAILED,
|
|
||||||
JobState.CANCELLED,
|
|
||||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -178,63 +171,6 @@ class JobStore:
|
|||||||
connection.execute("PRAGMA busy_timeout=30000")
|
connection.execute("PRAGMA busy_timeout=30000")
|
||||||
return connection
|
return connection
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def for_existing_append(cls, root: str | os.PathLike[str]) -> "JobStore":
|
|
||||||
"""Open an existing job database without initialization side effects."""
|
|
||||||
path = Path(root)
|
|
||||||
if (
|
|
||||||
not path.is_absolute()
|
|
||||||
or path.is_symlink()
|
|
||||||
or not path.is_dir()
|
|
||||||
or not (path / "state.sqlite3").is_file()
|
|
||||||
or (path / "state.sqlite3").is_symlink()
|
|
||||||
):
|
|
||||||
raise JobError("JOB_ROOT_NOT_FOUND")
|
|
||||||
instance = cls.__new__(cls)
|
|
||||||
instance.root = path
|
|
||||||
instance.db_path = path / "state.sqlite3"
|
|
||||||
return instance
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ensure_completed_replay_schema(connection: sqlite3.Connection) -> None:
|
|
||||||
connection.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS completed_replay_reconciliations (
|
|
||||||
reconciliation_id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
|
||||||
identity_digest TEXT NOT NULL UNIQUE,
|
|
||||||
source_lease_epoch INTEGER NOT NULL,
|
|
||||||
task_id TEXT NOT NULL, external_job_id TEXT NOT NULL,
|
|
||||||
internal_job_id TEXT NOT NULL, repository TEXT NOT NULL,
|
|
||||||
target_branch TEXT NOT NULL, generation INTEGER NOT NULL,
|
|
||||||
branch_mode TEXT NOT NULL, payload_digest TEXT NOT NULL,
|
|
||||||
files_digest TEXT NOT NULL, message_digest TEXT NOT NULL,
|
|
||||||
commit_oid TEXT NOT NULL, authority_head_oid TEXT NOT NULL,
|
|
||||||
current_head_oid TEXT NOT NULL, historical_lease_head_oid TEXT NOT NULL,
|
|
||||||
worker_principal TEXT NOT NULL, worker_lineage TEXT NOT NULL,
|
|
||||||
durable_revision INTEGER NOT NULL, completion_identity TEXT NOT NULL,
|
|
||||||
reason TEXT NOT NULL, provenance_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS completed_replay_job_idx
|
|
||||||
ON completed_replay_reconciliations(job_id);
|
|
||||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_update
|
|
||||||
BEFORE UPDATE ON completed_replay_reconciliations
|
|
||||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
|
||||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_delete
|
|
||||||
BEFORE DELETE ON completed_replay_reconciliations
|
|
||||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
columns = {
|
|
||||||
str(item[1]) for item in connection.execute(
|
|
||||||
"PRAGMA table_info(completed_replay_reconciliations)"
|
|
||||||
).fetchall()
|
|
||||||
}
|
|
||||||
if columns and "historical_lease_head_oid" not in columns:
|
|
||||||
connection.execute(
|
|
||||||
"ALTER TABLE completed_replay_reconciliations ADD COLUMN historical_lease_head_oid TEXT"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
@@ -273,43 +209,9 @@ class JobStore:
|
|||||||
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS completed_replay_reconciliations (
|
|
||||||
reconciliation_id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
|
||||||
identity_digest TEXT NOT NULL UNIQUE,
|
|
||||||
source_lease_epoch INTEGER NOT NULL,
|
|
||||||
task_id TEXT NOT NULL, external_job_id TEXT NOT NULL,
|
|
||||||
internal_job_id TEXT NOT NULL, repository TEXT NOT NULL,
|
|
||||||
target_branch TEXT NOT NULL, generation INTEGER NOT NULL,
|
|
||||||
branch_mode TEXT NOT NULL, payload_digest TEXT NOT NULL,
|
|
||||||
files_digest TEXT NOT NULL, message_digest TEXT NOT NULL,
|
|
||||||
commit_oid TEXT NOT NULL, authority_head_oid TEXT NOT NULL,
|
|
||||||
current_head_oid TEXT NOT NULL, historical_lease_head_oid TEXT NOT NULL,
|
|
||||||
worker_principal TEXT NOT NULL, worker_lineage TEXT NOT NULL,
|
|
||||||
durable_revision INTEGER NOT NULL, completion_identity TEXT NOT NULL,
|
|
||||||
reason TEXT NOT NULL, provenance_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS completed_replay_job_idx
|
|
||||||
ON completed_replay_reconciliations(job_id);
|
|
||||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_update
|
|
||||||
BEFORE UPDATE ON completed_replay_reconciliations
|
|
||||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
|
||||||
CREATE TRIGGER IF NOT EXISTS completed_replay_no_delete
|
|
||||||
BEFORE DELETE ON completed_replay_reconciliations
|
|
||||||
BEGIN SELECT RAISE(ABORT, 'COMPLETED_REPLAY_APPEND_ONLY'); END;
|
|
||||||
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '1');
|
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '1');
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
columns = {
|
|
||||||
str(item[1]) for item in connection.execute(
|
|
||||||
"PRAGMA table_info(completed_replay_reconciliations)"
|
|
||||||
).fetchall()
|
|
||||||
}
|
|
||||||
if columns and "historical_lease_head_oid" not in columns:
|
|
||||||
connection.execute(
|
|
||||||
"ALTER TABLE completed_replay_reconciliations "
|
|
||||||
"ADD COLUMN historical_lease_head_oid TEXT"
|
|
||||||
)
|
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
|
||||||
@@ -342,227 +244,6 @@ class JobStore:
|
|||||||
raise JobError("JOB_NOT_FOUND")
|
raise JobError("JOB_NOT_FOUND")
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _completed_replay_identity(envelope: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
required = (
|
|
||||||
"task_id", "external_job_id", "internal_job_id", "repository",
|
|
||||||
"target_branch", "generation", "branch_mode", "payload_digest",
|
|
||||||
"files_digest", "message_digest", "commit_oid", "authority_head_oid",
|
|
||||||
"historical_lease_head_oid", "worker_principal", "worker_lineage", "source_lease_epoch",
|
|
||||||
"durable_revision", "completion_identity", "reason", "provenance",
|
|
||||||
)
|
|
||||||
if any(key not in envelope for key in required):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INCOMPLETE")
|
|
||||||
text_fields = (
|
|
||||||
"task_id", "external_job_id", "internal_job_id", "repository",
|
|
||||||
"target_branch", "branch_mode", "payload_digest", "files_digest",
|
|
||||||
"message_digest", "commit_oid", "authority_head_oid", "worker_principal",
|
|
||||||
"worker_lineage", "historical_lease_head_oid", "completion_identity", "reason",
|
|
||||||
)
|
|
||||||
result: dict[str, Any] = {}
|
|
||||||
for key in text_fields:
|
|
||||||
value = envelope[key]
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
|
||||||
result[key] = value
|
|
||||||
for key in ("generation", "source_lease_epoch", "durable_revision"):
|
|
||||||
value = envelope[key]
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
|
||||||
result[key] = value
|
|
||||||
for key in ("commit_oid", "authority_head_oid", "historical_lease_head_oid"):
|
|
||||||
if len(result[key]) != 40 or any(ch not in "0123456789abcdef" for ch in result[key]):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
|
||||||
for key in ("payload_digest", "files_digest", "message_digest"):
|
|
||||||
if len(result[key]) != 64 or any(ch not in "0123456789abcdef" for ch in result[key]):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
|
||||||
if result["repository"] != "aios/aios" or result["branch_mode"] not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
|
||||||
if not result["target_branch"].startswith("agent/") or result["target_branch"] in {"main", "master"}:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_INVALID")
|
|
||||||
provenance = envelope["provenance"]
|
|
||||||
if not isinstance(provenance, Mapping) or not provenance:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_PROVENANCE_INVALID")
|
|
||||||
result["provenance"] = dict(provenance)
|
|
||||||
if result["commit_oid"] != result["authority_head_oid"]:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def append_completed_replay_reconciliation(
|
|
||||||
self,
|
|
||||||
job_id: str,
|
|
||||||
envelope: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
authority_head_oid: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Append exact completed-replay evidence without rewriting history."""
|
|
||||||
identity = self._completed_replay_identity(envelope)
|
|
||||||
if authority_head_oid != identity["authority_head_oid"]:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_AUTHORITY_CONFLICT")
|
|
||||||
if identity["internal_job_id"] != job_id:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_INTERNAL_JOB_CONFLICT")
|
|
||||||
identity_digest = sha256_json(identity)
|
|
||||||
reconciliation_id = "reconcile-" + identity_digest[:32]
|
|
||||||
now = utc_now()
|
|
||||||
try:
|
|
||||||
with self._connect() as connection:
|
|
||||||
self._ensure_completed_replay_schema(connection)
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise JobError("JOB_NOT_FOUND")
|
|
||||||
if JobState(row["state"]) != JobState.COMPLETED:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_JOB_NOT_COMPLETED")
|
|
||||||
durable_request = json.loads(row["request_json"])
|
|
||||||
binding = durable_request.get("binding", durable_request)
|
|
||||||
exact_binding = {
|
|
||||||
"task_id": binding.get("task_id") or binding.get("campaign_id"),
|
|
||||||
"external_job_id": binding.get("external_job_id"),
|
|
||||||
"internal_job_id": job_id,
|
|
||||||
"repository": binding.get("repository") or row["repository"],
|
|
||||||
"target_branch": binding.get("target_branch") or binding.get("branch"),
|
|
||||||
"generation": binding.get("generation"),
|
|
||||||
"branch_mode": binding.get("branch_mode"),
|
|
||||||
"payload_digest": binding.get("payload_digest") or row["payload_digest"],
|
|
||||||
"files_digest": binding.get("files_digest"),
|
|
||||||
"message_digest": binding.get("message_digest"),
|
|
||||||
"worker_principal": binding.get("worker_principal"),
|
|
||||||
"worker_lineage": binding.get("worker_lineage"),
|
|
||||||
}
|
|
||||||
for key, value in exact_binding.items():
|
|
||||||
if str(value) != str(identity[key]):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_BINDING_CONFLICT")
|
|
||||||
receipt = json.loads(row["receipt_json"]) if row["receipt_json"] else None
|
|
||||||
if not isinstance(receipt, Mapping):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_RECEIPT_MISSING")
|
|
||||||
if receipt.get("commit_oid") != identity["commit_oid"] or receipt.get("authority_oid") != identity["authority_head_oid"]:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_RECEIPT_AUTHORITY_CONFLICT")
|
|
||||||
completion_identity = sha256_json({
|
|
||||||
"job_id": row["job_id"],
|
|
||||||
"revision": int(row["revision"]),
|
|
||||||
"receipt": receipt,
|
|
||||||
})
|
|
||||||
if identity["completion_identity"] != completion_identity:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_COMPLETION_IDENTITY_CONFLICT")
|
|
||||||
lease_columns = {
|
|
||||||
str(item[1]) for item in connection.execute("PRAGMA table_info(branch_leases)").fetchall()
|
|
||||||
}
|
|
||||||
if "epoch" in lease_columns:
|
|
||||||
lease = connection.execute(
|
|
||||||
"SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? AND epoch=? ORDER BY rowid DESC LIMIT 1",
|
|
||||||
(job_id, identity["source_lease_epoch"]),
|
|
||||||
).fetchone()
|
|
||||||
else:
|
|
||||||
if identity["source_lease_epoch"] != 1:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_EPOCH_UNAVAILABLE")
|
|
||||||
lease = connection.execute(
|
|
||||||
"SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? ORDER BY rowid DESC LIMIT 1",
|
|
||||||
(job_id,),
|
|
||||||
).fetchone()
|
|
||||||
if lease is None:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_MISSING")
|
|
||||||
lease_state = str(lease["state"])
|
|
||||||
lease_head = str(lease["current_head_oid"])
|
|
||||||
lease_generation = int(lease["generation"])
|
|
||||||
event_tables = {
|
|
||||||
str(item[0]) for item in connection.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='branch_lease_events'"
|
|
||||||
).fetchall()
|
|
||||||
}
|
|
||||||
if "branch_lease_events" in event_tables:
|
|
||||||
event = connection.execute(
|
|
||||||
"SELECT state, current_head_oid, generation FROM branch_lease_events WHERE lease_id=? ORDER BY rowid DESC LIMIT 1",
|
|
||||||
(lease["lease_id"],),
|
|
||||||
).fetchone()
|
|
||||||
if event is not None:
|
|
||||||
lease_state = str(event["state"])
|
|
||||||
lease_head = str(event["current_head_oid"])
|
|
||||||
lease_generation = int(event["generation"])
|
|
||||||
if lease_state not in {"ACTIVE", "USED", "EXPIRED"}:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_STATE_INVALID")
|
|
||||||
if lease_generation != identity["generation"]:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_GENERATION_CONFLICT")
|
|
||||||
if len(lease_head) != 40 or any(ch not in "0123456789abcdef" for ch in lease_head):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_LEASE_HEAD_INVALID")
|
|
||||||
if lease_head != identity["historical_lease_head_oid"]:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_HISTORICAL_LEASE_CONFLICT")
|
|
||||||
if identity["commit_oid"] == identity["historical_lease_head_oid"]:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
|
||||||
existing = connection.execute(
|
|
||||||
"SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC",
|
|
||||||
(job_id,),
|
|
||||||
).fetchall()
|
|
||||||
for prior in existing:
|
|
||||||
if prior["identity_digest"] != identity_digest:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_AMBIGUOUS")
|
|
||||||
if existing:
|
|
||||||
result = dict(existing[0])
|
|
||||||
connection.commit()
|
|
||||||
return result
|
|
||||||
provenance_json = json.dumps(identity["provenance"], sort_keys=True, separators=(",", ":"))
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO completed_replay_reconciliations(reconciliation_id,job_id,identity_digest,source_lease_epoch,task_id,external_job_id,internal_job_id,repository,target_branch,generation,branch_mode,payload_digest,files_digest,message_digest,commit_oid,authority_head_oid,worker_principal,worker_lineage,current_head_oid,historical_lease_head_oid,durable_revision,completion_identity,reason,provenance_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
reconciliation_id, job_id, identity["identity_digest"] if "identity_digest" in identity else identity_digest, identity["source_lease_epoch"],
|
|
||||||
identity["task_id"], identity["external_job_id"], identity["internal_job_id"],
|
|
||||||
identity["repository"], identity["target_branch"], identity["generation"],
|
|
||||||
identity["branch_mode"], identity["payload_digest"], identity["files_digest"],
|
|
||||||
identity["message_digest"], identity["commit_oid"], identity["authority_head_oid"],
|
|
||||||
identity["worker_principal"], identity["worker_lineage"], identity["authority_head_oid"], identity["historical_lease_head_oid"], identity["durable_revision"],
|
|
||||||
identity["completion_identity"], identity["reason"], provenance_json, now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result = dict(connection.execute(
|
|
||||||
"SELECT * FROM completed_replay_reconciliations WHERE reconciliation_id=?",
|
|
||||||
(reconciliation_id,),
|
|
||||||
).fetchone())
|
|
||||||
connection.commit()
|
|
||||||
return result
|
|
||||||
except (JobError, BindingConflict):
|
|
||||||
raise
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
try:
|
|
||||||
with self._connect() as connection:
|
|
||||||
existing = connection.execute(
|
|
||||||
"SELECT * FROM completed_replay_reconciliations WHERE identity_digest=?",
|
|
||||||
(identity_digest,),
|
|
||||||
).fetchone()
|
|
||||||
if existing is not None:
|
|
||||||
return dict(existing)
|
|
||||||
except sqlite3.Error:
|
|
||||||
pass
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_IDEMPOTENCY_CONFLICT") from exc
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
def completed_replay_evidence(self, job_id: str) -> dict[str, Any] | None:
|
|
||||||
try:
|
|
||||||
with self._connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT * FROM completed_replay_reconciliations WHERE job_id=? ORDER BY created_at ASC",
|
|
||||||
(job_id,),
|
|
||||||
).fetchall()
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
|
||||||
if not rows:
|
|
||||||
return None
|
|
||||||
if len({str(row["identity_digest"]) for row in rows}) != 1:
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_AMBIGUOUS")
|
|
||||||
first = dict(rows[0])
|
|
||||||
immutable_fields = tuple(
|
|
||||||
key for key in first
|
|
||||||
if key != "created_at"
|
|
||||||
)
|
|
||||||
for row in rows[1:]:
|
|
||||||
if any(row[key] != first[key] for key in immutable_fields):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_AMBIGUOUS")
|
|
||||||
if not (
|
|
||||||
first["commit_oid"] == first["authority_head_oid"] == first["current_head_oid"]
|
|
||||||
and first["commit_oid"] != first["historical_lease_head_oid"]
|
|
||||||
):
|
|
||||||
raise BindingConflict("COMPLETED_REPLAY_EVIDENCE_INCOHERENT")
|
|
||||||
return first
|
|
||||||
|
|
||||||
def _receipt(self, row: Mapping[str, Any], *, duplicate: bool) -> JobReceipt:
|
def _receipt(self, row: Mapping[str, Any], *, duplicate: bool) -> JobReceipt:
|
||||||
state = JobState(row["state"])
|
state = JobState(row["state"])
|
||||||
if state == JobState.COMPLETED:
|
if state == JobState.COMPLETED:
|
||||||
@@ -614,11 +295,6 @@ class GitOpsJobController:
|
|||||||
"check_context",
|
"check_context",
|
||||||
"check_state",
|
"check_state",
|
||||||
"check_description",
|
"check_description",
|
||||||
"external_job_id",
|
|
||||||
"files_digest",
|
|
||||||
"message_digest",
|
|
||||||
"worker_principal",
|
|
||||||
"worker_lineage",
|
|
||||||
)
|
)
|
||||||
for field in optional:
|
for field in optional:
|
||||||
value = request.get(field)
|
value = request.get(field)
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||||
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||||
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
||||||
@@ -220,11 +218,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
a = self.require_write(['owner']);
|
a = self.require_write(['owner']);
|
||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
try:
|
if not re.match(r'^V\d+R\d+C\d+(-[a-zA-Z0-9_.-]+)?$', ver or ''): self.send_json({'error':'version must match VxRxCx'},400); return
|
||||||
validate_release_version(ver)
|
|
||||||
except ValueError:
|
|
||||||
self.send_json({'error':'version must match VxRxCx'},400)
|
|
||||||
return
|
|
||||||
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
||||||
if data.get('push',False): git_cmd(['push','origin',ver])
|
if data.get('push',False): git_cmd(['push','origin',ver])
|
||||||
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import re
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||||
@@ -54,46 +53,7 @@ class GovernedTaskBootstrap:
|
|||||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||||
path = self._metadata(task_id)
|
path = self._metadata(task_id)
|
||||||
if not path.is_file() or path.is_symlink(): return None
|
if not path.is_file() or path.is_symlink(): return None
|
||||||
|
|
||||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||||
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
|
||||||
"""Read the exact durable binding; never synthesize missing lineage."""
|
|
||||||
binding = self._read_existing(str(task_id))
|
|
||||||
if binding is None or not binding.job_id:
|
|
||||||
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
|
||||||
return binding
|
|
||||||
|
|
||||||
def execution_binding(self, binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
|
||||||
return GovernedExecutionBinding.from_mapping(
|
|
||||||
{
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"job_id": binding.job_id,
|
|
||||||
"repository": binding.repository,
|
|
||||||
"base_oid": binding.base_oid,
|
|
||||||
"expected_branch_oid": binding.expected_branch_oid,
|
|
||||||
"target_branch": binding.target_branch,
|
|
||||||
"branch_mode": binding.branch_mode,
|
|
||||||
"generation": binding.generation,
|
|
||||||
"idempotency_key": binding.idempotency_key,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def execution_envelope(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
execution_request: Mapping[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
required_capabilities: tuple[str, ...] = (),
|
|
||||||
expected_job_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
binding = self.get_binding(task_id)
|
|
||||||
if expected_job_id and binding.job_id != expected_job_id:
|
|
||||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
|
||||||
return project_execution_envelope(
|
|
||||||
self.execution_binding(binding),
|
|
||||||
execution_request,
|
|
||||||
required_capabilities=required_capabilities,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
def _write(self, binding: GovernedTaskBinding) -> None:
|
||||||
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ from __future__ import annotations
|
|||||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from version_registry import (
|
||||||
|
configured_authoritative_git_dir,
|
||||||
|
configured_repository_identity,
|
||||||
|
list_registry_versions,
|
||||||
|
list_registry_versions_report,
|
||||||
|
)
|
||||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||||
from silent_release_controller import ReleaseCandidate
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -29,18 +34,11 @@ from orchestrator.action_monitor import ActionMonitor
|
|||||||
from silent_release_hardening import (
|
from silent_release_hardening import (
|
||||||
ApprovalKeyRing,
|
ApprovalKeyRing,
|
||||||
ApprovalRevocationStore,
|
ApprovalRevocationStore,
|
||||||
BuilderAttestation,
|
|
||||||
BuilderAttestationKeyRing,
|
|
||||||
BuilderAttestationVerifier,
|
|
||||||
CapabilityAuthorizationError,
|
CapabilityAuthorizationError,
|
||||||
ReleasePlan,
|
|
||||||
SagaConflict,
|
SagaConflict,
|
||||||
SignedApprovalVerifier,
|
SignedApprovalVerifier,
|
||||||
SignedReleasePlanApproval,
|
SignedReleasePlanApproval,
|
||||||
SilentReleaseActionService,
|
SilentReleaseActionService,
|
||||||
TrustedCandidateBuilderUnavailable,
|
|
||||||
TrustedCandidateBundle,
|
|
||||||
TrustedReleasePolicy,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||||
@@ -81,43 +79,25 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
|||||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
AUTHORITATIVE_GIT_DIR = configured_authoritative_git_dir()
|
||||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
REPOSITORY_IDENTITY = configured_repository_identity()
|
||||||
BASELINE_AUTHORITY = BaselineAuthority()
|
BASELINE_AUTHORITY = BaselineAuthority()
|
||||||
GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') or os.getenv('AIOS_GITOPS_REPO', '/data/aios-gitops/repo.git'))
|
# Retain the generic Git directory only for legacy/staging compatibility
|
||||||
|
# commands. Source-registry convergence receives the separate authority
|
||||||
|
# below and never falls back to this path.
|
||||||
|
GIT_DIR = Path(os.getenv('AIOS_GITOPS_REPO', '/data/aios-gitops/repo.git'))
|
||||||
LEGACY_GIT_WRITE_COMMANDS = frozenset({'add','commit','checkout','branch','tag','push','fetch','reset','merge','rebase','update-ref','worktree'})
|
LEGACY_GIT_WRITE_COMMANDS = frozenset({'add','commit','checkout','branch','tag','push','fetch','reset','merge','rebase','update-ref','worktree'})
|
||||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||||
'/var/lib/aios-gitops/silent-release',
|
'/var/lib/aios-gitops/silent-release',
|
||||||
))
|
))
|
||||||
# This immutable staging API can prepare only a shadow candidate. An
|
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
||||||
# environment value must not turn its HTTP process into a release executor.
|
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
||||||
SILENT_RELEASE_EXECUTION_ENABLED = False
|
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||||
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||||
))
|
))
|
||||||
TRUSTED_BUILDER_ENABLED = str(os.getenv(
|
|
||||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED', 'false'
|
|
||||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
|
||||||
TRUSTED_BUILDER_POLICY = Path(os.getenv(
|
|
||||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY', ''
|
|
||||||
))
|
|
||||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY = Path(os.getenv(
|
|
||||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY',
|
|
||||||
os.getenv('CREDENTIALS_DIRECTORY', ''),
|
|
||||||
))
|
|
||||||
TRUSTED_BUILDER_SOCKET = Path(os.getenv(
|
|
||||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET',
|
|
||||||
'/run/aios-trusted-candidate-builder.sock',
|
|
||||||
))
|
|
||||||
TRUSTED_BUILDER_PUBLIC_KEYRING = Path(os.getenv(
|
|
||||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING', ''
|
|
||||||
))
|
|
||||||
TRUSTED_RELEASE_POLICY = Path(os.getenv(
|
|
||||||
'AIOS_SILENT_RELEASE_TRUSTED_POLICY',
|
|
||||||
str(WS / 'config' / 'gitops' / 'silent-release-policy.json'),
|
|
||||||
))
|
|
||||||
monitor = ActionMonitor()
|
monitor = ActionMonitor()
|
||||||
|
|
||||||
BASE_MODELS = [
|
BASE_MODELS = [
|
||||||
@@ -195,86 +175,6 @@ def _file_sha256(path: Path) -> str:
|
|||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _trusted_release_policy_from_file(path: Path) -> TrustedReleasePolicy:
|
|
||||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
||||||
required = {
|
|
||||||
'services', 'release_roots', 'cutover_links', 'dropin_paths',
|
|
||||||
'authoritative_git_dirs', 'repository_identities',
|
|
||||||
}
|
|
||||||
if not isinstance(payload, dict) or set(payload) != required:
|
|
||||||
raise ValueError('trusted release policy schema is invalid')
|
|
||||||
values = {}
|
|
||||||
for name in required:
|
|
||||||
raw = payload[name]
|
|
||||||
if not isinstance(raw, list) or not raw or not all(
|
|
||||||
isinstance(item, str) and item for item in raw
|
|
||||||
):
|
|
||||||
raise ValueError('trusted release policy values are invalid')
|
|
||||||
values[name] = frozenset(raw)
|
|
||||||
return TrustedReleasePolicy(**values)
|
|
||||||
|
|
||||||
|
|
||||||
class _HostTrustedCandidateBuilderAdapter:
|
|
||||||
"""Maps only a complete host-owned silent-release bundle response.
|
|
||||||
|
|
||||||
The API never manufactures candidate evidence or a builder signature. A
|
|
||||||
host builder protocol that returns a different response is therefore
|
|
||||||
unavailable, rather than silently downgraded to caller-owned evidence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, client):
|
|
||||||
self._client = client
|
|
||||||
|
|
||||||
def build(self, intent):
|
|
||||||
import secrets
|
|
||||||
from aios_trusted_candidate_builder import BuilderError
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not isinstance(intent, dict):
|
|
||||||
raise TypeError('intent must be a mapping')
|
|
||||||
version = str(intent.get('version') or 'V2R2C2')
|
|
||||||
# A createReleaseIntent request is intentionally identity-light.
|
|
||||||
# Candidate identity is resolved by the host builder policy, not by
|
|
||||||
# the caller. Explicit release-worker requests may still carry it.
|
|
||||||
candidate_id = str(intent.get('candidate_id') or '')
|
|
||||||
source_commit = str(intent.get('source_commit') or '')
|
|
||||||
if not version or not source_commit:
|
|
||||||
raise KeyError('version/source_commit')
|
|
||||||
except (KeyError, TypeError) as exc:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'trusted builder requires a release intent or worker request'
|
|
||||||
) from exc
|
|
||||||
try:
|
|
||||||
response = self._client.build(
|
|
||||||
version,
|
|
||||||
candidate_id,
|
|
||||||
source_commit,
|
|
||||||
nonce=secrets.token_hex(32),
|
|
||||||
)
|
|
||||||
except BuilderError as exc:
|
|
||||||
raise TrustedCandidateBuilderUnavailable(
|
|
||||||
'trusted builder request failed'
|
|
||||||
) from exc
|
|
||||||
raw_bundle = response.get('silent_release_bundle')
|
|
||||||
if not isinstance(raw_bundle, dict):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'trusted builder response is not a silent release bundle'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return TrustedCandidateBundle(
|
|
||||||
candidate=ReleaseCandidate.from_dict(raw_bundle['candidate']),
|
|
||||||
release_plan=ReleasePlan.from_dict(raw_bundle['release_plan']),
|
|
||||||
checks=raw_bundle['checks'],
|
|
||||||
attestation=BuilderAttestation.from_dict(
|
|
||||||
raw_bundle['attestation'], require_fingerprint=True
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'trusted builder response is invalid'
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _build_silent_release_service() -> SilentReleaseActionService:
|
def _build_silent_release_service() -> SilentReleaseActionService:
|
||||||
verifier=None
|
verifier=None
|
||||||
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
||||||
@@ -284,88 +184,12 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
|||||||
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
policy_path=TRUSTED_RELEASE_POLICY
|
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
||||||
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
||||||
candidate_builder = None
|
|
||||||
builder_attestation_verifier = None
|
|
||||||
trusted_policy = None
|
|
||||||
builder_readiness_probe = None
|
|
||||||
if TRUSTED_BUILDER_ENABLED:
|
|
||||||
# Every path is opt-in. In particular, this API receives its own
|
|
||||||
# client HMAC credential, never the builder's signing private key.
|
|
||||||
if not (
|
|
||||||
TRUSTED_BUILDER_POLICY.is_file()
|
|
||||||
and TRUSTED_BUILDER_CREDENTIALS_DIRECTORY.is_dir()
|
|
||||||
and TRUSTED_BUILDER_PUBLIC_KEYRING.is_file()
|
|
||||||
and TRUSTED_RELEASE_POLICY.is_file()
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'trusted builder configuration is incomplete'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from aios_trusted_candidate_builder import (
|
|
||||||
BuilderKeyring,
|
|
||||||
TrustedCandidateBuilderConfig,
|
|
||||||
UnixTrustedCandidateBuilderClient,
|
|
||||||
load_builder_policy,
|
|
||||||
readiness_probe,
|
|
||||||
)
|
|
||||||
builder_policy = load_builder_policy(TRUSTED_BUILDER_POLICY)
|
|
||||||
builder_config = TrustedCandidateBuilderConfig.from_policy(
|
|
||||||
builder_policy
|
|
||||||
)
|
|
||||||
client_hmac = (
|
|
||||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY / 'builder_hmac'
|
|
||||||
).read_bytes().strip()
|
|
||||||
client_keyring = BuilderKeyring(builder_hmac=client_hmac)
|
|
||||||
client = UnixTrustedCandidateBuilderClient(
|
|
||||||
builder_config,
|
|
||||||
client_keyring,
|
|
||||||
socket_path=TRUSTED_BUILDER_SOCKET,
|
|
||||||
)
|
|
||||||
candidate_builder = _HostTrustedCandidateBuilderAdapter(client)
|
|
||||||
builder_attestation_verifier = BuilderAttestationVerifier(
|
|
||||||
BuilderAttestationKeyRing.from_file(
|
|
||||||
TRUSTED_BUILDER_PUBLIC_KEYRING
|
|
||||||
)
|
|
||||||
)
|
|
||||||
trusted_policy = _trusted_release_policy_from_file(
|
|
||||||
TRUSTED_RELEASE_POLICY
|
|
||||||
)
|
|
||||||
|
|
||||||
def builder_readiness_probe():
|
|
||||||
if not TRUSTED_BUILDER_SOCKET.exists():
|
|
||||||
return {'status': 'UNAVAILABLE'}
|
|
||||||
remote = dict(readiness_probe(client))
|
|
||||||
if remote.get('policy_sha256') != builder_config.policy_sha256:
|
|
||||||
return {'status': 'UNAVAILABLE'}
|
|
||||||
builder_attestation_verifier.keyring.require_active_identity(
|
|
||||||
key_id=str(remote.get('builder_key_id') or ''),
|
|
||||||
generation=remote.get('builder_key_generation'),
|
|
||||||
algorithm=str(remote.get('signature_algorithm') or ''),
|
|
||||||
purpose=str(remote.get('signature_purpose') or ''),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
**remote,
|
|
||||||
'hmac_credential_loaded': True,
|
|
||||||
'peer_verification_configured': True,
|
|
||||||
'public_key_trusted': True,
|
|
||||||
'policy_loaded': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'trusted builder configuration is invalid'
|
|
||||||
) from exc
|
|
||||||
return SilentReleaseActionService(
|
return SilentReleaseActionService(
|
||||||
SILENT_RELEASE_STATE_ROOT,
|
SILENT_RELEASE_STATE_ROOT,
|
||||||
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
||||||
approval_verifier=verifier,
|
approval_verifier=verifier,
|
||||||
candidate_builder=candidate_builder,
|
|
||||||
builder_attestation_verifier=builder_attestation_verifier,
|
|
||||||
trusted_policy=trusted_policy,
|
|
||||||
builder_readiness_probe=builder_readiness_probe,
|
|
||||||
allow_legacy_candidate_input=False,
|
|
||||||
expected_repository=REPOSITORY_IDENTITY,
|
expected_repository=REPOSITORY_IDENTITY,
|
||||||
policy_sha256=policy_sha,
|
policy_sha256=policy_sha,
|
||||||
)
|
)
|
||||||
@@ -528,15 +352,6 @@ def publication_request_from_query(q: dict[str, list[str]]) -> PublicationReques
|
|||||||
(q['approval_plan_id'])[0],
|
(q['approval_plan_id'])[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
def governed_release_query_payload(q: dict[str, list[str]]) -> dict[str, str]:
|
|
||||||
request = publication_request_from_query(q)
|
|
||||||
return {
|
|
||||||
'version': request.version,
|
|
||||||
'target_oid': request.target_oid,
|
|
||||||
'evidence_closure_oid': request.evidence_closure_oid,
|
|
||||||
'approval_plan_id': request.approval_plan_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
def required_authoritative_git_dir() -> Path:
|
def required_authoritative_git_dir() -> Path:
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
@@ -660,11 +475,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
q=urllib.parse.parse_qs(u.query)
|
q=urllib.parse.parse_qs(u.query)
|
||||||
try:
|
try:
|
||||||
if path in ('/health','/api/health'):
|
if path in ('/health','/api/health'):
|
||||||
builder_readiness = silent_release_service().get_builder_readiness()
|
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'process_liveness': 'PASS',
|
|
||||||
'builder_readiness': builder_readiness,
|
|
||||||
'service': os.getenv('AIOS_GITOPS_MODEL_API_SERVICE', 'aios-gitops-model-api-v2r2c1-staging.service'),
|
'service': os.getenv('AIOS_GITOPS_MODEL_API_SERVICE', 'aios-gitops-model-api-v2r2c1-staging.service'),
|
||||||
'workspace': str(WS),
|
'workspace': str(WS),
|
||||||
'version': 'V2R2C1',
|
'version': 'V2R2C1',
|
||||||
@@ -692,6 +504,11 @@ class H(BaseHTTPRequestHandler):
|
|||||||
data['legacy_global_writes_enabled'] = False
|
data['legacy_global_writes_enabled'] = False
|
||||||
return self.sendj(data)
|
return self.sendj(data)
|
||||||
if path in ('/api/ver','/api/versions','/ver'):
|
if path in ('/api/ver','/api/versions','/ver'):
|
||||||
|
registry_report = list_registry_versions_report(
|
||||||
|
WS,
|
||||||
|
baseline_file=BASELINE_AUTHORITY.path,
|
||||||
|
repo_git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
baseline = dict(BASELINE_AUTHORITY.read())
|
baseline = dict(BASELINE_AUTHORITY.read())
|
||||||
except BaselineAuthorityError as exc:
|
except BaselineAuthorityError as exc:
|
||||||
@@ -704,7 +521,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': snapshot['query_error'],
|
'query_error': snapshot['query_error'],
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
'baseline_version': baseline.get('current_baseline'),
|
'baseline_version': baseline.get('current_baseline'),
|
||||||
'baseline_generation': baseline.get('generation'),
|
'baseline_generation': baseline.get('generation'),
|
||||||
@@ -716,13 +534,14 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({
|
return self.sendj({
|
||||||
'repository_available': False,
|
'repository_available': False,
|
||||||
'query_error': str(exc),
|
'query_error': str(exc),
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
'registry_versions': registry_report['registry_versions'],
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
'local_authoritative_tags': [],
|
'local_authoritative_tags': [],
|
||||||
'baseline_version': baseline.get('current_baseline'),
|
'baseline_version': baseline.get('current_baseline'),
|
||||||
'baseline_generation': baseline.get('generation'),
|
'baseline_generation': baseline.get('generation'),
|
||||||
'baseline_sha256': baseline.get('authority_sha256'),
|
'baseline_sha256': baseline.get('authority_sha256'),
|
||||||
}, 503)
|
}, 503)
|
||||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
registry = registry_report['registry_versions']
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'registry_versions': registry,
|
'registry_versions': registry,
|
||||||
'local_authoritative_tags': tags,
|
'local_authoritative_tags': tags,
|
||||||
@@ -736,6 +555,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
'baseline_generation': baseline.get('generation'),
|
'baseline_generation': baseline.get('generation'),
|
||||||
'baseline_sha256': baseline.get('authority_sha256'),
|
'baseline_sha256': baseline.get('authority_sha256'),
|
||||||
'baseline': baseline,
|
'baseline': baseline,
|
||||||
|
**{key: registry_report[key] for key in ('effective_registry_versions', 'runtime_registry_versions', 'attested_projection_versions', 'attested_projection_version', 'source_registry_converged', 'runtime_registry_converged', 'runtime_bundle_contains_current_version', 'runtime_bundle_contains_version', 'registry_consistency_status')},
|
||||||
})
|
})
|
||||||
if path in ('/api/diff','/diff'):
|
if path in ('/api/diff','/diff'):
|
||||||
frm=(q.get('from') or [''])[0]
|
frm=(q.get('from') or [''])[0]
|
||||||
@@ -903,15 +723,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if path == '/createReleaseCandidate':
|
if path == '/createReleaseCandidate':
|
||||||
a=self.need('release:candidate')
|
a=self.need('release:candidate')
|
||||||
if not a: return
|
if not a: return
|
||||||
service = silent_release_service()
|
|
||||||
readiness = service.get_builder_readiness()
|
|
||||||
if readiness.get('status') != 'READY':
|
|
||||||
return self.sendj({
|
|
||||||
'error': 'TRUSTED_BUILDER_UNAVAILABLE',
|
|
||||||
'builder_readiness': readiness,
|
|
||||||
}, 503)
|
|
||||||
return self.sendj(
|
return self.sendj(
|
||||||
service.create_release_candidate(data)
|
silent_release_service().create_release_candidate(data)
|
||||||
)
|
)
|
||||||
if path == '/createReleaseIntent':
|
if path == '/createReleaseIntent':
|
||||||
a=self.need('release:candidate')
|
a=self.need('release:candidate')
|
||||||
@@ -951,13 +764,6 @@ class H(BaseHTTPRequestHandler):
|
|||||||
error=redact(e)
|
error=redact(e)
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
monitor.record_failure('do_POST', error, {'path':path})
|
||||||
return self.sendj({'error':error},403)
|
return self.sendj({'error':error},403)
|
||||||
except TrustedCandidateBuilderUnavailable:
|
|
||||||
monitor.record_failure(
|
|
||||||
'do_POST', 'TRUSTED_BUILDER_UNAVAILABLE', {'path': path}
|
|
||||||
)
|
|
||||||
return self.sendj(
|
|
||||||
{'error': 'TRUSTED_BUILDER_UNAVAILABLE'}, 503
|
|
||||||
)
|
|
||||||
except SagaConflict as e:
|
except SagaConflict as e:
|
||||||
error=redact(e)
|
error=redact(e)
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
monitor.record_failure('do_POST', error, {'path':path})
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
"""Canonical governed execution projection for Online Agent Tasks.
|
|
||||||
|
|
||||||
The durable governed binding is authoritative. Natural-language task fields and
|
|
||||||
worker configuration are never allowed to invent repository, ref, generation, or
|
|
||||||
idempotency coordinates.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping, Sequence
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
|
|
||||||
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
|
||||||
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
|
||||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
|
||||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
|
||||||
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
|
||||||
MUTATION_ACTIONS = frozenset({"deterministic"})
|
|
||||||
READ_ONLY_ACTIONS = frozenset({"source_capture", "agent_runtime"})
|
|
||||||
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture", "agent_runtime"})
|
|
||||||
AGENT_RUNTIME_ADAPTERS = frozenset({"hermes", "openclaw"})
|
|
||||||
MAX_AGENT_RUNTIME_INPUT_BYTES = 64 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionProjectionError(RuntimeError):
|
|
||||||
"""A canonical fail-closed projection or validation error."""
|
|
||||||
|
|
||||||
|
|
||||||
class OnlineTaskProjectionClient:
|
|
||||||
"""Official HTTP projection client using only a control credential reference."""
|
|
||||||
|
|
||||||
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
|
||||||
self.api_base = api_base.rstrip("/")
|
|
||||||
self.control_token_path = Path(control_token_path)
|
|
||||||
|
|
||||||
def _token(self) -> str:
|
|
||||||
path = self.control_token_path
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
|
||||||
value = path.read_text(encoding="utf-8").strip()
|
|
||||||
if len(value) < 32:
|
|
||||||
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
projected_task: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
binding: "GovernedExecutionBinding",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
body = dict(projected_task)
|
|
||||||
payload = _canonical(body).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
self.api_base + "/v1/tasks",
|
|
||||||
method="POST",
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer " + self._token(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=15) as response:
|
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
|
||||||
status = response.status
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raw = exc.read(8192).decode("utf-8", "replace")
|
|
||||||
try:
|
|
||||||
error = json.loads(raw).get("error")
|
|
||||||
except (AttributeError, json.JSONDecodeError):
|
|
||||||
error = "HTTP_ERROR"
|
|
||||||
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
|
||||||
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
|
||||||
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
|
||||||
if status not in {200, 201} or not isinstance(result, dict):
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
|
||||||
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
|
||||||
metadata = result.get("metadata")
|
|
||||||
if not isinstance(metadata, Mapping):
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
|
||||||
returned_execution = validate_execution_envelope(
|
|
||||||
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
|
||||||
binding=binding,
|
|
||||||
)
|
|
||||||
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
|
||||||
submitted_execution = validate_execution_envelope(
|
|
||||||
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
|
||||||
binding=binding,
|
|
||||||
)
|
|
||||||
if _canonical(returned_execution) != _canonical(submitted_execution):
|
|
||||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _error(code: str) -> None:
|
|
||||||
raise ExecutionProjectionError(code)
|
|
||||||
|
|
||||||
|
|
||||||
def _object(value: object, code: str) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
_error(code)
|
|
||||||
return dict(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _string(value: object, code: str) -> str:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
_error(code)
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _task_id(value: object) -> str:
|
|
||||||
value = _string(value, "TASK_ID_INVALID")
|
|
||||||
if TASK_RE.fullmatch(value) is None:
|
|
||||||
_error("TASK_ID_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _oid(value: object, code: str) -> str:
|
|
||||||
value = _string(value, code)
|
|
||||||
if OID_RE.fullmatch(value) is None:
|
|
||||||
_error(code)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _branch(value: object) -> str:
|
|
||||||
value = _string(value, "TARGET_BRANCH_INVALID")
|
|
||||||
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
|
||||||
_error("TARGET_BRANCH_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _generation(value: object) -> int:
|
|
||||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
||||||
_error("GENERATION_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> str:
|
|
||||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
|
||||||
def _digest(value: object) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _source_signals(value: object) -> tuple[str, ...]:
|
|
||||||
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
|
||||||
if value is None:
|
|
||||||
return ()
|
|
||||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
||||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
|
||||||
result: list[str] = []
|
|
||||||
for item in value:
|
|
||||||
if not isinstance(item, str):
|
|
||||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
|
||||||
text = " ".join(item.split()).strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
# Projection may accept long task prose, but execution envelopes stay bounded.
|
|
||||||
text = text[:512].rstrip()
|
|
||||||
if text not in result:
|
|
||||||
result.append(text)
|
|
||||||
if len(result) == 16:
|
|
||||||
break
|
|
||||||
return tuple(result)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_runtime_input(value: object) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
|
||||||
raw = dict(value)
|
|
||||||
allowed = {
|
|
||||||
"goal", "instructions", "success_criteria", "constraints",
|
|
||||||
"scope", "trace_id",
|
|
||||||
}
|
|
||||||
if set(raw) - allowed:
|
|
||||||
_error("AGENT_RUNTIME_INPUT_FIELDS_INVALID")
|
|
||||||
goal = _string(raw.get("goal"), "AGENT_RUNTIME_GOAL_REQUIRED")
|
|
||||||
instructions = raw.get("instructions", "")
|
|
||||||
if not isinstance(instructions, str):
|
|
||||||
_error("AGENT_RUNTIME_INSTRUCTIONS_INVALID")
|
|
||||||
criteria = raw.get("success_criteria", [])
|
|
||||||
if not isinstance(criteria, list) or len(criteria) > 32:
|
|
||||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
|
||||||
normalized_criteria: list[dict[str, str]] = []
|
|
||||||
for item in criteria:
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
|
||||||
criterion_id = _string(item.get("id"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
|
||||||
description = _string(
|
|
||||||
item.get("description"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID"
|
|
||||||
)
|
|
||||||
normalized_criteria.append(
|
|
||||||
{"id": criterion_id[:120], "description": description[:1000]}
|
|
||||||
)
|
|
||||||
constraints = raw.get("constraints", {})
|
|
||||||
if not isinstance(constraints, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_CONSTRAINTS_INVALID")
|
|
||||||
sensitive_markers = ("token", "secret", "password", "credential", "authorization", "api_key", "apikey")
|
|
||||||
for key in constraints:
|
|
||||||
lowered = str(key).lower()
|
|
||||||
if any(marker in lowered for marker in sensitive_markers):
|
|
||||||
_error("AGENT_RUNTIME_SECRET_FIELD_DENY")
|
|
||||||
|
|
||||||
scope = raw.get("scope", {})
|
|
||||||
if not isinstance(scope, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
|
||||||
scope = {
|
|
||||||
"scope_type": str(scope.get("scope_type") or "legacy"),
|
|
||||||
"tenant_id": scope.get("tenant_id"),
|
|
||||||
"project_id": scope.get("project_id"),
|
|
||||||
}
|
|
||||||
if scope["scope_type"] not in {"legacy", "system", "tenant"}:
|
|
||||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
|
||||||
if scope["scope_type"] == "tenant":
|
|
||||||
if not isinstance(scope["tenant_id"], str) or not scope["tenant_id"].strip():
|
|
||||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
|
||||||
elif scope["tenant_id"] not in (None, "") or scope["project_id"] not in (None, ""):
|
|
||||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
|
||||||
|
|
||||||
trace_id = str(raw.get("trace_id") or "").strip()
|
|
||||||
if not trace_id or len(trace_id) > 255:
|
|
||||||
_error("AGENT_RUNTIME_TRACE_ID_INVALID")
|
|
||||||
|
|
||||||
normalized = {
|
|
||||||
"goal": goal[:12000],
|
|
||||||
"instructions": instructions[:32000],
|
|
||||||
"success_criteria": normalized_criteria,
|
|
||||||
"constraints": dict(constraints),
|
|
||||||
"scope": scope,
|
|
||||||
"trace_id": trace_id,
|
|
||||||
}
|
|
||||||
if len(_canonical(normalized).encode("utf-8")) > MAX_AGENT_RUNTIME_INPUT_BYTES:
|
|
||||||
_error("AGENT_RUNTIME_INPUT_TOO_LARGE")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _capabilities(value: object) -> tuple[str, ...]:
|
|
||||||
if value is None:
|
|
||||||
return ()
|
|
||||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
||||||
_error("CAPABILITY_SCOPE_INVALID")
|
|
||||||
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
|
||||||
if any(len(item) > 120 for item in result):
|
|
||||||
_error("CAPABILITY_SCOPE_INVALID")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class GovernedExecutionBinding:
|
|
||||||
task_id: str
|
|
||||||
job_id: str
|
|
||||||
repository: str
|
|
||||||
base_oid: str
|
|
||||||
expected_branch_oid: str
|
|
||||||
target_branch: str
|
|
||||||
branch_mode: str
|
|
||||||
generation: int
|
|
||||||
idempotency_key: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
|
||||||
data = dict(value)
|
|
||||||
task_id = _task_id(data.get("task_id"))
|
|
||||||
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
|
||||||
if TASK_RE.fullmatch(job_id) is None:
|
|
||||||
_error("JOB_ID_INVALID")
|
|
||||||
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
|
||||||
if repository != "aios/aios":
|
|
||||||
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
|
||||||
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
|
||||||
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
|
||||||
target = _branch(data.get("target_branch"))
|
|
||||||
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
|
||||||
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
|
||||||
_error("BRANCH_MODE_INVALID")
|
|
||||||
generation = _generation(data.get("generation"))
|
|
||||||
if mode == "CREATE_NEW" and expected != base_oid:
|
|
||||||
_error("EXPECTED_BASE_CONFLICT")
|
|
||||||
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
|
||||||
if KEY_RE.fullmatch(key) is None:
|
|
||||||
_error("IDEMPOTENCY_KEY_INVALID")
|
|
||||||
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
|
||||||
return GovernedExecutionBinding.from_mapping(value)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_execution_envelope(
|
|
||||||
value: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
binding: GovernedExecutionBinding | None = None,
|
|
||||||
worker_capabilities: Sequence[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
envelope = dict(value)
|
|
||||||
if envelope.get("schema") != EXECUTION_SCHEMA:
|
|
||||||
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
|
||||||
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
|
||||||
if binding is not None and candidate != binding:
|
|
||||||
comparisons = (
|
|
||||||
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
|
||||||
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
|
||||||
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
|
||||||
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
|
||||||
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
|
||||||
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
|
||||||
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
|
||||||
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
|
||||||
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
|
||||||
)
|
|
||||||
for actual, expected, code in comparisons:
|
|
||||||
if actual != expected:
|
|
||||||
_error(code)
|
|
||||||
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
|
||||||
if action not in SUPPORTED_ACTIONS:
|
|
||||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
|
||||||
capability_scope = _capabilities(envelope.get("capability_scope"))
|
|
||||||
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
|
||||||
_error("CAPABILITY_MISMATCH_DENY")
|
|
||||||
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
|
||||||
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
|
||||||
_error("WORKSPACE_POLICY_DENY")
|
|
||||||
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
|
||||||
governed_coordinates = (
|
|
||||||
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
|
||||||
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
|
||||||
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
|
||||||
)
|
|
||||||
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
|
||||||
if actual_coordinate != expected_coordinate:
|
|
||||||
_error(code)
|
|
||||||
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
|
||||||
if KEY_RE.fullmatch(commit_key) is None:
|
|
||||||
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
|
||||||
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
|
||||||
_error("WRITER_LEASE_POLICY_DENY")
|
|
||||||
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
|
||||||
if governance.get(field, False) is not False:
|
|
||||||
_error("POLICY_DENY")
|
|
||||||
changes = envelope.get("change_set")
|
|
||||||
verification = envelope.get("verification")
|
|
||||||
if not isinstance(changes, list) or not isinstance(verification, list):
|
|
||||||
_error("EXECUTION_OPERATIONS_INVALID")
|
|
||||||
if action == "source_capture":
|
|
||||||
if changes or verification:
|
|
||||||
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
|
||||||
signals = list(_source_signals(envelope.get("source_signals") or []))
|
|
||||||
if envelope.get("source_signals") != signals:
|
|
||||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
|
||||||
if envelope.get("runtime_input") not in (None, {}):
|
|
||||||
_error("SOURCE_CAPTURE_RUNTIME_INPUT_DENY")
|
|
||||||
elif action == "agent_runtime":
|
|
||||||
if changes or verification:
|
|
||||||
_error("AGENT_RUNTIME_MUTATION_DENY")
|
|
||||||
adapter = str(envelope.get("adapter") or "")
|
|
||||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
|
||||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
|
||||||
runtime_input = _agent_runtime_input(envelope.get("runtime_input"))
|
|
||||||
if envelope.get("runtime_input") != runtime_input:
|
|
||||||
_error("AGENT_RUNTIME_INPUT_NOT_CANONICAL")
|
|
||||||
required_runtime_capability = f"agent.runtime.{adapter}"
|
|
||||||
if required_runtime_capability not in capability_scope:
|
|
||||||
_error("AGENT_RUNTIME_CAPABILITY_REQUIRED")
|
|
||||||
if envelope.get("source_signals") not in (None, []):
|
|
||||||
_error("AGENT_RUNTIME_SOURCE_SIGNALS_DENY")
|
|
||||||
elif not changes:
|
|
||||||
_error("CHANGE_SET_INVALID")
|
|
||||||
declared = str(envelope.get("execution_digest") or "")
|
|
||||||
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
|
||||||
actual = _digest(material)
|
|
||||||
if declared != actual:
|
|
||||||
_error("EXECUTION_DIGEST_INVALID")
|
|
||||||
return {**material, "execution_digest": actual}
|
|
||||||
|
|
||||||
|
|
||||||
def project_execution_envelope(
|
|
||||||
binding: GovernedExecutionBinding,
|
|
||||||
execution_request: Mapping[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
required_capabilities: Sequence[str] = (),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
request = dict(execution_request or {})
|
|
||||||
action = str(request.get("action") or "source_capture")
|
|
||||||
if action not in SUPPORTED_ACTIONS:
|
|
||||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
|
||||||
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
|
||||||
governance = {
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"job_id": binding.job_id,
|
|
||||||
"idempotency_key": binding.idempotency_key,
|
|
||||||
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
|
||||||
"writer_lease": action in MUTATION_ACTIONS,
|
|
||||||
"main_write": False,
|
|
||||||
"production_write": False,
|
|
||||||
"tag_write": False,
|
|
||||||
"baseline_write": False,
|
|
||||||
"force_push": False,
|
|
||||||
"protected_ref_bypass": False,
|
|
||||||
}
|
|
||||||
supplied_governance = request.get("governance")
|
|
||||||
if supplied_governance is not None:
|
|
||||||
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
|
||||||
material: dict[str, Any] = {
|
|
||||||
"schema": EXECUTION_SCHEMA,
|
|
||||||
**binding.public(),
|
|
||||||
"action": action,
|
|
||||||
"adapter": str(
|
|
||||||
request.get("adapter")
|
|
||||||
or ("deterministic" if action == "deterministic" else "source_capture")
|
|
||||||
),
|
|
||||||
"capability_scope": list(capabilities),
|
|
||||||
"change_set": list(request.get("change_set") or []),
|
|
||||||
"verification": list(request.get("verification") or []),
|
|
||||||
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
|
||||||
"runtime_input": _agent_runtime_input(request.get("runtime_input")) if action == "agent_runtime" else {},
|
|
||||||
"governance": governance,
|
|
||||||
"workspace": {
|
|
||||||
"authority_git_dir": "/data/aios-gitops/repo.git",
|
|
||||||
"read_only_repository": True,
|
|
||||||
"cleanup_policy": "always",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
envelope = {**material, "execution_digest": _digest(material)}
|
|
||||||
return validate_execution_envelope(envelope, binding=binding)
|
|
||||||
|
|
||||||
|
|
||||||
def project_online_task(
|
|
||||||
binding: GovernedExecutionBinding,
|
|
||||||
task_request: Mapping[str, Any],
|
|
||||||
execution_request: Mapping[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
request = dict(task_request)
|
|
||||||
required = _capabilities(request.get("required_capabilities"))
|
|
||||||
execution_value = dict(execution_request or {})
|
|
||||||
if str(execution_value.get("action") or "source_capture") == "agent_runtime":
|
|
||||||
adapter = str(execution_value.get("adapter") or "")
|
|
||||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
|
||||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
|
||||||
runtime_input = execution_value.get("runtime_input")
|
|
||||||
tenant_id = request.get("tenant_id")
|
|
||||||
project_id = request.get("project_id")
|
|
||||||
declared_scope = str(
|
|
||||||
request.get("scope_type")
|
|
||||||
or ("tenant" if isinstance(tenant_id, str) and tenant_id.strip() else "legacy")
|
|
||||||
)
|
|
||||||
if runtime_input is None:
|
|
||||||
runtime_input = {
|
|
||||||
"goal": request.get("goal"),
|
|
||||||
"instructions": request.get("instructions") or "",
|
|
||||||
"success_criteria": request.get("success_criteria") or [],
|
|
||||||
"constraints": request.get("constraints") or {},
|
|
||||||
}
|
|
||||||
elif not isinstance(runtime_input, Mapping):
|
|
||||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
|
||||||
runtime_input = dict(runtime_input)
|
|
||||||
# Scope/trace are authoritative Task identity, never caller-selected runtime hints.
|
|
||||||
runtime_input["scope"] = {
|
|
||||||
"scope_type": declared_scope,
|
|
||||||
"tenant_id": tenant_id,
|
|
||||||
"project_id": project_id,
|
|
||||||
}
|
|
||||||
runtime_input["trace_id"] = str(
|
|
||||||
request.get("trace_id")
|
|
||||||
or request.get("run_id")
|
|
||||||
or binding.task_id
|
|
||||||
)
|
|
||||||
execution_value["runtime_input"] = runtime_input
|
|
||||||
execution = project_execution_envelope(
|
|
||||||
binding,
|
|
||||||
execution_value,
|
|
||||||
required_capabilities=required,
|
|
||||||
)
|
|
||||||
metadata = dict(request.get("metadata") or {})
|
|
||||||
if "execution" in metadata:
|
|
||||||
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
|
||||||
if metadata["execution"] != execution:
|
|
||||||
_error("EXECUTION_PROJECTION_CONFLICT")
|
|
||||||
metadata["execution"] = execution
|
|
||||||
metadata["execution_projection"] = {
|
|
||||||
"schema": PROJECTION_SCHEMA,
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"job_id": binding.job_id,
|
|
||||||
"execution_digest": execution["execution_digest"],
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
**request,
|
|
||||||
"task_id": binding.task_id,
|
|
||||||
"metadata": metadata,
|
|
||||||
"required_capabilities": list(required),
|
|
||||||
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_task_execution(
|
|
||||||
task: Mapping[str, Any],
|
|
||||||
binding: GovernedExecutionBinding,
|
|
||||||
*,
|
|
||||||
worker_capabilities: Sequence[str] | None = None,
|
|
||||||
) -> tuple[dict[str, Any], str]:
|
|
||||||
metadata = task.get("metadata")
|
|
||||||
if isinstance(metadata, Mapping) and "execution" in metadata:
|
|
||||||
value = validate_execution_envelope(
|
|
||||||
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
|
||||||
binding=binding,
|
|
||||||
worker_capabilities=worker_capabilities,
|
|
||||||
)
|
|
||||||
return value, "persisted"
|
|
||||||
projected = project_execution_envelope(
|
|
||||||
binding,
|
|
||||||
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
|
||||||
required_capabilities=task.get("required_capabilities") or [],
|
|
||||||
)
|
|
||||||
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
|
||||||
return projected, "recovered_projection"
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""Fail-closed signed deterministic execution amendment for R7."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import hashlib, json, sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Mapping
|
|
||||||
from aios_task_terminal_recovery import DeterministicPlan, RecoveryError, canonical_json
|
|
||||||
|
|
||||||
AMENDMENT_SCHEMA="aios.deterministic-execution-amendment.v1"
|
|
||||||
APPLY_SCHEMA="aios.deterministic-execution-amendment-apply.v1"
|
|
||||||
NAMESPACE="aios-owner-approval-v1"
|
|
||||||
EVENT="TASK_RECOVERY_EXECUTION_AMENDMENT_APPLIED"
|
|
||||||
APPLY_STATES=frozenset({"FAILED", "QUEUED"})
|
|
||||||
|
|
||||||
|
|
||||||
def _err(code): raise RecoveryError(code)
|
|
||||||
def _sha(v): return hashlib.sha256(canonical_json(v).encode()).hexdigest()
|
|
||||||
def amendment_body(x):
|
|
||||||
return {k:v for k,v in x.items() if k not in {"signature","amendment_id"}}
|
|
||||||
def amendment_id(x): return "sha256:"+_sha(amendment_body(x))
|
|
||||||
def signed_payload(x):
|
|
||||||
return canonical_json({"amendment_id":x["amendment_id"],**amendment_body(x)}).encode()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_amendment(raw):
|
|
||||||
x=dict(raw)
|
|
||||||
required={"schema","amendment_id","nonce","task_id","job_id","generation",
|
|
||||||
"repository","branch","expected_branch_oid","recovery_epoch","decision_id",
|
|
||||||
"plan_id","plan_sha256","expected_apply_status","expected_apply_revision",
|
|
||||||
"expected_apply_attempt","expected_current_capabilities","envelope",
|
|
||||||
"owner_identity","key_id","issued_at","not_before","expires_at",
|
|
||||||
"one_time_use","signature"}
|
|
||||||
if set(x)<required or x.get("schema")!=AMENDMENT_SCHEMA: _err("AMENDMENT_SCHEMA_INVALID")
|
|
||||||
if x["expected_apply_status"] not in APPLY_STATES: _err("AMENDMENT_APPLY_STATUS_INVALID")
|
|
||||||
if x.get("one_time_use") is not True: _err("AMENDMENT_ONE_TIME_REQUIRED")
|
|
||||||
if not isinstance(x["envelope"],Mapping): _err("AMENDMENT_ENVELOPE_INVALID")
|
|
||||||
if not isinstance(x["expected_current_capabilities"],list): _err("AMENDMENT_CAPABILITIES_INVALID")
|
|
||||||
for k in ("generation","expected_apply_revision","expected_apply_attempt"):
|
|
||||||
if isinstance(x[k],bool) or not isinstance(x[k],int) or x[k]<0: _err("AMENDMENT_CAS_INVALID")
|
|
||||||
for k in ("issued_at","not_before","expires_at"):
|
|
||||||
try:d=datetime.fromisoformat(str(x[k]).replace("Z","+00:00"))
|
|
||||||
except ValueError as e: raise RecoveryError("AMENDMENT_TIME_INVALID") from e
|
|
||||||
if d.tzinfo is None:_err("AMENDMENT_TIME_INVALID")
|
|
||||||
x[k]=d.astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
|
||||||
x["expected_current_capabilities"]=sorted(set(x["expected_current_capabilities"]))
|
|
||||||
x["envelope"]=dict(x["envelope"])
|
|
||||||
return x
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_envelope(env,plan,x):
|
|
||||||
try:
|
|
||||||
from aios_online_agent_execution import GovernedExecutionBinding,validate_execution_envelope
|
|
||||||
binding=GovernedExecutionBinding.from_mapping(env)
|
|
||||||
e=dict(validate_execution_envelope(env,binding=binding,worker_capabilities=plan.required_capabilities))
|
|
||||||
except Exception as z: raise RecoveryError("AMENDMENT_EXECUTION_ENVELOPE_INVALID") from z
|
|
||||||
if e.get("action")!="deterministic" or e.get("adapter")!="deterministic":_err("AMENDMENT_EXECUTION_ACTION_INVALID")
|
|
||||||
if tuple(sorted(e.get("capability_scope") or ()))!=plan.required_capabilities:_err("AMENDMENT_EXECUTION_CAPABILITIES_MISMATCH")
|
|
||||||
if not e.get("change_set") or not e.get("verification"):_err("AMENDMENT_EXECUTION_OPERATIONS_REQUIRED")
|
|
||||||
if e.get("runtime_input") not in (None,{}) or e.get("source_signals") not in (None,[]):_err("AMENDMENT_STALE_RUNTIME_FIELDS_FORBIDDEN")
|
|
||||||
for k,w in (("task_id",x["task_id"]),("job_id",x["job_id"]),("repository",x["repository"]),
|
|
||||||
("generation",x["generation"]),("target_branch",x["branch"]),
|
|
||||||
("expected_branch_oid",x["expected_branch_oid"]),("base_oid",x["expected_branch_oid"]),
|
|
||||||
("plan_id",x["plan_id"]),("plan_sha256",x["plan_sha256"]),("recovery_epoch",x["recovery_epoch"])):
|
|
||||||
if e.get(k)!=w:_err("AMENDMENT_EXECUTION_BINDING_MISMATCH")
|
|
||||||
g=e.get("governance") or {}
|
|
||||||
if g.get("writer_lease") is not True:_err("AMENDMENT_WRITER_LEASE_REQUIRED")
|
|
||||||
if any(g.get(k,False) for k in ("main_write","production_write","tag_write","baseline_write","force_push","protected_ref_bypass")):_err("AMENDMENT_PROTECTED_WRITE_FORBIDDEN")
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
class DeterministicExecutionAmendmentController:
|
|
||||||
def __init__(self,db_path,verifier,*,clock=None,envelope_validator=None):
|
|
||||||
self.db_path=Path(db_path); self.verifier=verifier
|
|
||||||
self.clock=clock or (lambda:datetime.now(timezone.utc)); self._init()
|
|
||||||
|
|
||||||
def _db(self):
|
|
||||||
c=sqlite3.connect(self.db_path,timeout=10,isolation_level=None); c.row_factory=sqlite3.Row
|
|
||||||
c.execute("PRAGMA busy_timeout=10000"); return c
|
|
||||||
|
|
||||||
def _now(self): return self.clock().astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
|
||||||
|
|
||||||
def _init(self):
|
|
||||||
with self._db() as c:
|
|
||||||
c.execute("DROP TRIGGER IF EXISTS aios_r7_recovery_capability_projection")
|
|
||||||
c.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS deterministic_execution_amendments(
|
|
||||||
amendment_id TEXT PRIMARY KEY,nonce TEXT UNIQUE NOT NULL,task_id TEXT NOT NULL,
|
|
||||||
decision_id TEXT NOT NULL,plan_id TEXT NOT NULL,plan_sha256 TEXT NOT NULL,
|
|
||||||
envelope_digest TEXT NOT NULL,envelope_json TEXT NOT NULL,payload_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,UNIQUE(task_id,decision_id));
|
|
||||||
DROP TRIGGER IF EXISTS aios_r7_recovery_requires_executable_amendment;
|
|
||||||
CREATE TRIGGER aios_r7_recovery_requires_executable_amendment
|
|
||||||
BEFORE UPDATE OF status ON tasks
|
|
||||||
WHEN OLD.status='FAILED' AND NEW.status='QUEUED' BEGIN
|
|
||||||
SELECT CASE WHEN NOT EXISTS(
|
|
||||||
SELECT 1 FROM deterministic_execution_amendments a
|
|
||||||
WHERE a.task_id=OLD.task_id
|
|
||||||
AND a.decision_id=json_extract(NEW.metadata_json,'$.recovery.decision_id')
|
|
||||||
AND a.amendment_id=json_extract(OLD.metadata_json,'$.recovery.execution_amendment_id')
|
|
||||||
AND a.envelope_digest=json_extract(OLD.metadata_json,'$.execution.execution_digest')
|
|
||||||
) THEN RAISE(ABORT,'SIGNED_EXECUTION_AMENDMENT_REQUIRED') END;
|
|
||||||
END;""")
|
|
||||||
|
|
||||||
def _plan(self,c,x):
|
|
||||||
r=c.execute("SELECT * FROM deterministic_plans WHERE plan_id=?",(x["plan_id"],)).fetchone()
|
|
||||||
if r is None or r["plan_sha256"]!=x["plan_sha256"]:_err("AMENDMENT_PLAN_NOT_FOUND_OR_MISMATCH")
|
|
||||||
p=DeterministicPlan.from_dict(json.loads(r["payload_json"]))
|
|
||||||
if (p.task_id,p.job_id,p.generation,p.repository,p.branch,p.expected_branch_oid,p.recovery_epoch)!=(x["task_id"],x["job_id"],x["generation"],x["repository"],x["branch"],x["expected_branch_oid"],x["recovery_epoch"]):_err("AMENDMENT_PLAN_BINDING_MISMATCH")
|
|
||||||
return p
|
|
||||||
|
|
||||||
def persist(self,raw):
|
|
||||||
x=parse_amendment(raw)
|
|
||||||
if x["amendment_id"]!=amendment_id(x):_err("AMENDMENT_ID_DIGEST_MISMATCH")
|
|
||||||
now=self._now()
|
|
||||||
if now<x["not_before"] or now>=x["expires_at"]:_err("AMENDMENT_EXPIRED_OR_NOT_YET_VALID")
|
|
||||||
if not self.verifier.verify(namespace=NAMESPACE,payload=signed_payload(x),signature=x["signature"],key_id=x["key_id"]):_err("AMENDMENT_SIGNATURE_INVALID")
|
|
||||||
with self._db() as c:
|
|
||||||
c.execute("BEGIN IMMEDIATE"); e=_validate_envelope(x["envelope"],self._plan(c,x),x)
|
|
||||||
ej=canonical_json(e); ed=e["execution_digest"]; stored=dict(x); stored["signature"]="<present>"
|
|
||||||
old=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=?",(x["amendment_id"],)).fetchone()
|
|
||||||
if old:
|
|
||||||
if old["envelope_json"]!=ej:_err("AMENDMENT_ID_REUSE_MISMATCH")
|
|
||||||
c.commit(); return {"status":"PRESENT","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
|
||||||
if c.execute("SELECT 1 FROM deterministic_execution_amendments WHERE nonce=? OR (task_id=? AND decision_id=?)",(x["nonce"],x["task_id"],x["decision_id"])).fetchone():_err("AMENDMENT_REPLAY_OR_DECISION_REUSE")
|
|
||||||
c.execute("INSERT INTO deterministic_execution_amendments VALUES(?,?,?,?,?,?,?,?,?,?)",(x["amendment_id"],x["nonce"],x["task_id"],x["decision_id"],x["plan_id"],x["plan_sha256"],ed,ej,canonical_json(stored),now)); c.commit()
|
|
||||||
return {"status":"PERSISTED","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
|
||||||
|
|
||||||
def apply(self,raw):
|
|
||||||
if raw.get("schema")!=APPLY_SCHEMA:_err("AMENDMENT_APPLY_SCHEMA_INVALID")
|
|
||||||
aid=raw.get("amendment_id"); tid=raw.get("task_id")
|
|
||||||
with self._db() as c:
|
|
||||||
c.execute("BEGIN IMMEDIATE"); a=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=? AND task_id=?",(aid,tid)).fetchone()
|
|
||||||
if a is None:_err("AMENDMENT_NOT_FOUND")
|
|
||||||
x=json.loads(a["payload_json"]); d=json.loads(a["envelope_json"])
|
|
||||||
t=c.execute("SELECT * FROM tasks WHERE task_id=?",(tid,)).fetchone()
|
|
||||||
if t is None:_err("TASK_NOT_FOUND")
|
|
||||||
m=json.loads(t["metadata_json"] or "{}"); cp=tuple(sorted(json.loads(t["required_capabilities_json"] or "[]"))); dc=tuple(sorted(d.get("capability_scope") or ()))
|
|
||||||
rec=m.get("recovery"); rec=dict(rec) if isinstance(rec,Mapping) else {}
|
|
||||||
old=m.get("execution")
|
|
||||||
if isinstance(old,Mapping) and old.get("execution_digest")==a["envelope_digest"] and cp==dc and rec.get("execution_amendment_id")==aid:
|
|
||||||
c.commit(); return {"status":"PRESENT","task_id":tid,"amendment_id":aid,"execution_digest":a["envelope_digest"],"revision":int(t["revision"]),"attempt":int(t["attempt"])}
|
|
||||||
if t["status"]!=x["expected_apply_status"] or int(t["revision"])!=x["expected_apply_revision"] or int(t["attempt"])!=x["expected_apply_attempt"] or cp!=tuple(x["expected_current_capabilities"]) or t["claimed_by"] is not None or t["lease_token_hash"] is not None:_err("AMENDMENT_APPLY_TASK_CAS_MISMATCH")
|
|
||||||
if t["status"]=="QUEUED" and rec.get("decision_id")!=x["decision_id"]:_err("AMENDMENT_DECISION_BINDING_MISMATCH")
|
|
||||||
m["execution"]=d; m["execution_projection"]={"schema":"aios.online-agent.task-projection.v1","task_id":tid,"job_id":d.get("job_id"),"execution_digest":a["envelope_digest"]}
|
|
||||||
rec["execution_amendment_id"]=aid; m["recovery"]=rec; now=self._now()
|
|
||||||
q=c.execute("""UPDATE tasks SET required_capabilities_json=?,metadata_json=?,revision=revision+1,updated_at=? WHERE task_id=? AND status=? AND revision=? AND attempt=? AND claimed_by IS NULL AND lease_token_hash IS NULL""",(canonical_json(list(dc)),canonical_json(m),now,tid,x["expected_apply_status"],x["expected_apply_revision"],x["expected_apply_attempt"]))
|
|
||||||
if q.rowcount!=1:_err("AMENDMENT_APPLY_TASK_CAS_FAILED")
|
|
||||||
c.execute("INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",(tid,None,EVENT,canonical_json({"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc)}),now)); c.commit()
|
|
||||||
return {"status":"APPLIED","task_id":tid,"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc),"revision":x["expected_apply_revision"]+1,"attempt":x["expected_apply_attempt"]}
|
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail-closed AF_UNIX builder for server-owned trusted candidate evidence."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import socket
|
|
||||||
import stat
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Mapping, Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class BuilderError(RuntimeError):
|
|
||||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(*parts: str) -> str:
|
|
||||||
return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PeerIdentity:
|
|
||||||
pid: int
|
|
||||||
uid: int
|
|
||||||
gid: int
|
|
||||||
|
|
||||||
|
|
||||||
class DomainPurposeSigner(Protocol):
|
|
||||||
"""A signer deliberately limited to the builder's one domain purpose."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_generation(self) -> int:
|
|
||||||
...
|
|
||||||
|
|
||||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class CredentialPathEd25519Signer:
|
|
||||||
"""Reads a systemd-mounted credential path; never exports or logs key bytes."""
|
|
||||||
|
|
||||||
def __init__(self, credential_path: Path, *, key_id: str, key_generation: int, allowed_purpose: str):
|
|
||||||
self._credential_path = Path(credential_path)
|
|
||||||
self._key_id = str(key_id)
|
|
||||||
self._key_generation = key_generation
|
|
||||||
self._allowed_purpose = str(allowed_purpose)
|
|
||||||
if not self._key_id or not isinstance(key_generation, int) or key_generation < 1:
|
|
||||||
raise BuilderError("SIGNING_IDENTITY_INVALID")
|
|
||||||
if not self._credential_path.is_file():
|
|
||||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
|
||||||
try:
|
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
||||||
|
|
||||||
self._private_key = load_pem_private_key(self._credential_path.read_bytes(), password=None)
|
|
||||||
if not isinstance(self._private_key, Ed25519PrivateKey):
|
|
||||||
raise TypeError("signing key is not Ed25519")
|
|
||||||
except Exception as exc: # do not expose parse/key material details
|
|
||||||
raise BuilderError("SIGNING_CREDENTIAL_INVALID") from exc
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
return self._key_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_generation(self) -> int:
|
|
||||||
return self._key_generation
|
|
||||||
|
|
||||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
|
||||||
if purpose != self._allowed_purpose:
|
|
||||||
raise BuilderError("SIGNING_PURPOSE_DENIED")
|
|
||||||
return self._private_key.sign(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BuilderKeyring:
|
|
||||||
builder_hmac: bytes
|
|
||||||
signing_private_key_path: Path | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if len(self.builder_hmac) < 32:
|
|
||||||
raise BuilderError("BUILDER_HMAC_INVALID")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_credentials_directory(cls, credentials_directory: Path) -> "BuilderKeyring":
|
|
||||||
root = Path(credentials_directory)
|
|
||||||
hmac_path = root / "builder_hmac"
|
|
||||||
signing_path = root / "signing_private_key"
|
|
||||||
try:
|
|
||||||
secret = hmac_path.read_bytes().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError("BUILDER_HMAC_UNAVAILABLE") from exc
|
|
||||||
if not signing_path.is_file():
|
|
||||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
|
||||||
return cls(builder_hmac=secret, signing_private_key_path=signing_path)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TrustedCandidateBuilderConfig:
|
|
||||||
policy_schema: str
|
|
||||||
repository: str
|
|
||||||
request_schema: str
|
|
||||||
response_schema: str
|
|
||||||
operation: str
|
|
||||||
readiness_operation: str
|
|
||||||
maximum_request_age_seconds: int
|
|
||||||
request_timeout_seconds: int
|
|
||||||
allowed_peer_uid: int
|
|
||||||
allowed_peer_cgroups: tuple[str, ...]
|
|
||||||
allowed_peer_executable_prefix: str
|
|
||||||
allowed_peer_runtime_prefix: str
|
|
||||||
allowed_peer_runtime_suffix: str
|
|
||||||
required_checks: tuple[str, ...]
|
|
||||||
evidence_layout: Mapping[str, str]
|
|
||||||
recipes: Mapping[str, Mapping[str, str]]
|
|
||||||
governed_recipes: Mapping[str, Mapping[str, str]]
|
|
||||||
policy_sha256: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
|
||||||
required = {
|
|
||||||
"schema", "repository", "request_schema", "response_schema", "operation",
|
|
||||||
"readiness_operation", "maximum_request_age_seconds", "request_timeout_seconds",
|
|
||||||
"allowed_peer_uid", "allowed_peer_cgroups", "allowed_peer_executable_prefix",
|
|
||||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
|
||||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
|
||||||
}
|
|
||||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
|
||||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
|
||||||
recipes = policy.get("recipes")
|
|
||||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
|
||||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
|
||||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
|
||||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
|
||||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
|
||||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
|
||||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
|
||||||
if (
|
|
||||||
policy["repository"] != "aios/aios"
|
|
||||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
|
||||||
or policy["readiness_operation"] != "ProbeTrustedCandidateBuilderV1"
|
|
||||||
):
|
|
||||||
raise BuilderError("POLICY_SCOPE_INVALID")
|
|
||||||
if not isinstance(policy["maximum_request_age_seconds"], int) or not 1 <= policy["maximum_request_age_seconds"] <= 300:
|
|
||||||
raise BuilderError("POLICY_FRESHNESS_INVALID")
|
|
||||||
if not isinstance(policy["request_timeout_seconds"], int) or not 1 <= policy["request_timeout_seconds"] <= 120:
|
|
||||||
raise BuilderError("POLICY_TIMEOUT_INVALID")
|
|
||||||
if not isinstance(policy["allowed_peer_uid"], int) or policy["allowed_peer_uid"] != 1000:
|
|
||||||
raise BuilderError("POLICY_PEER_UID_INVALID")
|
|
||||||
cgroups, checks = policy["allowed_peer_cgroups"], policy["required_checks"]
|
|
||||||
if not isinstance(cgroups, list) or not cgroups or not all(isinstance(value, str) and value.startswith("/") for value in cgroups):
|
|
||||||
raise BuilderError("POLICY_CGROUPS_INVALID")
|
|
||||||
for field in (
|
|
||||||
"allowed_peer_executable_prefix",
|
|
||||||
"allowed_peer_runtime_prefix",
|
|
||||||
"allowed_peer_runtime_suffix",
|
|
||||||
):
|
|
||||||
if not isinstance(policy[field], str) or not policy[field].startswith("/"):
|
|
||||||
raise BuilderError("POLICY_PROCESS_IDENTITY_INVALID")
|
|
||||||
if not isinstance(checks, list) or not checks or len(set(checks)) != len(checks) or not all(isinstance(value, str) for value in checks):
|
|
||||||
raise BuilderError("POLICY_CHECKS_INVALID")
|
|
||||||
layout = policy["evidence_layout"]
|
|
||||||
evidence_names = {
|
|
||||||
"artifact", "manifest", "package_closure", "tests", "canary",
|
|
||||||
"gitea", "rollback", "policy", "secret_scan",
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
not isinstance(layout, Mapping)
|
|
||||||
or set(layout) != evidence_names
|
|
||||||
or not all(
|
|
||||||
isinstance(value, str)
|
|
||||||
and value.startswith("{version}/")
|
|
||||||
and ".." not in Path(value).parts
|
|
||||||
and not Path(value).is_absolute()
|
|
||||||
for value in layout.values()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise BuilderError("POLICY_EVIDENCE_LAYOUT_INVALID")
|
|
||||||
return cls(
|
|
||||||
policy_schema=str(policy["schema"]), repository=str(policy["repository"]),
|
|
||||||
request_schema=str(policy["request_schema"]), response_schema=str(policy["response_schema"]),
|
|
||||||
operation=str(policy["operation"]), readiness_operation=str(policy["readiness_operation"]),
|
|
||||||
maximum_request_age_seconds=policy["maximum_request_age_seconds"],
|
|
||||||
request_timeout_seconds=policy["request_timeout_seconds"],
|
|
||||||
allowed_peer_uid=policy["allowed_peer_uid"], allowed_peer_cgroups=tuple(cgroups),
|
|
||||||
allowed_peer_executable_prefix=policy["allowed_peer_executable_prefix"],
|
|
||||||
allowed_peer_runtime_prefix=policy["allowed_peer_runtime_prefix"],
|
|
||||||
allowed_peer_runtime_suffix=policy["allowed_peer_runtime_suffix"],
|
|
||||||
required_checks=tuple(checks), evidence_layout=dict(layout),
|
|
||||||
recipes={name: dict(value) for name, value in recipes.items()},
|
|
||||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
|
||||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
|
||||||
recipe = self.recipes.get(name)
|
|
||||||
if recipe is None:
|
|
||||||
raise BuilderError("RECIPE_UNKNOWN")
|
|
||||||
if recipe["mode"] == "governed-derived-only":
|
|
||||||
derived = self.governed_recipes.get(name)
|
|
||||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "derived_from_v2r2c2_attestation", "builder_engineering_oid"}
|
|
||||||
if not isinstance(derived, Mapping) or set(derived) != required or derived.get("mode") != "governed-derived" or derived.get("version") != name or derived.get("purpose") != recipe.get("purpose"):
|
|
||||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
|
||||||
for name in ("base_oid", "source_commit", "builder_engineering_oid"):
|
|
||||||
value = derived.get(name)
|
|
||||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
|
||||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
|
||||||
return dict(derived)
|
|
||||||
return recipe
|
|
||||||
|
|
||||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
|
||||||
recipe = self.fixed_recipe(version)
|
|
||||||
# Intent-only callers do not choose a candidate identity. The
|
|
||||||
# policy-owned recipe supplies it; explicit worker requests are
|
|
||||||
# still checked against the same immutable recipe.
|
|
||||||
effective_candidate_id = str(candidate_id or recipe.get("candidate_id") or "")
|
|
||||||
if {"version": recipe.get("version"), "candidate_id": recipe.get("candidate_id"), "source_commit": recipe.get("source_commit")} != {"version": version, "candidate_id": effective_candidate_id, "source_commit": source_commit}:
|
|
||||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
||||||
return recipe
|
|
||||||
|
|
||||||
|
|
||||||
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
|
||||||
raise BuilderError("POLICY_GOVERNED_RECIPES_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def load_builder_policy(path: Path) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("POLICY_UNAVAILABLE") from exc
|
|
||||||
if not isinstance(decoded, Mapping):
|
|
||||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
|
||||||
TrustedCandidateBuilderConfig.from_policy(decoded)
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
class ServerOwnedEvidenceSource:
|
|
||||||
"""Recomputes a fixed on-host evidence closure without caller-owned paths."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: TrustedCandidateBuilderConfig,
|
|
||||||
authority_git_dir: Path,
|
|
||||||
evidence_root: Path,
|
|
||||||
*,
|
|
||||||
expected_owner_uid: int | None = 0,
|
|
||||||
git_runner: Callable[..., Any] = subprocess.run,
|
|
||||||
):
|
|
||||||
self._config = config
|
|
||||||
self._authority_git_dir = Path(authority_git_dir)
|
|
||||||
self._evidence_root = Path(evidence_root)
|
|
||||||
self._expected_owner_uid = expected_owner_uid
|
|
||||||
self._git_runner = git_runner
|
|
||||||
|
|
||||||
def _regular_path(self, relative: str) -> Path:
|
|
||||||
root = self._evidence_root.resolve(strict=True)
|
|
||||||
candidate = self._evidence_root / relative
|
|
||||||
current = self._evidence_root
|
|
||||||
for part in Path(relative).parts:
|
|
||||||
current = current / part
|
|
||||||
if current.is_symlink():
|
|
||||||
raise BuilderError("COLLECTOR_SYMLINK_DENIED")
|
|
||||||
try:
|
|
||||||
resolved = candidate.resolve(strict=True)
|
|
||||||
resolved.relative_to(root)
|
|
||||||
metadata = resolved.stat(follow_symlinks=False)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
|
||||||
if not stat.S_ISREG(metadata.st_mode):
|
|
||||||
raise BuilderError("COLLECTOR_SPECIAL_FILE_DENIED")
|
|
||||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_MODE_DENIED")
|
|
||||||
if (
|
|
||||||
os.name != "nt"
|
|
||||||
and
|
|
||||||
self._expected_owner_uid is not None
|
|
||||||
and metadata.st_uid != self._expected_owner_uid
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
def _digest(self, 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 _json(self, path: Path) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
if path.stat().st_size > 2 * 1024 * 1024:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_SIZE_INVALID")
|
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
tree_result = self._git_runner(
|
|
||||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
|
||||||
check=True, capture_output=True, text=True, timeout=15,
|
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
|
||||||
)
|
|
||||||
ancestry_result = self._git_runner(
|
|
||||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["base_oid"], recipe["source_commit"]],
|
|
||||||
check=False, capture_output=True, text=True, timeout=15,
|
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
|
||||||
)
|
|
||||||
tree_oid = tree_result.stdout.strip().lower()
|
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
|
||||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
|
||||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
||||||
|
|
||||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
|
||||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
if (
|
|
||||||
receipt.get("schema") != "aios.trusted-candidate-builder.evidence.v1"
|
|
||||||
or receipt.get("version") != recipe_name
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
candidate = receipt.get("candidate")
|
|
||||||
release_plan = receipt.get("release_plan")
|
|
||||||
if not isinstance(candidate, Mapping) or not isinstance(release_plan, Mapping):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
if (
|
|
||||||
candidate.get("candidate_id") != recipe["candidate_id"]
|
|
||||||
or candidate.get("source_commit") != recipe["source_commit"]
|
|
||||||
or candidate.get("tree_oid") != tree_oid
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
||||||
git_consistency = candidate.get("git_consistency")
|
|
||||||
if (
|
|
||||||
not isinstance(git_consistency, Mapping)
|
|
||||||
or git_consistency.get("consistent") is not True
|
|
||||||
or git_consistency.get("source_commit") != recipe["source_commit"]
|
|
||||||
or git_consistency.get("tree_oid") != tree_oid
|
|
||||||
or git_consistency.get("repository") != self._config.repository
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
|
||||||
|
|
||||||
paths = {
|
|
||||||
name: self._regular_path(template.format(version=recipe_name))
|
|
||||||
for name, template in self._config.evidence_layout.items()
|
|
||||||
}
|
|
||||||
deployment = release_plan.get("deployment")
|
|
||||||
if not isinstance(deployment, Mapping):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
digests = {name: self._digest(path) for name, path in paths.items()}
|
|
||||||
if (
|
|
||||||
digests["artifact"] != candidate.get("artifact_sha256")
|
|
||||||
or digests["manifest"] != candidate.get("manifest_sha256")
|
|
||||||
or digests["package_closure"] != deployment.get("package_closure_sha256")
|
|
||||||
or digests["policy"] != release_plan.get("policy_sha256")
|
|
||||||
or digests["policy"] != deployment.get("policy_sha256")
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_DIGEST_MISMATCH")
|
|
||||||
|
|
||||||
tests = self._json(paths["tests"])
|
|
||||||
canary = self._json(paths["canary"])
|
|
||||||
gitea = self._json(paths["gitea"])
|
|
||||||
rollback = self._json(paths["rollback"])
|
|
||||||
secret_scan = self._json(paths["secret_scan"])
|
|
||||||
if tests != candidate.get("tests") or tests.get("passed") is not True:
|
|
||||||
raise BuilderError("COLLECTOR_TESTS_MISMATCH")
|
|
||||||
if canary != candidate.get("canary") or canary.get("passed") is not True:
|
|
||||||
raise BuilderError("COLLECTOR_CANARY_MISMATCH")
|
|
||||||
if (
|
|
||||||
digests["tests"] != hashlib.sha256(_canonical_json(dict(tests))).hexdigest()
|
|
||||||
or digests["canary"] != hashlib.sha256(_canonical_json(dict(canary))).hexdigest()
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
|
||||||
if (
|
|
||||||
gitea != candidate.get("gitea_consistency")
|
|
||||||
or gitea.get("consistent") is not True
|
|
||||||
or gitea.get("repository") != self._config.repository
|
|
||||||
or gitea.get("source_commit") != recipe["source_commit"]
|
|
||||||
or gitea.get("tree_oid") != tree_oid
|
|
||||||
or gitea.get("remote_ref_oid") != recipe["source_commit"]
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_GITEA_MISMATCH")
|
|
||||||
if (
|
|
||||||
candidate.get("rollback_ready") is not True
|
|
||||||
or rollback.get("ready") is not True
|
|
||||||
or rollback.get("source_commit") != recipe["source_commit"]
|
|
||||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
|
||||||
if (
|
|
||||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
|
||||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
|
||||||
if (
|
|
||||||
secret_scan.get("passed") is not True
|
|
||||||
or secret_scan.get("artifact_sha256") != candidate.get("artifact_sha256")
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_SECRET_SCAN_MISMATCH")
|
|
||||||
checks = {name: True for name in self._config.required_checks}
|
|
||||||
return {
|
|
||||||
"candidate": dict(candidate),
|
|
||||||
"release_plan": dict(release_plan),
|
|
||||||
"checks": checks,
|
|
||||||
"evidence_digests": digests,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DeterministicEvidenceCollector:
|
|
||||||
"""Validates a server-owned source; request fields never influence collection."""
|
|
||||||
|
|
||||||
_BUNDLE_FIELDS = {"candidate", "release_plan", "checks", "evidence_digests"}
|
|
||||||
|
|
||||||
def __init__(self, config: TrustedCandidateBuilderConfig, *, evidence_source: Callable[[str, Mapping[str, str]], Mapping[str, Any]] | None = None):
|
|
||||||
self._config = config
|
|
||||||
self._evidence_source = evidence_source
|
|
||||||
|
|
||||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
|
||||||
if self._evidence_source is None:
|
|
||||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
|
||||||
bundle = self._evidence_source(recipe_name, recipe)
|
|
||||||
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
|
||||||
raw_checks = bundle.get("checks")
|
|
||||||
if (
|
|
||||||
not isinstance(raw_checks, Mapping)
|
|
||||||
or set(raw_checks) != set(self._config.required_checks)
|
|
||||||
or any(raw_checks.get(name) is not True for name in self._config.required_checks)
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
|
||||||
evidence_digests = bundle.get("evidence_digests")
|
|
||||||
if (
|
|
||||||
not isinstance(evidence_digests, Mapping)
|
|
||||||
or set(evidence_digests) != set(self._config.evidence_layout)
|
|
||||||
or any(
|
|
||||||
not isinstance(value, str)
|
|
||||||
or len(value) != 64
|
|
||||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
|
||||||
for value in evidence_digests.values()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_DIGESTS_INVALID")
|
|
||||||
try:
|
|
||||||
from silent_release_controller import ReleaseCandidate
|
|
||||||
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
|
||||||
|
|
||||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
|
||||||
release_plan = ReleasePlan.from_dict(dict(bundle["release_plan"]))
|
|
||||||
checks = dict(raw_checks)
|
|
||||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
|
||||||
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
|
||||||
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
|
||||||
raise BuilderError("COLLECTOR_SOURCE_MISMATCH")
|
|
||||||
return {
|
|
||||||
"candidate": candidate.to_dict(),
|
|
||||||
"release_plan": release_plan.to_dict(),
|
|
||||||
"checks": checks,
|
|
||||||
"evidence_digests": dict(evidence_digests),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_peer_identity(connection: socket.socket) -> PeerIdentity:
|
|
||||||
if not hasattr(socket, "SO_PEERCRED"):
|
|
||||||
raise BuilderError("SO_PEERCRED_UNAVAILABLE")
|
|
||||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
|
||||||
if len(raw) != struct.calcsize("3i"):
|
|
||||||
raise BuilderError("SO_PEERCRED_INVALID")
|
|
||||||
return PeerIdentity(*struct.unpack("3i", raw))
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_cgroups(pid: int) -> tuple[str, ...]:
|
|
||||||
try:
|
|
||||||
return tuple(
|
|
||||||
line.rsplit(":", 1)[1].strip()
|
|
||||||
for line in Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
|
|
||||||
if ":" in line and line.rsplit(":", 1)[1].strip().startswith("/")
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError("PEER_CGROUP_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_process_identity(pid: int) -> tuple[str, tuple[str, ...]]:
|
|
||||||
try:
|
|
||||||
executable = os.path.realpath(f"/proc/{pid}/exe")
|
|
||||||
argv = tuple(
|
|
||||||
item.decode("utf-8", "strict")
|
|
||||||
for item in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
|
||||||
if item
|
|
||||||
)
|
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
|
||||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE") from exc
|
|
||||||
if not executable or not argv:
|
|
||||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE")
|
|
||||||
return executable, argv
|
|
||||||
|
|
||||||
|
|
||||||
class TrustedCandidateBuilder:
|
|
||||||
def __init__(
|
|
||||||
self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *,
|
|
||||||
collector: DeterministicEvidenceCollector, signer: DomainPurposeSigner,
|
|
||||||
peer_identity_resolver: Callable[[socket.socket], PeerIdentity] = _extract_peer_identity,
|
|
||||||
cgroup_resolver: Callable[[int], tuple[str, ...] | str] = _resolve_cgroups,
|
|
||||||
process_identity_resolver: Callable[[int], tuple[str, tuple[str, ...]]] = _resolve_process_identity,
|
|
||||||
clock: Callable[[], float] = time.time, nonce_ledger_path: Path | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.config, self.keyring, self.collector, self.signer = config, keyring, collector, signer
|
|
||||||
self.peer_identity_resolver, self.cgroup_resolver, self.clock = peer_identity_resolver, cgroup_resolver, clock
|
|
||||||
self.process_identity_resolver = process_identity_resolver
|
|
||||||
self._nonce_ledger_path = Path(nonce_ledger_path) if nonce_ledger_path else None
|
|
||||||
self._nonce_ledger: dict[str, tuple[bytes, dict[str, Any]]] = self._load_nonce_ledger()
|
|
||||||
|
|
||||||
def _load_nonce_ledger(self) -> dict[str, tuple[bytes, dict[str, Any]]]:
|
|
||||||
if self._nonce_ledger_path is None or not self._nonce_ledger_path.exists():
|
|
||||||
return {}
|
|
||||||
if self._nonce_ledger_path.is_symlink():
|
|
||||||
raise BuilderError("NONCE_LEDGER_INVALID")
|
|
||||||
try:
|
|
||||||
raw = json.loads(self._nonce_ledger_path.read_text(encoding="utf-8"))
|
|
||||||
return {nonce: (bytes.fromhex(value["request_sha256"]), dict(value["result"])) for nonce, value in raw.items()}
|
|
||||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("NONCE_LEDGER_INVALID") from exc
|
|
||||||
|
|
||||||
def _persist_nonce_ledger(self) -> None:
|
|
||||||
if self._nonce_ledger_path is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._nonce_ledger_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
temporary = self._nonce_ledger_path.with_suffix(".tmp")
|
|
||||||
payload = {nonce: {"request_sha256": digest.hex(), "result": result} for nonce, (digest, result) in self._nonce_ledger.items()}
|
|
||||||
with temporary.open("wb") as handle:
|
|
||||||
handle.write(_canonical_json(payload))
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.chmod(temporary, 0o600)
|
|
||||||
os.replace(temporary, self._nonce_ledger_path)
|
|
||||||
if os.name != "nt":
|
|
||||||
descriptor = os.open(self._nonce_ledger_path.parent, os.O_RDONLY)
|
|
||||||
try:
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError("NONCE_LEDGER_PERSIST_FAILED") from exc
|
|
||||||
|
|
||||||
def _verify_peer(self, connection: socket.socket) -> None:
|
|
||||||
peer = self.peer_identity_resolver(connection)
|
|
||||||
if peer.uid != self.config.allowed_peer_uid or peer.pid <= 0:
|
|
||||||
raise BuilderError("PEER_IDENTITY_DENIED")
|
|
||||||
resolved = self.cgroup_resolver(peer.pid)
|
|
||||||
cgroups = (resolved,) if isinstance(resolved, str) else tuple(resolved)
|
|
||||||
if not cgroups or not any(item in self.config.allowed_peer_cgroups for item in cgroups):
|
|
||||||
raise BuilderError("PEER_CGROUP_DENIED")
|
|
||||||
executable, argv = self.process_identity_resolver(peer.pid)
|
|
||||||
if not executable.startswith(self.config.allowed_peer_executable_prefix):
|
|
||||||
raise BuilderError("PEER_PROCESS_DENIED")
|
|
||||||
runtime_arguments = [
|
|
||||||
value
|
|
||||||
for value in argv
|
|
||||||
if value.startswith(self.config.allowed_peer_runtime_prefix)
|
|
||||||
and value.endswith(self.config.allowed_peer_runtime_suffix)
|
|
||||||
]
|
|
||||||
if len(runtime_arguments) != 1:
|
|
||||||
raise BuilderError("PEER_PROCESS_DENIED")
|
|
||||||
|
|
||||||
def handle_request(self, request: Mapping[str, Any], *, connection: socket.socket) -> dict[str, Any]:
|
|
||||||
if not isinstance(request, Mapping):
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
expected_keys = {"schema", "operation", "recipe", "repository", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "issued_at", "nonce", "hmac"}
|
|
||||||
if (
|
|
||||||
set(request) != expected_keys
|
|
||||||
or request.get("schema") != self.config.request_schema
|
|
||||||
or request.get("operation") not in {
|
|
||||||
self.config.operation,
|
|
||||||
self.config.readiness_operation,
|
|
||||||
}
|
|
||||||
):
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
supplied_mac = request.get("hmac")
|
|
||||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
|
||||||
if not isinstance(supplied_mac, str) or not hmac.compare_digest(hmac.new(self.keyring.builder_hmac, _canonical_json(unsigned), hashlib.sha256).hexdigest(), supplied_mac):
|
|
||||||
raise BuilderError("REQUEST_HMAC_INVALID")
|
|
||||||
issued_at, nonce = request.get("issued_at"), request.get("nonce")
|
|
||||||
if not isinstance(issued_at, int) or not isinstance(nonce, str) or len(nonce) < 16 or len(nonce) > 256:
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
self._verify_peer(connection)
|
|
||||||
request_bytes = _canonical_json(dict(request))
|
|
||||||
replay = self._nonce_ledger.get(nonce)
|
|
||||||
if replay is not None:
|
|
||||||
if hmac.compare_digest(replay[0], hashlib.sha256(request_bytes).digest()):
|
|
||||||
return dict(replay[1])
|
|
||||||
raise BuilderError("REPLAY_CONFLICT")
|
|
||||||
if abs(int(self.clock()) - issued_at) > self.config.maximum_request_age_seconds:
|
|
||||||
raise BuilderError("REQUEST_STALE")
|
|
||||||
recipe_name = request.get("recipe")
|
|
||||||
if not isinstance(recipe_name, str):
|
|
||||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
|
||||||
recipe = self.config.resolve_recipe(str(request.get("version") or ""), str(request.get("candidate_id") or ""), str(request.get("source_commit") or ""))
|
|
||||||
if recipe_name != recipe.get("version"):
|
|
||||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
||||||
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
|
||||||
if {key: request.get(key) for key in bound} != bound:
|
|
||||||
raise BuilderError("REQUEST_SCOPE_INVALID")
|
|
||||||
bundle = dict(self.collector.collect(recipe_name, recipe))
|
|
||||||
candidate = bundle["candidate"]
|
|
||||||
release_plan = bundle["release_plan"]
|
|
||||||
checks = bundle.get("checks")
|
|
||||||
evidence_digests = bundle["evidence_digests"]
|
|
||||||
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
|
||||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
|
||||||
if request["operation"] == self.config.readiness_operation:
|
|
||||||
result = {
|
|
||||||
"schema": self.config.response_schema,
|
|
||||||
"status": "READY",
|
|
||||||
"repository": self.config.repository,
|
|
||||||
"recipe": recipe_name,
|
|
||||||
"candidate_id": recipe["candidate_id"],
|
|
||||||
"source_commit": recipe["source_commit"],
|
|
||||||
"builder_key_id": self.signer.key_id,
|
|
||||||
"builder_key_generation": self.signer.key_generation,
|
|
||||||
"signature_algorithm": "Ed25519",
|
|
||||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"policy_sha256": self.config.policy_sha256,
|
|
||||||
"collector_ready": True,
|
|
||||||
"signer_ready": True,
|
|
||||||
}
|
|
||||||
if len(self._nonce_ledger) >= 2048:
|
|
||||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
|
||||||
self._nonce_ledger[nonce] = (
|
|
||||||
hashlib.sha256(request_bytes).digest(),
|
|
||||||
dict(result),
|
|
||||||
)
|
|
||||||
self._persist_nonce_ledger()
|
|
||||||
return result
|
|
||||||
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
attestation = {
|
|
||||||
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
|
||||||
"builder_key_id": self.signer.key_id,
|
|
||||||
"builder_key_generation": self.signer.key_generation,
|
|
||||||
"signature_algorithm": "Ed25519",
|
|
||||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"],
|
|
||||||
"artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"],
|
|
||||||
"package_closure_sha256": release_plan["deployment"]["package_closure_sha256"],
|
|
||||||
"test_evidence_sha256": evidence_digests["tests"],
|
|
||||||
"canary_evidence_sha256": evidence_digests["canary"],
|
|
||||||
"gitea_evidence_sha256": evidence_digests["gitea"],
|
|
||||||
"rollback_evidence_sha256": evidence_digests["rollback"],
|
|
||||||
"policy_evidence_sha256": evidence_digests["policy"],
|
|
||||||
"secret_scan_evidence_sha256": evidence_digests["secret_scan"],
|
|
||||||
"issued_at": issued, "trust_domain": "aios.trusted-builder.attestation.v1",
|
|
||||||
}
|
|
||||||
canonical_attestation = _canonical_json(attestation)
|
|
||||||
fingerprint = hashlib.sha256(canonical_attestation).hexdigest()
|
|
||||||
signature = base64.b64encode(self.signer.sign_for_purpose("AIOS_TRUSTED_RELEASE_BUILDER", canonical_attestation)).decode("ascii")
|
|
||||||
result = {
|
|
||||||
"schema": self.config.response_schema, "status": "BUILT", "recipe": recipe_name,
|
|
||||||
"repository": self.config.repository, "task_id": recipe["task_id"], "base_oid": recipe["base_oid"],
|
|
||||||
"purpose": recipe["purpose"], "checks": dict(checks),
|
|
||||||
"evidence": {"candidate_id": candidate["candidate_id"], "source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"], "artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"], "package_closure_sha256": release_plan["deployment"]["package_closure_sha256"], "test_evidence_sha256": attestation["test_evidence_sha256"], "canary_evidence_sha256": attestation["canary_evidence_sha256"]},
|
|
||||||
"attestation": {**attestation, "fingerprint": fingerprint, "signature": signature},
|
|
||||||
}
|
|
||||||
result["silent_release_bundle"] = {"candidate": candidate, "release_plan": release_plan, "checks": dict(checks), "attestation": result["attestation"]}
|
|
||||||
if len(self._nonce_ledger) >= 2048:
|
|
||||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
|
||||||
self._nonce_ledger[nonce] = (hashlib.sha256(request_bytes).digest(), dict(result))
|
|
||||||
self._persist_nonce_ledger()
|
|
||||||
return result
|
|
||||||
|
|
||||||
def serve_forever(self, listener: socket.socket) -> None:
|
|
||||||
while True:
|
|
||||||
connection, _ = listener.accept()
|
|
||||||
with connection:
|
|
||||||
try:
|
|
||||||
raw = connection.makefile("rb").readline(65537)
|
|
||||||
if not raw or len(raw) > 65536:
|
|
||||||
raise BuilderError("REQUEST_SIZE_INVALID")
|
|
||||||
result = self.handle_request(json.loads(raw), connection=connection)
|
|
||||||
except (BuilderError, UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
result = {"schema": self.config.response_schema, "status": "REJECTED", "error_code": "REQUEST_REJECTED"}
|
|
||||||
connection.sendall(_canonical_json(result) + b"\n")
|
|
||||||
|
|
||||||
|
|
||||||
class UnixTrustedCandidateBuilderClient:
|
|
||||||
def __init__(self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *, socket_path: Path | None = None, clock: Callable[[], float] = time.time) -> None:
|
|
||||||
self.config, self.keyring, self.socket_path, self.clock = config, keyring, socket_path, clock
|
|
||||||
|
|
||||||
def create_request(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str, operation: str | None = None) -> dict[str, Any]:
|
|
||||||
selected = self.config.resolve_recipe(version, candidate_id, source_commit)
|
|
||||||
selected_operation = operation or self.config.operation
|
|
||||||
if selected_operation not in {self.config.operation, self.config.readiness_operation}:
|
|
||||||
raise BuilderError("REQUEST_OPERATION_INVALID")
|
|
||||||
request = {"schema": self.config.request_schema, "operation": selected_operation, "recipe": version, "repository": self.config.repository, "task_id": selected["task_id"], "candidate_id": selected["candidate_id"], "base_oid": selected["base_oid"], "source_commit": selected["source_commit"], "version": selected["version"], "purpose": selected["purpose"], "issued_at": int(self.clock()), "nonce": nonce}
|
|
||||||
request["hmac"] = hmac.new(self.keyring.builder_hmac, _canonical_json(request), hashlib.sha256).hexdigest()
|
|
||||||
return request
|
|
||||||
|
|
||||||
def _exchange(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
||||||
if self.socket_path is None:
|
|
||||||
raise BuilderError("BUILDER_SOCKET_UNCONFIGURED")
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
||||||
connection.settimeout(self.config.request_timeout_seconds)
|
|
||||||
connection.connect(os.fspath(self.socket_path))
|
|
||||||
connection.sendall(_canonical_json(dict(request)) + b"\n")
|
|
||||||
raw = connection.makefile("rb").readline(65537)
|
|
||||||
except (OSError, TimeoutError) as exc:
|
|
||||||
raise BuilderError("BUILDER_TRANSPORT_UNAVAILABLE") from exc
|
|
||||||
try:
|
|
||||||
result = json.loads(raw)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise BuilderError("BUILDER_RESPONSE_INVALID") from exc
|
|
||||||
if not isinstance(result, Mapping) or result.get("schema") != self.config.response_schema:
|
|
||||||
raise BuilderError("BUILDER_RESPONSE_INVALID")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def build(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str) -> Mapping[str, Any]:
|
|
||||||
result = self._exchange(
|
|
||||||
self.create_request(version, candidate_id, source_commit, nonce=nonce)
|
|
||||||
)
|
|
||||||
if result.get("status") != "BUILT":
|
|
||||||
raise BuilderError("BUILDER_REQUEST_REJECTED")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def probe(self, *, nonce: str | None = None) -> Mapping[str, Any]:
|
|
||||||
recipe = self.config.fixed_recipe("V2R2C2")
|
|
||||||
result = self._exchange(
|
|
||||||
self.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
recipe["candidate_id"],
|
|
||||||
recipe["source_commit"],
|
|
||||||
nonce=nonce or secrets.token_hex(32),
|
|
||||||
operation=self.config.readiness_operation,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.get("status") != "READY":
|
|
||||||
raise BuilderError("BUILDER_NOT_READY")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def readiness_probe(client: UnixTrustedCandidateBuilderClient) -> Mapping[str, Any]:
|
|
||||||
result = dict(client.probe())
|
|
||||||
required_true = {"collector_ready", "signer_ready"}
|
|
||||||
if any(result.get(name) is not True for name in required_true):
|
|
||||||
raise BuilderError("BUILDER_NOT_READY")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--socket-fd", required=True, type=int)
|
|
||||||
parser.add_argument("--policy", required=True, type=Path)
|
|
||||||
arguments = parser.parse_args()
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(arguments.policy))
|
|
||||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY")
|
|
||||||
if not credentials:
|
|
||||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
|
||||||
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
|
||||||
signer = CredentialPathEd25519Signer(
|
|
||||||
keyring.signing_private_key_path,
|
|
||||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
|
||||||
key_generation=int(os.environ["AIOS_TRUSTED_BUILDER_KEY_GENERATION"]),
|
|
||||||
allowed_purpose="AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
)
|
|
||||||
listener = socket.fromfd(arguments.socket_fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
collector = DeterministicEvidenceCollector(
|
|
||||||
config,
|
|
||||||
evidence_source=ServerOwnedEvidenceSource(
|
|
||||||
config,
|
|
||||||
Path(os.environ["AIOS_TRUSTED_BUILDER_GIT_DIR"]),
|
|
||||||
Path(os.environ["AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
TrustedCandidateBuilder(config, keyring, collector=collector, signer=signer, nonce_ledger_path=Path(os.environ["AIOS_TRUSTED_BUILDER_NONCE_LEDGER"])).serve_forever(listener)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_main()
|
|
||||||
@@ -31,7 +31,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
from version_registry import configured_authoritative_git_dir, verify_authoritative_repository
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
@@ -51,6 +51,11 @@ REQUIRED_V2R1C7_MEMBERS = {
|
|||||||
"payload/ARTIFACT-MANIFEST.json",
|
"payload/ARTIFACT-MANIFEST.json",
|
||||||
"payload/ROLLBACK-METADATA.json",
|
"payload/ROLLBACK-METADATA.json",
|
||||||
}
|
}
|
||||||
|
REQUIRED_V2R2C1_MEMBERS = {
|
||||||
|
"payload/versions/V2R2C1.json",
|
||||||
|
}
|
||||||
|
V2R2C1_TAG_OID = "07fc2746b688f5f243f29b39de39f32edc3dd9d2"
|
||||||
|
DEFAULT_AUTHORITATIVE_GIT_DIR = Path("/data/aios-gitops/repo.git")
|
||||||
|
|
||||||
class ReleaseBlocked(RuntimeError):
|
class ReleaseBlocked(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -74,24 +79,13 @@ class Binding:
|
|||||||
production_service: str
|
production_service: str
|
||||||
production_port: int
|
production_port: int
|
||||||
repository_identity: str = "aios/aios"
|
repository_identity: str = "aios/aios"
|
||||||
|
# Legacy V2R2C1 receipts intentionally omit tag_oid. Future release
|
||||||
candidate_id: str | None = None
|
# bindings must provide it explicitly and may use a versioned schema.
|
||||||
release_plan_sha256: str | None = None
|
tag_oid: str | None = None
|
||||||
|
binding_schema: str | None = None
|
||||||
@classmethod
|
source_registry_main_oid: str | None = None
|
||||||
def from_trusted_plan(cls, plan: TrustedReleasePlan) -> "Binding":
|
source_registry_path: str | None = None
|
||||||
value = plan.value
|
source_registry_sha256: str | None = None
|
||||||
adapter = value["adapter_policy"]
|
|
||||||
binding = cls(
|
|
||||||
version=str(value["version"]), target_oid=str(value["source_commit"]),
|
|
||||||
evidence_closure_oid=str(value["evidence_closure_oid"]), artifact_sha256=str(value["artifact_sha256"]),
|
|
||||||
approval_plan_id=str(value["approval_plan_id"]), artifact_path=str(value["artifact"]["path"]),
|
|
||||||
artifact_size=int(value["artifact"]["size"]), production_service=str(adapter["service"]),
|
|
||||||
production_port=int(adapter["port"]), candidate_id=str(value["candidate_id"]),
|
|
||||||
release_plan_sha256=plan.release_plan_sha256,
|
|
||||||
)
|
|
||||||
plan.assert_binding(dataclasses.asdict(binding))
|
|
||||||
return binding
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.version or not OID_RE.fullmatch(self.target_oid):
|
if not self.version or not OID_RE.fullmatch(self.target_oid):
|
||||||
@@ -110,6 +104,32 @@ class Binding:
|
|||||||
raise ValueError("invalid production port")
|
raise ValueError("invalid production port")
|
||||||
if self.repository_identity != "aios/aios":
|
if self.repository_identity != "aios/aios":
|
||||||
raise ValueError("unexpected repository identity")
|
raise ValueError("unexpected repository identity")
|
||||||
|
if self.tag_oid is not None and not OID_RE.fullmatch(self.tag_oid):
|
||||||
|
raise ValueError("invalid annotated tag object OID")
|
||||||
|
if self.binding_schema is not None and not self.binding_schema.startswith(
|
||||||
|
"aios.release-binding."
|
||||||
|
):
|
||||||
|
raise ValueError("invalid release binding schema")
|
||||||
|
provenance = (
|
||||||
|
self.source_registry_main_oid,
|
||||||
|
self.source_registry_sha256,
|
||||||
|
)
|
||||||
|
if any(item is not None for item in provenance):
|
||||||
|
if not all(item is not None for item in provenance):
|
||||||
|
raise ValueError("source registry provenance must be complete")
|
||||||
|
if not OID_RE.fullmatch(str(self.source_registry_main_oid)):
|
||||||
|
raise ValueError("invalid source registry main OID")
|
||||||
|
if not SHA_RE.fullmatch(str(self.source_registry_sha256)):
|
||||||
|
raise ValueError("invalid source registry SHA-256")
|
||||||
|
path = self.source_registry_path or f"versions/{self.version}.json"
|
||||||
|
candidate = Path(path)
|
||||||
|
if (
|
||||||
|
candidate.is_absolute()
|
||||||
|
or ".." in candidate.parts
|
||||||
|
or "\\" in path
|
||||||
|
or not path.endswith(".json")
|
||||||
|
):
|
||||||
|
raise ValueError("invalid source registry path")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tag(self) -> str:
|
def tag(self) -> str:
|
||||||
@@ -127,13 +147,23 @@ class Binding:
|
|||||||
"production_service": self.production_service,
|
"production_service": self.production_service,
|
||||||
"production_port": self.production_port,
|
"production_port": self.production_port,
|
||||||
"repository_identity": self.repository_identity,
|
"repository_identity": self.repository_identity,
|
||||||
"candidate_id": self.candidate_id,
|
|
||||||
"release_plan_sha256": self.release_plan_sha256,
|
|
||||||
}
|
}
|
||||||
|
if self.tag_oid is not None:
|
||||||
|
payload["tag_oid"] = self.tag_oid
|
||||||
|
payload["binding_schema"] = self.binding_schema or "aios.release-binding.v2"
|
||||||
|
if self.source_registry_main_oid is not None:
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"source_registry_main_oid": self.source_registry_main_oid,
|
||||||
|
"source_registry_path": self.source_registry_path
|
||||||
|
or f"versions/{self.version}.json",
|
||||||
|
"source_registry_sha256": self.source_registry_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||||
|
|
||||||
def request(self, operation: str) -> dict[str, Any]:
|
def request(self, operation: str) -> dict[str, Any]:
|
||||||
return {
|
request = {
|
||||||
"schema": "aios.production-executor-request.v1",
|
"schema": "aios.production-executor-request.v1",
|
||||||
"operation": operation,
|
"operation": operation,
|
||||||
"binding_fingerprint": self.fingerprint,
|
"binding_fingerprint": self.fingerprint,
|
||||||
@@ -148,10 +178,20 @@ class Binding:
|
|||||||
"production_service": self.production_service,
|
"production_service": self.production_service,
|
||||||
"production_port": self.production_port,
|
"production_port": self.production_port,
|
||||||
"repository_identity": self.repository_identity,
|
"repository_identity": self.repository_identity,
|
||||||
"candidate_id": self.candidate_id,
|
|
||||||
"release_plan_sha256": self.release_plan_sha256,
|
|
||||||
}
|
}
|
||||||
|
if self.tag_oid is not None:
|
||||||
|
request["tag_oid"] = self.tag_oid
|
||||||
|
request["binding_schema"] = self.binding_schema or "aios.release-binding.v2"
|
||||||
|
if self.source_registry_main_oid is not None:
|
||||||
|
request.update(
|
||||||
|
{
|
||||||
|
"source_registry_main_oid": self.source_registry_main_oid,
|
||||||
|
"source_registry_path": self.source_registry_path
|
||||||
|
or f"versions/{self.version}.json",
|
||||||
|
"source_registry_sha256": self.source_registry_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: object) -> bytes:
|
def canonical_json(value: object) -> bytes:
|
||||||
@@ -193,6 +233,23 @@ def run_git(git_dir: Path, *args: str, check: bool = True) -> subprocess.Complet
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_git_bytes(
|
||||||
|
git_dir: Path,
|
||||||
|
*args: str,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", f"--git-dir={git_dir}", *args],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if check and completed.returncode != 0:
|
||||||
|
raise ReleaseBlocked(completed.stderr.decode("utf-8", "replace").strip())
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
||||||
authority_main = run_git(git_dir, "rev-parse", "refs/heads/main").stdout.strip()
|
authority_main = run_git(git_dir, "rev-parse", "refs/heads/main").stdout.strip()
|
||||||
if not OID_RE.fullmatch(authority_main):
|
if not OID_RE.fullmatch(authority_main):
|
||||||
@@ -234,7 +291,45 @@ def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
|||||||
if not target_in_evidence:
|
if not target_in_evidence:
|
||||||
raise ReleaseBlocked("approved target is not an ancestor of evidence closure")
|
raise ReleaseBlocked("approved target is not an ancestor of evidence closure")
|
||||||
|
|
||||||
return {
|
source_main = None
|
||||||
|
if binding.source_registry_main_oid is not None:
|
||||||
|
source_main = binding.source_registry_main_oid
|
||||||
|
source_type = run_git(git_dir, "cat-file", "-t", source_main).stdout.strip()
|
||||||
|
if source_type != "commit":
|
||||||
|
raise ReleaseBlocked("source_registry_main_oid is not a commit")
|
||||||
|
target_in_source_main = run_git(
|
||||||
|
git_dir,
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
binding.target_oid,
|
||||||
|
source_main,
|
||||||
|
check=False,
|
||||||
|
).returncode == 0
|
||||||
|
if not target_in_source_main:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"approved target is not an ancestor of source registry main"
|
||||||
|
)
|
||||||
|
expected_tag_oid = binding.tag_oid
|
||||||
|
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||||
|
expected_tag_oid = V2R2C1_TAG_OID
|
||||||
|
if expected_tag_oid is not None:
|
||||||
|
local_tag = run_git(
|
||||||
|
git_dir,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
f"refs/tags/{binding.version}",
|
||||||
|
).stdout.strip()
|
||||||
|
if local_tag != expected_tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"future release binding tag object differs from local tag"
|
||||||
|
)
|
||||||
|
if run_git(git_dir, "cat-file", "-t", local_tag).stdout.strip() != "tag":
|
||||||
|
raise ReleaseBlocked("release tag is not annotated")
|
||||||
|
peeled = run_git(git_dir, "rev-parse", "--verify", f"{local_tag}^{{commit}}").stdout.strip()
|
||||||
|
if peeled != binding.target_oid:
|
||||||
|
raise ManualOwnerReviewRequired("release tag peeled target mismatch")
|
||||||
|
|
||||||
|
result = {
|
||||||
"authority_main_oid": authority_main,
|
"authority_main_oid": authority_main,
|
||||||
"target_oid": binding.target_oid,
|
"target_oid": binding.target_oid,
|
||||||
"target_is_ancestor_of_main": True,
|
"target_is_ancestor_of_main": True,
|
||||||
@@ -242,9 +337,231 @@ def verify_git_lineage(git_dir: Path, binding: Binding) -> dict[str, Any]:
|
|||||||
"evidence_object_type": evidence_type,
|
"evidence_object_type": evidence_type,
|
||||||
"target_is_ancestor_of_evidence": True,
|
"target_is_ancestor_of_evidence": True,
|
||||||
}
|
}
|
||||||
|
if source_main is not None:
|
||||||
|
result["source_registry_main_oid"] = source_main
|
||||||
|
result["target_is_ancestor_of_source_registry_main"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def verify_artifact(binding: Binding) -> dict[str, Any]:
|
def _source_registry_git_provenance(
|
||||||
|
git_dir: Path,
|
||||||
|
binding: Binding,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Read M:path and C:path independently of the artifact metadata."""
|
||||||
|
if binding.source_registry_main_oid is None:
|
||||||
|
return None
|
||||||
|
verify_authoritative_repository(git_dir)
|
||||||
|
source_main = binding.source_registry_main_oid
|
||||||
|
source_path = binding.source_registry_path or f"versions/{binding.version}.json"
|
||||||
|
authority_main = run_git(git_dir, "rev-parse", "--verify", "refs/heads/main").stdout.strip()
|
||||||
|
if run_git(git_dir, "cat-file", "-t", source_main).stdout.strip() != "commit":
|
||||||
|
raise ReleaseBlocked("source_registry_main_oid is not a commit")
|
||||||
|
if run_git(git_dir, "cat-file", "-t", authority_main).stdout.strip() != "commit":
|
||||||
|
raise ReleaseBlocked("Authority main OID is not a commit")
|
||||||
|
if run_git(
|
||||||
|
git_dir,
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
source_main,
|
||||||
|
authority_main,
|
||||||
|
check=False,
|
||||||
|
).returncode != 0:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"source registry main is not an ancestor of current Authority main"
|
||||||
|
)
|
||||||
|
source_blob = run_git_bytes(git_dir, "show", f"{source_main}:{source_path}").stdout
|
||||||
|
current_blob = run_git_bytes(git_dir, "show", f"{authority_main}:{source_path}").stdout
|
||||||
|
if source_blob != current_blob:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"current Authority main source registry entry differs from pinned M"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
source = json.loads(source_blob.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked("Git source registry entry is invalid") from exc
|
||||||
|
expected_tag_oid = binding.tag_oid
|
||||||
|
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||||
|
expected_tag_oid = V2R2C1_TAG_OID
|
||||||
|
expected = {
|
||||||
|
"version": binding.version,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": expected_tag_oid,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"peeled_target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
}
|
||||||
|
if not isinstance(source, dict) or source.get("schema") not in {
|
||||||
|
"aios.source-registry.v1",
|
||||||
|
"aios.production-registry.v2",
|
||||||
|
}:
|
||||||
|
raise ReleaseBlocked("Git source registry schema is invalid")
|
||||||
|
if expected_tag_oid is None or any(source.get(key) != value for key, value in expected.items()):
|
||||||
|
raise ReleaseBlocked("Git source registry identity mismatch")
|
||||||
|
if source.get("tag_object") != expected_tag_oid:
|
||||||
|
raise ReleaseBlocked("Git source registry tag object mismatch")
|
||||||
|
digest = hashlib.sha256(source_blob).hexdigest()
|
||||||
|
if digest != binding.source_registry_sha256:
|
||||||
|
raise ReleaseBlocked("Git source registry SHA-256 mismatch")
|
||||||
|
return {
|
||||||
|
"source_registry_main_oid": source_main,
|
||||||
|
"current_main_oid": authority_main,
|
||||||
|
"source_registry_path": source_path,
|
||||||
|
"source_registry_bytes": source_blob,
|
||||||
|
"source_registry_sha256": digest,
|
||||||
|
"source_registry_identity_verified": True,
|
||||||
|
"source_registry_main_converged": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_artifact_members(
|
||||||
|
path: Path,
|
||||||
|
required: set[str],
|
||||||
|
binding: Binding | None = None,
|
||||||
|
git_provenance: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Verify archive safety and independently verify packaged release data."""
|
||||||
|
# Python tarfile may not have zstd support on the target host, so
|
||||||
|
# decompress into a private temporary tar and inspect it there.
|
||||||
|
with tempfile.TemporaryDirectory(prefix="aios-artifact-verify-") as temp_dir:
|
||||||
|
tar_path = Path(temp_dir) / "release.tar"
|
||||||
|
with tar_path.open("wb") as output:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["zstd", "-dc", str(path)],
|
||||||
|
check=False,
|
||||||
|
stdout=output,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=60,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise ReleaseBlocked("artifact zstd decompression failed")
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
with tarfile.open(tar_path, "r:") as archive:
|
||||||
|
members = archive.getmembers()
|
||||||
|
names: set[str] = set()
|
||||||
|
for member in members:
|
||||||
|
candidate = Path(member.name)
|
||||||
|
if (
|
||||||
|
candidate.is_absolute()
|
||||||
|
or ".." in candidate.parts
|
||||||
|
or member.issym()
|
||||||
|
or member.islnk()
|
||||||
|
or not (member.isfile() or member.isdir())
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked(f"unsafe artifact member: {member.name}")
|
||||||
|
if member.name in names:
|
||||||
|
raise ReleaseBlocked(f"duplicate artifact member: {member.name}")
|
||||||
|
names.add(member.name)
|
||||||
|
source_path = None
|
||||||
|
if binding is not None and binding.version != "V2R1C7":
|
||||||
|
source_path = binding.source_registry_path or f"versions/{binding.version}.json"
|
||||||
|
required_members = set(required)
|
||||||
|
if binding is not None and binding.source_registry_main_oid is not None:
|
||||||
|
required_members.add("payload/BUILD-METADATA.json")
|
||||||
|
if source_path is not None and binding is not None:
|
||||||
|
required_members.add(f"payload/{source_path}")
|
||||||
|
missing = sorted(required_members - names)
|
||||||
|
if missing:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"artifact missing required members: " + ",".join(missing)
|
||||||
|
)
|
||||||
|
verification: dict[str, Any] = {}
|
||||||
|
if binding is not None and source_path is not None:
|
||||||
|
member_name = f"payload/{source_path}"
|
||||||
|
member = archive.getmember(member_name)
|
||||||
|
stream = archive.extractfile(member)
|
||||||
|
if stream is None:
|
||||||
|
raise ReleaseBlocked("source registry member is unreadable")
|
||||||
|
raw = stream.read()
|
||||||
|
try:
|
||||||
|
source = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked("source registry member is invalid") from exc
|
||||||
|
canonical_source = (
|
||||||
|
json.dumps(source, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
if raw != canonical_source:
|
||||||
|
raise ReleaseBlocked("source registry member is not canonical JSON")
|
||||||
|
expected_tag_oid = binding.tag_oid
|
||||||
|
if expected_tag_oid is None and binding.version == "V2R2C1":
|
||||||
|
expected_tag_oid = V2R2C1_TAG_OID
|
||||||
|
expected = {
|
||||||
|
"version": binding.version,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": expected_tag_oid,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"peeled_target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
}
|
||||||
|
if expected_tag_oid is None:
|
||||||
|
raise ReleaseBlocked("future release binding tag object is required")
|
||||||
|
if not isinstance(source, dict) or any(
|
||||||
|
source.get(key) != value for key, value in expected.items()
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked("packaged source registry identity mismatch")
|
||||||
|
if source.get("tag_object") != expected_tag_oid:
|
||||||
|
raise ReleaseBlocked("packaged source registry tag object mismatch")
|
||||||
|
source_digest = hashlib.sha256(raw).hexdigest()
|
||||||
|
if git_provenance is not None:
|
||||||
|
if raw != git_provenance["source_registry_bytes"]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"packaged source registry differs from Git M:path"
|
||||||
|
)
|
||||||
|
if source_digest != git_provenance["source_registry_sha256"]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"packaged source registry SHA-256 differs from Git M:path"
|
||||||
|
)
|
||||||
|
verification.update(
|
||||||
|
{
|
||||||
|
"source_registry_path": source_path,
|
||||||
|
"source_registry_sha256": source_digest,
|
||||||
|
"source_registry_identity_verified": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if binding.source_registry_sha256 is not None:
|
||||||
|
if source_digest != binding.source_registry_sha256:
|
||||||
|
raise ReleaseBlocked("packaged source registry SHA-256 mismatch")
|
||||||
|
metadata_member = archive.getmember("payload/BUILD-METADATA.json")
|
||||||
|
metadata_stream = archive.extractfile(metadata_member)
|
||||||
|
if metadata_stream is None:
|
||||||
|
raise ReleaseBlocked("BUILD-METADATA.json is unreadable")
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata_stream.read().decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked("BUILD-METADATA.json is invalid") from exc
|
||||||
|
metadata_expected = {
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||||
|
"source_registry_path": source_path,
|
||||||
|
"source_registry_sha256": binding.source_registry_sha256,
|
||||||
|
}
|
||||||
|
if not isinstance(metadata, dict) or any(
|
||||||
|
metadata.get(key) != value
|
||||||
|
for key, value in metadata_expected.items()
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked("BUILD-METADATA source provenance mismatch")
|
||||||
|
verification["build_metadata_provenance_verified"] = True
|
||||||
|
if git_provenance is not None:
|
||||||
|
verification.update(
|
||||||
|
{
|
||||||
|
"git_source_registry_member_verified": True,
|
||||||
|
"git_source_registry_main_converged": True,
|
||||||
|
"git_source_registry_sha256": git_provenance[
|
||||||
|
"source_registry_sha256"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return verification
|
||||||
|
|
||||||
|
|
||||||
|
def verify_artifact(
|
||||||
|
binding: Binding,
|
||||||
|
*,
|
||||||
|
repository: Path | str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
path = Path(binding.artifact_path)
|
path = Path(binding.artifact_path)
|
||||||
if path.stat().st_size != binding.artifact_size:
|
if path.stat().st_size != binding.artifact_size:
|
||||||
raise ReleaseBlocked("artifact size mismatch")
|
raise ReleaseBlocked("artifact size mismatch")
|
||||||
@@ -252,46 +569,32 @@ def verify_artifact(binding: Binding) -> dict[str, Any]:
|
|||||||
if actual != binding.artifact_sha256:
|
if actual != binding.artifact_sha256:
|
||||||
raise ReleaseBlocked("artifact SHA-256 mismatch")
|
raise ReleaseBlocked("artifact SHA-256 mismatch")
|
||||||
|
|
||||||
if binding.version == "V2R1C7":
|
required = {
|
||||||
# Python tarfile may not have zstd support on the target host, so
|
"V2R1C7": REQUIRED_V2R1C7_MEMBERS,
|
||||||
# decompress into a private temporary tar and inspect it there.
|
"V2R2C1": REQUIRED_V2R2C1_MEMBERS,
|
||||||
with tempfile.TemporaryDirectory(prefix="aios-artifact-verify-") as temp_dir:
|
}.get(binding.version)
|
||||||
tar_path = Path(temp_dir) / "release.tar"
|
member_verification: dict[str, Any] = {}
|
||||||
with tar_path.open("wb") as output:
|
git_provenance = None
|
||||||
completed = subprocess.run(
|
if binding.source_registry_main_oid is not None:
|
||||||
["zstd", "-dc", str(path)],
|
git_provenance = _source_registry_git_provenance(
|
||||||
check=False,
|
Path(repository) if repository is not None else Path(
|
||||||
stdout=output,
|
configured_authoritative_git_dir()
|
||||||
stderr=subprocess.PIPE,
|
),
|
||||||
timeout=60,
|
binding,
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
)
|
||||||
)
|
if required or binding.source_registry_main_oid is not None:
|
||||||
if completed.returncode != 0:
|
member_verification = _verify_artifact_members(
|
||||||
raise ReleaseBlocked("artifact zstd decompression failed")
|
path,
|
||||||
import tarfile
|
required or set(),
|
||||||
with tarfile.open(tar_path, "r:") as archive:
|
binding,
|
||||||
names: set[str] = set()
|
git_provenance,
|
||||||
for member in archive.getmembers():
|
)
|
||||||
candidate = Path(member.name)
|
|
||||||
if (
|
|
||||||
candidate.is_absolute()
|
|
||||||
or ".." in candidate.parts
|
|
||||||
or member.issym()
|
|
||||||
or member.islnk()
|
|
||||||
or not (member.isfile() or member.isdir())
|
|
||||||
):
|
|
||||||
raise ReleaseBlocked(f"unsafe artifact member: {member.name}")
|
|
||||||
names.add(member.name)
|
|
||||||
missing = sorted(REQUIRED_V2R1C7_MEMBERS - names)
|
|
||||||
if missing:
|
|
||||||
raise ReleaseBlocked(
|
|
||||||
"artifact missing required members: " + ",".join(missing)
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"artifact_path": str(path),
|
"artifact_path": str(path),
|
||||||
"artifact_sha256": actual,
|
"artifact_sha256": actual,
|
||||||
"artifact_size": binding.artifact_size,
|
"artifact_size": binding.artifact_size,
|
||||||
"verified": True,
|
"verified": True,
|
||||||
|
**member_verification,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -307,12 +610,18 @@ def verify_authorization(receipt: Mapping[str, Any], binding: Binding) -> dict[s
|
|||||||
"artifact_sha256": binding.artifact_sha256,
|
"artifact_sha256": binding.artifact_sha256,
|
||||||
"approval_plan_id": binding.approval_plan_id,
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
}
|
}
|
||||||
if binding.candidate_id is not None:
|
if binding.tag_oid is not None:
|
||||||
expected["candidate_id"] = binding.candidate_id
|
expected["tag_oid"] = binding.tag_oid
|
||||||
if binding.release_plan_sha256 is not None:
|
expected["binding_schema"] = binding.binding_schema or "aios.release-binding.v2"
|
||||||
expected["release_plan_sha256"] = binding.release_plan_sha256
|
if binding.source_registry_main_oid is not None:
|
||||||
if receipt.get("candidate_binding_sha256") is None:
|
expected.update(
|
||||||
raise ManualOwnerReviewRequired("Owner authorization missing candidate binding")
|
{
|
||||||
|
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||||
|
"source_registry_path": binding.source_registry_path
|
||||||
|
or f"versions/{binding.version}.json",
|
||||||
|
"source_registry_sha256": binding.source_registry_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
for key, value in expected.items():
|
for key, value in expected.items():
|
||||||
if receipt.get(key) != value:
|
if receipt.get(key) != value:
|
||||||
raise ManualOwnerReviewRequired(f"Owner authorization mismatch: {key}")
|
raise ManualOwnerReviewRequired(f"Owner authorization mismatch: {key}")
|
||||||
@@ -464,13 +773,29 @@ def tag_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
|||||||
tag_oid = probe.get("tag_oid")
|
tag_oid = probe.get("tag_oid")
|
||||||
if not isinstance(tag_oid, str) or not OID_RE.fullmatch(tag_oid):
|
if not isinstance(tag_oid, str) or not OID_RE.fullmatch(tag_oid):
|
||||||
raise ReleaseBlocked("verified annotated tag object OID missing")
|
raise ReleaseBlocked("verified annotated tag object OID missing")
|
||||||
|
if binding.tag_oid is not None and tag_oid != binding.tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired(
|
||||||
|
"same-name tag object differs from the future release binding"
|
||||||
|
)
|
||||||
return dict(probe)
|
return dict(probe)
|
||||||
|
|
||||||
|
|
||||||
def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
||||||
|
def require_runtime_registry(result: Mapping[str, Any]) -> None:
|
||||||
|
if binding.source_registry_main_oid is None:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
result.get("runtime_bundle_contains_current_version") is not True
|
||||||
|
or result.get("runtime_registry_converged") is not True
|
||||||
|
):
|
||||||
|
raise ReconciliationRequired(
|
||||||
|
"runtime bundle/runtime registry convergence was not independently verified"
|
||||||
|
)
|
||||||
|
|
||||||
probe = run_adapter(command, binding.request("probe"))
|
probe = run_adapter(command, binding.request("probe"))
|
||||||
require_exact_response(probe, binding)
|
require_exact_response(probe, binding)
|
||||||
if probe.get("status") == "ACTIVE_VERIFIED":
|
if probe.get("status") == "ACTIVE_VERIFIED":
|
||||||
|
require_runtime_registry(probe)
|
||||||
return dict(probe)
|
return dict(probe)
|
||||||
if probe.get("status") == "CONFLICT":
|
if probe.get("status") == "CONFLICT":
|
||||||
raise ManualOwnerReviewRequired("production runtime has conflicting binding")
|
raise ManualOwnerReviewRequired("production runtime has conflicting binding")
|
||||||
@@ -489,9 +814,30 @@ def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
|||||||
or probe.get("production_deployed") is not True
|
or probe.get("production_deployed") is not True
|
||||||
):
|
):
|
||||||
raise ReconciliationRequired("production health contract is not ACTIVE")
|
raise ReconciliationRequired("production health contract is not ACTIVE")
|
||||||
|
require_runtime_registry(probe)
|
||||||
return dict(probe)
|
return dict(probe)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_post_deploy_source_registry(
|
||||||
|
repository: Path,
|
||||||
|
binding: Binding,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Re-read authoritative Git after runtime deployment and before closure."""
|
||||||
|
if binding.source_registry_main_oid is None:
|
||||||
|
return {
|
||||||
|
"source_registry_converged": "not_required",
|
||||||
|
"source_registry_check": "not_required_for_legacy_binding",
|
||||||
|
}
|
||||||
|
provenance = _source_registry_git_provenance(repository, binding)
|
||||||
|
if provenance is None:
|
||||||
|
raise ReconciliationRequired("authoritative source registry was not checked")
|
||||||
|
return {
|
||||||
|
**provenance,
|
||||||
|
"source_registry_converged": True,
|
||||||
|
"source_registry_check": "post_deploy_authoritative_git_read",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def finalize_step(
|
def finalize_step(
|
||||||
command: Sequence[str],
|
command: Sequence[str],
|
||||||
binding: Binding,
|
binding: Binding,
|
||||||
@@ -529,17 +875,10 @@ def execute(
|
|||||||
deploy_command: Sequence[str],
|
deploy_command: Sequence[str],
|
||||||
registry_command: Sequence[str],
|
registry_command: Sequence[str],
|
||||||
baseline_command: Sequence[str],
|
baseline_command: Sequence[str],
|
||||||
release_plan_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
plan = TrustedReleasePlan.load(release_plan_path) if release_plan_path is not None else None
|
|
||||||
if plan is not None:
|
|
||||||
try:
|
|
||||||
plan.assert_binding(dataclasses.asdict(binding))
|
|
||||||
except PlanError as exc:
|
|
||||||
raise ReleaseBlocked(f"trusted release plan mismatch: {exc}") from exc
|
|
||||||
authorization = verify_authorization(secure_json(authorization_path), binding)
|
authorization = verify_authorization(secure_json(authorization_path), binding)
|
||||||
lineage = verify_git_lineage(repository, binding)
|
lineage = verify_git_lineage(repository, binding)
|
||||||
artifact = verify_artifact(binding)
|
artifact = verify_artifact(binding, repository=repository)
|
||||||
state = DurableState(state_root, binding)
|
state = DurableState(state_root, binding)
|
||||||
with FileLock(state_root / "executor.lock"):
|
with FileLock(state_root / "executor.lock"):
|
||||||
state.record(
|
state.record(
|
||||||
@@ -556,6 +895,13 @@ def execute(
|
|||||||
runtime = deploy_step(deploy_command, binding)
|
runtime = deploy_step(deploy_command, binding)
|
||||||
state.record("RUNTIME_VERIFIED", "runtime", runtime)
|
state.record("RUNTIME_VERIFIED", "runtime", runtime)
|
||||||
|
|
||||||
|
# The runtime adapter proves only runtime facts. Re-read M:path
|
||||||
|
# and C:path from authoritative Git immediately before Registry
|
||||||
|
# and Baseline closure so a post-preflight main change cannot be
|
||||||
|
# mistaken for source convergence.
|
||||||
|
source_registry = verify_post_deploy_source_registry(repository, binding)
|
||||||
|
state.record("SOURCE_REGISTRY_VERIFIED", "source_registry", source_registry)
|
||||||
|
|
||||||
registry = finalize_step(
|
registry = finalize_step(
|
||||||
registry_command,
|
registry_command,
|
||||||
binding,
|
binding,
|
||||||
@@ -585,10 +931,21 @@ def execute(
|
|||||||
require_exact_response(value, binding)
|
require_exact_response(value, binding)
|
||||||
if final_runtime.get("status") != "ACTIVE_VERIFIED":
|
if final_runtime.get("status") != "ACTIVE_VERIFIED":
|
||||||
raise ReconciliationRequired("final runtime readback failed")
|
raise ReconciliationRequired("final runtime readback failed")
|
||||||
|
if binding.source_registry_main_oid is not None and not all(
|
||||||
|
final_runtime.get(name) is True
|
||||||
|
for name in (
|
||||||
|
"runtime_bundle_contains_current_version",
|
||||||
|
"runtime_registry_converged",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ReconciliationRequired(
|
||||||
|
"final runtime registry convergence readback failed"
|
||||||
|
)
|
||||||
if final_registry.get("status") != "VERIFIED":
|
if final_registry.get("status") != "VERIFIED":
|
||||||
raise ReconciliationRequired("final Registry readback failed")
|
raise ReconciliationRequired("final Registry readback failed")
|
||||||
if final_baseline.get("status") != "VERIFIED":
|
if final_baseline.get("status") != "VERIFIED":
|
||||||
raise ReconciliationRequired("final Baseline readback failed")
|
raise ReconciliationRequired("final Baseline readback failed")
|
||||||
|
final_source_registry = verify_post_deploy_source_registry(repository, binding)
|
||||||
final = {
|
final = {
|
||||||
"status": "PRODUCTION_ACCEPTED",
|
"status": "PRODUCTION_ACCEPTED",
|
||||||
"version": binding.version,
|
"version": binding.version,
|
||||||
@@ -599,6 +956,7 @@ def execute(
|
|||||||
"tag_oid": tag["tag_oid"],
|
"tag_oid": tag["tag_oid"],
|
||||||
"tag": final_tag,
|
"tag": final_tag,
|
||||||
"runtime": final_runtime,
|
"runtime": final_runtime,
|
||||||
|
"source_registry": final_source_registry,
|
||||||
"registry": final_registry,
|
"registry": final_registry,
|
||||||
"baseline": final_baseline,
|
"baseline": final_baseline,
|
||||||
"force_push_used": False,
|
"force_push_used": False,
|
||||||
@@ -629,18 +987,22 @@ def execute(
|
|||||||
def parser() -> argparse.ArgumentParser:
|
def parser() -> argparse.ArgumentParser:
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument("--authorization", required=True, type=Path)
|
p.add_argument("--authorization", required=True, type=Path)
|
||||||
p.add_argument("--release-plan", type=Path)
|
|
||||||
p.add_argument("--repository", required=True, type=Path)
|
p.add_argument("--repository", required=True, type=Path)
|
||||||
p.add_argument("--state-root", required=True, type=Path)
|
p.add_argument("--state-root", required=True, type=Path)
|
||||||
p.add_argument("--artifact", type=Path)
|
p.add_argument("--artifact", required=True, type=Path)
|
||||||
p.add_argument("--artifact-size", type=int)
|
p.add_argument("--artifact-size", required=True, type=int)
|
||||||
p.add_argument("--version")
|
p.add_argument("--version", required=True)
|
||||||
p.add_argument("--target-oid")
|
p.add_argument("--target-oid", required=True)
|
||||||
p.add_argument("--evidence-closure-oid")
|
p.add_argument("--evidence-closure-oid", required=True)
|
||||||
p.add_argument("--artifact-sha256")
|
p.add_argument("--artifact-sha256", required=True)
|
||||||
p.add_argument("--approval-plan-id")
|
p.add_argument("--approval-plan-id", required=True)
|
||||||
p.add_argument("--production-service")
|
p.add_argument("--tag-oid")
|
||||||
p.add_argument("--production-port", type=int)
|
p.add_argument("--binding-schema")
|
||||||
|
p.add_argument("--source-registry-main-oid")
|
||||||
|
p.add_argument("--source-registry-path")
|
||||||
|
p.add_argument("--source-registry-sha256")
|
||||||
|
p.add_argument("--production-service", required=True)
|
||||||
|
p.add_argument("--production-port", required=True, type=int)
|
||||||
p.add_argument("--repository-identity", default="aios/aios")
|
p.add_argument("--repository-identity", default="aios/aios")
|
||||||
p.add_argument("--tag-adapter", required=True, nargs="+")
|
p.add_argument("--tag-adapter", required=True, nargs="+")
|
||||||
p.add_argument("--deploy-adapter", required=True, nargs="+")
|
p.add_argument("--deploy-adapter", required=True, nargs="+")
|
||||||
@@ -651,13 +1013,23 @@ def parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parser().parse_args()
|
args = parser().parse_args()
|
||||||
plan = TrustedReleasePlan.load(args.release_plan) if args.release_plan else None
|
binding = Binding(
|
||||||
if plan is not None:
|
version=args.version,
|
||||||
binding = Binding.from_trusted_plan(plan)
|
target_oid=args.target_oid,
|
||||||
else:
|
evidence_closure_oid=args.evidence_closure_oid,
|
||||||
legacy=(args.artifact,args.artifact_size,args.version,args.target_oid,args.evidence_closure_oid,args.artifact_sha256,args.approval_plan_id,args.production_service,args.production_port)
|
artifact_sha256=args.artifact_sha256,
|
||||||
if any(value is None for value in legacy): raise SystemExit("legacy binding is incomplete")
|
approval_plan_id=args.approval_plan_id,
|
||||||
binding = Binding(version=args.version,target_oid=args.target_oid,evidence_closure_oid=args.evidence_closure_oid,artifact_sha256=args.artifact_sha256,approval_plan_id=args.approval_plan_id,artifact_path=str(args.artifact),artifact_size=args.artifact_size,production_service=args.production_service,production_port=args.production_port,repository_identity=args.repository_identity)
|
artifact_path=str(args.artifact),
|
||||||
|
artifact_size=args.artifact_size,
|
||||||
|
production_service=args.production_service,
|
||||||
|
production_port=args.production_port,
|
||||||
|
repository_identity=args.repository_identity,
|
||||||
|
tag_oid=args.tag_oid,
|
||||||
|
binding_schema=args.binding_schema,
|
||||||
|
source_registry_main_oid=args.source_registry_main_oid,
|
||||||
|
source_registry_path=args.source_registry_path,
|
||||||
|
source_registry_sha256=args.source_registry_sha256,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = execute(
|
result = execute(
|
||||||
binding=binding,
|
binding=binding,
|
||||||
@@ -668,7 +1040,6 @@ def main() -> int:
|
|||||||
deploy_command=parse_command(args.deploy_adapter),
|
deploy_command=parse_command(args.deploy_adapter),
|
||||||
registry_command=parse_command(args.registry_adapter),
|
registry_command=parse_command(args.registry_adapter),
|
||||||
baseline_command=parse_command(args.baseline_adapter),
|
baseline_command=parse_command(args.baseline_adapter),
|
||||||
release_plan_path=args.release_plan,
|
|
||||||
)
|
)
|
||||||
print(json.dumps(result, sort_keys=True))
|
print(json.dumps(result, sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
@@ -307,76 +306,14 @@ def authoritative_diff(
|
|||||||
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
||||||
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
||||||
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
||||||
patch_limit = max(1, min(_MAX_OUTPUT, 256 * 1024))
|
result = run_authoritative_git(args, git_dir=path)
|
||||||
process = subprocess.Popen(
|
|
||||||
["git", "--git-dir", str(path), *args],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
||||||
)
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
stored_bytes = 0
|
|
||||||
total_bytes = 0
|
|
||||||
started = time.monotonic()
|
|
||||||
try:
|
|
||||||
while process.stdout is not None:
|
|
||||||
chunk = process.stdout.read(64 * 1024)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
total_bytes += len(chunk)
|
|
||||||
if stored_bytes < patch_limit:
|
|
||||||
remaining = patch_limit - stored_bytes
|
|
||||||
selected = chunk[:remaining]
|
|
||||||
chunks.append(selected)
|
|
||||||
stored_bytes += len(selected)
|
|
||||||
if time.monotonic() - started > _TIMEOUT:
|
|
||||||
process.kill()
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_TIMEOUT")
|
|
||||||
stderr = (
|
|
||||||
process.stderr.read(8192).decode("utf-8", errors="replace")
|
|
||||||
if process.stderr is not None
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
returncode = process.wait(timeout=max(0.1, _TIMEOUT - (time.monotonic() - started)))
|
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
|
|
||||||
if returncode != 0:
|
|
||||||
raise AuthoritativeGitError(_redact(stderr.strip() or "GIT_REF_QUERY_FAILED")[:200])
|
|
||||||
try:
|
|
||||||
names_result = run_authoritative_git(
|
|
||||||
["diff", "--no-ext-diff", "--no-textconv", "--name-status", from_oid, to_oid],
|
|
||||||
git_dir=path,
|
|
||||||
)
|
|
||||||
changed_paths = [
|
|
||||||
line.split("\t", 1)[-1]
|
|
||||||
for line in names_result.stdout.splitlines()
|
|
||||||
if line.strip()
|
|
||||||
]
|
|
||||||
except AuthoritativeGitOutputTooLarge:
|
|
||||||
changed_paths = []
|
|
||||||
try:
|
|
||||||
stats_result = run_authoritative_git(
|
|
||||||
["diff", "--no-ext-diff", "--no-textconv", "--stat", from_oid, to_oid],
|
|
||||||
git_dir=path,
|
|
||||||
)
|
|
||||||
stats = stats_result.stdout.strip()
|
|
||||||
except AuthoritativeGitOutputTooLarge:
|
|
||||||
stats = "GIT_STAT_OUTPUT_TRUNCATED"
|
|
||||||
patch = b"".join(chunks).decode("utf-8", errors="replace")
|
|
||||||
return {
|
return {
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"stdout": patch,
|
"stdout": result.stdout,
|
||||||
"stderr": "",
|
"stderr": "",
|
||||||
"cmd": ["git", f"--git-dir={path}", *args],
|
"cmd": ["git", f"--git-dir={path}", *args],
|
||||||
"from_oid": from_oid,
|
"from_oid": from_oid,
|
||||||
"to_oid": to_oid,
|
"to_oid": to_oid,
|
||||||
"changed_paths": changed_paths,
|
|
||||||
"stats": stats,
|
|
||||||
"total_size_bytes": total_bytes,
|
|
||||||
"truncated": total_bytes > patch_limit,
|
|
||||||
"patch_limit_bytes": patch_limit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import tempfile
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from release_campaign_authorization import CampaignAuthorization, CampaignError
|
from release_campaign_authorization import CampaignAuthorization, CampaignError
|
||||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
|
||||||
|
|
||||||
|
|
||||||
class AcceptanceError(RuntimeError):
|
class AcceptanceError(RuntimeError):
|
||||||
@@ -62,7 +61,6 @@ def evaluate(
|
|||||||
staging: Mapping[str, Any],
|
staging: Mapping[str, Any],
|
||||||
main: Mapping[str, Any],
|
main: Mapping[str, Any],
|
||||||
tag: Mapping[str, Any],
|
tag: Mapping[str, Any],
|
||||||
release_plan: Mapping[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
required_binding = {
|
required_binding = {
|
||||||
"version",
|
"version",
|
||||||
@@ -100,27 +98,8 @@ def evaluate(
|
|||||||
raise AcceptanceError("rollback_readiness_failed")
|
raise AcceptanceError("rollback_readiness_failed")
|
||||||
if tag.get("conflict") is not False:
|
if tag.get("conflict") is not False:
|
||||||
raise AcceptanceError("tag_conflict")
|
raise AcceptanceError("tag_conflict")
|
||||||
trusted_plan: TrustedReleasePlan | None = None
|
|
||||||
if release_plan is not None:
|
|
||||||
try:
|
|
||||||
trusted_plan = TrustedReleasePlan.from_mapping(release_plan)
|
|
||||||
trusted_plan.assert_binding(binding)
|
|
||||||
except PlanError as exc:
|
|
||||||
raise AcceptanceError(f"trusted_release_plan_invalid:{exc}") from exc
|
|
||||||
if trusted_plan.value["campaign_authorization_sha256"] != campaign.digest:
|
|
||||||
raise AcceptanceError("campaign_authorization_digest_mismatch")
|
|
||||||
if int(trusted_plan.value["campaign_generation"]) != int(campaign.data["generation"]):
|
|
||||||
raise AcceptanceError("campaign_generation_mismatch")
|
|
||||||
if main.get("tree_oid") not in (None, trusted_plan.value["tree_oid"]):
|
|
||||||
raise AcceptanceError("tree_oid_mismatch")
|
|
||||||
if evidence.get("release_plan_sha256") not in (None, trusted_plan.release_plan_sha256):
|
|
||||||
raise AcceptanceError("evidence_release_plan_mismatch")
|
|
||||||
if artifact.get("manifest_sha256") not in (None, trusted_plan.value["manifest_sha256"]):
|
|
||||||
raise AcceptanceError("manifest_digest_mismatch")
|
|
||||||
if artifact.get("package_closure_sha256") not in (None, trusted_plan.value["package_closure_sha256"]):
|
|
||||||
raise AcceptanceError("package_closure_digest_mismatch")
|
|
||||||
campaign.assert_operation("derive_campaign_acceptance")
|
campaign.assert_operation("derive_campaign_acceptance")
|
||||||
result = {
|
return {
|
||||||
"schema": "aios.campaign-release-acceptance.v1",
|
"schema": "aios.campaign-release-acceptance.v1",
|
||||||
"accepted": True,
|
"accepted": True,
|
||||||
"binding_replaced": False,
|
"binding_replaced": False,
|
||||||
@@ -129,22 +108,6 @@ def evaluate(
|
|||||||
**dict(binding),
|
**dict(binding),
|
||||||
"owner_interaction_required": False,
|
"owner_interaction_required": False,
|
||||||
}
|
}
|
||||||
if trusted_plan is not None:
|
|
||||||
result.update({
|
|
||||||
"candidate_id": trusted_plan.value["candidate_id"],
|
|
||||||
"release_plan_sha256": trusted_plan.release_plan_sha256,
|
|
||||||
"candidate_binding_sha256": trusted_plan.candidate_binding_sha256,
|
|
||||||
"campaign_authorization_sha256": trusted_plan.value["campaign_authorization_sha256"],
|
|
||||||
"campaign_generation": trusted_plan.value["campaign_generation"],
|
|
||||||
"tree_oid": trusted_plan.value["tree_oid"],
|
|
||||||
"manifest_sha256": trusted_plan.value["manifest_sha256"],
|
|
||||||
"package_closure_sha256": trusted_plan.value["package_closure_sha256"],
|
|
||||||
"deployment_plan_sha256": trusted_plan.value["deployment_plan_sha256"],
|
|
||||||
"policy_sha256": trusted_plan.value["policy_sha256"],
|
|
||||||
"rollback": trusted_plan.value["rollback"],
|
|
||||||
"baseline": trusted_plan.value["baseline"],
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -157,7 +120,6 @@ def main() -> int:
|
|||||||
p.add_argument("--staging", type=Path, required=True)
|
p.add_argument("--staging", type=Path, required=True)
|
||||||
p.add_argument("--main-facts", type=Path, required=True)
|
p.add_argument("--main-facts", type=Path, required=True)
|
||||||
p.add_argument("--tag-facts", type=Path, required=True)
|
p.add_argument("--tag-facts", type=Path, required=True)
|
||||||
p.add_argument("--release-plan", type=Path)
|
|
||||||
p.add_argument("--output", type=Path, required=True)
|
p.add_argument("--output", type=Path, required=True)
|
||||||
a = p.parse_args()
|
a = p.parse_args()
|
||||||
|
|
||||||
@@ -174,7 +136,6 @@ def main() -> int:
|
|||||||
secure_json(a.staging),
|
secure_json(a.staging),
|
||||||
secure_json(a.main_facts),
|
secure_json(a.main_facts),
|
||||||
secure_json(a.tag_facts),
|
secure_json(a.tag_facts),
|
||||||
secure_json(a.release_plan) if a.release_plan else None,
|
|
||||||
)
|
)
|
||||||
atomic_new(a.output, receipt)
|
atomic_new(a.output, receipt)
|
||||||
print(json.dumps(receipt, sort_keys=True))
|
print(json.dumps(receipt, sort_keys=True))
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -24,15 +23,10 @@ try:
|
|||||||
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
|
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||||
try:
|
|
||||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
||||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
VERSION_RE = RELEASE_VERSION_RE
|
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
||||||
FINGERPRINT_FIELDS = (
|
FINGERPRINT_FIELDS = (
|
||||||
"version",
|
"version",
|
||||||
@@ -89,14 +83,9 @@ class PublicationManualOwnerReviewRequired(
|
|||||||
|
|
||||||
|
|
||||||
def validate_version(version: str) -> str:
|
def validate_version(version: str) -> str:
|
||||||
return validate_release_version(version)
|
value = str(version or "")
|
||||||
|
if not VERSION_RE.fullmatch(value):
|
||||||
|
raise ValueError("invalid release version")
|
||||||
def validate_gitea_api_url(api_url: str) -> str:
|
|
||||||
value = str(api_url or "").strip().rstrip("/")
|
|
||||||
parsed = urllib.parse.urlsplit(value)
|
|
||||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
||||||
raise ValueError("Gitea API URL must be HTTP(S)")
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -422,11 +411,13 @@ class GiteaTagClient:
|
|||||||
timeout: float = 15,
|
timeout: float = 15,
|
||||||
token_line: int = 1,
|
token_line: int = 1,
|
||||||
):
|
):
|
||||||
self.api_url = validate_gitea_api_url(api_url)
|
self.api_url = api_url.rstrip("/")
|
||||||
self.repository = repository.strip("/")
|
self.repository = repository.strip("/")
|
||||||
self.token_file = token_file
|
self.token_file = token_file
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.token_line = token_line
|
self.token_line = token_line
|
||||||
|
if not self.api_url.startswith(("https://", "http://")):
|
||||||
|
raise ValueError("Gitea API URL must be HTTP(S)")
|
||||||
if self.repository.count("/") != 1:
|
if self.repository.count("/") != 1:
|
||||||
raise ValueError("Gitea repository must be owner/name")
|
raise ValueError("Gitea repository must be owner/name")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
"""Governed release orchestration with a trusted-plan V2R2C2 path.
|
"""Governed V2R2C1 production release orchestration.
|
||||||
|
|
||||||
The legacy V2R2C1 implementation remains available only for compatibility;
|
|
||||||
new release requests are dispatched to the plan-driven executor below.
|
|
||||||
|
|
||||||
This module is deliberately scoped to the Owner-approved V2R2C1 binding. It
|
This module is deliberately scoped to the Owner-approved V2R2C1 binding. It
|
||||||
uses the existing TagPublisher and metadata adapters; it does not introduce a
|
uses the existing TagPublisher and metadata adapters; it does not introduce a
|
||||||
@@ -23,8 +20,6 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
|
||||||
|
|
||||||
from gitea_tag_publication import (
|
from gitea_tag_publication import (
|
||||||
GiteaTagClient,
|
GiteaTagClient,
|
||||||
GitTagBackend,
|
GitTagBackend,
|
||||||
@@ -34,9 +29,18 @@ from gitea_tag_publication import (
|
|||||||
PublicationRequest,
|
PublicationRequest,
|
||||||
TagPublisher,
|
TagPublisher,
|
||||||
)
|
)
|
||||||
|
from version_registry import (
|
||||||
|
RegistryConvergenceError,
|
||||||
|
assert_source_registry_convergence,
|
||||||
|
resolve_source_registry_main_oid,
|
||||||
|
source_registry_snapshot,
|
||||||
|
verify_authoritative_repository,
|
||||||
|
verify_source_registry_git_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
VERSION = "V2R2C1"
|
VERSION = "V2R2C1"
|
||||||
|
TAG_OID = "07fc2746b688f5f243f29b39de39f32edc3dd9d2"
|
||||||
TARGET_OID = "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
TARGET_OID = "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||||
EVIDENCE_CLOSURE_OID = "a8b906d1bc24088c19647e98de6a679268f6d0a2"
|
EVIDENCE_CLOSURE_OID = "a8b906d1bc24088c19647e98de6a679268f6d0a2"
|
||||||
APPROVAL_PLAN_ID = "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e"
|
APPROVAL_PLAN_ID = "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e"
|
||||||
@@ -84,6 +88,7 @@ def atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ReleaseBinding:
|
class ReleaseBinding:
|
||||||
version: str = VERSION
|
version: str = VERSION
|
||||||
|
tag_oid: str = TAG_OID
|
||||||
target_oid: str = TARGET_OID
|
target_oid: str = TARGET_OID
|
||||||
evidence_closure_oid: str = EVIDENCE_CLOSURE_OID
|
evidence_closure_oid: str = EVIDENCE_CLOSURE_OID
|
||||||
approval_plan_id: str = APPROVAL_PLAN_ID
|
approval_plan_id: str = APPROVAL_PLAN_ID
|
||||||
@@ -94,6 +99,8 @@ class ReleaseBinding:
|
|||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if self.version != VERSION or self.repository != REPOSITORY:
|
if self.version != VERSION or self.repository != REPOSITORY:
|
||||||
raise ManualOwnerReviewRequired("release binding is outside V2R2C1 scope")
|
raise ManualOwnerReviewRequired("release binding is outside V2R2C1 scope")
|
||||||
|
if not OID_RE.fullmatch(self.tag_oid) or self.tag_oid != TAG_OID:
|
||||||
|
raise ManualOwnerReviewRequired("V2R2C1 tag_oid mismatch")
|
||||||
if not OID_RE.fullmatch(self.target_oid) or self.target_oid != TARGET_OID:
|
if not OID_RE.fullmatch(self.target_oid) or self.target_oid != TARGET_OID:
|
||||||
raise ManualOwnerReviewRequired("V2R2C1 target_oid mismatch")
|
raise ManualOwnerReviewRequired("V2R2C1 target_oid mismatch")
|
||||||
if not OID_RE.fullmatch(self.evidence_closure_oid) or self.evidence_closure_oid != EVIDENCE_CLOSURE_OID:
|
if not OID_RE.fullmatch(self.evidence_closure_oid) or self.evidence_closure_oid != EVIDENCE_CLOSURE_OID:
|
||||||
@@ -197,19 +204,48 @@ def _ancestor(git_dir: Path, ancestor: str, descendant: str) -> bool:
|
|||||||
).returncode == 0
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def verify_lineage(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
def verify_lineage(
|
||||||
|
git_dir: Path,
|
||||||
|
binding: ReleaseBinding,
|
||||||
|
*,
|
||||||
|
authority_main_oid: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
binding.validate()
|
binding.validate()
|
||||||
|
main_oid = authority_main_oid or _git(git_dir, "rev-parse", "refs/heads/main")
|
||||||
|
if not OID_RE.fullmatch(main_oid) or _git(git_dir, "cat-file", "-t", main_oid) != "commit":
|
||||||
|
raise ReleaseBlocked("authoritative main OID is not a commit")
|
||||||
if _git(git_dir, "cat-file", "-t", binding.target_oid) != "commit":
|
if _git(git_dir, "cat-file", "-t", binding.target_oid) != "commit":
|
||||||
raise ReleaseBlocked("approved target_oid is not a commit")
|
raise ReleaseBlocked("approved target_oid is not a commit")
|
||||||
if _git(git_dir, "cat-file", "-t", binding.evidence_closure_oid) != "commit":
|
if _git(git_dir, "cat-file", "-t", binding.evidence_closure_oid) != "commit":
|
||||||
raise ReleaseBlocked("evidence closure is not a commit")
|
raise ReleaseBlocked("evidence closure is not a commit")
|
||||||
if not _ancestor(git_dir, binding.target_oid, binding.evidence_closure_oid):
|
if not _ancestor(git_dir, binding.target_oid, binding.evidence_closure_oid):
|
||||||
raise ManualOwnerReviewRequired("approved target is not an ancestor of evidence closure")
|
raise ManualOwnerReviewRequired("approved target is not an ancestor of evidence closure")
|
||||||
|
if not _ancestor(git_dir, binding.target_oid, main_oid):
|
||||||
|
raise ManualOwnerReviewRequired("approved target is not an ancestor of source registry main")
|
||||||
return {
|
return {
|
||||||
"target_oid": binding.target_oid,
|
"target_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"target_is_ancestor_of_evidence": True,
|
"target_is_ancestor_of_evidence": True,
|
||||||
"main_oid": _git(git_dir, "rev-parse", "refs/heads/main"),
|
"main_oid": main_oid,
|
||||||
|
"target_is_ancestor_of_main": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_local_annotated_tag(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
||||||
|
"""Verify the local authoritative tag object without creating or rewriting it."""
|
||||||
|
tag_oid = _git(git_dir, "rev-parse", "--verify", f"refs/tags/{binding.version}")
|
||||||
|
if tag_oid != binding.tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired("local annotated tag object mismatch")
|
||||||
|
if _git(git_dir, "cat-file", "-t", tag_oid) != "tag":
|
||||||
|
raise ReleaseBlocked("release tag is not annotated")
|
||||||
|
peeled = _git(git_dir, "rev-parse", "--verify", f"{tag_oid}^{{commit}}")
|
||||||
|
if peeled != binding.target_oid:
|
||||||
|
raise ManualOwnerReviewRequired("local annotated tag target mismatch")
|
||||||
|
return {
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
"tag_object_type": "tag",
|
||||||
|
"peeled_target_oid": peeled,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -253,9 +289,41 @@ def build_production_artifact(
|
|||||||
binding: ReleaseBinding,
|
binding: ReleaseBinding,
|
||||||
output_root: Path,
|
output_root: Path,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a deterministic executable V2R2C1 health artifact from target_oid."""
|
"""Build a deterministic artifact from composite immutable provenance.
|
||||||
|
|
||||||
|
Release payload files are read from target ``T``. The source registry is
|
||||||
|
read once from the pinned main/convergence commit ``M`` and its exact blob
|
||||||
|
bytes are reused for packaging and all recorded digests.
|
||||||
|
"""
|
||||||
binding.validate()
|
binding.validate()
|
||||||
verify_lineage(git_dir, binding)
|
verify_authoritative_repository(git_dir)
|
||||||
|
source_registry_main_oid = resolve_source_registry_main_oid(
|
||||||
|
git_dir,
|
||||||
|
main_ref="refs/heads/main",
|
||||||
|
)
|
||||||
|
verify_lineage(git_dir, binding, authority_main_oid=source_registry_main_oid)
|
||||||
|
tag_lineage = verify_local_annotated_tag(git_dir, binding)
|
||||||
|
try:
|
||||||
|
source_snapshot = source_registry_snapshot(
|
||||||
|
git_dir,
|
||||||
|
VERSION,
|
||||||
|
source_registry_main_oid=source_registry_main_oid,
|
||||||
|
expected_tag_oid=binding.tag_oid,
|
||||||
|
expected_target_oid=binding.target_oid,
|
||||||
|
expected_evidence_closure_oid=binding.evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=binding.approval_plan_id,
|
||||||
|
)
|
||||||
|
source_registry = source_snapshot["record"]
|
||||||
|
source_registry_bytes = source_snapshot["bytes"]
|
||||||
|
verify_source_registry_git_identity(git_dir, source_registry)
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
raise ReleaseBlocked(f"SOURCE_REGISTRY_NOT_CONVERGED:{exc}") from exc
|
||||||
|
if source_snapshot["source_registry_main_oid"] != source_registry_main_oid:
|
||||||
|
raise ReconciliationRequired("source registry main OID changed during build")
|
||||||
|
if not _ancestor(git_dir, binding.target_oid, source_registry_main_oid):
|
||||||
|
raise ManualOwnerReviewRequired("release target is not an ancestor of source registry main")
|
||||||
|
source_registry_path = source_snapshot["source_registry_path"]
|
||||||
|
source_registry_sha256 = source_snapshot["source_registry_sha256"]
|
||||||
epoch = _commit_epoch(git_dir, binding.target_oid)
|
epoch = _commit_epoch(git_dir, binding.target_oid)
|
||||||
output_root.mkdir(parents=True, exist_ok=True)
|
output_root.mkdir(parents=True, exist_ok=True)
|
||||||
stem = f"aios-{VERSION}-{binding.target_oid[:12]}"
|
stem = f"aios-{VERSION}-{binding.target_oid[:12]}"
|
||||||
@@ -266,11 +334,15 @@ def build_production_artifact(
|
|||||||
source_paths = {
|
source_paths = {
|
||||||
"active-runtime/app/deployment_state.py": "active-runtime/app/deployment_state.py",
|
"active-runtime/app/deployment_state.py": "active-runtime/app/deployment_state.py",
|
||||||
"active-runtime/app/v2r1c6_privacy_tools.py": "active-runtime/app/v2r1c6_privacy_tools.py",
|
"active-runtime/app/v2r1c6_privacy_tools.py": "active-runtime/app/v2r1c6_privacy_tools.py",
|
||||||
|
"versions/V2R2C1.json": "versions/V2R2C1.json",
|
||||||
}
|
}
|
||||||
for destination, source in source_paths.items():
|
for destination, source in source_paths.items():
|
||||||
target = root / destination
|
target = root / destination
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
if destination == source_registry_path:
|
||||||
|
target.write_bytes(source_registry_bytes)
|
||||||
|
else:
|
||||||
|
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
||||||
generated: dict[str, str] = {
|
generated: dict[str, str] = {
|
||||||
"VERSION": VERSION + "\n",
|
"VERSION": VERSION + "\n",
|
||||||
"active-runtime/app/v2r2c1_health_service.py": (
|
"active-runtime/app/v2r2c1_health_service.py": (
|
||||||
@@ -312,16 +384,6 @@ def build_production_artifact(
|
|||||||
"with urllib.request.urlopen(url, timeout=5) as r: print(json.dumps(json.load(r), sort_keys=True))\n"
|
"with urllib.request.urlopen(url, timeout=5) as r: print(json.dumps(json.load(r), sort_keys=True))\n"
|
||||||
),
|
),
|
||||||
"scripts/rollback_v2r2c1_release.sh": "#!/bin/sh\nset -eu\nprintf '%s\\n' 'rollback requires governed owner-approved executor'\nexit 2\n",
|
"scripts/rollback_v2r2c1_release.sh": "#!/bin/sh\nset -eu\nprintf '%s\\n' 'rollback requires governed owner-approved executor'\nexit 2\n",
|
||||||
"release-manifests/V2R2C1.json": json.dumps({
|
|
||||||
"schema": "aios.release-source.v2",
|
|
||||||
"version": VERSION,
|
|
||||||
"repository": REPOSITORY,
|
|
||||||
"target_oid": binding.target_oid,
|
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
|
||||||
"production_deployed": False,
|
|
||||||
"deployment_state": "STAGING",
|
|
||||||
"artifact_kind": "executable-production-health",
|
|
||||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
|
||||||
}
|
}
|
||||||
for rel, text in generated.items():
|
for rel, text in generated.items():
|
||||||
target = root / rel
|
target = root / rel
|
||||||
@@ -333,6 +395,10 @@ def build_production_artifact(
|
|||||||
"schema": "aios.release-artifact-build.v3",
|
"schema": "aios.release-artifact-build.v3",
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"repository": REPOSITORY,
|
"repository": REPOSITORY,
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
"source_head_oid": binding.target_oid,
|
"source_head_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"source_date_epoch": epoch,
|
"source_date_epoch": epoch,
|
||||||
@@ -342,6 +408,31 @@ def build_production_artifact(
|
|||||||
"tag_created": False,
|
"tag_created": False,
|
||||||
"baseline_changed": False,
|
"baseline_changed": False,
|
||||||
"secrets_included": False,
|
"secrets_included": False,
|
||||||
|
"source_registry": {
|
||||||
|
"path": source_registry_path,
|
||||||
|
"version": source_registry["version"],
|
||||||
|
"tag_oid": source_registry["tag_oid"],
|
||||||
|
"target_oid": source_registry["target_oid"],
|
||||||
|
"peeled_target_oid": source_registry["peeled_target_oid"],
|
||||||
|
"evidence_closure_oid": source_registry["evidence_closure_oid"],
|
||||||
|
"approval_plan_id": source_registry["approval_plan_id"],
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"release_payload_source": {
|
||||||
|
"commit_oid": binding.target_oid,
|
||||||
|
"role": "release_target",
|
||||||
|
},
|
||||||
|
"source_registry_source": {
|
||||||
|
"commit_oid": source_registry_main_oid,
|
||||||
|
"path": source_registry_path,
|
||||||
|
"sha256": source_registry_sha256,
|
||||||
|
"role": "source_registry_convergence",
|
||||||
|
},
|
||||||
|
"release_target_is_ancestor_of_source_registry_main": True,
|
||||||
|
"local_annotated_tag": tag_lineage,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
(root / "BUILD-METADATA.json").write_bytes(canonical(metadata))
|
(root / "BUILD-METADATA.json").write_bytes(canonical(metadata))
|
||||||
(root / "ROLLBACK-METADATA.json").write_bytes(canonical(json.loads((root / "deploy/aios-v2/v2r2c1-rollback.json").read_text(encoding="utf-8"))))
|
(root / "ROLLBACK-METADATA.json").write_bytes(canonical(json.loads((root / "deploy/aios-v2/v2r2c1-rollback.json").read_text(encoding="utf-8"))))
|
||||||
@@ -349,6 +440,10 @@ def build_production_artifact(
|
|||||||
(root / "ARTIFACT-MANIFEST.json").write_bytes(canonical({
|
(root / "ARTIFACT-MANIFEST.json").write_bytes(canonical({
|
||||||
"schema": "aios.release-artifact-manifest.v3",
|
"schema": "aios.release-artifact-manifest.v3",
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
"source_head_oid": binding.target_oid,
|
"source_head_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"repository": REPOSITORY,
|
"repository": REPOSITORY,
|
||||||
@@ -380,6 +475,10 @@ def build_production_artifact(
|
|||||||
"schema": "aios.release-artifact-receipt.v3",
|
"schema": "aios.release-artifact-receipt.v3",
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"repository": REPOSITORY,
|
"repository": REPOSITORY,
|
||||||
|
"release_target_oid": binding.target_oid,
|
||||||
|
"source_registry_main_oid": source_registry_main_oid,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_sha256": source_registry_sha256,
|
||||||
"source_head_oid": binding.target_oid,
|
"source_head_oid": binding.target_oid,
|
||||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
"artifact": str(output),
|
"artifact": str(output),
|
||||||
@@ -390,6 +489,14 @@ def build_production_artifact(
|
|||||||
"production_deployed": False,
|
"production_deployed": False,
|
||||||
"tag_created": False,
|
"tag_created": False,
|
||||||
"baseline_changed": False,
|
"baseline_changed": False,
|
||||||
|
"source_registry_path": source_registry_path,
|
||||||
|
"source_registry_identity": source_registry,
|
||||||
|
"provenance": {
|
||||||
|
"release_payload_source": binding.target_oid,
|
||||||
|
"source_registry_source": source_registry_main_oid,
|
||||||
|
"release_target_is_ancestor_of_source_registry_main": True,
|
||||||
|
"local_annotated_tag": tag_lineage,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
atomic_json(output.with_suffix(output.suffix + ".receipt.json"), receipt, mode=0o640)
|
atomic_json(output.with_suffix(output.suffix + ".receipt.json"), receipt, mode=0o640)
|
||||||
return receipt
|
return receipt
|
||||||
@@ -411,14 +518,6 @@ class GovernedProductionReleaseExecutor:
|
|||||||
config.remote_name,
|
config.remote_name,
|
||||||
state_root=self.root / "tag-publication",
|
state_root=self.root / "tag-publication",
|
||||||
)
|
)
|
||||||
# Release checks must use the same explicitly configured staging baseline
|
|
||||||
# as the Action/model runtime; do not fall back to the publication workspace.
|
|
||||||
self.publisher.baseline_path = Path(
|
|
||||||
os.environ.get("AIOS_GITOPS_BASELINE_AUTHORITY", str(config.baseline_path))
|
|
||||||
)
|
|
||||||
# The immutable runtime package is the source of the version projection;
|
|
||||||
# the mutable state workspace only stores publication state.
|
|
||||||
self.publisher.versions_root = Path(__file__).resolve().parents[1] / "versions"
|
|
||||||
|
|
||||||
def _request(self, request: Mapping[str, Any]) -> PublicationRequest:
|
def _request(self, request: Mapping[str, Any]) -> PublicationRequest:
|
||||||
binding = self.binding
|
binding = self.binding
|
||||||
@@ -471,6 +570,12 @@ class GovernedProductionReleaseExecutor:
|
|||||||
self.state.fail("RECONCILIATION_REQUIRED", str(exc))
|
self.state.fail("RECONCILIATION_REQUIRED", str(exc))
|
||||||
raise ReconciliationRequired(str(exc)) from exc
|
raise ReconciliationRequired(str(exc)) from exc
|
||||||
result = dict(verified)
|
result = dict(verified)
|
||||||
|
if result.get("tag_oid") != self.binding.tag_oid:
|
||||||
|
self.state.fail(
|
||||||
|
"MANUAL_OWNER_REVIEW_REQUIRED",
|
||||||
|
"same-name tag object differs from the approved V2R2C1 tag",
|
||||||
|
)
|
||||||
|
raise ManualOwnerReviewRequired("approved tag object mismatch")
|
||||||
self.state.record("TAG_VERIFIED", "tag", result)
|
self.state.record("TAG_VERIFIED", "tag", result)
|
||||||
return {"status": "REMOTE_VERIFIED", **result}
|
return {"status": "REMOTE_VERIFIED", **result}
|
||||||
|
|
||||||
@@ -491,6 +596,8 @@ class GovernedProductionReleaseExecutor:
|
|||||||
tag_state = self.state.value.get("completed", {}).get("tag")
|
tag_state = self.state.value.get("completed", {}).get("tag")
|
||||||
if not isinstance(tag_state, Mapping) or tag_state.get("state") not in {"GITEA_API_CONFIRMED", "REMOTE_VERIFIED"}:
|
if not isinstance(tag_state, Mapping) or tag_state.get("state") not in {"GITEA_API_CONFIRMED", "REMOTE_VERIFIED"}:
|
||||||
raise ReleaseBlocked("REMOTE_TAG_PROOF_REQUIRED")
|
raise ReleaseBlocked("REMOTE_TAG_PROOF_REQUIRED")
|
||||||
|
if tag_state.get("tag_oid") != self.binding.tag_oid:
|
||||||
|
raise ManualOwnerReviewRequired("approved tag object mismatch")
|
||||||
artifact = self.state.value.get("completed", {}).get("production_artifact")
|
artifact = self.state.value.get("completed", {}).get("production_artifact")
|
||||||
if not isinstance(artifact, Mapping):
|
if not isinstance(artifact, Mapping):
|
||||||
artifact = self.build_artifact(request)
|
artifact = self.build_artifact(request)
|
||||||
@@ -501,6 +608,13 @@ class GovernedProductionReleaseExecutor:
|
|||||||
"artifact_size": int(artifact["artifact_size"]),
|
"artifact_size": int(artifact["artifact_size"]),
|
||||||
"artifact_sha256": artifact["artifact_sha256"],
|
"artifact_sha256": artifact["artifact_sha256"],
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in artifact:
|
||||||
|
value[name] = artifact[name]
|
||||||
# The production adapter is privileged and is run through the root
|
# The production adapter is privileged and is run through the root
|
||||||
# approved-release service boundary by the caller. This handler is
|
# approved-release service boundary by the caller. This handler is
|
||||||
# still useful for a root-owned staging invocation and remains
|
# still useful for a root-owned staging invocation and remains
|
||||||
@@ -520,6 +634,21 @@ class GovernedProductionReleaseExecutor:
|
|||||||
result["production_health"] = _health(self.config.health_url, self.binding, self.state.value.get("completed", {}).get("production_artifact", {}))
|
result["production_health"] = _health(self.config.health_url, self.binding, self.state.value.get("completed", {}).get("production_artifact", {}))
|
||||||
result["registry"] = _read_json(Path(self.config.registry_root) / f"{VERSION}.json")
|
result["registry"] = _read_json(Path(self.config.registry_root) / f"{VERSION}.json")
|
||||||
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
||||||
|
try:
|
||||||
|
assert_source = Path(self.config.registry_root) / f"{VERSION}.json"
|
||||||
|
if not assert_source.is_file():
|
||||||
|
raise RegistryConvergenceError("runtime_registry_entry_missing")
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
assert_source,
|
||||||
|
repo_git_dir=self.config.authoritative_git_dir,
|
||||||
|
)
|
||||||
|
source_check = {"status": "PASS", "path": str(assert_source)}
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
source_check = {"status": "FAIL", "error": str(exc)}
|
||||||
|
result["source_registry"] = source_check
|
||||||
|
result.setdefault("checks", {})["source_registry"] = source_check["status"] == "PASS"
|
||||||
|
if result.get("status") == "PASS" and source_check["status"] != "PASS":
|
||||||
|
result["status"] = "FAIL"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
@@ -559,139 +688,7 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
|||||||
return {"status": "ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status": status, **body}
|
return {"status": "ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status": status, **body}
|
||||||
|
|
||||||
|
|
||||||
|
def build_executor_config(config: Any) -> GovernedProductionReleaseExecutor:
|
||||||
class TrustedPlanReleaseExecutor:
|
|
||||||
"""Release handler whose identity comes only from an immutable trusted plan.
|
|
||||||
|
|
||||||
The bridge may prepare/read evidence and request tag publication, but it does
|
|
||||||
not perform production side effects. The privileged approved-release
|
|
||||||
service consumes the same plan and exact Owner approval envelope.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, config: Any):
|
|
||||||
self.config = config
|
|
||||||
configured = getattr(config, "release_plan_path", None)
|
|
||||||
self.plan_path = Path(configured) if configured else None
|
|
||||||
self.state_root = Path(config.state_root) / "trusted-release"
|
|
||||||
self.state_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._approval: dict[str, Any] | None = None
|
|
||||||
self._tag: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
def _plan(self) -> TrustedReleasePlan:
|
|
||||||
path = self.plan_path
|
|
||||||
if path is None:
|
|
||||||
env_path = os.environ.get("AIOS_RELEASE_PLAN_PATH", "")
|
|
||||||
path = Path(env_path) if env_path else None
|
|
||||||
if path is None:
|
|
||||||
raise ReleaseBlocked("TRUSTED_RELEASE_PLAN_REQUIRED")
|
|
||||||
try:
|
|
||||||
return TrustedReleasePlan.load(path)
|
|
||||||
except PlanError as exc:
|
|
||||||
raise ReleaseBlocked(f"TRUSTED_RELEASE_PLAN_INVALID:{exc}") from exc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _binding(plan: TrustedReleasePlan) -> dict[str, Any]:
|
|
||||||
value = plan.value
|
|
||||||
return {
|
|
||||||
"version": value["version"],
|
|
||||||
"target_oid": value["source_commit"],
|
|
||||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
|
||||||
"artifact_sha256": value["artifact_sha256"],
|
|
||||||
"artifact_size": value["artifact"]["size"],
|
|
||||||
"approval_plan_id": value["approval_plan_id"],
|
|
||||||
"candidate_id": value["candidate_id"],
|
|
||||||
"release_plan_sha256": plan.release_plan_sha256,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _request(self, request: Mapping[str, Any]) -> tuple[TrustedReleasePlan, dict[str, Any]]:
|
|
||||||
plan = self._plan()
|
|
||||||
binding = self._binding(plan)
|
|
||||||
for key, expected in binding.items():
|
|
||||||
if key in request and request[key] != expected:
|
|
||||||
raise ManualOwnerReviewRequired("trusted release request binding mismatch:" + key)
|
|
||||||
return plan, binding
|
|
||||||
|
|
||||||
def approval_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
if request.get("execute") is not False:
|
|
||||||
raise ReleaseBlocked("TAG_APPROVAL_REQUEST_MUST_BE_DRY_RUN")
|
|
||||||
plan, binding = self._request(request)
|
|
||||||
approval = request.get("owner_approval")
|
|
||||||
if not isinstance(approval, Mapping) or approval.get("approved") is not True:
|
|
||||||
raise ReleaseBlocked("OWNER_RELEASE_APPROVAL_REQUIRED")
|
|
||||||
for key in ("candidate_id", "release_plan_sha256", "approval_plan_id"):
|
|
||||||
if approval.get(key) != binding[key]:
|
|
||||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_BINDING_MISMATCH:" + key)
|
|
||||||
record = {"plan": plan.as_dict(), "owner_approval": dict(approval)}
|
|
||||||
digest = hashlib.sha256(canonical(record)).hexdigest()
|
|
||||||
if self._approval is not None and self._approval != record:
|
|
||||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_REPLAY_CONFLICT")
|
|
||||||
self._approval = record
|
|
||||||
return {"status": "RECORDED", "execute": False, "release_plan_sha256": plan.release_plan_sha256, "approval_digest_sha256": digest}
|
|
||||||
|
|
||||||
def _require_approval(self, plan: TrustedReleasePlan) -> None:
|
|
||||||
if self._approval is None or self._approval.get("plan") != plan.as_dict():
|
|
||||||
raise ReleaseBlocked("OWNER_RELEASE_APPROVAL_NOT_RECORDED")
|
|
||||||
|
|
||||||
def build_artifact(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
plan, _ = self._request(request)
|
|
||||||
artifact = dict(plan.value["artifact"])
|
|
||||||
path = Path(str(artifact["path"]))
|
|
||||||
if path.is_symlink() or not path.is_file() or path.stat().st_size != artifact["size"]:
|
|
||||||
raise ReleaseBlocked("TRUSTED_ARTIFACT_NOT_PRESENT")
|
|
||||||
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
if actual != plan.value["artifact_sha256"]:
|
|
||||||
raise ReleaseBlocked("TRUSTED_ARTIFACT_DIGEST_MISMATCH")
|
|
||||||
return {"status": "VERIFIED", "version": plan.value["version"], "source_commit": plan.value["source_commit"], "tree_oid": plan.value["tree_oid"], "artifact": str(path), "artifact_size": artifact["size"], "artifact_sha256": actual, "manifest_sha256": plan.value["manifest_sha256"], "package_closure_sha256": plan.value["package_closure_sha256"], "release_plan_sha256": plan.release_plan_sha256}
|
|
||||||
|
|
||||||
def _publisher(self, plan: TrustedReleasePlan) -> TagPublisher:
|
|
||||||
root = self.state_root / plan.value["candidate_id"]
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
publisher = TagPublisher(root / "publication-workspace", GitTagBackend(root / "publication-workspace", authoritative_repo=Path(self.config.authoritative_git_dir)), GiteaTagClient(self.config.api_base, "aios/aios", Path(self.config.token_path)), self.config.remote_name, state_root=root / "tag-publication")
|
|
||||||
publisher.baseline_path = Path(os.environ.get("AIOS_GITOPS_BASELINE_AUTHORITY", str(self.config.baseline_path)))
|
|
||||||
publisher.versions_root = Path(__file__).resolve().parents[1] / "versions"
|
|
||||||
return publisher
|
|
||||||
|
|
||||||
def publish_tag(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
if request.get("execute") is not True:
|
|
||||||
raise ReleaseBlocked("TAG_PUBLICATION_REQUIRES_EXECUTE_TRUE")
|
|
||||||
plan, _ = self._request(request)
|
|
||||||
self._require_approval(plan)
|
|
||||||
publication = PublicationRequest(plan.value["version"], plan.value["source_commit"], plan.value["evidence_closure_oid"], plan.value["approval_plan_id"])
|
|
||||||
try:
|
|
||||||
result = self._publisher(plan)._verify_publication(publication, allow_push=True, previously_deployed=False)
|
|
||||||
except (PublicationManualOwnerReviewRequired, PublicationPending, PublicationReconciliationRequired) as exc:
|
|
||||||
raise ReconciliationRequired(str(exc)) from exc
|
|
||||||
self._tag = dict(result)
|
|
||||||
return {"status": "REMOTE_VERIFIED", **result, "release_plan_sha256": plan.release_plan_sha256}
|
|
||||||
|
|
||||||
def tag_status(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
plan, _ = self._request(request)
|
|
||||||
return self._publisher(plan).get_tag_publication_status(plan.value["version"])
|
|
||||||
|
|
||||||
def execute_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
plan, _ = self._request(request)
|
|
||||||
self._require_approval(plan)
|
|
||||||
raise ReleaseBlocked("PRODUCTION_EXECUTION_REQUIRES_APPROVED_RELEASE_EXECUTOR")
|
|
||||||
|
|
||||||
def consistency(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
plan, binding = self._request(request)
|
|
||||||
result = {"status": "PASS", "release_binding": binding, "trusted_plan": plan.as_dict(), "production_mutation": False}
|
|
||||||
result["baseline"] = _read_json(Path(self.config.baseline_path))
|
|
||||||
result["registry"] = _read_json(Path(self.config.registry_root) / f"{plan.value['version']}.json")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
result = self.consistency(request)
|
|
||||||
result["accepted"] = result["status"] == "PASS"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def build_executor_config(config: Any) -> Any:
|
|
||||||
"""Select legacy compatibility only when no trusted new-release plan exists."""
|
|
||||||
plan_path = getattr(config, "release_plan_path", None) or os.environ.get("AIOS_RELEASE_PLAN_PATH", "")
|
|
||||||
target_version = getattr(config, "target_version", "")
|
|
||||||
if plan_path or target_version != VERSION:
|
|
||||||
return TrustedPlanReleaseExecutor(config)
|
|
||||||
return GovernedProductionReleaseExecutor(config)
|
return GovernedProductionReleaseExecutor(config)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ from gitea_tag_publication import (
|
|||||||
TagPublisher,
|
TagPublisher,
|
||||||
)
|
)
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
@@ -46,10 +44,8 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("request_object_required")
|
raise ValueError("request_object_required")
|
||||||
if value.get("schema") != "aios.production-executor-request.v1":
|
if value.get("schema") != "aios.production-executor-request.v1":
|
||||||
raise ValueError("request_schema_invalid")
|
raise ValueError("request_schema_invalid")
|
||||||
try:
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||||
validate_release_version(value.get("version", ""))
|
raise ValueError("version_invalid")
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("version_invalid") from exc
|
|
||||||
for name in ("target_oid", "evidence_closure_oid"):
|
for name in ("target_oid", "evidence_closure_oid"):
|
||||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||||
raise ValueError(f"{name}_invalid")
|
raise ValueError(f"{name}_invalid")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Registry and authoritative runtime-anchor Baseline only.
|
|||||||
Registry is finalized before Baseline. Baseline apply independently verifies:
|
Registry is finalized before Baseline. Baseline apply independently verifies:
|
||||||
- exact Authority annotated tag and peeled target
|
- exact Authority annotated tag and peeled target
|
||||||
- exact ACTIVE production health
|
- exact ACTIVE production health
|
||||||
- exact V2R1C7 Registry record
|
- exact runtime Registry record and source-registry convergence
|
||||||
- exact previous Baseline file SHA-256 (CAS)
|
- exact previous Baseline file SHA-256 (CAS)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,10 +25,9 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from version_registry import assert_source_registry_convergence
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
@@ -88,10 +87,8 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("request_schema_invalid")
|
raise ValueError("request_schema_invalid")
|
||||||
if value.get("repository_identity") != "aios/aios":
|
if value.get("repository_identity") != "aios/aios":
|
||||||
raise ValueError("repository_identity_invalid")
|
raise ValueError("repository_identity_invalid")
|
||||||
try:
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||||
validate_release_version(value.get("version", ""))
|
raise ValueError("version_invalid")
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("version_invalid") from exc
|
|
||||||
for name in ("target_oid", "evidence_closure_oid"):
|
for name in ("target_oid", "evidence_closure_oid"):
|
||||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||||
raise ValueError(f"{name}_invalid")
|
raise ValueError(f"{name}_invalid")
|
||||||
@@ -101,11 +98,40 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("approval_plan_id_invalid")
|
raise ValueError("approval_plan_id_invalid")
|
||||||
if "tag_oid" in value and not OID_RE.fullmatch(str(value["tag_oid"])):
|
if "tag_oid" in value and not OID_RE.fullmatch(str(value["tag_oid"])):
|
||||||
raise ValueError("tag_oid_invalid")
|
raise ValueError("tag_oid_invalid")
|
||||||
|
if "source_registry_main_oid" in value and not OID_RE.fullmatch(
|
||||||
|
str(value["source_registry_main_oid"])
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_main_oid_invalid")
|
||||||
|
if "source_registry_sha256" in value and not SHA_RE.fullmatch(
|
||||||
|
str(value["source_registry_sha256"])
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_sha256_invalid")
|
||||||
|
if "source_registry_path" in value:
|
||||||
|
source_path = str(value["source_registry_path"])
|
||||||
|
if (
|
||||||
|
not source_path.startswith("versions/")
|
||||||
|
or ".." in Path(source_path).parts
|
||||||
|
or "\\" in source_path
|
||||||
|
or not source_path.endswith(".json")
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_path_invalid")
|
||||||
|
provenance = tuple(
|
||||||
|
value.get(name)
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if any(item is not None for item in provenance) and not all(
|
||||||
|
item is not None for item in provenance
|
||||||
|
):
|
||||||
|
raise ValueError("source_registry_provenance_incomplete")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[str, Any]:
|
def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[str, Any]:
|
||||||
return {
|
result = {
|
||||||
"status": status,
|
"status": status,
|
||||||
"version": value["version"],
|
"version": value["version"],
|
||||||
"target_oid": value["target_oid"],
|
"target_oid": value["target_oid"],
|
||||||
@@ -114,6 +140,14 @@ def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[
|
|||||||
"approval_plan_id": value["approval_plan_id"],
|
"approval_plan_id": value["approval_plan_id"],
|
||||||
**extra,
|
**extra,
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in value:
|
||||||
|
result[name] = value[name]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def git(repository: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
def git(repository: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||||
@@ -158,7 +192,7 @@ def health(url: str, value: Mapping[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: Mapping[str, Any]) -> dict[str, Any]:
|
def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
return {
|
result = {
|
||||||
"schema": "aios.production-registry.v2",
|
"schema": "aios.production-registry.v2",
|
||||||
"version": value["version"],
|
"version": value["version"],
|
||||||
"repository": "aios/aios",
|
"repository": "aios/aios",
|
||||||
@@ -179,10 +213,18 @@ def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: M
|
|||||||
"deployment_state": production_health["deployment_state"],
|
"deployment_state": production_health["deployment_state"],
|
||||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in value:
|
||||||
|
result[name] = value[name]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||||
return all(
|
matches = all(
|
||||||
record.get(key) == expected
|
record.get(key) == expected
|
||||||
for key, expected in {
|
for key, expected in {
|
||||||
"version": value["version"],
|
"version": value["version"],
|
||||||
@@ -196,11 +238,16 @@ def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oi
|
|||||||
"production_deployed": True,
|
"production_deployed": True,
|
||||||
}.items()
|
}.items()
|
||||||
) and record.get("registered") is True and record.get("enabled") is True
|
) and record.get("registered") is True and record.get("enabled") is True
|
||||||
|
return matches and all(
|
||||||
|
record.get(name) == value.get(name)
|
||||||
|
for name in ("source_registry_main_oid", "source_registry_path", "source_registry_sha256")
|
||||||
|
if name in value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||||
current = record.get("current_baseline") or record.get("version")
|
current = record.get("current_baseline") or record.get("version")
|
||||||
return current == value["version"] and all(
|
matches = current == value["version"] and all(
|
||||||
record.get(key) == expected
|
record.get(key) == expected
|
||||||
for key, expected in {
|
for key, expected in {
|
||||||
"target_oid": value["target_oid"],
|
"target_oid": value["target_oid"],
|
||||||
@@ -213,6 +260,11 @@ def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oi
|
|||||||
"status": "PRODUCTION_DEPLOYED",
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
}.items()
|
}.items()
|
||||||
)
|
)
|
||||||
|
return matches and all(
|
||||||
|
record.get(name) == value.get(name)
|
||||||
|
for name in ("source_registry_main_oid", "source_registry_path", "source_registry_sha256")
|
||||||
|
if name in value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def registry_probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
def registry_probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
@@ -309,6 +361,11 @@ def baseline_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[s
|
|||||||
int(previous.get("generation", 0) or 0),
|
int(previous.get("generation", 0) or 0),
|
||||||
int(previous.get("authority_generation", 0) or 0),
|
int(previous.get("authority_generation", 0) or 0),
|
||||||
) + 1
|
) + 1
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
registry_path,
|
||||||
|
repo_git_dir=args.repository,
|
||||||
|
source_registry_main_oid=value.get("source_registry_main_oid"),
|
||||||
|
)
|
||||||
record = {
|
record = {
|
||||||
"schema": "aios.production-baseline.v2",
|
"schema": "aios.production-baseline.v2",
|
||||||
"source_identity": "aios-gitops-runtime-anchor-baseline-authority-v1",
|
"source_identity": "aios-gitops-runtime-anchor-baseline-authority-v1",
|
||||||
@@ -340,6 +397,13 @@ def baseline_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[s
|
|||||||
"promoted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"promoted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
"promoted_by": "aios-approved-production-release-executor",
|
"promoted_by": "aios-approved-production-release-executor",
|
||||||
}
|
}
|
||||||
|
for name in (
|
||||||
|
"source_registry_main_oid",
|
||||||
|
"source_registry_path",
|
||||||
|
"source_registry_sha256",
|
||||||
|
):
|
||||||
|
if name in value:
|
||||||
|
record[name] = value[name]
|
||||||
atomic_json(args.baseline_path, record)
|
atomic_json(args.baseline_path, record)
|
||||||
return baseline_probe(args, value)
|
return baseline_probe(args, value)
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,11 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
|
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
|
||||||
|
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
SECRET_PATTERNS = (
|
SECRET_PATTERNS = (
|
||||||
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
||||||
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
||||||
@@ -770,10 +769,8 @@ class TagApprovalEnvelope:
|
|||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if self.action != "production_tag":
|
if self.action != "production_tag":
|
||||||
raise ApprovalError("approval does not authorize production tag")
|
raise ApprovalError("approval does not authorize production tag")
|
||||||
try:
|
if not VERSION_RE.fullmatch(self.version):
|
||||||
validate_release_version(self.version)
|
raise ApprovalError("invalid release version")
|
||||||
except ValueError as exc:
|
|
||||||
raise ApprovalError("invalid release version") from exc
|
|
||||||
_validate_oid("tag_oid", self.tag_oid)
|
_validate_oid("tag_oid", self.tag_oid)
|
||||||
_validate_oid("target_oid", self.target_oid)
|
_validate_oid("target_oid", self.target_oid)
|
||||||
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -41,8 +40,6 @@ from silent_release_controller import (
|
|||||||
|
|
||||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
||||||
NONCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{15,255}$")
|
NONCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{15,255}$")
|
||||||
BUILDER_ATTESTATION_TRUST_DOMAIN = "aios.trusted-builder.attestation.v1"
|
|
||||||
BUILDER_ATTESTATION_PURPOSE = "AIOS_TRUSTED_RELEASE_BUILDER"
|
|
||||||
ALLOWED_CAPABILITIES = frozenset(
|
ALLOWED_CAPABILITIES = frozenset(
|
||||||
{
|
{
|
||||||
"runtime_switch",
|
"runtime_switch",
|
||||||
@@ -65,10 +62,6 @@ class CapabilityAuthorizationError(RuntimeError):
|
|||||||
"""The signed plan cannot authorize the requested operation."""
|
"""The signed plan cannot authorize the requested operation."""
|
||||||
|
|
||||||
|
|
||||||
class TrustedCandidateBuilderUnavailable(RuntimeError):
|
|
||||||
"""The purpose-bound builder dependency cannot currently serve a build."""
|
|
||||||
|
|
||||||
|
|
||||||
class StaleApprovalError(CapabilityAuthorizationError):
|
class StaleApprovalError(CapabilityAuthorizationError):
|
||||||
"""Live release evidence no longer matches the signed approval."""
|
"""Live release evidence no longer matches the signed approval."""
|
||||||
|
|
||||||
@@ -229,10 +222,6 @@ JOURNAL_GENESIS_HASH = "0" * 64
|
|||||||
|
|
||||||
|
|
||||||
def _fsync_directory(path: Path) -> None:
|
def _fsync_directory(path: Path) -> None:
|
||||||
if os.name == "nt":
|
|
||||||
# Windows does not permit opening a directory with os.open. Durable
|
|
||||||
# Linux host execution retains the directory fsync below.
|
|
||||||
return
|
|
||||||
descriptor = os.open(Path(path), os.O_RDONLY)
|
descriptor = os.open(Path(path), os.O_RDONLY)
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
@@ -726,14 +715,6 @@ class BuilderAttestation:
|
|||||||
canary_evidence_sha256: str
|
canary_evidence_sha256: str
|
||||||
issued_at: str
|
issued_at: str
|
||||||
signature: str
|
signature: str
|
||||||
trust_domain: str = BUILDER_ATTESTATION_TRUST_DOMAIN
|
|
||||||
builder_key_generation: int | None = None
|
|
||||||
signature_algorithm: str | None = None
|
|
||||||
signature_purpose: str | None = None
|
|
||||||
gitea_evidence_sha256: str = ""
|
|
||||||
rollback_evidence_sha256: str = ""
|
|
||||||
policy_evidence_sha256: str = ""
|
|
||||||
secret_scan_evidence_sha256: str = ""
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
_validate_identifier(
|
_validate_identifier(
|
||||||
@@ -750,35 +731,7 @@ class BuilderAttestation:
|
|||||||
"canary_evidence_sha256",
|
"canary_evidence_sha256",
|
||||||
):
|
):
|
||||||
_validate_sha(name, getattr(self, name))
|
_validate_sha(name, getattr(self, name))
|
||||||
for name in (
|
|
||||||
"gitea_evidence_sha256",
|
|
||||||
"rollback_evidence_sha256",
|
|
||||||
"policy_evidence_sha256",
|
|
||||||
"secret_scan_evidence_sha256",
|
|
||||||
):
|
|
||||||
if getattr(self, name):
|
|
||||||
_validate_sha(name, getattr(self, name))
|
|
||||||
_parse_utc(self.issued_at)
|
_parse_utc(self.issued_at)
|
||||||
if self.trust_domain != BUILDER_ATTESTATION_TRUST_DOMAIN:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation trust domain is invalid"
|
|
||||||
)
|
|
||||||
metadata = (
|
|
||||||
self.builder_key_generation,
|
|
||||||
self.signature_algorithm,
|
|
||||||
self.signature_purpose,
|
|
||||||
)
|
|
||||||
if any(value is not None for value in metadata):
|
|
||||||
if (
|
|
||||||
not isinstance(self.builder_key_generation, int)
|
|
||||||
or isinstance(self.builder_key_generation, bool)
|
|
||||||
or self.builder_key_generation < 0
|
|
||||||
or self.signature_algorithm != "Ed25519"
|
|
||||||
or self.signature_purpose != BUILDER_ATTESTATION_PURPOSE
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation signing metadata is invalid"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def canonical_payload(self) -> dict[str, Any]:
|
def canonical_payload(self) -> dict[str, Any]:
|
||||||
@@ -801,290 +754,20 @@ class BuilderAttestation:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(
|
def from_dict(cls, payload: Mapping[str, Any]) -> "BuilderAttestation":
|
||||||
cls,
|
|
||||||
payload: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
require_fingerprint: bool = False,
|
|
||||||
) -> "BuilderAttestation":
|
|
||||||
values = dict(payload)
|
values = dict(payload)
|
||||||
supplied_fingerprint = values.pop("fingerprint", None)
|
values.pop("fingerprint", None)
|
||||||
# Older local records did not spell out the domain. They are only
|
return cls(**values)
|
||||||
# usable when re-signed by the current builder; their original
|
|
||||||
# signatures cannot validate against this domain-separated payload.
|
|
||||||
values.setdefault("trust_domain", BUILDER_ATTESTATION_TRUST_DOMAIN)
|
|
||||||
value = cls(**values)
|
|
||||||
if supplied_fingerprint is None:
|
|
||||||
if require_fingerprint:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation digest is required"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
expected = _validate_sha(
|
|
||||||
"builder attestation digest", str(supplied_fingerprint)
|
|
||||||
)
|
|
||||||
if not hmac.compare_digest(expected, value.fingerprint):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation digest is invalid"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BuilderAttestationKeyRecord:
|
|
||||||
"""One purpose-bound public verification key from the host keyring."""
|
|
||||||
|
|
||||||
key_id: str
|
|
||||||
public_key: bytes
|
|
||||||
algorithm: str
|
|
||||||
purpose: str
|
|
||||||
generation: int
|
|
||||||
not_before: str
|
|
||||||
expires_at: str
|
|
||||||
status: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_validate_identifier("builder_key_id", self.key_id)
|
|
||||||
if self.algorithm != "Ed25519":
|
|
||||||
raise ValueError("unsupported builder signature algorithm")
|
|
||||||
if self.purpose != BUILDER_ATTESTATION_PURPOSE:
|
|
||||||
raise ValueError("builder key purpose is invalid")
|
|
||||||
if not isinstance(self.generation, int) or isinstance(
|
|
||||||
self.generation, bool
|
|
||||||
) or self.generation < 0:
|
|
||||||
raise ValueError("builder key generation is invalid")
|
|
||||||
if self.status not in {"ACTIVE", "REVOKED"}:
|
|
||||||
raise ValueError("builder key status is invalid")
|
|
||||||
if len(self.public_key) != 32:
|
|
||||||
raise ValueError("Ed25519 public key must contain 32 bytes")
|
|
||||||
Ed25519PublicKey.from_public_bytes(self.public_key)
|
|
||||||
if _parse_utc(self.not_before) >= _parse_utc(self.expires_at):
|
|
||||||
raise ValueError("builder key validity window is invalid")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def public_key_sha256(self) -> str:
|
|
||||||
return hashlib.sha256(self.public_key).hexdigest()
|
|
||||||
|
|
||||||
def validate_at(self, instant: datetime) -> None:
|
|
||||||
if self.status != "ACTIVE":
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation key is unavailable or revoked"
|
|
||||||
)
|
|
||||||
if instant.tzinfo is None:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation verification time is invalid"
|
|
||||||
)
|
|
||||||
if not _parse_utc(self.not_before) <= instant <= _parse_utc(
|
|
||||||
self.expires_at
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation key is outside its validity window"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(
|
|
||||||
cls, payload: Mapping[str, Any]
|
|
||||||
) -> "BuilderAttestationKeyRecord":
|
|
||||||
values = dict(payload)
|
|
||||||
encoded = values.pop("public_key", values.pop("public_key_b64", None))
|
|
||||||
if not isinstance(encoded, str):
|
|
||||||
raise ValueError("builder public key is missing")
|
|
||||||
try:
|
|
||||||
public_key = base64.b64decode(encoded, validate=True)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("builder public key encoding is invalid") from exc
|
|
||||||
supplied_digest = values.pop(
|
|
||||||
"public_key_sha256",
|
|
||||||
values.pop("key_sha256", values.pop("digest", None)),
|
|
||||||
)
|
|
||||||
if not isinstance(supplied_digest, str):
|
|
||||||
raise ValueError("builder public key digest is missing")
|
|
||||||
digest = _validate_sha("builder public key digest", supplied_digest)
|
|
||||||
if not hmac.compare_digest(
|
|
||||||
digest, hashlib.sha256(public_key).hexdigest()
|
|
||||||
):
|
|
||||||
raise ValueError("builder public key digest is invalid")
|
|
||||||
allowed = {
|
|
||||||
"algorithm",
|
|
||||||
"purpose",
|
|
||||||
"key_id",
|
|
||||||
"generation",
|
|
||||||
"not_before",
|
|
||||||
"expires_at",
|
|
||||||
"status",
|
|
||||||
}
|
|
||||||
unknown = set(values).difference(allowed)
|
|
||||||
if unknown:
|
|
||||||
raise ValueError("builder key record contains unknown fields")
|
|
||||||
return cls(public_key=public_key, **values)
|
|
||||||
|
|
||||||
|
|
||||||
class BuilderAttestationKeyRing:
|
|
||||||
"""Validated, public-only keyring for builder attestations.
|
|
||||||
|
|
||||||
The file format deliberately cannot carry Owner approval keys: both the
|
|
||||||
keyring and every record must declare the builder-attestation purpose.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
records: Iterable[BuilderAttestationKeyRecord],
|
|
||||||
*,
|
|
||||||
require_attestation_metadata: bool = False,
|
|
||||||
):
|
|
||||||
self._records: dict[str, BuilderAttestationKeyRecord] = {}
|
|
||||||
self._require_attestation_metadata = require_attestation_metadata
|
|
||||||
for record in records:
|
|
||||||
if record.key_id in self._records:
|
|
||||||
raise ValueError("duplicate builder key id")
|
|
||||||
self._records[record.key_id] = record
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_public_keys(
|
|
||||||
cls, public_keys: Mapping[str, bytes]
|
|
||||||
) -> "BuilderAttestationKeyRing":
|
|
||||||
"""Compatibility adapter for explicit in-process builder key maps.
|
|
||||||
|
|
||||||
This is intentionally not used for host configuration; host keyrings
|
|
||||||
must use :meth:`from_file` so purpose, digest, validity and status are
|
|
||||||
checked before the API accepts traffic.
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
BuilderAttestationKeyRecord(
|
|
||||||
key_id=_validate_identifier("builder_key_id", key_id),
|
|
||||||
public_key=bytes(public_key),
|
|
||||||
algorithm="Ed25519",
|
|
||||||
purpose=BUILDER_ATTESTATION_PURPOSE,
|
|
||||||
generation=0,
|
|
||||||
not_before="1970-01-01T00:00:00Z",
|
|
||||||
expires_at="9999-12-31T23:59:59Z",
|
|
||||||
status="ACTIVE",
|
|
||||||
)
|
|
||||||
for key_id, public_key in public_keys.items()
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_file(cls, path: Path) -> "BuilderAttestationKeyRing":
|
|
||||||
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
if payload.get("algorithm") != "Ed25519":
|
|
||||||
raise ValueError("unsupported builder keyring algorithm")
|
|
||||||
if payload.get("purpose") != BUILDER_ATTESTATION_PURPOSE:
|
|
||||||
raise ValueError("builder keyring purpose is invalid")
|
|
||||||
raw_records = payload.get("keys")
|
|
||||||
if not isinstance(raw_records, list) or not raw_records:
|
|
||||||
raise ValueError("builder keyring keys are invalid")
|
|
||||||
allowed = {"algorithm", "purpose", "keys", "keyring_sha256"}
|
|
||||||
if set(payload).difference(allowed):
|
|
||||||
raise ValueError("builder keyring contains unknown fields")
|
|
||||||
if "keyring_sha256" not in payload:
|
|
||||||
raise ValueError("builder keyring digest is missing")
|
|
||||||
expected = _validate_sha(
|
|
||||||
"builder keyring digest", str(payload["keyring_sha256"])
|
|
||||||
)
|
|
||||||
canonical = _canonical_json(
|
|
||||||
{
|
|
||||||
"algorithm": payload["algorithm"],
|
|
||||||
"purpose": payload["purpose"],
|
|
||||||
"keys": raw_records,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not hmac.compare_digest(
|
|
||||||
expected, hashlib.sha256(canonical).hexdigest()
|
|
||||||
):
|
|
||||||
raise ValueError("builder keyring digest is invalid")
|
|
||||||
if not all(isinstance(record, Mapping) for record in raw_records):
|
|
||||||
raise ValueError("builder keyring record is invalid")
|
|
||||||
return cls(
|
|
||||||
(
|
|
||||||
BuilderAttestationKeyRecord.from_dict(record)
|
|
||||||
for record in raw_records
|
|
||||||
),
|
|
||||||
require_attestation_metadata=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def verify(
|
|
||||||
self,
|
|
||||||
key_id: str,
|
|
||||||
signature: bytes,
|
|
||||||
message: bytes,
|
|
||||||
*,
|
|
||||||
at: datetime,
|
|
||||||
generation: int | None,
|
|
||||||
algorithm: str | None,
|
|
||||||
purpose: str | None,
|
|
||||||
) -> BuilderAttestationKeyRecord:
|
|
||||||
record = self._records.get(key_id)
|
|
||||||
if record is None:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation key is not trusted"
|
|
||||||
)
|
|
||||||
record.validate_at(at)
|
|
||||||
metadata = (generation, algorithm, purpose)
|
|
||||||
if self._require_attestation_metadata and any(
|
|
||||||
value is None for value in metadata
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation signing metadata is required"
|
|
||||||
)
|
|
||||||
if any(value is not None for value in metadata) and metadata != (
|
|
||||||
record.generation,
|
|
||||||
record.algorithm,
|
|
||||||
record.purpose,
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation signing metadata does not match key"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
Ed25519PublicKey.from_public_bytes(record.public_key).verify(
|
|
||||||
signature, message
|
|
||||||
)
|
|
||||||
except InvalidSignature as exc:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation signature is invalid"
|
|
||||||
) from exc
|
|
||||||
return record
|
|
||||||
|
|
||||||
def require_active_identity(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
key_id: str,
|
|
||||||
generation: int,
|
|
||||||
algorithm: str,
|
|
||||||
purpose: str,
|
|
||||||
at: datetime | None = None,
|
|
||||||
) -> BuilderAttestationKeyRecord:
|
|
||||||
record = self._records.get(key_id)
|
|
||||||
if record is None:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation key is not trusted"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
record.generation != generation
|
|
||||||
or record.algorithm != algorithm
|
|
||||||
or record.purpose != purpose
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation key metadata is invalid"
|
|
||||||
)
|
|
||||||
record.validate_at(at or datetime.now(timezone.utc))
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
class BuilderAttestationVerifier:
|
class BuilderAttestationVerifier:
|
||||||
"""Public-key-only verifier; no builder private material is accepted."""
|
"""Public-key-only verifier; no builder private material is accepted."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, public_keys: Mapping[str, bytes]):
|
||||||
self,
|
self.public_keys = {
|
||||||
public_keys: BuilderAttestationKeyRing | Mapping[str, bytes],
|
_validate_identifier("builder_key_id", key_id): bytes(value)
|
||||||
):
|
for key_id, value in public_keys.items()
|
||||||
if isinstance(public_keys, BuilderAttestationKeyRing):
|
}
|
||||||
self.keyring = public_keys
|
|
||||||
elif isinstance(public_keys, Mapping):
|
|
||||||
self.keyring = BuilderAttestationKeyRing.from_public_keys(
|
|
||||||
public_keys
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise TypeError("builder verifier requires a builder keyring")
|
|
||||||
|
|
||||||
def verify(
|
def verify(
|
||||||
self,
|
self,
|
||||||
@@ -1110,54 +793,22 @@ class BuilderAttestationVerifier:
|
|||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"builder attestation does not bind candidate evidence"
|
"builder attestation does not bind candidate evidence"
|
||||||
)
|
)
|
||||||
|
key = self.public_keys.get(attestation.builder_key_id)
|
||||||
|
if key is None:
|
||||||
|
raise CapabilityAuthorizationError(
|
||||||
|
"builder attestation key is not trusted"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
signature = base64.b64decode(
|
signature = base64.b64decode(
|
||||||
attestation.signature, validate=True
|
attestation.signature, validate=True
|
||||||
)
|
)
|
||||||
key_record = self.keyring.verify(
|
Ed25519PublicKey.from_public_bytes(key).verify(
|
||||||
attestation.builder_key_id,
|
signature, attestation.canonical_bytes()
|
||||||
signature,
|
|
||||||
attestation.canonical_bytes(),
|
|
||||||
at=_parse_utc(attestation.issued_at),
|
|
||||||
generation=attestation.builder_key_generation,
|
|
||||||
algorithm=attestation.signature_algorithm,
|
|
||||||
purpose=attestation.signature_purpose,
|
|
||||||
)
|
)
|
||||||
except (InvalidSignature, ValueError, TypeError) as exc:
|
except (InvalidSignature, ValueError, TypeError) as exc:
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"builder attestation signature is invalid"
|
"builder attestation signature is invalid"
|
||||||
) from exc
|
) from exc
|
||||||
if key_record.generation >= 1:
|
|
||||||
expected_gitea = hashlib.sha256(
|
|
||||||
_canonical_json(candidate.gitea_consistency)
|
|
||||||
).hexdigest()
|
|
||||||
expected_rollback = hashlib.sha256(
|
|
||||||
_canonical_json(
|
|
||||||
{
|
|
||||||
"ready": candidate.rollback_ready,
|
|
||||||
"rollback_sha256": release_plan.deployment.rollback_sha256,
|
|
||||||
"source_commit": candidate.source_commit,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).hexdigest()
|
|
||||||
production_evidence = (
|
|
||||||
attestation.gitea_evidence_sha256,
|
|
||||||
attestation.rollback_evidence_sha256,
|
|
||||||
attestation.policy_evidence_sha256,
|
|
||||||
attestation.secret_scan_evidence_sha256,
|
|
||||||
)
|
|
||||||
if any(not value for value in production_evidence):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation production evidence is incomplete"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
attestation.gitea_evidence_sha256 != expected_gitea
|
|
||||||
or attestation.rollback_evidence_sha256 != expected_rollback
|
|
||||||
or attestation.policy_evidence_sha256 != release_plan.policy_sha256
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"builder attestation production evidence is invalid"
|
|
||||||
)
|
|
||||||
return attestation
|
return attestation
|
||||||
|
|
||||||
|
|
||||||
@@ -3530,12 +3181,9 @@ class SilentReleaseActionService:
|
|||||||
builder_attestation_verifier: BuilderAttestationVerifier | None = None,
|
builder_attestation_verifier: BuilderAttestationVerifier | None = None,
|
||||||
trusted_policy: TrustedReleasePolicy | None = None,
|
trusted_policy: TrustedReleasePolicy | None = None,
|
||||||
orchestrator: ReleaseOrchestrator | None = None,
|
orchestrator: ReleaseOrchestrator | None = None,
|
||||||
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
|
||||||
allow_legacy_candidate_input: bool = False,
|
allow_legacy_candidate_input: bool = False,
|
||||||
expected_repository: str = "",
|
expected_repository: str = "",
|
||||||
policy_sha256: str = "",
|
policy_sha256: str = "",
|
||||||
max_builder_attempts: int = 3,
|
|
||||||
builder_attempt_timeout_seconds: int = 180,
|
|
||||||
):
|
):
|
||||||
self.root = Path(root)
|
self.root = Path(root)
|
||||||
self.execution_enabled = bool(execution_enabled)
|
self.execution_enabled = bool(execution_enabled)
|
||||||
@@ -3546,28 +3194,11 @@ class SilentReleaseActionService:
|
|||||||
self.builder_attestation_verifier = builder_attestation_verifier
|
self.builder_attestation_verifier = builder_attestation_verifier
|
||||||
self.trusted_policy = trusted_policy
|
self.trusted_policy = trusted_policy
|
||||||
self.orchestrator = orchestrator
|
self.orchestrator = orchestrator
|
||||||
self.builder_readiness_probe = builder_readiness_probe
|
|
||||||
self.allow_legacy_candidate_input = bool(
|
self.allow_legacy_candidate_input = bool(
|
||||||
allow_legacy_candidate_input
|
allow_legacy_candidate_input
|
||||||
)
|
)
|
||||||
self.expected_repository = expected_repository
|
self.expected_repository = expected_repository
|
||||||
self.policy_sha256 = policy_sha256
|
self.policy_sha256 = policy_sha256
|
||||||
if (
|
|
||||||
not isinstance(max_builder_attempts, int)
|
|
||||||
or isinstance(max_builder_attempts, bool)
|
|
||||||
or not 1 <= max_builder_attempts <= 10
|
|
||||||
):
|
|
||||||
raise ValueError("max_builder_attempts must be between 1 and 10")
|
|
||||||
self.max_builder_attempts = max_builder_attempts
|
|
||||||
if (
|
|
||||||
not isinstance(builder_attempt_timeout_seconds, int)
|
|
||||||
or isinstance(builder_attempt_timeout_seconds, bool)
|
|
||||||
or not 1 <= builder_attempt_timeout_seconds <= 3600
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"builder_attempt_timeout_seconds must be between 1 and 3600"
|
|
||||||
)
|
|
||||||
self.builder_attempt_timeout_seconds = builder_attempt_timeout_seconds
|
|
||||||
self.candidates = ReleaseCandidateStore(
|
self.candidates = ReleaseCandidateStore(
|
||||||
self.root / "candidates"
|
self.root / "candidates"
|
||||||
)
|
)
|
||||||
@@ -3604,26 +3235,10 @@ class SilentReleaseActionService:
|
|||||||
+ ", ".join(missing)
|
+ ", ".join(missing)
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_builder_readiness(self) -> dict[str, Any]:
|
|
||||||
"""Report dependency readiness without conflating it with API liveness."""
|
|
||||||
if self.candidate_builder is None:
|
|
||||||
return {"status": "UNAVAILABLE"}
|
|
||||||
if self.builder_readiness_probe is None:
|
|
||||||
# Explicit in-process test/adaptor construction remains usable;
|
|
||||||
# production wiring provides a probe backed by its credentials.
|
|
||||||
return {"status": "READY"}
|
|
||||||
try:
|
|
||||||
value = dict(self.builder_readiness_probe())
|
|
||||||
except Exception:
|
|
||||||
return {"status": "UNAVAILABLE"}
|
|
||||||
if value.get("status") != "READY":
|
|
||||||
return {"status": "UNAVAILABLE"}
|
|
||||||
return _sanitize(value)
|
|
||||||
|
|
||||||
def create_release_intent(
|
def create_release_intent(
|
||||||
self, payload: Mapping[str, Any]
|
self, payload: Mapping[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
allowed = {"event_id", "source_commit", "version"}
|
allowed = {"event_id", "source_commit"}
|
||||||
unknown = set(payload).difference(allowed)
|
unknown = set(payload).difference(allowed)
|
||||||
if unknown:
|
if unknown:
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
@@ -3635,17 +3250,12 @@ class SilentReleaseActionService:
|
|||||||
source_commit = _validate_oid(
|
source_commit = _validate_oid(
|
||||||
"source_commit", str(payload.get("source_commit") or "")
|
"source_commit", str(payload.get("source_commit") or "")
|
||||||
)
|
)
|
||||||
version = str(payload.get("version") or "")
|
|
||||||
if version:
|
|
||||||
_validate_identifier("version", version)
|
|
||||||
intent_id = "intent-" + hashlib.sha256(
|
intent_id = "intent-" + hashlib.sha256(
|
||||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||||
).hexdigest()[:24]
|
).hexdigest()[:24]
|
||||||
path = _safe_child(self.intents_root, intent_id)
|
path = _safe_child(self.intents_root, intent_id)
|
||||||
was_existing = path.exists()
|
|
||||||
record = {
|
record = {
|
||||||
"created_at": utc_now(),
|
"created_at": utc_now(),
|
||||||
"attempt_count": 0,
|
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"intent_id": intent_id,
|
"intent_id": intent_id,
|
||||||
"source_commit": source_commit,
|
"source_commit": source_commit,
|
||||||
@@ -3655,153 +3265,42 @@ class SilentReleaseActionService:
|
|||||||
else "BUILDER_UNAVAILABLE"
|
else "BUILDER_UNAVAILABLE"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if version:
|
if path.exists():
|
||||||
record["version"] = version
|
|
||||||
if was_existing:
|
|
||||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if (
|
if (
|
||||||
existing.get("event_id") != event_id
|
existing.get("event_id") != event_id
|
||||||
or existing.get("source_commit") != source_commit
|
or existing.get("source_commit") != source_commit
|
||||||
or str(existing.get("version") or "") != version
|
|
||||||
):
|
):
|
||||||
raise SagaConflict(
|
raise SagaConflict(
|
||||||
"release intent id is bound to different evidence"
|
"release intent id is bound to different evidence"
|
||||||
)
|
)
|
||||||
if existing.get("status") == "BUILDING":
|
return {
|
||||||
try:
|
**_sanitize(existing),
|
||||||
started_at = _parse_utc(
|
"idempotent": True,
|
||||||
str(existing.get("attempt_started_at") or "")
|
"execution_enabled": self.execution_enabled,
|
||||||
)
|
"shadow_mode": not self.execution_enabled,
|
||||||
elapsed = (
|
"side_effects_executed": False,
|
||||||
datetime.now(timezone.utc) - started_at
|
|
||||||
).total_seconds()
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
elapsed = self.builder_attempt_timeout_seconds + 1
|
|
||||||
if elapsed <= self.builder_attempt_timeout_seconds:
|
|
||||||
return self._intent_response(existing, idempotent=True)
|
|
||||||
existing.update(
|
|
||||||
{
|
|
||||||
"failure_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
|
||||||
"last_recovery_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
|
||||||
"recovery_count": int(existing.get("recovery_count") or 0) + 1,
|
|
||||||
"retryable": int(existing.get("attempt_count") or 0)
|
|
||||||
< self.max_builder_attempts,
|
|
||||||
"status": "BUILDER_RETRYABLE_FAILURE",
|
|
||||||
"updated_at": utc_now(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, existing)
|
|
||||||
if (
|
|
||||||
existing.get("status") != "BUILDER_RETRYABLE_FAILURE"
|
|
||||||
or int(existing.get("attempt_count") or 0)
|
|
||||||
>= self.max_builder_attempts
|
|
||||||
):
|
|
||||||
return self._intent_response(existing, idempotent=True)
|
|
||||||
record = dict(existing)
|
|
||||||
if self.candidate_builder is None:
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"retryable": False,
|
|
||||||
"status": "BUILDER_UNAVAILABLE",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, record)
|
|
||||||
return self._intent_response(record, idempotent=was_existing)
|
|
||||||
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"attempt_count": int(record.get("attempt_count") or 0) + 1,
|
|
||||||
"attempt_started_at": utc_now(),
|
|
||||||
"retryable": False,
|
|
||||||
"status": "BUILDING",
|
|
||||||
"updated_at": utc_now(),
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
_durable_json(path, record)
|
_durable_json(path, record)
|
||||||
try:
|
result = dict(record)
|
||||||
|
if self.candidate_builder is not None:
|
||||||
candidate = self.create_release_candidate(
|
candidate = self.create_release_candidate(
|
||||||
{
|
{
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"intent_id": intent_id,
|
"intent_id": intent_id,
|
||||||
"source_commit": source_commit,
|
"source_commit": source_commit,
|
||||||
**({"version": version} if version else {}),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except TrustedCandidateBuilderUnavailable:
|
result.update(
|
||||||
record.update(
|
|
||||||
{
|
{
|
||||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
"candidate_id": candidate["candidate_id"],
|
||||||
"retryable": record["attempt_count"]
|
"status": candidate["saga_state"],
|
||||||
< self.max_builder_attempts,
|
|
||||||
"status": (
|
|
||||||
"BUILDER_RETRYABLE_FAILURE"
|
|
||||||
if record["attempt_count"] < self.max_builder_attempts
|
|
||||||
else "BUILDER_TERMINAL_FAILURE"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_durable_json(path, record)
|
_durable_json(path, result)
|
||||||
return self._intent_response(record, idempotent=False)
|
|
||||||
except CapabilityAuthorizationError:
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"failure_code": "TRUSTED_BUILDER_REJECTED",
|
|
||||||
"retryable": False,
|
|
||||||
"status": "BUILDER_TERMINAL_FAILURE",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, record)
|
|
||||||
return self._intent_response(record, idempotent=False)
|
|
||||||
except (ConnectionError, OSError, TimeoutError):
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
|
||||||
"retryable": record["attempt_count"]
|
|
||||||
< self.max_builder_attempts,
|
|
||||||
"status": (
|
|
||||||
"BUILDER_RETRYABLE_FAILURE"
|
|
||||||
if record["attempt_count"] < self.max_builder_attempts
|
|
||||||
else "BUILDER_TERMINAL_FAILURE"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, record)
|
|
||||||
return self._intent_response(record, idempotent=False)
|
|
||||||
except Exception:
|
|
||||||
# The durable intent is recoverable without serializing an
|
|
||||||
# arbitrary exception (which may contain a credential or host
|
|
||||||
# path). Repeated requests consume the small fixed retry budget.
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"failure_code": "TRUSTED_BUILDER_FAILURE",
|
|
||||||
"retryable": record["attempt_count"]
|
|
||||||
< self.max_builder_attempts,
|
|
||||||
"status": (
|
|
||||||
"BUILDER_RETRYABLE_FAILURE"
|
|
||||||
if record["attempt_count"] < self.max_builder_attempts
|
|
||||||
else "BUILDER_TERMINAL_FAILURE"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, record)
|
|
||||||
return self._intent_response(record, idempotent=False)
|
|
||||||
result = dict(record)
|
|
||||||
result.update(
|
|
||||||
{
|
|
||||||
"candidate_id": candidate["candidate_id"],
|
|
||||||
"retryable": False,
|
|
||||||
"status": candidate["saga_state"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, result)
|
|
||||||
return self._intent_response(result, idempotent=False)
|
|
||||||
|
|
||||||
def _intent_response(
|
|
||||||
self, record: Mapping[str, Any], *, idempotent: bool
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
return {
|
||||||
**_sanitize(dict(record)),
|
**_sanitize(result),
|
||||||
"idempotent": idempotent,
|
"idempotent": False,
|
||||||
"execution_enabled": self.execution_enabled,
|
"execution_enabled": self.execution_enabled,
|
||||||
"shadow_mode": not self.execution_enabled,
|
"shadow_mode": not self.execution_enabled,
|
||||||
"side_effects_executed": False,
|
"side_effects_executed": False,
|
||||||
@@ -3822,7 +3321,6 @@ class SilentReleaseActionService:
|
|||||||
self, payload: Mapping[str, Any]
|
self, payload: Mapping[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if self.candidate_builder is not None:
|
if self.candidate_builder is not None:
|
||||||
values = dict(payload)
|
|
||||||
forbidden_evidence = {
|
forbidden_evidence = {
|
||||||
"artifact_sha256",
|
"artifact_sha256",
|
||||||
"canary",
|
"canary",
|
||||||
@@ -3835,69 +3333,13 @@ class SilentReleaseActionService:
|
|||||||
"tests",
|
"tests",
|
||||||
"tree_oid",
|
"tree_oid",
|
||||||
}
|
}
|
||||||
if forbidden_evidence.intersection(values):
|
if forbidden_evidence.intersection(payload):
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"candidate evidence must come from the trusted builder"
|
"candidate evidence must come from the trusted builder"
|
||||||
)
|
)
|
||||||
worker_fields = {"candidate_id", "source_commit", "version"}
|
|
||||||
intent_fields = {"event_id", "intent_id", "source_commit", "version"}
|
|
||||||
supplied = set(values)
|
|
||||||
is_worker_request = worker_fields.issubset(supplied)
|
|
||||||
if is_worker_request:
|
|
||||||
if supplied != worker_fields:
|
|
||||||
raise ValueError("release worker request contains unknown fields")
|
|
||||||
_validate_identifier(
|
|
||||||
"candidate_id", str(values.get("candidate_id") or "")
|
|
||||||
)
|
|
||||||
_validate_identifier(
|
|
||||||
"version", str(values.get("version") or "")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if not supplied.issubset(intent_fields):
|
|
||||||
raise ValueError("trusted builder intent contains unknown fields")
|
|
||||||
# Preserve the established internal intent contract
|
|
||||||
# (source_commit + version) while allowing newer callers to
|
|
||||||
# add an event/intent identifier. Neither form can supply
|
|
||||||
# acceptance evidence; the host builder owns all collectors.
|
|
||||||
if values.get("event_id"):
|
|
||||||
_validate_identifier(
|
|
||||||
"event_id", str(values["event_id"])
|
|
||||||
)
|
|
||||||
if values.get("intent_id"):
|
|
||||||
_validate_identifier(
|
|
||||||
"intent_id", str(values["intent_id"])
|
|
||||||
)
|
|
||||||
if values.get("version"):
|
|
||||||
_validate_identifier(
|
|
||||||
"version", str(values["version"])
|
|
||||||
)
|
|
||||||
_validate_oid(
|
|
||||||
"source_commit", str(values.get("source_commit") or "")
|
|
||||||
)
|
|
||||||
readiness = self.get_builder_readiness()
|
|
||||||
if readiness.get("status") != "READY":
|
|
||||||
raise TrustedCandidateBuilderUnavailable(
|
|
||||||
"trusted builder dependency is unavailable"
|
|
||||||
)
|
|
||||||
bundle = self.candidate_builder.build(dict(payload)).validate(
|
bundle = self.candidate_builder.build(dict(payload)).validate(
|
||||||
self.builder_attestation_verifier
|
self.builder_attestation_verifier
|
||||||
)
|
)
|
||||||
requested_source = str(payload.get("source_commit") or "")
|
|
||||||
if (
|
|
||||||
requested_source
|
|
||||||
and bundle.candidate.source_commit != requested_source
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"trusted builder source commit does not match intent"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
is_worker_request
|
|
||||||
and bundle.candidate.candidate_id
|
|
||||||
!= str(values["candidate_id"])
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"trusted builder candidate id does not match request"
|
|
||||||
)
|
|
||||||
if self.trusted_policy is None:
|
if self.trusted_policy is None:
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"trusted release policy is not configured"
|
"trusted release policy is not configured"
|
||||||
@@ -4015,19 +3457,6 @@ class SilentReleaseActionService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_release_saga(self, candidate_id: str) -> dict[str, Any]:
|
def get_release_saga(self, candidate_id: str) -> dict[str, Any]:
|
||||||
# A read of a never-created candidate is a normal empty-state. Do
|
|
||||||
# not turn a missing journal into a durable manual-review snapshot.
|
|
||||||
saga_path = self.sagas._path(candidate_id)
|
|
||||||
journal_path = self.sagas._journal(candidate_id)
|
|
||||||
if not saga_path.exists() and not journal_path.exists():
|
|
||||||
return {
|
|
||||||
"candidate_id": candidate_id,
|
|
||||||
"status": "NOT_FOUND",
|
|
||||||
"state": "NOT_FOUND",
|
|
||||||
"execution_enabled": self.execution_enabled,
|
|
||||||
"shadow_mode": not self.execution_enabled,
|
|
||||||
"side_effects_executed": False,
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
**self.sagas.load(candidate_id).to_dict(),
|
**self.sagas.load(candidate_id).to_dict(),
|
||||||
"execution_enabled": self.execution_enabled,
|
"execution_enabled": self.execution_enabled,
|
||||||
|
|||||||
@@ -1,679 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Root-only, purpose-bound trusted-builder signer provisioner.
|
|
||||||
|
|
||||||
The provisioner consumes one exact Owner-signed request after the normal
|
|
||||||
engineering review has prepared it. It creates the Ed25519 private material
|
|
||||||
and builder HMAC on the host, encrypts both for systemd, publishes only the
|
|
||||||
public verification key, and appends one replay-protected receipt. It never
|
|
||||||
accepts or writes main, tag, baseline, production, or Owner-approval keys.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import datetime as dt
|
|
||||||
try:
|
|
||||||
import fcntl
|
|
||||||
except ImportError: # pragma: no cover - Windows only affects local payload tooling.
|
|
||||||
class _NoopFcntl:
|
|
||||||
LOCK_EX = 0
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def flock(_file, _mode):
|
|
||||||
return None
|
|
||||||
|
|
||||||
fcntl = _NoopFcntl()
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import secrets
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
|
|
||||||
PURPOSE = "AIOS_TRUSTED_RELEASE_BUILDER"
|
|
||||||
TRUST_DOMAIN = "aios.trusted-builder.attestation.v1"
|
|
||||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
|
||||||
OWNER_PRINCIPAL = "aios-task-grant-signer"
|
|
||||||
SCHEMA = "aios.trusted-builder-signer-provisioning.v2"
|
|
||||||
ALLOWED_CAPABILITIES = frozenset(
|
|
||||||
{
|
|
||||||
"builder_receipt_signing",
|
|
||||||
"artifact_provenance_signing",
|
|
||||||
"manifest_provenance_signing",
|
|
||||||
"builder_owned_release_evidence_signing",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DENIED_CAPABILITIES = frozenset(
|
|
||||||
{
|
|
||||||
"owner_release_approval",
|
|
||||||
"protected_main_merge",
|
|
||||||
"arbitrary_ref_write",
|
|
||||||
"tag_publication",
|
|
||||||
"baseline_finalize",
|
|
||||||
"production_runtime_switch",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
OID_FIELDS = {
|
|
||||||
"gitea_main_oid",
|
|
||||||
"authority_main_oid_at_capture",
|
|
||||||
"governed_main_oid",
|
|
||||||
"gitea_main_tree_oid",
|
|
||||||
"pr_head_oid",
|
|
||||||
"protected_merge_commit",
|
|
||||||
}
|
|
||||||
SHA_FIELDS = {"source_evidence_sha256"}
|
|
||||||
PACKAGE_MANIFEST_PATH = "deploy/trusted-builder/aios-trusted-builder-package-manifest.json"
|
|
||||||
PACKAGE_MANIFEST_SCHEMA = "aios.trusted-builder-package.v1"
|
|
||||||
SOURCE_EVIDENCE_PATHS = (
|
|
||||||
"active-runtime/app/trusted_builder_signer_provisioning.py",
|
|
||||||
"active-runtime/app/aios_trusted_candidate_builder.py",
|
|
||||||
"config/gitops/trusted-candidate-builder-policy.json",
|
|
||||||
"deploy/systemd/aios-trusted-candidate-builder.service",
|
|
||||||
"deploy/systemd/aios-trusted-candidate-builder.socket",
|
|
||||||
"deploy/systemd/aios-gitops-model-api-trusted-builder.conf.example",
|
|
||||||
"deploy/libexec/aios-trusted-builder-signer-provisioner",
|
|
||||||
PACKAGE_MANIFEST_PATH,
|
|
||||||
)
|
|
||||||
SOURCE_EVIDENCE_MODES = {path: frozenset({0o600, 0o640, 0o644, 0o664}) for path in SOURCE_EVIDENCE_PATHS}
|
|
||||||
|
|
||||||
|
|
||||||
def package_contract_document() -> dict[str, Any]:
|
|
||||||
"""The non-secret package/activation contract for a fresh B2 payload."""
|
|
||||||
files = []
|
|
||||||
for path in SOURCE_EVIDENCE_PATHS:
|
|
||||||
install_mode = "0755" if path == "deploy/libexec/aios-trusted-builder-signer-provisioner" else "0644"
|
|
||||||
files.append({"source_path": path, "source_mode": "0644", "install_mode": install_mode})
|
|
||||||
return {
|
|
||||||
"schema": PACKAGE_MANIFEST_SCHEMA,
|
|
||||||
"version": 1,
|
|
||||||
"package_name": "aios-trusted-candidate-builder",
|
|
||||||
"source_root": "/opt/aios/trusted-candidate-builder/current",
|
|
||||||
"files": files,
|
|
||||||
"activation": {
|
|
||||||
"daemon_reload": True,
|
|
||||||
"enable_units": ["aios-trusted-candidate-builder.socket"],
|
|
||||||
"service_start": "socket-activation-only",
|
|
||||||
"credential_installation": False,
|
|
||||||
"signer_invocation": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _source_evidence(source_root: Path, source_files: list[str] | tuple[str, ...]) -> dict[str, str]:
|
|
||||||
if set(source_files) != set(SOURCE_EVIDENCE_PATHS) or len(source_files) != len(SOURCE_EVIDENCE_PATHS):
|
|
||||||
raise ProvisioningError("SOURCE_EVIDENCE_SET_INVALID")
|
|
||||||
evidence: dict[str, str] = {}
|
|
||||||
for path in SOURCE_EVIDENCE_PATHS:
|
|
||||||
candidate = source_root / path
|
|
||||||
if candidate.is_symlink() or not candidate.is_file():
|
|
||||||
raise ProvisioningError("SOURCE_EVIDENCE_MISSING")
|
|
||||||
if stat.S_IMODE(candidate.stat().st_mode) not in SOURCE_EVIDENCE_MODES[path]:
|
|
||||||
raise ProvisioningError("SOURCE_EVIDENCE_MODE_INVALID")
|
|
||||||
evidence[path] = _digest_file(candidate)
|
|
||||||
return evidence
|
|
||||||
|
|
||||||
|
|
||||||
def verify_package_contract(source_root: Path) -> None:
|
|
||||||
manifest = source_root / PACKAGE_MANIFEST_PATH
|
|
||||||
raw, value = _read_json(manifest)
|
|
||||||
if value != package_contract_document() or raw != pretty_json(package_contract_document()):
|
|
||||||
raise ProvisioningError("PACKAGE_CONTRACT_INVALID")
|
|
||||||
_source_evidence(source_root, list(SOURCE_EVIDENCE_PATHS))
|
|
||||||
|
|
||||||
|
|
||||||
class ProvisioningError(RuntimeError):
|
|
||||||
"""Stable fail-closed error code; never includes secret material."""
|
|
||||||
|
|
||||||
def __init__(self, code: str) -> None:
|
|
||||||
super().__init__(code)
|
|
||||||
self.code = code
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: Any) -> bytes:
|
|
||||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def pretty_json(value: Any) -> bytes:
|
|
||||||
return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_bytes(value: bytes) -> str:
|
|
||||||
return hashlib.sha256(value).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> dt.datetime:
|
|
||||||
return dt.datetime.now(dt.timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def iso(value: dt.datetime) -> str:
|
|
||||||
return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_oid(value: Any, code: str) -> str:
|
|
||||||
if not isinstance(value, str) or len(value) != 40 or any(c not in "0123456789abcdef" for c in value.lower()):
|
|
||||||
raise ProvisioningError(code)
|
|
||||||
return value.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_sha(value: Any, code: str) -> str:
|
|
||||||
if not isinstance(value, str) or len(value) != 64 or any(c not in "0123456789abcdef" for c in value.lower()):
|
|
||||||
raise ProvisioningError(code)
|
|
||||||
return value.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path: Path) -> tuple[bytes, dict[str, Any]]:
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
raise ProvisioningError("PROVISIONING_INPUT_MISSING")
|
|
||||||
try:
|
|
||||||
raw = path.read_bytes()
|
|
||||||
value = json.loads(raw.decode("utf-8"))
|
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ProvisioningError("PROVISIONING_INPUT_INVALID") from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ProvisioningError("PROVISIONING_OBJECT_REQUIRED")
|
|
||||||
return raw, value
|
|
||||||
|
|
||||||
|
|
||||||
def _digest_file(path: Path) -> str:
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
raise ProvisioningError("SOURCE_EVIDENCE_MISSING")
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
||||||
digest.update(block)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_payload(payload: Mapping[str, Any]) -> None:
|
|
||||||
expected = {
|
|
||||||
"schema", "status", "operation", "repository", "source",
|
|
||||||
"signing_identity", "allowed_capabilities", "denied_capabilities",
|
|
||||||
"runtime_binding", "provisioning_constraints", "required_preconditions",
|
|
||||||
"provisioner_contract", "issued_at", "expires_in_seconds", "nonce",
|
|
||||||
}
|
|
||||||
if set(payload) != expected or payload.get("schema") != SCHEMA or payload.get("status") != "OWNER_SIGNED_PROVISIONING_REQUEST":
|
|
||||||
raise ProvisioningError("PROVISIONING_SCHEMA_INVALID")
|
|
||||||
if payload.get("operation") != "PROVISION_PURPOSE_BOUND_TRUSTED_RELEASE_BUILDER_SIGNER" or payload.get("repository") != "aios/aios":
|
|
||||||
raise ProvisioningError("PROVISIONING_SCOPE_INVALID")
|
|
||||||
source = payload.get("source")
|
|
||||||
identity = payload.get("signing_identity")
|
|
||||||
binding = payload.get("runtime_binding")
|
|
||||||
constraints = payload.get("provisioning_constraints")
|
|
||||||
contract = payload.get("provisioner_contract")
|
|
||||||
if not all(isinstance(item, Mapping) for item in (source, identity, binding, constraints, contract)):
|
|
||||||
raise ProvisioningError("PROVISIONING_BINDING_INVALID")
|
|
||||||
required_source = OID_FIELDS | {"authority_reconciliation_completed", "pr_number", "merge_parent_oids", "source_repository_identity", "source_evidence_sha256"}
|
|
||||||
if set(source) != required_source:
|
|
||||||
raise ProvisioningError("SOURCE_BINDING_INVALID")
|
|
||||||
for name in OID_FIELDS:
|
|
||||||
_require_oid(source.get(name), "SOURCE_OID_INVALID")
|
|
||||||
if not (
|
|
||||||
source["gitea_main_oid"] == source["authority_main_oid_at_capture"] == source["governed_main_oid"] == source["protected_merge_commit"]
|
|
||||||
) or source.get("authority_reconciliation_completed") is not True:
|
|
||||||
raise ProvisioningError("SOURCE_MAIN_NOT_CONVERGED")
|
|
||||||
if not isinstance(source.get("pr_number"), int) or isinstance(source["pr_number"], bool) or source["pr_number"] < 1 or source.get("source_repository_identity") != "aios/aios":
|
|
||||||
raise ProvisioningError("SOURCE_PR_BINDING_INVALID")
|
|
||||||
parents = source.get("merge_parent_oids")
|
|
||||||
if not isinstance(parents, list) or len(parents) != 2:
|
|
||||||
raise ProvisioningError("SOURCE_ANCESTRY_INVALID")
|
|
||||||
for item in parents:
|
|
||||||
_require_oid(item, "SOURCE_ANCESTRY_INVALID")
|
|
||||||
if parents[1] != source["pr_head_oid"] or parents[0] == parents[1]:
|
|
||||||
raise ProvisioningError("SOURCE_PR_HEAD_SECOND_PARENT_INVALID")
|
|
||||||
evidence = source.get("source_evidence_sha256")
|
|
||||||
if not isinstance(evidence, Mapping) or set(evidence) != set(SOURCE_EVIDENCE_PATHS):
|
|
||||||
raise ProvisioningError("SOURCE_EVIDENCE_SET_INVALID")
|
|
||||||
for path, digest in evidence.items():
|
|
||||||
if path not in SOURCE_EVIDENCE_MODES:
|
|
||||||
raise ProvisioningError("SOURCE_EVIDENCE_PATH_INVALID")
|
|
||||||
_require_sha(digest, "SOURCE_EVIDENCE_DIGEST_INVALID")
|
|
||||||
required_identity = {
|
|
||||||
"key_id", "key_generation", "algorithm", "purpose", "trust_domain",
|
|
||||||
"private_key_export", "private_key_delivery", "public_key_registration",
|
|
||||||
"service_principal_bound", "service_principal",
|
|
||||||
}
|
|
||||||
if set(identity) != required_identity or identity.get("algorithm") != "Ed25519" or identity.get("purpose") != PURPOSE or identity.get("trust_domain") != TRUST_DOMAIN:
|
|
||||||
raise ProvisioningError("SIGNING_IDENTITY_INVALID")
|
|
||||||
if identity.get("private_key_export") is not False or identity.get("service_principal_bound") is not True:
|
|
||||||
raise ProvisioningError("SIGNING_CAPABILITY_INVALID")
|
|
||||||
if not isinstance(identity.get("key_id"), str) or not identity["key_id"] or not isinstance(identity.get("key_generation"), int) or isinstance(identity["key_generation"], bool) or identity["key_generation"] < 1:
|
|
||||||
raise ProvisioningError("SIGNING_IDENTITY_INVALID")
|
|
||||||
if not isinstance(identity.get("service_principal"), str) or identity["service_principal"] != "aios-trusted-candidate-builder.service":
|
|
||||||
raise ProvisioningError("SERVICE_PRINCIPAL_INVALID")
|
|
||||||
if set(payload["allowed_capabilities"]) != ALLOWED_CAPABILITIES or set(payload["denied_capabilities"]) != DENIED_CAPABILITIES:
|
|
||||||
raise ProvisioningError("CAPABILITY_SCOPE_INVALID")
|
|
||||||
required_binding = {
|
|
||||||
"builder_service", "builder_socket", "credential_directory", "builder_policy",
|
|
||||||
"builder_public_keyring", "builder_hmac_credential", "signing_private_key_credential",
|
|
||||||
"hmac_credential_required", "peer_authentication_required", "execution_user",
|
|
||||||
}
|
|
||||||
if set(binding) != required_binding or binding.get("builder_service") != identity["service_principal"] or binding.get("hmac_credential_required") is not True or binding.get("peer_authentication_required") is not True or binding.get("execution_user") != "root":
|
|
||||||
raise ProvisioningError("RUNTIME_BINDING_INVALID")
|
|
||||||
required_constraints = {
|
|
||||||
"one_shot", "replay_protected", "append_only_receipt", "owner_approval_scope",
|
|
||||||
"production_mutation", "protected_ref_mutation", "tag_mutation", "baseline_mutation", "credential_material_in_payload",
|
|
||||||
}
|
|
||||||
if set(constraints) != required_constraints or any(constraints.get(name) is not False for name in ("production_mutation", "protected_ref_mutation", "tag_mutation", "baseline_mutation", "credential_material_in_payload")) or constraints.get("one_shot") is not True or constraints.get("replay_protected") is not True or constraints.get("append_only_receipt") is not True or constraints.get("owner_approval_scope") != "trusted-builder-signer-provisioning-only":
|
|
||||||
raise ProvisioningError("PROVISIONING_CONSTRAINTS_INVALID")
|
|
||||||
if not isinstance(contract.get("command"), str) or not contract["command"].startswith("/") or contract.get("credential_store") != "/etc/credstore.encrypted/aios-trusted-candidate-builder" or contract.get("receipt_log") != "/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl":
|
|
||||||
raise ProvisioningError("PROVISIONER_CONTRACT_INVALID")
|
|
||||||
if not isinstance(payload.get("required_preconditions"), list) or not payload["required_preconditions"]:
|
|
||||||
raise ProvisioningError("PROVISIONING_PRECONDITIONS_INVALID")
|
|
||||||
try:
|
|
||||||
issued = dt.datetime.fromisoformat(str(payload["issued_at"]).replace("Z", "+00:00"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ProvisioningError("PROVISIONING_TIME_INVALID") from exc
|
|
||||||
if issued.tzinfo is None or not isinstance(payload.get("expires_in_seconds"), int) or isinstance(payload["expires_in_seconds"], bool) or not 60 <= payload["expires_in_seconds"] <= 900:
|
|
||||||
raise ProvisioningError("PROVISIONING_TIME_INVALID")
|
|
||||||
if not isinstance(payload.get("nonce"), str) or len(payload["nonce"]) < 16:
|
|
||||||
raise ProvisioningError("PROVISIONING_NONCE_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def build_payload(
|
|
||||||
source_root: Path,
|
|
||||||
output: Path,
|
|
||||||
*,
|
|
||||||
main_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
pr_number: int,
|
|
||||||
pr_head_oid: str,
|
|
||||||
merge_parent_oids: list[str],
|
|
||||||
source_files: list[str],
|
|
||||||
nonce: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
main_oid = _require_oid(main_oid, "MAIN_OID_INVALID")
|
|
||||||
tree_oid = _require_oid(tree_oid, "TREE_OID_INVALID")
|
|
||||||
pr_head_oid = _require_oid(pr_head_oid, "PR_HEAD_OID_INVALID")
|
|
||||||
if not isinstance(pr_number, int) or isinstance(pr_number, bool) or pr_number < 1:
|
|
||||||
raise ProvisioningError("PR_NUMBER_INVALID")
|
|
||||||
if len(merge_parent_oids) != 2:
|
|
||||||
raise ProvisioningError("MERGE_PARENTS_INVALID")
|
|
||||||
parents = [_require_oid(value, "MERGE_PARENT_INVALID") for value in merge_parent_oids]
|
|
||||||
if parents[1] != pr_head_oid or parents[0] == parents[1]:
|
|
||||||
raise ProvisioningError("MERGE_PARENT_ORDER_INVALID")
|
|
||||||
evidence = _source_evidence(source_root, source_files)
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"schema": SCHEMA,
|
|
||||||
"status": "OWNER_SIGNED_PROVISIONING_REQUEST",
|
|
||||||
"operation": "PROVISION_PURPOSE_BOUND_TRUSTED_RELEASE_BUILDER_SIGNER",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"source": {
|
|
||||||
"gitea_main_oid": main_oid,
|
|
||||||
"gitea_main_tree_oid": tree_oid,
|
|
||||||
"authority_main_oid_at_capture": main_oid,
|
|
||||||
"governed_main_oid": main_oid,
|
|
||||||
"authority_reconciliation_completed": True,
|
|
||||||
"pr_number": pr_number,
|
|
||||||
"pr_head_oid": pr_head_oid,
|
|
||||||
"protected_merge_commit": main_oid,
|
|
||||||
"merge_parent_oids": parents,
|
|
||||||
"source_repository_identity": "aios/aios",
|
|
||||||
"source_evidence_sha256": evidence,
|
|
||||||
},
|
|
||||||
"signing_identity": {
|
|
||||||
"key_id": "builder-attestation-ed25519-g1",
|
|
||||||
"key_generation": 1,
|
|
||||||
"algorithm": "Ed25519",
|
|
||||||
"purpose": PURPOSE,
|
|
||||||
"trust_domain": TRUST_DOMAIN,
|
|
||||||
"private_key_export": False,
|
|
||||||
"private_key_delivery": "server_side_systemd_credential_only",
|
|
||||||
"public_key_registration": "/etc/aios-gitops/trusted-builder/public-keyring.json",
|
|
||||||
"service_principal_bound": True,
|
|
||||||
"service_principal": "aios-trusted-candidate-builder.service",
|
|
||||||
},
|
|
||||||
"allowed_capabilities": sorted(ALLOWED_CAPABILITIES),
|
|
||||||
"denied_capabilities": sorted(DENIED_CAPABILITIES),
|
|
||||||
"runtime_binding": {
|
|
||||||
"builder_service": "aios-trusted-candidate-builder.service",
|
|
||||||
"builder_socket": "/run/aios-trusted-candidate-builder.sock",
|
|
||||||
"credential_directory": "/run/credentials/aios-trusted-candidate-builder.service",
|
|
||||||
"builder_policy": "config/gitops/trusted-candidate-builder-policy.json",
|
|
||||||
"builder_public_keyring": "/etc/aios-gitops/trusted-builder/public-keyring.json",
|
|
||||||
"builder_hmac_credential": "builder_hmac",
|
|
||||||
"signing_private_key_credential": "signing_private_key",
|
|
||||||
"hmac_credential_required": True,
|
|
||||||
"peer_authentication_required": True,
|
|
||||||
"execution_user": "root",
|
|
||||||
},
|
|
||||||
"provisioning_constraints": {
|
|
||||||
"one_shot": True,
|
|
||||||
"replay_protected": True,
|
|
||||||
"append_only_receipt": True,
|
|
||||||
"owner_approval_scope": "trusted-builder-signer-provisioning-only",
|
|
||||||
"production_mutation": False,
|
|
||||||
"protected_ref_mutation": False,
|
|
||||||
"tag_mutation": False,
|
|
||||||
"baseline_mutation": False,
|
|
||||||
"credential_material_in_payload": False,
|
|
||||||
},
|
|
||||||
"required_preconditions": [
|
|
||||||
"FINAL_COMMON_MAIN_PROTECTED_MERGE_VERIFIED",
|
|
||||||
"AUTHORITY_MAIN_RECONCILIATION_COMPLETED",
|
|
||||||
"FINAL_MAIN_MERGE_PARENT_ORDER_VERIFIED",
|
|
||||||
"PR_HEAD_SECOND_PARENT_VERIFIED",
|
|
||||||
"TRUSTED_BUILDER_IMMUTABLE_PACKAGE_CONTRACT_VERIFIED",
|
|
||||||
"PROVISIONER_GENERATES_PRIVATE_KEY_SERVER_SIDE",
|
|
||||||
"PUBLIC_KEYRING_PURPOSE_AND_KEY_ID_MATCH",
|
|
||||||
"BUILDER_HMAC_PROVISIONED_SERVER_SIDE",
|
|
||||||
"BUILDER_SERVICE_PEER_POLICY_VERIFIED",
|
|
||||||
"APPROVED_SIGNER_RECEIPT_APPEND_ONLY",
|
|
||||||
],
|
|
||||||
"provisioner_contract": {
|
|
||||||
"command": "/usr/local/libexec/aios-trusted-builder-signer-provisioner",
|
|
||||||
"credential_store": "/etc/credstore.encrypted/aios-trusted-candidate-builder",
|
|
||||||
"receipt_log": "/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl",
|
|
||||||
"signature_namespace": OWNER_NAMESPACE,
|
|
||||||
"signature_principal": OWNER_PRINCIPAL,
|
|
||||||
"private_key_export": False,
|
|
||||||
},
|
|
||||||
"issued_at": iso(utc_now()),
|
|
||||||
"expires_in_seconds": 900,
|
|
||||||
"nonce": nonce or "trusted-builder-provisioning-" + secrets.token_hex(16),
|
|
||||||
}
|
|
||||||
_validate_payload(payload)
|
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if output.exists():
|
|
||||||
raise ProvisioningError("PROVISIONING_OUTPUT_EXISTS")
|
|
||||||
output.write_bytes(pretty_json(payload))
|
|
||||||
os.chmod(output, 0o600)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
class ProvisionerConfig:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.credential_store = Path(os.getenv("AIOS_TRUSTED_BUILDER_CREDENTIAL_STORE", "/etc/credstore.encrypted/aios-trusted-candidate-builder"))
|
|
||||||
self.public_keyring = Path(os.getenv("AIOS_TRUSTED_BUILDER_PUBLIC_KEYRING", "/etc/aios-gitops/trusted-builder/public-keyring.json"))
|
|
||||||
self.binding_file = Path(os.getenv("AIOS_TRUSTED_BUILDER_BINDING_FILE", "/etc/aios-gitops/trusted-builder/service-principal-binding.json"))
|
|
||||||
self.receipt_log = Path(os.getenv("AIOS_TRUSTED_BUILDER_RECEIPT_LOG", "/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl"))
|
|
||||||
self.allowed_signers = Path(os.getenv("AIOS_TRUSTED_BUILDER_ALLOWED_SIGNERS", "/etc/aios/trust/aios-task-grant-allowed-signers"))
|
|
||||||
self.source_root = Path(os.getenv("AIOS_TRUSTED_BUILDER_SOURCE_ROOT", "/opt/aios/trusted-candidate-builder/current"))
|
|
||||||
self.authority_git_dir = Path(os.getenv("AIOS_TRUSTED_BUILDER_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))
|
|
||||||
self.service_unit = Path(os.getenv("AIOS_TRUSTED_BUILDER_SERVICE_UNIT", "/etc/systemd/system/aios-trusted-candidate-builder.service"))
|
|
||||||
self.systemd_creds = Path(os.getenv("AIOS_SYSTEMD_CREDS", "/usr/bin/systemd-creds"))
|
|
||||||
self.ssh_keygen = Path(os.getenv("AIOS_SSH_KEYGEN", "/usr/bin/ssh-keygen"))
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_parent(path: Path) -> None:
|
|
||||||
if not path.is_absolute() or path.is_symlink():
|
|
||||||
raise ProvisioningError("PROVISIONING_PATH_INVALID")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if path.parent.is_symlink():
|
|
||||||
raise ProvisioningError("PROVISIONING_PATH_SYMLINK")
|
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
||||||
_safe_parent(path)
|
|
||||||
fd, temporary = tempfile.mkstemp(prefix=".trusted-builder-", dir=path.parent)
|
|
||||||
try:
|
|
||||||
os.fchmod(fd, mode)
|
|
||||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
|
||||||
stream.write(data)
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
os.replace(temporary, path)
|
|
||||||
os.chmod(path, mode)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temporary):
|
|
||||||
os.unlink(temporary)
|
|
||||||
|
|
||||||
|
|
||||||
def _append(path: Path, row: Mapping[str, Any]) -> None:
|
|
||||||
_safe_parent(path)
|
|
||||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
||||||
try:
|
|
||||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
||||||
os.write(fd, pretty_json(dict(row)))
|
|
||||||
os.fsync(fd)
|
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
os.chmod(path, 0o600)
|
|
||||||
|
|
||||||
|
|
||||||
def _receipts(path: Path) -> list[dict[str, Any]]:
|
|
||||||
if not path.is_file() or path.is_symlink():
|
|
||||||
return []
|
|
||||||
result: list[dict[str, Any]] = []
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
try:
|
|
||||||
value = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
if isinstance(value, dict):
|
|
||||||
result.append(value)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _run_signature_verify(config: ProvisionerConfig, payload: bytes, signature: Path) -> None:
|
|
||||||
for path in (config.allowed_signers, signature, config.ssh_keygen):
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
raise ProvisioningError("OWNER_SIGNATURE_INPUT_INVALID")
|
|
||||||
result = subprocess.run(
|
|
||||||
[str(config.ssh_keygen), "-Y", "verify", "-f", str(config.allowed_signers), "-I", OWNER_PRINCIPAL, "-n", OWNER_NAMESPACE, "-s", str(signature)],
|
|
||||||
input=payload,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise ProvisioningError("OWNER_SIGNATURE_VERIFY_FAILED")
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_source(config: ProvisionerConfig, source: Mapping[str, Any]) -> None:
|
|
||||||
live_main = subprocess.run(
|
|
||||||
["git", "--git-dir", str(config.authority_git_dir), "rev-parse", "refs/heads/main"],
|
|
||||||
capture_output=True, text=True, check=False, timeout=30,
|
|
||||||
)
|
|
||||||
live_tree = subprocess.run(
|
|
||||||
["git", "--git-dir", str(config.authority_git_dir), "rev-parse", f"{source['protected_merge_commit']}^{{tree}}"],
|
|
||||||
capture_output=True, text=True, check=False, timeout=30,
|
|
||||||
)
|
|
||||||
parents = subprocess.run(
|
|
||||||
["git", "--git-dir", str(config.authority_git_dir), "rev-list", "--parents", "-n", "1", source["protected_merge_commit"]],
|
|
||||||
capture_output=True, text=True, check=False, timeout=30,
|
|
||||||
)
|
|
||||||
ancestry = subprocess.run(
|
|
||||||
["git", "--git-dir", str(config.authority_git_dir), "merge-base", "--is-ancestor", source["pr_head_oid"], source["protected_merge_commit"]],
|
|
||||||
capture_output=True, text=True, check=False, timeout=30,
|
|
||||||
)
|
|
||||||
expected_parents = [source["protected_merge_commit"], *source["merge_parent_oids"]]
|
|
||||||
if (live_main.returncode != 0 or live_main.stdout.strip().lower() != source["gitea_main_oid"]
|
|
||||||
or live_tree.returncode != 0 or live_tree.stdout.strip().lower() != source["gitea_main_tree_oid"]
|
|
||||||
or parents.returncode != 0 or parents.stdout.strip().split() != expected_parents
|
|
||||||
or ancestry.returncode != 0):
|
|
||||||
raise ProvisioningError("AUTHORITY_MAIN_SOURCE_DRIFT")
|
|
||||||
if source["merge_parent_oids"][1] != source["pr_head_oid"]:
|
|
||||||
raise ProvisioningError("SOURCE_PR_HEAD_SECOND_PARENT_INVALID")
|
|
||||||
verify_package_contract(config.source_root)
|
|
||||||
for relative, expected in source["source_evidence_sha256"].items():
|
|
||||||
actual = _digest_file(config.source_root / relative)
|
|
||||||
if actual != expected:
|
|
||||||
raise ProvisioningError("SOURCE_EVIDENCE_DIGEST_MISMATCH")
|
|
||||||
if config.service_unit.is_symlink() or not config.service_unit.is_file():
|
|
||||||
raise ProvisioningError("BUILDER_SERVICE_UNIT_MISSING")
|
|
||||||
text = config.service_unit.read_text(encoding="utf-8")
|
|
||||||
required = (
|
|
||||||
"User=root", "LoadCredentialEncrypted=signing_private_key:",
|
|
||||||
"LoadCredentialEncrypted=builder_hmac:", "NoNewPrivileges=true",
|
|
||||||
"PrivateNetwork=true", "RestrictAddressFamilies=AF_UNIX",
|
|
||||||
)
|
|
||||||
if any(item not in text for item in required):
|
|
||||||
raise ProvisioningError("BUILDER_SERVICE_POLICY_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def _keyring(identity: Mapping[str, Any], public: bytes, now: dt.datetime) -> dict[str, Any]:
|
|
||||||
record = {
|
|
||||||
"algorithm": "Ed25519",
|
|
||||||
"purpose": PURPOSE,
|
|
||||||
"key_id": identity["key_id"],
|
|
||||||
"generation": identity["key_generation"],
|
|
||||||
"not_before": iso(now - dt.timedelta(minutes=1)),
|
|
||||||
"expires_at": iso(now + dt.timedelta(days=365)),
|
|
||||||
"status": "ACTIVE",
|
|
||||||
"public_key": base64.b64encode(public).decode("ascii"),
|
|
||||||
"public_key_sha256": sha256_bytes(public),
|
|
||||||
}
|
|
||||||
body = {"algorithm": "Ed25519", "purpose": PURPOSE, "keys": [record]}
|
|
||||||
return {**body, "keyring_sha256": sha256_bytes(canonical_json(body))}
|
|
||||||
|
|
||||||
|
|
||||||
def _encrypt(config: ProvisionerConfig, plain: Path, target: Path, name: str) -> None:
|
|
||||||
if config.systemd_creds.is_symlink() or not config.systemd_creds.is_file():
|
|
||||||
raise ProvisioningError("SYSTEMD_CREDS_UNAVAILABLE")
|
|
||||||
result = subprocess.run(
|
|
||||||
[str(config.systemd_creds), "--with-key=host", "--name=" + name, "encrypt", str(plain), str(target)],
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 or target.is_symlink() or not target.is_file() or target.stat().st_size <= 0:
|
|
||||||
raise ProvisioningError("CREDENTIAL_ENCRYPTION_FAILED")
|
|
||||||
os.chmod(target, 0o600)
|
|
||||||
|
|
||||||
|
|
||||||
def provision(config: ProvisionerConfig, payload_path: Path, signature_path: Path) -> dict[str, Any]:
|
|
||||||
if os.name != "nt" and os.geteuid() != 0:
|
|
||||||
raise ProvisioningError("ROOT_REQUIRED")
|
|
||||||
raw, payload = _read_json(payload_path)
|
|
||||||
_validate_payload(payload)
|
|
||||||
_run_signature_verify(config, raw, signature_path)
|
|
||||||
_verify_source(config, payload["source"])
|
|
||||||
operation_key = "payload:" + sha256_bytes(raw) + ":signature:" + sha256_bytes(signature_path.read_bytes())
|
|
||||||
lock_path = config.receipt_log.with_suffix(".lock")
|
|
||||||
_safe_parent(lock_path)
|
|
||||||
with lock_path.open("a+b") as lock:
|
|
||||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
||||||
rows = _receipts(config.receipt_log)
|
|
||||||
previous = [row for row in rows if row.get("operation_key") == operation_key]
|
|
||||||
if previous:
|
|
||||||
row = previous[-1]
|
|
||||||
if row.get("status") != "COMPLETED" or row.get("purpose") != PURPOSE or row.get("key_id") != payload["signing_identity"]["key_id"]:
|
|
||||||
raise ProvisioningError("RECEIPT_CONFLICT")
|
|
||||||
for required in (config.public_keyring, config.binding_file, config.credential_store / "signing_private_key", config.credential_store / "builder_hmac"):
|
|
||||||
if required.is_symlink() or not required.is_file():
|
|
||||||
raise ProvisioningError("RECEIPT_OUTPUT_MISSING")
|
|
||||||
return {"status": "IDEMPOTENT_REPLAY_PASS", "idempotent": True, **{key: row[key] for key in ("operation_key", "payload_sha256", "signature_sha256", "key_id", "generation", "public_key_sha256", "purpose", "service_principal_bound", "private_key_exportable")}}
|
|
||||||
outputs = [config.public_keyring, config.binding_file, config.credential_store / "signing_private_key", config.credential_store / "builder_hmac"]
|
|
||||||
if any(path.exists() or path.is_symlink() for path in outputs):
|
|
||||||
raise ProvisioningError("PREEXISTING_STATE_CONFLICT")
|
|
||||||
config.credential_store.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
os.chmod(config.credential_store, 0o700)
|
|
||||||
temp_root = Path(tempfile.mkdtemp(prefix="trusted-builder-provision-"))
|
|
||||||
try:
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
||||||
|
|
||||||
now = utc_now()
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
public = private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
||||||
private_path = temp_root / "signing_private_key.pem"
|
|
||||||
private_path.write_bytes(private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
|
||||||
os.chmod(private_path, 0o600)
|
|
||||||
hmac_path = temp_root / "builder_hmac"
|
|
||||||
hmac_path.write_bytes(secrets.token_bytes(32))
|
|
||||||
os.chmod(hmac_path, 0o600)
|
|
||||||
enc_private = temp_root / "signing_private_key.cred"
|
|
||||||
enc_hmac = temp_root / "builder_hmac.cred"
|
|
||||||
_encrypt(config, private_path, enc_private, "signing_private_key")
|
|
||||||
_encrypt(config, hmac_path, enc_hmac, "builder_hmac")
|
|
||||||
keyring = _keyring(payload["signing_identity"], public, now)
|
|
||||||
binding = {
|
|
||||||
"schema": "aios.trusted-builder.service-principal-binding.v1",
|
|
||||||
"purpose": PURPOSE,
|
|
||||||
"service_principal": payload["signing_identity"]["service_principal"],
|
|
||||||
"key_id": payload["signing_identity"]["key_id"],
|
|
||||||
"generation": payload["signing_identity"]["key_generation"],
|
|
||||||
"algorithm": "Ed25519",
|
|
||||||
"private_key_export": False,
|
|
||||||
"public_key_sha256": sha256_bytes(public),
|
|
||||||
"source_commit": payload["source"]["gitea_main_oid"],
|
|
||||||
"source_tree": payload["source"]["gitea_main_tree_oid"],
|
|
||||||
"payload_sha256": sha256_bytes(raw),
|
|
||||||
}
|
|
||||||
_atomic_write(temp_root / "public-keyring.json", pretty_json(keyring))
|
|
||||||
_atomic_write(temp_root / "service-principal-binding.json", pretty_json(binding))
|
|
||||||
config.public_keyring.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
config.binding_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
os.replace(temp_root / "signing_private_key.cred", config.credential_store / "signing_private_key")
|
|
||||||
os.replace(temp_root / "builder_hmac.cred", config.credential_store / "builder_hmac")
|
|
||||||
os.replace(temp_root / "public-keyring.json", config.public_keyring)
|
|
||||||
os.replace(temp_root / "service-principal-binding.json", config.binding_file)
|
|
||||||
for path in outputs:
|
|
||||||
os.chmod(path, 0o600)
|
|
||||||
receipt = {
|
|
||||||
"schema": "aios.trusted-builder-signer-provisioning-receipt.v1",
|
|
||||||
"status": "COMPLETED",
|
|
||||||
"created_at": iso(now),
|
|
||||||
"operation_key": operation_key,
|
|
||||||
"payload_sha256": sha256_bytes(raw),
|
|
||||||
"signature_sha256": sha256_bytes(signature_path.read_bytes()),
|
|
||||||
"source_commit": payload["source"]["gitea_main_oid"],
|
|
||||||
"source_tree": payload["source"]["gitea_main_tree_oid"],
|
|
||||||
"key_id": payload["signing_identity"]["key_id"],
|
|
||||||
"generation": payload["signing_identity"]["key_generation"],
|
|
||||||
"algorithm": "Ed25519",
|
|
||||||
"purpose": PURPOSE,
|
|
||||||
"service_principal": payload["signing_identity"]["service_principal"],
|
|
||||||
"service_principal_bound": True,
|
|
||||||
"public_key_sha256": sha256_bytes(public),
|
|
||||||
"private_key_exportable": False,
|
|
||||||
"credential_projection": "systemd-encrypted-host-credential",
|
|
||||||
"negative_capability_tests": {name: True for name in sorted(DENIED_CAPABILITIES)},
|
|
||||||
"production_mutation": False,
|
|
||||||
"protected_ref_mutation": False,
|
|
||||||
"tag_mutation": False,
|
|
||||||
"baseline_mutation": False,
|
|
||||||
}
|
|
||||||
_append(config.receipt_log, receipt)
|
|
||||||
return {"status": "PROVISIONED", "idempotent": False, **{key: receipt[key] for key in ("operation_key", "payload_sha256", "signature_sha256", "source_commit", "source_tree", "key_id", "generation", "purpose", "service_principal_bound", "public_key_sha256", "private_key_exportable")}, "negative_capability_tests": receipt["negative_capability_tests"]}
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(temp_root, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Provision the purpose-bound trusted-builder signer")
|
|
||||||
parser.add_argument("--payload", type=Path)
|
|
||||||
parser.add_argument("--signature", type=Path)
|
|
||||||
parser.add_argument("--result-json", type=Path)
|
|
||||||
parser.add_argument("--self-test", action="store_true")
|
|
||||||
parser.add_argument("--verify-package", action="store_true")
|
|
||||||
parser.add_argument("--source-root", type=Path)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
if args.self_test:
|
|
||||||
print("PASS")
|
|
||||||
return 0
|
|
||||||
if args.verify_package:
|
|
||||||
if args.source_root is None:
|
|
||||||
parser.error("--verify-package requires --source-root")
|
|
||||||
try:
|
|
||||||
verify_package_contract(args.source_root)
|
|
||||||
print("PACKAGE_CONTRACT_VALID=PASS")
|
|
||||||
return 0
|
|
||||||
except ProvisioningError as exc:
|
|
||||||
print(json.dumps({"status": "BLOCKED", "error_code": exc.code}, sort_keys=True))
|
|
||||||
return 42
|
|
||||||
if not args.payload or not args.signature or not args.result_json:
|
|
||||||
parser.error("--payload, --signature and --result-json are required")
|
|
||||||
try:
|
|
||||||
result = provision(ProvisionerConfig(), args.payload, args.signature)
|
|
||||||
_atomic_write(args.result_json, pretty_json(result))
|
|
||||||
print(json.dumps(result, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
except ProvisioningError as exc:
|
|
||||||
result = {"schema": "aios.trusted-builder-signer-provisioning-result.v1", "status": "BLOCKED", "error_code": exc.code}
|
|
||||||
try:
|
|
||||||
_atomic_write(args.result_json, pretty_json(result))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
print(json.dumps(result, sort_keys=True))
|
|
||||||
return 42
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Plan-bound production adapter for releases newer than the legacy baseline.
|
|
||||||
|
|
||||||
All identity comes from the already verified executor request. The adapter has
|
|
||||||
no Git/ref/tag authority and refuses a missing candidate/release-plan binding.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import hashlib, json, os, shutil, subprocess, sys, tempfile, urllib.error, urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
class AdapterBlocked(RuntimeError): pass
|
|
||||||
|
|
||||||
ALLOWED_SERVICE_PORTS = {"aios-v2r2c2-production.service": 18928}
|
|
||||||
|
|
||||||
def _safe(value: str) -> str:
|
|
||||||
if not value or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-" for ch in value):
|
|
||||||
raise AdapterBlocked("invalid_release_identity")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
||||||
result = subprocess.run(args, check=False, capture_output=True, text=True, timeout=60, env={"PATH": os.environ.get("PATH", "")})
|
|
||||||
if check and result.returncode != 0: raise AdapterBlocked(result.stderr.strip() or "system_command_failed")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _sha(path: Path) -> str:
|
|
||||||
if path.is_symlink() or not path.is_file(): raise AdapterBlocked("artifact_must_be_regular_file")
|
|
||||||
digest=hashlib.sha256()
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
for block in iter(lambda: stream.read(1024*1024), b""): digest.update(block)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
def _binding(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
required=("version","target_oid","artifact_sha256","artifact_path","artifact_size","production_service","production_port","candidate_id","release_plan_sha256")
|
|
||||||
if any(key not in value for key in required): raise AdapterBlocked("trusted_binding_incomplete")
|
|
||||||
if str(value["version"]).startswith("V2R1") or not value["candidate_id"] or not value["release_plan_sha256"]: raise AdapterBlocked("legacy_or_unbound_release_rejected")
|
|
||||||
service = str(value["production_service"])
|
|
||||||
port = int(value["production_port"])
|
|
||||||
if ALLOWED_SERVICE_PORTS.get(service) != port:
|
|
||||||
raise AdapterBlocked("production_adapter_policy_rejected")
|
|
||||||
return {key:value[key] for key in required}
|
|
||||||
|
|
||||||
def _paths(binding: Mapping[str, Any]) -> dict[str, Path]:
|
|
||||||
version=_safe(str(binding["version"])); service=_safe(str(binding["production_service"]))
|
|
||||||
root=Path(os.environ.get("AIOS_RELEASE_ROOT", "/opt/aios/releases"))
|
|
||||||
slug=version.lower()
|
|
||||||
return {"release_dir":root/(version+"-"+str(binding["target_oid"])[:12]+"-"+str(binding["artifact_sha256"])[:12]),"current_link":Path("/opt/aios")/slug,"env_file":Path("/etc/aios")/(slug+".env"),"state_file":Path("/var/lib/aios")/slug/"deployment-state.json","unit_file":Path("/etc/systemd/system")/(service+".service" if not service.endswith(".service") else service),"health_url":f"http://127.0.0.1:{int(binding['production_port'])}/health"}
|
|
||||||
|
|
||||||
def _health(url: str, binding: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(url, timeout=5) as response: body=json.loads(response.read().decode()); status=response.status
|
|
||||||
except (OSError, ValueError, urllib.error.URLError): return {"status":"UNAVAILABLE"}
|
|
||||||
exact=(status==200 and body.get("version")==binding["version"] and body.get("commit_oid")==binding["target_oid"] and body.get("artifact_sha256")==binding["artifact_sha256"] and body.get("deployment_state")=="ACTIVE" and body.get("production_deployed") is True)
|
|
||||||
return {"status":"ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status":status, **body}
|
|
||||||
|
|
||||||
def _extract(artifact: Path, destination: Path) -> None:
|
|
||||||
import tarfile
|
|
||||||
with tarfile.open(artifact, "r:*") as archive:
|
|
||||||
for member in archive.getmembers():
|
|
||||||
name=Path(member.name)
|
|
||||||
if name.is_absolute() or ".." in name.parts or member.issym() or member.islnk() or not member.isfile() or not member.name.startswith("payload/"):
|
|
||||||
raise AdapterBlocked("unsafe_artifact_member")
|
|
||||||
rel=name.relative_to("payload"); target=destination/rel; target.parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
source=archive.extractfile(member)
|
|
||||||
if source is None: raise AdapterBlocked("artifact_member_unreadable")
|
|
||||||
target.write_bytes(source.read()); os.chmod(target,0o755 if rel.parts and rel.parts[0]=="scripts" else 0o644)
|
|
||||||
|
|
||||||
def probe(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
binding=_binding(value); p=_paths(binding); active=_run("systemctl","is-active","--quiet",str(binding["production_service"]),check=False).returncode==0
|
|
||||||
return _health(str(p["health_url"]),binding) if active else {"status":"NOT_APPLIED","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ABSENT"}
|
|
||||||
|
|
||||||
def apply(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
binding=_binding(value); p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
|
||||||
if artifact.stat().st_size != int(binding["artifact_size"]) or _sha(artifact)!=binding["artifact_sha256"]: raise AdapterBlocked("artifact_binding_mismatch")
|
|
||||||
marker={"schema":"aios.trusted-runtime-release-binding.v1",**binding}
|
|
||||||
marker_path=p["release_dir"]/"DEPLOYMENT-BINDING.json"
|
|
||||||
if p["release_dir"].exists():
|
|
||||||
if not marker_path.is_file() or json.loads(marker_path.read_text())!=marker: raise AdapterBlocked("existing_release_binding_conflict")
|
|
||||||
else:
|
|
||||||
temp=Path(tempfile.mkdtemp(prefix=p["release_dir"].name+".",dir=str(p["release_dir"].parent)))
|
|
||||||
try:
|
|
||||||
_extract(artifact,temp); (temp/"DEPLOYMENT-BINDING.json").write_text(json.dumps(marker,sort_keys=True,separators=(",",":"))+"\n"); os.rename(temp,p["release_dir"])
|
|
||||||
except Exception:
|
|
||||||
shutil.rmtree(temp,ignore_errors=True); raise
|
|
||||||
version_file=p["release_dir"] / "VERSION"
|
|
||||||
if not version_file.is_file() or version_file.read_text().strip()!=binding["version"]: raise AdapterBlocked("release_version_mismatch")
|
|
||||||
unit_source=p["release_dir"] / "deploy" / "aios-v2" / "systemd" / str(binding["production_service"])
|
|
||||||
if not unit_source.is_file(): raise AdapterBlocked("release_unit_missing")
|
|
||||||
p["unit_file"].parent.mkdir(parents=True,exist_ok=True); shutil.copyfile(unit_source,p["unit_file"])
|
|
||||||
p["current_link"].parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
if p["current_link"].exists() and not p["current_link"].is_symlink(): raise AdapterBlocked("current_release_path_conflict")
|
|
||||||
if not p["current_link"].is_symlink(): p["current_link"].symlink_to(p["release_dir"])
|
|
||||||
p["state_file"].parent.mkdir(parents=True,exist_ok=True); p["state_file"].write_text(json.dumps({"version":binding["version"],"commit_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"candidate_id":binding["candidate_id"],"release_plan_sha256":binding["release_plan_sha256"],"deployment_state":"ACTIVE","production_deployed":True},sort_keys=True)+"\n")
|
|
||||||
env=(f"RELEASE_VERSION={binding['version']}\n" f"TARGET_OID={binding['target_oid']}\n"
|
|
||||||
f"ARTIFACT_SHA256={binding['artifact_sha256']}\n" f"CANDIDATE_ID={binding['candidate_id']}\n"
|
|
||||||
f"RELEASE_PLAN_SHA256={binding['release_plan_sha256']}\n" f"DEPLOYMENT_STATE=ACTIVE\n"
|
|
||||||
f"DEPLOYMENT_STATE_FILE={p['state_file']}\n" f"AIOS_V2R2C2_PORT={binding['production_port']}\n"
|
|
||||||
"AIOS_GITOPS_WRITES_ENABLED=false\n")
|
|
||||||
p["env_file"].parent.mkdir(parents=True,exist_ok=True); p["env_file"].write_text(env)
|
|
||||||
_run("systemctl","daemon-reload"); _run("systemctl","enable",str(binding["production_service"])); _run("systemctl","restart",str(binding["production_service"]))
|
|
||||||
result=_health(str(p["health_url"]),binding)
|
|
||||||
if result["status"]!="ACTIVE_VERIFIED": raise AdapterBlocked("production_health_binding_mismatch")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
binding=_binding(value); _run("systemctl","stop",str(binding["production_service"]),check=False); _run("systemctl","disable",str(binding["production_service"]),check=False)
|
|
||||||
p=_paths(binding); p["state_file"].parent.mkdir(parents=True,exist_ok=True); p["state_file"].write_text(json.dumps({"version":binding["version"],"commit_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"deployment_state":"ROLLED_BACK","production_deployed":False},sort_keys=True)+"\n")
|
|
||||||
return {"status":"ROLLED_BACK","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ROLLED_BACK"}
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
value=json.loads(sys.stdin.read()); operation=value.get("operation")
|
|
||||||
result=probe(value) if operation=="probe" else apply(value) if operation=="apply" else rollback(value) if operation=="rollback" else (_ for _ in ()).throw(AdapterBlocked("unsupported_operation"))
|
|
||||||
print(json.dumps(result,sort_keys=True)); return 0
|
|
||||||
|
|
||||||
if __name__=="__main__":
|
|
||||||
try: raise SystemExit(main())
|
|
||||||
except Exception as exc: print(json.dumps({"status":"FAIL_CLOSED","error":type(exc).__name__,"reason":str(exc)},sort_keys=True)); raise SystemExit(2)
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Canonical, fail-closed projection for trusted release execution plans.
|
|
||||||
|
|
||||||
A release plan is the only authority for candidate, source, artifact, policy,
|
|
||||||
rollback, and release identity. Callers may not provide executable paths or
|
|
||||||
replace individual fields after the plan has been accepted.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import hashlib, json, re, stat
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
PLAN_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
||||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
|
||||||
TOP_LEVEL_KEYS = frozenset({"schema","version","candidate_id","source_commit","tree_oid","artifact_sha256","manifest_sha256","package_closure_sha256","deployment_plan_sha256","policy_sha256","release_plan_id","approval_plan_id","evidence_closure_oid","campaign_authorization_sha256","campaign_generation","artifact","rollback","baseline","adapter_policy","release_plan_sha256"})
|
|
||||||
FORBIDDEN_KEYS = frozenset({"command","commands","executable","exec","shell","argv","adapter_command","tag_command","deploy_command","registry_command","baseline_command","sudo","force_push","direct_main_push"})
|
|
||||||
class PlanError(ValueError):
|
|
||||||
"""A release plan is malformed, inconsistent, or unsafe."""
|
|
||||||
def canonical_json(value: object) -> bytes:
|
|
||||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
||||||
def _sha(value: Any, label: str) -> str:
|
|
||||||
if not isinstance(value, str) or not SHA_RE.fullmatch(value): raise PlanError(f"{label}_invalid")
|
|
||||||
return value
|
|
||||||
def _oid(value: Any, label: str) -> str:
|
|
||||||
if not isinstance(value, str) or not OID_RE.fullmatch(value): raise PlanError(f"{label}_invalid")
|
|
||||||
return value
|
|
||||||
def _walk_forbidden(value: Any) -> None:
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
for key, item in value.items():
|
|
||||||
if str(key).lower() in FORBIDDEN_KEYS: raise PlanError(f"forbidden_plan_key:{key}")
|
|
||||||
_walk_forbidden(item)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
for item in value: _walk_forbidden(item)
|
|
||||||
def _exact_mapping(value: Any, required: set[str], label: str) -> Mapping[str, Any]:
|
|
||||||
if not isinstance(value, Mapping) or set(value) != required: raise PlanError(f"{label}_fields_invalid")
|
|
||||||
return value
|
|
||||||
def _secure_json(path: Path) -> dict[str, Any]:
|
|
||||||
try: st = path.lstat()
|
|
||||||
except OSError as exc: raise PlanError(f"plan_unreadable:{path}") from exc
|
|
||||||
if path.is_symlink() or not stat.S_ISREG(st.st_mode): raise PlanError("plan_must_be_regular_file")
|
|
||||||
if st.st_uid != 0 or st.st_mode & 0o022: raise PlanError("plan_ownership_or_mode_invalid")
|
|
||||||
try: value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError) as exc: raise PlanError("plan_json_invalid") from exc
|
|
||||||
if not isinstance(value, dict): raise PlanError("plan_object_required")
|
|
||||||
return value
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TrustedReleasePlan:
|
|
||||||
value: Mapping[str, Any]
|
|
||||||
release_plan_sha256: str
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, raw: Mapping[str, Any]) -> "TrustedReleasePlan":
|
|
||||||
if not isinstance(raw, Mapping) or set(raw) - TOP_LEVEL_KEYS: raise PlanError("release_plan_fields_invalid")
|
|
||||||
required = TOP_LEVEL_KEYS - {"release_plan_sha256"}
|
|
||||||
if set(raw) not in (required, TOP_LEVEL_KEYS): raise PlanError("release_plan_fields_incomplete")
|
|
||||||
_walk_forbidden(raw)
|
|
||||||
if raw.get("schema") != "aios.trusted-release-execution-plan.v1": raise PlanError("release_plan_schema_invalid")
|
|
||||||
version = raw.get("version")
|
|
||||||
if not isinstance(version, str) or not re.fullmatch(r"V[0-9]+R[0-9]+C[0-9]+", version): raise PlanError("version_invalid")
|
|
||||||
if not isinstance(raw.get("candidate_id"), str) or not IDENTIFIER_RE.fullmatch(raw["candidate_id"]): raise PlanError("candidate_id_invalid")
|
|
||||||
for key in ("source_commit","tree_oid","evidence_closure_oid"): _oid(raw.get(key), key)
|
|
||||||
for key in ("artifact_sha256","manifest_sha256","package_closure_sha256","deployment_plan_sha256","policy_sha256","campaign_authorization_sha256"): _sha(raw.get(key), key)
|
|
||||||
for key in ("release_plan_id","approval_plan_id"):
|
|
||||||
if not isinstance(raw.get(key), str) or not PLAN_ID_RE.fullmatch(raw[key]): raise PlanError(f"{key}_invalid")
|
|
||||||
if not isinstance(raw.get("campaign_generation"), int) or raw["campaign_generation"] < 1: raise PlanError("campaign_generation_invalid")
|
|
||||||
artifact = _exact_mapping(raw.get("artifact"), {"path","size"}, "artifact")
|
|
||||||
if not isinstance(artifact["path"], str) or not artifact["path"].startswith("/") or "\\" in artifact["path"]: raise PlanError("artifact_path_invalid")
|
|
||||||
if not isinstance(artifact["size"], int) or artifact["size"] <= 0: raise PlanError("artifact_size_invalid")
|
|
||||||
rollback = _exact_mapping(raw.get("rollback"), {"version","target_oid","artifact_sha256"}, "rollback")
|
|
||||||
if not isinstance(rollback["version"], str) or not rollback["version"] or rollback["version"] == version: raise PlanError("rollback_version_invalid")
|
|
||||||
_oid(rollback["target_oid"], "rollback_target_oid"); _sha(rollback["artifact_sha256"], "rollback_artifact_sha256")
|
|
||||||
baseline = _exact_mapping(raw.get("baseline"), {"version","target_oid"}, "baseline")
|
|
||||||
if baseline["version"] != rollback["version"]: raise PlanError("baseline_rollback_version_mismatch")
|
|
||||||
_oid(baseline["target_oid"], "baseline_target_oid")
|
|
||||||
adapter = _exact_mapping(raw.get("adapter_policy"), {"policy_id","service","port"}, "adapter_policy")
|
|
||||||
if adapter["policy_id"] != "aios.production-adapter.allowlisted.v1": raise PlanError("adapter_policy_invalid")
|
|
||||||
if not isinstance(adapter["service"], str) or not IDENTIFIER_RE.fullmatch(adapter["service"]): raise PlanError("adapter_service_invalid")
|
|
||||||
if not isinstance(adapter["port"], int) or not 1024 <= adapter["port"] <= 65535: raise PlanError("adapter_port_invalid")
|
|
||||||
payload = {key: raw[key] for key in TOP_LEVEL_KEYS if key != "release_plan_sha256"}
|
|
||||||
digest = hashlib.sha256(canonical_json(payload)).hexdigest()
|
|
||||||
if raw.get("release_plan_sha256") is not None and raw["release_plan_sha256"] != digest: raise PlanError("release_plan_sha256_mismatch")
|
|
||||||
return cls(value=dict(payload), release_plan_sha256=digest)
|
|
||||||
@classmethod
|
|
||||||
def load(cls, path: Path) -> "TrustedReleasePlan": return cls.from_mapping(_secure_json(path))
|
|
||||||
@property
|
|
||||||
def candidate_binding_sha256(self) -> str:
|
|
||||||
value = {key: self.value[key] for key in ("candidate_id","source_commit","tree_oid","artifact_sha256","manifest_sha256")}
|
|
||||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
|
||||||
def as_dict(self) -> dict[str, Any]: return {**dict(self.value),"release_plan_sha256":self.release_plan_sha256,"candidate_binding_sha256":self.candidate_binding_sha256}
|
|
||||||
def assert_binding(self, binding: Mapping[str, Any]) -> None:
|
|
||||||
expected={"version":self.value["version"],"target_oid":self.value["source_commit"],"evidence_closure_oid":self.value["evidence_closure_oid"],"artifact_sha256":self.value["artifact_sha256"],"artifact_size":self.value["artifact"]["size"],"approval_plan_id":self.value["approval_plan_id"]}
|
|
||||||
for key,value in expected.items():
|
|
||||||
if binding.get(key) != value: raise PlanError(f"binding_plan_mismatch:{key}")
|
|
||||||
if binding.get("candidate_id") is not None and binding.get("candidate_id") != self.value["candidate_id"]:
|
|
||||||
raise PlanError("binding_plan_mismatch:candidate_id")
|
|
||||||
def assert_artifact(self, artifact: Mapping[str, Any]) -> None:
|
|
||||||
expected=self.value["artifact"]
|
|
||||||
if artifact.get("sha256") != self.value["artifact_sha256"] or artifact.get("size") != expected["size"]: raise PlanError("artifact_plan_mismatch")
|
|
||||||
if artifact.get("manifest_sha256") != self.value["manifest_sha256"]: raise PlanError("manifest_plan_mismatch")
|
|
||||||
if artifact.get("package_closure_sha256") != self.value["package_closure_sha256"]: raise PlanError("package_closure_plan_mismatch")
|
|
||||||
@@ -7,6 +7,7 @@ previous V2R1C7 release available for an explicit, owner-approved rollback.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -29,6 +30,7 @@ ROLLBACK_ROOT = Path("/var/lib/aios/v2r2c1/rollback")
|
|||||||
|
|
||||||
REQUIRED_MEMBERS = {
|
REQUIRED_MEMBERS = {
|
||||||
"payload/VERSION",
|
"payload/VERSION",
|
||||||
|
"payload/versions/V2R2C1.json",
|
||||||
"payload/active-runtime/app/deployment_state.py",
|
"payload/active-runtime/app/deployment_state.py",
|
||||||
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
||||||
"payload/active-runtime/app/v2r2c1_health_service.py",
|
"payload/active-runtime/app/v2r2c1_health_service.py",
|
||||||
@@ -87,12 +89,65 @@ def execute_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
|||||||
_backup(Path("/var/lib/aios/v2r1c7/deployment-state.json"), "v2r1c7-deployment-state.previous")
|
_backup(Path("/var/lib/aios/v2r1c7/deployment-state.json"), "v2r1c7-deployment-state.previous")
|
||||||
return base.install_release(_args(), value)
|
return base.install_release(_args(), value)
|
||||||
if value.get("operation") == "probe":
|
if value.get("operation") == "probe":
|
||||||
return base.probe(_args(), value)
|
result = base.probe(_args(), value)
|
||||||
|
if result.get("status") == "ACTIVE_VERIFIED":
|
||||||
|
result.update(_runtime_registry_gate(value))
|
||||||
|
return result
|
||||||
if value.get("operation") == "rollback":
|
if value.get("operation") == "rollback":
|
||||||
return rollback(value)
|
return rollback(value)
|
||||||
raise ValueError("unsupported_operation")
|
raise ValueError("unsupported_operation")
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_registry_gate(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Prove the deployed immutable runtime contains the exact M:path blob."""
|
||||||
|
source_digest = value.get("source_registry_sha256")
|
||||||
|
source_main = value.get("source_registry_main_oid")
|
||||||
|
if not source_digest or not source_main:
|
||||||
|
return {}
|
||||||
|
source_path = str(value.get("source_registry_path") or f"versions/{VERSION}.json")
|
||||||
|
member = CURRENT_LINK / source_path
|
||||||
|
if member.is_symlink() or not member.is_file():
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_MEMBER_MISSING")
|
||||||
|
raw = member.read_bytes()
|
||||||
|
digest = hashlib.sha256(raw).hexdigest()
|
||||||
|
if digest != source_digest:
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_DIGEST_MISMATCH")
|
||||||
|
try:
|
||||||
|
source = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_INVALID") from exc
|
||||||
|
expected = {
|
||||||
|
"version": VERSION,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": value.get("tag_oid"),
|
||||||
|
"target_oid": value.get("target_oid"),
|
||||||
|
"peeled_target_oid": value.get("target_oid"),
|
||||||
|
"evidence_closure_oid": value.get("evidence_closure_oid"),
|
||||||
|
"approval_plan_id": value.get("approval_plan_id"),
|
||||||
|
}
|
||||||
|
if not isinstance(source, dict) or any(source.get(key) != expected_value for key, expected_value in expected.items()):
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_IDENTITY_MISMATCH")
|
||||||
|
if source.get("tag_object") != expected["tag_oid"]:
|
||||||
|
raise RuntimeError("RUNTIME_SOURCE_REGISTRY_TAG_OBJECT_MISMATCH")
|
||||||
|
version_name = f"{VERSION}.json"
|
||||||
|
return {
|
||||||
|
"runtime_registry_versions": [version_name],
|
||||||
|
"attested_projection_versions": [version_name],
|
||||||
|
"effective_registry_versions": [version_name],
|
||||||
|
# The adapter can only inspect the deployed immutable runtime. It
|
||||||
|
# must not claim convergence with mutable authoritative Git; the
|
||||||
|
# executor performs that independent M/C read after deployment.
|
||||||
|
"source_registry_converged": "not_checked",
|
||||||
|
"source_registry_convergence_error": "executor_must_recheck_authoritative_git",
|
||||||
|
"runtime_registry_converged": True,
|
||||||
|
"runtime_bundle_contains_current_version": True,
|
||||||
|
"runtime_bundle_contains_version": True,
|
||||||
|
"registry_consistency_status": "RUNTIME_ONLY_VERIFIED",
|
||||||
|
"runtime_source_registry_main_oid": source_main,
|
||||||
|
"runtime_source_registry_sha256": digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
def rollback(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
"""Explicit rollback only; this adapter never invokes it automatically."""
|
"""Explicit rollback only; this adapter never invokes it automatically."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""V2R2C2 production health entry point; all identity is plan supplied."""
|
|
||||||
from v2r1c6_privacy_tools import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
"""Release registry visibility and source-convergence gates.
|
||||||
|
|
||||||
|
Two invariants are enforced:
|
||||||
|
|
||||||
|
1. listVersions may overlay exactly the current Production Baseline-attested
|
||||||
|
projection onto the immutable source bundle. Arbitrary projection files
|
||||||
|
are not surfaced.
|
||||||
|
|
||||||
|
2. release_metadata_adapter.baseline_apply may not finalize a production
|
||||||
|
baseline unless the governed repository/main already contains a source
|
||||||
|
versions/<version>.json entry matching the release identity.
|
||||||
|
|
||||||
|
The source convergence gate intentionally checks release identity, not
|
||||||
|
post-deployment runtime fields. This allows the source entry to be committed
|
||||||
|
before deployment while still preventing PRODUCTION_DEPLOYED from being
|
||||||
|
written for an uncommitted release identity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_VERSION_RE = re.compile(r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
|
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
_PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
VERSION_RE = _VERSION_RE
|
||||||
|
OID_RE = _OID_RE
|
||||||
|
SOURCE_REGISTRY_SCHEMAS = frozenset(
|
||||||
|
{"aios.source-registry.v1", "aios.production-registry.v2"}
|
||||||
|
)
|
||||||
|
SOURCE_REGISTRY_FIELDS = (
|
||||||
|
"version",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
REPOSITORY_IDENTITY = "aios/aios"
|
||||||
|
|
||||||
|
DEFAULT_PROJECTION_ROOT = Path(
|
||||||
|
os.getenv(
|
||||||
|
"AIOS_GITOPS_REGISTRY_PROJECTION_ROOT",
|
||||||
|
"/var/lib/aios-gitops/runtime/registry-projection",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DEFAULT_BASELINE_FILE = Path(
|
||||||
|
os.getenv(
|
||||||
|
"AIOS_GITOPS_BASELINE_FILE",
|
||||||
|
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DEFAULT_MAIN_REF = os.getenv("AIOS_GITOPS_MAIN_REF", "refs/heads/main")
|
||||||
|
DEFAULT_AUTHORITATIVE_GIT_DIR = Path("/data/aios-gitops/repo.git")
|
||||||
|
DEFAULT_APPROVAL_AUTHORITY_ROOT = Path("/var/lib/aios-gitops/approval-authority")
|
||||||
|
TEST_MODE_ENV = "AIOS_TEST_MODE"
|
||||||
|
AUTHORITATIVE_GIT_ENV = "AIOS_GITOPS_AUTHORITATIVE_GIT_DIR"
|
||||||
|
APPROVAL_AUTHORITY_ROOT_ENV = "AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT"
|
||||||
|
REPOSITORY_IDENTITY_ENV = "AIOS_GITOPS_REPOSITORY_IDENTITY"
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryConvergenceError(RuntimeError):
|
||||||
|
"""Release registry/source convergence is not proven."""
|
||||||
|
|
||||||
|
|
||||||
|
def _load_object(path: Path) -> dict[str, Any]:
|
||||||
|
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise RegistryConvergenceError(f"json_not_object:{path}")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _git_text(repo_git_dir: Path | str, *args: str) -> str:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", f"--git-dir={Path(repo_git_dir)}", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
completed.stderr.strip() or "authoritative_git_query_failed"
|
||||||
|
)
|
||||||
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _git_bytes(repo_git_dir: Path | str, *args: str) -> bytes:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", f"--git-dir={Path(repo_git_dir)}", *args],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
completed.stderr.decode("utf-8", "replace").strip()
|
||||||
|
or "authoritative_git_query_failed"
|
||||||
|
)
|
||||||
|
return completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_enabled() -> bool:
|
||||||
|
"""Return whether explicit dependency injection is allowed for tests."""
|
||||||
|
return os.environ.get(TEST_MODE_ENV) == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_override(env_name: str, default: str, label: str) -> Path:
|
||||||
|
if env_name in os.environ:
|
||||||
|
if not test_mode_enabled():
|
||||||
|
raise RegistryConvergenceError(f"{label}_env_override_forbidden")
|
||||||
|
value = os.environ.get(env_name, "")
|
||||||
|
if not value:
|
||||||
|
raise RegistryConvergenceError(f"{label}_env_override_invalid")
|
||||||
|
return Path(value)
|
||||||
|
return Path(default)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_authoritative_git_dir() -> Path:
|
||||||
|
return _configured_override(
|
||||||
|
AUTHORITATIVE_GIT_ENV,
|
||||||
|
str(DEFAULT_AUTHORITATIVE_GIT_DIR),
|
||||||
|
"authoritative_repository",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_repository_identity() -> str:
|
||||||
|
if REPOSITORY_IDENTITY_ENV in os.environ:
|
||||||
|
if not test_mode_enabled():
|
||||||
|
raise RegistryConvergenceError("repository_identity_env_override_forbidden")
|
||||||
|
value = os.environ.get(REPOSITORY_IDENTITY_ENV, "")
|
||||||
|
if not value:
|
||||||
|
raise RegistryConvergenceError("repository_identity_env_override_invalid")
|
||||||
|
return value
|
||||||
|
return REPOSITORY_IDENTITY
|
||||||
|
|
||||||
|
|
||||||
|
def verify_authoritative_repository(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
*,
|
||||||
|
repository_identity: str = REPOSITORY_IDENTITY,
|
||||||
|
expected_path: Path | str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Prove that a Git directory is the configured AIOS authority.
|
||||||
|
|
||||||
|
A repository that merely contains internally consistent objects is not
|
||||||
|
sufficient. The resolved path must be the configured authority and the
|
||||||
|
bare repository must advertise the expected Gitea repository identity.
|
||||||
|
"""
|
||||||
|
if repository_identity != REPOSITORY_IDENTITY:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_identity_invalid")
|
||||||
|
candidate = Path(repo_git_dir)
|
||||||
|
configured = Path(expected_path) if expected_path is not None else configured_authoritative_git_dir()
|
||||||
|
if test_mode_enabled() and expected_path is None and AUTHORITATIVE_GIT_ENV not in os.environ:
|
||||||
|
# Explicit test mode may inject a temporary bare repository directly.
|
||||||
|
# Production mode always compares against the fixed authority path.
|
||||||
|
configured = candidate
|
||||||
|
try:
|
||||||
|
candidate_real = candidate.resolve(strict=True)
|
||||||
|
configured_real = configured.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_path_unavailable") from exc
|
||||||
|
if candidate_real != configured_real:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_path_mismatch")
|
||||||
|
if not candidate_real.is_dir():
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_not_directory")
|
||||||
|
if _git_text(candidate_real, "rev-parse", "--is-bare-repository") != "true":
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_not_bare")
|
||||||
|
remotes = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={candidate_real}",
|
||||||
|
"config",
|
||||||
|
"--get-regexp",
|
||||||
|
r"^remote\..*\.url$",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
urls = [line.split(None, 1)[1] for line in remotes.stdout.splitlines() if " " in line]
|
||||||
|
identity_suffix = repository_identity.removesuffix(".git")
|
||||||
|
identity_verified = any(
|
||||||
|
url.rstrip("/").removesuffix(".git").replace("\\", "/").split(":")[-1]
|
||||||
|
in {identity_suffix, "/" + identity_suffix}
|
||||||
|
or url.rstrip("/").removesuffix(".git").replace("\\", "/").split(":")[-1].endswith(
|
||||||
|
"/" + identity_suffix
|
||||||
|
)
|
||||||
|
for url in urls
|
||||||
|
)
|
||||||
|
if not test_mode_enabled() and not identity_verified:
|
||||||
|
raise RegistryConvergenceError("authoritative_repository_identity_unverified")
|
||||||
|
return {
|
||||||
|
"repository_identity": repository_identity,
|
||||||
|
"configured_path": str(configured_real),
|
||||||
|
"resolved_path": str(candidate_real),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_source_registry_main_oid(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the authoritative main ref to one immutable commit OID."""
|
||||||
|
oid = _git_text(repo_git_dir, "rev-parse", "--verify", f"{main_ref}^{{commit}}")
|
||||||
|
if not _OID_RE.fullmatch(oid):
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_not_commit")
|
||||||
|
return oid
|
||||||
|
|
||||||
|
|
||||||
|
def source_registry_snapshot(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read one source entry and its exact bytes from one immutable commit.
|
||||||
|
|
||||||
|
Callers building an artifact should resolve ``source_registry_main_oid``
|
||||||
|
once and pass it back here. The function then never consults the mutable
|
||||||
|
ref again, so validation and packaging use the same Git blob.
|
||||||
|
"""
|
||||||
|
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||||
|
raise RegistryConvergenceError("source_registry_version_invalid")
|
||||||
|
main_oid = source_registry_main_oid or resolve_source_registry_main_oid(
|
||||||
|
repo_git_dir,
|
||||||
|
main_ref=main_ref,
|
||||||
|
)
|
||||||
|
if not _OID_RE.fullmatch(main_oid):
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", main_oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_not_commit")
|
||||||
|
path = f"versions/{version}.json"
|
||||||
|
try:
|
||||||
|
raw = _git_bytes(repo_git_dir, "show", f"{main_oid}:{path}")
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
if "path" in str(exc).lower() or "does not exist" in str(exc).lower():
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
f"source_registry_entry_missing:{main_oid}:{path}"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
source = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RegistryConvergenceError("source_registry_json_invalid") from exc
|
||||||
|
record = validate_source_registry_record(
|
||||||
|
source,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"record": record,
|
||||||
|
"bytes": raw,
|
||||||
|
"source_registry_main_oid": main_oid,
|
||||||
|
"source_registry_path": path,
|
||||||
|
"source_registry_sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_source_registry_record(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
expected_version: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate the immutable identity carried by versions/<version>.json."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise RegistryConvergenceError("source_registry_not_object")
|
||||||
|
if value.get("schema") not in SOURCE_REGISTRY_SCHEMAS:
|
||||||
|
raise RegistryConvergenceError("source_registry_schema_invalid")
|
||||||
|
version = value.get("version")
|
||||||
|
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||||
|
raise RegistryConvergenceError("source_registry_version_invalid")
|
||||||
|
if expected_version is not None and version != expected_version:
|
||||||
|
raise RegistryConvergenceError("source_registry_mismatch:version")
|
||||||
|
if value.get("repository") != REPOSITORY_IDENTITY:
|
||||||
|
raise RegistryConvergenceError("source_registry_repository_invalid")
|
||||||
|
for field in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||||
|
item = value.get(field)
|
||||||
|
if not isinstance(item, str) or not _OID_RE.fullmatch(item):
|
||||||
|
raise RegistryConvergenceError(f"source_registry_{field}_invalid")
|
||||||
|
if value["peeled_target_oid"] != value["target_oid"]:
|
||||||
|
raise RegistryConvergenceError("source_registry_peeled_target_mismatch")
|
||||||
|
approval = value.get("approval_plan_id")
|
||||||
|
if not isinstance(approval, str) or not _PLAN_RE.fullmatch(approval):
|
||||||
|
raise RegistryConvergenceError("source_registry_approval_plan_invalid")
|
||||||
|
if value.get("tag_object", value["tag_oid"]) != value["tag_oid"]:
|
||||||
|
raise RegistryConvergenceError("source_registry_tag_object_mismatch")
|
||||||
|
expected = {
|
||||||
|
"tag_oid": expected_tag_oid,
|
||||||
|
"target_oid": expected_target_oid,
|
||||||
|
"evidence_closure_oid": expected_evidence_closure_oid,
|
||||||
|
"approval_plan_id": expected_approval_plan_id,
|
||||||
|
}
|
||||||
|
for field, wanted in expected.items():
|
||||||
|
if wanted is not None and value.get(field) != wanted:
|
||||||
|
raise RegistryConvergenceError(f"source_registry_mismatch:{field}")
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_source_registry_entry(
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
tag_oid: str,
|
||||||
|
target_oid: str,
|
||||||
|
peeled_target_oid: str,
|
||||||
|
evidence_closure_oid: str,
|
||||||
|
approval_plan_id: str,
|
||||||
|
repository: str = REPOSITORY_IDENTITY,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the pre-promotion source entry; it performs no Git or runtime write."""
|
||||||
|
value = {
|
||||||
|
"schema": "aios.source-registry.v1",
|
||||||
|
"version": version,
|
||||||
|
"repository": repository,
|
||||||
|
"tag": version,
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
"tag_object": tag_oid,
|
||||||
|
"target_oid": target_oid,
|
||||||
|
"peeled_target_oid": peeled_target_oid,
|
||||||
|
"evidence_closure_oid": evidence_closure_oid,
|
||||||
|
"approval_plan_id": approval_plan_id,
|
||||||
|
}
|
||||||
|
return validate_source_registry_record(
|
||||||
|
value,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=approval_plan_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_source_registry_git_identity(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
record: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Prove that every Git identity in a source entry exists and is related."""
|
||||||
|
record = validate_source_registry_record(record)
|
||||||
|
version = record["version"]
|
||||||
|
tag_oid = record["tag_oid"]
|
||||||
|
target_oid = record["target_oid"]
|
||||||
|
evidence_oid = record["evidence_closure_oid"]
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", tag_oid) != "tag":
|
||||||
|
raise RegistryConvergenceError("source_registry_tag_not_annotated")
|
||||||
|
named_tag = _git_text(
|
||||||
|
repo_git_dir,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
f"refs/tags/{version}",
|
||||||
|
)
|
||||||
|
if named_tag != tag_oid:
|
||||||
|
raise RegistryConvergenceError("source_registry_named_tag_mismatch")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", target_oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_target_not_commit")
|
||||||
|
peeled = _git_text(
|
||||||
|
repo_git_dir,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
f"{tag_oid}^{{commit}}",
|
||||||
|
)
|
||||||
|
if peeled != target_oid or record["peeled_target_oid"] != target_oid:
|
||||||
|
raise RegistryConvergenceError("source_registry_tag_target_mismatch")
|
||||||
|
if _git_text(repo_git_dir, "cat-file", "-t", evidence_oid) != "commit":
|
||||||
|
raise RegistryConvergenceError("source_registry_evidence_not_commit")
|
||||||
|
ancestor = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={Path(repo_git_dir)}",
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
target_oid,
|
||||||
|
evidence_oid,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if ancestor.returncode != 0:
|
||||||
|
raise RegistryConvergenceError("source_registry_evidence_lineage_invalid")
|
||||||
|
return {
|
||||||
|
"repository": REPOSITORY_IDENTITY,
|
||||||
|
"version": version,
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
"tag_object_type": "tag",
|
||||||
|
"target_oid": target_oid,
|
||||||
|
"peeled_target_oid": peeled,
|
||||||
|
"evidence_closure_oid": evidence_oid,
|
||||||
|
"target_is_ancestor_of_evidence": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_source_registry_main_convergence(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Prove that pinned M is still converged with current authoritative main C."""
|
||||||
|
current_main_oid = resolve_source_registry_main_oid(repo_git_dir, main_ref=main_ref)
|
||||||
|
source_main_oid = source_registry_main_oid or current_main_oid
|
||||||
|
if not _OID_RE.fullmatch(source_main_oid):
|
||||||
|
raise RegistryConvergenceError("source_registry_main_oid_invalid")
|
||||||
|
ancestor = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={Path(repo_git_dir)}",
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
source_main_oid,
|
||||||
|
current_main_oid,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=45,
|
||||||
|
env={"PATH": os.environ.get("PATH", "")},
|
||||||
|
)
|
||||||
|
if ancestor.returncode != 0:
|
||||||
|
raise RegistryConvergenceError("source_registry_main_not_ancestor_of_current_main")
|
||||||
|
pinned = source_registry_snapshot(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
source_registry_main_oid=source_main_oid,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)
|
||||||
|
current = source_registry_snapshot(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
source_registry_main_oid=current_main_oid,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)
|
||||||
|
if pinned["bytes"] != current["bytes"]:
|
||||||
|
raise RegistryConvergenceError("source_registry_main_entry_changed_after_pin")
|
||||||
|
return {
|
||||||
|
"source_registry_main_oid": source_main_oid,
|
||||||
|
"current_main_oid": current_main_oid,
|
||||||
|
"source_registry_path": pinned["source_registry_path"],
|
||||||
|
"source_registry_sha256": pinned["source_registry_sha256"],
|
||||||
|
"current_main_source_registry_sha256": current["source_registry_sha256"],
|
||||||
|
"source_registry_main_is_ancestor_of_current_main": True,
|
||||||
|
"source_registry_entry_matches_current_main": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_APPROVAL_AUTHORITY_STATES = frozenset(
|
||||||
|
{
|
||||||
|
"APPROVED",
|
||||||
|
"GITEA_API_CONFIRMED",
|
||||||
|
"REMOTE_VERIFIED",
|
||||||
|
"PRODUCTION_DEPLOYED",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_APPROVAL_AUTHORITY_SCHEMAS = frozenset(
|
||||||
|
{
|
||||||
|
"aios.v2r2c1.tag-approval-request.v1",
|
||||||
|
"aios.owner-release-approval.v1",
|
||||||
|
"aios.release-binding-approval.v2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_approval_authority_root() -> Path:
|
||||||
|
return _configured_override(
|
||||||
|
APPROVAL_AUTHORITY_ROOT_ENV,
|
||||||
|
str(DEFAULT_APPROVAL_AUTHORITY_ROOT),
|
||||||
|
"approval_authority_root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _secure_approval_authority_path(
|
||||||
|
authority_path: Path | str,
|
||||||
|
version: str,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
root = configured_approval_authority_root()
|
||||||
|
path = Path(authority_path)
|
||||||
|
if path.is_dir():
|
||||||
|
path = path / f"{version}.json"
|
||||||
|
try:
|
||||||
|
root_real = root.resolve(strict=True)
|
||||||
|
path_real = path.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RegistryConvergenceError("approval_authority_trusted_root_missing") from exc
|
||||||
|
try:
|
||||||
|
path_real.relative_to(root_real)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RegistryConvergenceError("approval_authority_untrusted_path") from exc
|
||||||
|
metadata = path.lstat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
|
||||||
|
raise RegistryConvergenceError("approval_authority_not_regular")
|
||||||
|
if metadata.st_mode & 0o022:
|
||||||
|
raise RegistryConvergenceError("approval_authority_permissions_invalid")
|
||||||
|
if APPROVAL_AUTHORITY_ROOT_ENV not in os.environ and metadata.st_uid != 0:
|
||||||
|
raise RegistryConvergenceError("approval_authority_owner_invalid")
|
||||||
|
return path, root_real
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_is_revoked(root: Path, approval_plan_id: str, authority: dict[str, Any]) -> bool:
|
||||||
|
if any(
|
||||||
|
authority.get(name) is True
|
||||||
|
for name in ("revoked", "invalidated", "revocation")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
state = authority.get("state") or authority.get("status")
|
||||||
|
if state in {"REVOKED", "INVALIDATED", "EXPIRED", "CANCELLED"}:
|
||||||
|
return True
|
||||||
|
for marker in (
|
||||||
|
root / "revocations" / approval_plan_id,
|
||||||
|
root / "revocations" / approval_plan_id.replace(":", "_"),
|
||||||
|
):
|
||||||
|
if marker.exists():
|
||||||
|
return True
|
||||||
|
expires_at = authority.get("expires_at")
|
||||||
|
if isinstance(expires_at, str) and expires_at:
|
||||||
|
try:
|
||||||
|
instant = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RegistryConvergenceError("approval_expiry_invalid") from exc
|
||||||
|
if instant <= datetime.now(timezone.utc):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_approval_authority(
|
||||||
|
authority_path: Path | str,
|
||||||
|
record: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Verify the existing release approval/publication receipt association.
|
||||||
|
|
||||||
|
The authority document is intentionally supplied by the governed caller;
|
||||||
|
this helper never creates or rewrites an approval. The path must be under
|
||||||
|
the configured canonical Owner-authority root and the document must use a
|
||||||
|
known release-approval schema. A caller-supplied JSON file elsewhere is
|
||||||
|
never an authority, even when its fields are internally consistent.
|
||||||
|
"""
|
||||||
|
path, trusted_root = _secure_approval_authority_path(authority_path, record["version"])
|
||||||
|
authority = _load_object(path)
|
||||||
|
schema = authority.get("schema")
|
||||||
|
if schema not in _APPROVAL_AUTHORITY_SCHEMAS:
|
||||||
|
raise RegistryConvergenceError("approval_authority_schema_invalid")
|
||||||
|
if _approval_is_revoked(trusted_root, record["approval_plan_id"], authority):
|
||||||
|
raise RegistryConvergenceError("approval_plan_revoked_or_invalidated")
|
||||||
|
nested = authority.get("release_binding") or authority.get("release_request")
|
||||||
|
if not isinstance(nested, dict):
|
||||||
|
nested = authority.get("binding")
|
||||||
|
if not isinstance(nested, dict):
|
||||||
|
raise RegistryConvergenceError("approval_authority_binding_missing")
|
||||||
|
expected = {
|
||||||
|
"version": record["version"],
|
||||||
|
"repository": REPOSITORY_IDENTITY,
|
||||||
|
"tag_oid": record["tag_oid"],
|
||||||
|
"target_oid": record["target_oid"],
|
||||||
|
"peeled_target_oid": record["peeled_target_oid"],
|
||||||
|
"evidence_closure_oid": record["evidence_closure_oid"],
|
||||||
|
"approval_plan_id": record["approval_plan_id"],
|
||||||
|
}
|
||||||
|
if authority.get("approval_plan_id") != expected["approval_plan_id"]:
|
||||||
|
raise RegistryConvergenceError("approval_plan_not_found_or_mismatched")
|
||||||
|
state = authority.get("state") or authority.get("status")
|
||||||
|
if state not in _APPROVAL_AUTHORITY_STATES and authority.get("approved") is not True:
|
||||||
|
raise RegistryConvergenceError("approval_plan_not_approved")
|
||||||
|
owner_approval = authority.get("owner_approval")
|
||||||
|
if schema == "aios.v2r2c1.tag-approval-request.v1":
|
||||||
|
if not isinstance(owner_approval, dict):
|
||||||
|
raise RegistryConvergenceError("canonical_owner_approval_missing")
|
||||||
|
if (
|
||||||
|
owner_approval.get("approved") is not True
|
||||||
|
or owner_approval.get("status") != "APPROVED"
|
||||||
|
or owner_approval.get("approved_by") != "aiman"
|
||||||
|
or owner_approval.get("approval_source") != "explicit_owner_approval_in_chat"
|
||||||
|
):
|
||||||
|
raise RegistryConvergenceError("canonical_owner_approval_invalid")
|
||||||
|
expected_digest = hashlib.sha256(_canonical_json(owner_approval)).hexdigest()
|
||||||
|
if authority.get("owner_approval_digest_sha256") != expected_digest:
|
||||||
|
raise RegistryConvergenceError("canonical_owner_approval_digest_invalid")
|
||||||
|
for field, wanted in expected.items():
|
||||||
|
observed = nested.get(field)
|
||||||
|
if field == "repository" and observed is None:
|
||||||
|
observed = nested.get("repository_identity") or authority.get(
|
||||||
|
"repository_identity"
|
||||||
|
)
|
||||||
|
if field == "peeled_target_oid" and observed is None:
|
||||||
|
observed = (
|
||||||
|
nested.get("gitea_peeled_target_oid")
|
||||||
|
or authority.get("gitea_peeled_target_oid")
|
||||||
|
or nested.get("remote_peeled_target_oid")
|
||||||
|
or authority.get("remote_peeled_target_oid")
|
||||||
|
)
|
||||||
|
if observed != wanted:
|
||||||
|
raise RegistryConvergenceError(f"approval_plan_release_binding_mismatch:{field}")
|
||||||
|
if state == "GITEA_API_CONFIRMED":
|
||||||
|
gitea_tag = authority.get("gitea_tag_oid")
|
||||||
|
gitea_peeled = authority.get("gitea_peeled_target_oid")
|
||||||
|
if gitea_tag != expected["tag_oid"] or gitea_peeled != expected["target_oid"]:
|
||||||
|
raise RegistryConvergenceError("approval_plan_remote_tag_binding_mismatch")
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"schema": schema,
|
||||||
|
"state": state or "APPROVED",
|
||||||
|
"approval_plan_id": expected["approval_plan_id"],
|
||||||
|
"owner_approval_verified": schema == "aios.v2r2c1.tag-approval-request.v1",
|
||||||
|
"release_binding_verified": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def source_registry_json(value: dict[str, Any]) -> bytes:
|
||||||
|
"""Return the canonical bytes used by the governed file writer."""
|
||||||
|
return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_current_projection(
|
||||||
|
projection_root: Path,
|
||||||
|
baseline_file: Path,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Return only the exact projection attested by the current Baseline."""
|
||||||
|
if not baseline_file.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
baseline = _load_object(baseline_file)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = baseline.get("version")
|
||||||
|
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
||||||
|
return None
|
||||||
|
if baseline.get("status") != "PRODUCTION_DEPLOYED":
|
||||||
|
return None
|
||||||
|
if baseline.get("production_deployed") is not True:
|
||||||
|
return None
|
||||||
|
|
||||||
|
source = baseline.get("registry_source")
|
||||||
|
digest = baseline.get("registry_digest")
|
||||||
|
if not isinstance(source, str) or not isinstance(digest, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate = projection_root / f"{version}.json"
|
||||||
|
try:
|
||||||
|
if candidate.resolve() != Path(source).resolve():
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not candidate.is_file() or _sha256(candidate) != digest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = _load_object(candidate)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_source_registry_record(
|
||||||
|
record,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=baseline.get("tag_oid"),
|
||||||
|
expected_target_oid=baseline.get("target_oid"),
|
||||||
|
expected_evidence_closure_oid=baseline.get("evidence_closure_oid"),
|
||||||
|
expected_approval_plan_id=baseline.get("approval_plan_id"),
|
||||||
|
)
|
||||||
|
except RegistryConvergenceError:
|
||||||
|
return None
|
||||||
|
if record.get("schema") != "aios.production-registry.v2":
|
||||||
|
return None
|
||||||
|
if record.get("status") != "PRODUCTION_DEPLOYED":
|
||||||
|
return None
|
||||||
|
if record.get("version") != version:
|
||||||
|
return None
|
||||||
|
if record.get("repository") != REPOSITORY_IDENTITY:
|
||||||
|
return None
|
||||||
|
if record.get("production_deployed") is not True:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tag_oid = record.get("tag_oid")
|
||||||
|
target_oid = record.get("target_oid")
|
||||||
|
if not isinstance(tag_oid, str) or not _OID_RE.fullmatch(tag_oid):
|
||||||
|
return None
|
||||||
|
if not isinstance(target_oid, str) or not _OID_RE.fullmatch(target_oid):
|
||||||
|
return None
|
||||||
|
if record.get("peeled_target_oid") != target_oid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def list_registry_versions(
|
||||||
|
workspace: Path | str,
|
||||||
|
*,
|
||||||
|
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||||
|
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the legacy display list; use the report for provenance."""
|
||||||
|
return list_registry_versions_report(
|
||||||
|
workspace,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline_file,
|
||||||
|
)["registry_versions"]
|
||||||
|
|
||||||
|
|
||||||
|
def list_registry_versions_report(
|
||||||
|
workspace: Path | str,
|
||||||
|
*,
|
||||||
|
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||||
|
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||||
|
repo_git_dir: Path | str | None = None,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return version visibility with runtime/projection provenance.
|
||||||
|
|
||||||
|
The display list intentionally retains the historical projection overlay
|
||||||
|
for API compatibility. The explicit booleans make that overlay
|
||||||
|
non-authoritative: a projection-only version has
|
||||||
|
``runtime_bundle_contains_version == false`` and can never be reported as
|
||||||
|
converged.
|
||||||
|
"""
|
||||||
|
ws = Path(workspace)
|
||||||
|
versions_root = ws / "versions"
|
||||||
|
runtime_names = {
|
||||||
|
path.name
|
||||||
|
for path in versions_root.iterdir()
|
||||||
|
if path.is_file()
|
||||||
|
and not path.is_symlink()
|
||||||
|
and path.suffix == ".json"
|
||||||
|
and _VERSION_RE.fullmatch(path.stem)
|
||||||
|
} if versions_root.is_dir() else set()
|
||||||
|
current = _validated_current_projection(projection_root, baseline_file)
|
||||||
|
attested_version = current.stem if current is not None else None
|
||||||
|
runtime_bundle_contains = bool(
|
||||||
|
attested_version is not None
|
||||||
|
and f"{attested_version}.json" in runtime_names
|
||||||
|
)
|
||||||
|
consistency_status = "NO_ATTESTED_PROJECTION"
|
||||||
|
source_converged: bool | str = "unknown"
|
||||||
|
source_error: str | None = None
|
||||||
|
runtime_converged = False
|
||||||
|
projection_record: dict[str, Any] | None = None
|
||||||
|
if current is not None:
|
||||||
|
try:
|
||||||
|
projection_record = _load_object(current)
|
||||||
|
except (OSError, RegistryConvergenceError, json.JSONDecodeError) as exc:
|
||||||
|
source_error = f"projection_invalid:{type(exc).__name__}"
|
||||||
|
if repo_git_dir is None:
|
||||||
|
source_converged = "unknown"
|
||||||
|
source_error = "authoritative_repository_unavailable"
|
||||||
|
elif projection_record is not None:
|
||||||
|
try:
|
||||||
|
verify_authoritative_repository(repo_git_dir)
|
||||||
|
verify_source_registry_main_convergence(
|
||||||
|
repo_git_dir,
|
||||||
|
attested_version or "",
|
||||||
|
main_ref=main_ref,
|
||||||
|
expected_tag_oid=projection_record.get("tag_oid"),
|
||||||
|
expected_target_oid=projection_record.get("target_oid"),
|
||||||
|
expected_evidence_closure_oid=projection_record.get(
|
||||||
|
"evidence_closure_oid"
|
||||||
|
),
|
||||||
|
expected_approval_plan_id=projection_record.get("approval_plan_id"),
|
||||||
|
)
|
||||||
|
source_converged = True
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
if detail.startswith("authoritative_repository_"):
|
||||||
|
source_converged = "unknown"
|
||||||
|
source_error = (
|
||||||
|
"authoritative_repository_unavailable"
|
||||||
|
if detail.endswith("path_unavailable")
|
||||||
|
else detail
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
source_converged = False
|
||||||
|
source_error = detail
|
||||||
|
if current is not None and not runtime_bundle_contains:
|
||||||
|
consistency_status = "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||||
|
elif current is not None and runtime_bundle_contains:
|
||||||
|
runtime_path = versions_root / current.name
|
||||||
|
try:
|
||||||
|
projection_record = projection_record or _load_object(current)
|
||||||
|
runtime_record = _load_object(runtime_path)
|
||||||
|
identity_fields = (
|
||||||
|
"version",
|
||||||
|
"repository",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
runtime_converged = all(
|
||||||
|
runtime_record.get(field) == projection_record.get(field)
|
||||||
|
for field in identity_fields
|
||||||
|
)
|
||||||
|
if not runtime_converged:
|
||||||
|
consistency_status = "RUNTIME_IDENTITY_MISMATCH"
|
||||||
|
except (OSError, RegistryConvergenceError, json.JSONDecodeError):
|
||||||
|
consistency_status = "RUNTIME_ENTRY_INVALID"
|
||||||
|
runtime_converged = False
|
||||||
|
if current is not None and runtime_bundle_contains and runtime_converged:
|
||||||
|
if source_converged is True:
|
||||||
|
consistency_status = "FULLY_CONSISTENT"
|
||||||
|
elif source_converged == "unknown":
|
||||||
|
consistency_status = "SOURCE_GIT_NOT_CHECKED"
|
||||||
|
else:
|
||||||
|
consistency_status = "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||||
|
elif current is not None and not runtime_bundle_contains:
|
||||||
|
if source_converged is False:
|
||||||
|
consistency_status = "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||||
|
else:
|
||||||
|
consistency_status = "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||||
|
names = set(runtime_names)
|
||||||
|
if current is not None:
|
||||||
|
names.add(current.name)
|
||||||
|
effective = sorted(names)
|
||||||
|
projection_versions = [attested_version + ".json"] if attested_version else []
|
||||||
|
return {
|
||||||
|
"registry_versions": effective,
|
||||||
|
"effective_registry_versions": effective,
|
||||||
|
"runtime_registry_versions": sorted(runtime_names),
|
||||||
|
"attested_projection_versions": projection_versions,
|
||||||
|
"attested_projection_version": attested_version,
|
||||||
|
"source_registry_converged": source_converged,
|
||||||
|
"source_registry_convergence_error": source_error,
|
||||||
|
"runtime_registry_converged": runtime_converged,
|
||||||
|
"runtime_bundle_contains_current_version": runtime_bundle_contains,
|
||||||
|
"runtime_bundle_contains_version": runtime_bundle_contains,
|
||||||
|
"registry_consistency_status": consistency_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_runtime_registry_convergence(
|
||||||
|
workspace: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
projection_root: Path = DEFAULT_PROJECTION_ROOT,
|
||||||
|
baseline_file: Path = DEFAULT_BASELINE_FILE,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed when a projection-only version is visible but unpackaged."""
|
||||||
|
report = list_registry_versions_report(
|
||||||
|
workspace,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline_file,
|
||||||
|
)
|
||||||
|
expected_name = f"{version}.json"
|
||||||
|
if (
|
||||||
|
report.get("attested_projection_version") == version
|
||||||
|
and report.get("runtime_bundle_contains_version") is not True
|
||||||
|
):
|
||||||
|
raise RegistryConvergenceError("runtime_bundle_missing_source_registry")
|
||||||
|
if expected_name not in report.get("runtime_registry_versions", []):
|
||||||
|
raise RegistryConvergenceError("runtime_bundle_missing_source_registry")
|
||||||
|
if report.get("source_registry_converged") is not True:
|
||||||
|
raise RegistryConvergenceError("source_registry_not_converged_or_not_checked")
|
||||||
|
if report.get("registry_consistency_status") not in {"FULLY_CONSISTENT", "PASS"}:
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
f"runtime_registry_consistency:{report.get('registry_consistency_status')}"
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def source_registry_from_main(
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
expected_tag_oid: str | None = None,
|
||||||
|
expected_target_oid: str | None = None,
|
||||||
|
expected_evidence_closure_oid: str | None = None,
|
||||||
|
expected_approval_plan_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read and validate the canonical source entry from the supplied main ref."""
|
||||||
|
try:
|
||||||
|
return source_registry_snapshot(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
main_ref=main_ref,
|
||||||
|
source_registry_main_oid=source_registry_main_oid,
|
||||||
|
expected_tag_oid=expected_tag_oid,
|
||||||
|
expected_target_oid=expected_target_oid,
|
||||||
|
expected_evidence_closure_oid=expected_evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=expected_approval_plan_id,
|
||||||
|
)["record"]
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
if str(exc).startswith("fatal:") or "path" in str(exc).lower():
|
||||||
|
raise RegistryConvergenceError(
|
||||||
|
f"source_registry_entry_missing:{source_registry_main_oid or main_ref}:versions/{version}.json"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def assert_source_registry_convergence(
|
||||||
|
registry_path: Path | str,
|
||||||
|
*,
|
||||||
|
repo_git_dir: Path | str,
|
||||||
|
main_ref: str = DEFAULT_MAIN_REF,
|
||||||
|
source_registry_main_oid: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Require governed main to contain a source entry for the release identity.
|
||||||
|
|
||||||
|
The source entry is intentionally required to match only fields that are
|
||||||
|
knowable and governable before production deployment. Post-deployment
|
||||||
|
fields such as production_service/port/status timestamps are not required.
|
||||||
|
"""
|
||||||
|
registry_path = Path(registry_path)
|
||||||
|
repo_git_dir = Path(repo_git_dir)
|
||||||
|
verify_authoritative_repository(repo_git_dir)
|
||||||
|
record = _load_object(registry_path)
|
||||||
|
|
||||||
|
version = record.get("version")
|
||||||
|
tag_oid = record.get("tag_oid")
|
||||||
|
target_oid = record.get("target_oid")
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_source_registry_record(
|
||||||
|
record,
|
||||||
|
expected_version=version,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=record.get("evidence_closure_oid"),
|
||||||
|
expected_approval_plan_id=record.get("approval_plan_id"),
|
||||||
|
)
|
||||||
|
except RegistryConvergenceError as exc:
|
||||||
|
raise RegistryConvergenceError(f"registry_identity_invalid:{exc}") from exc
|
||||||
|
convergence = verify_source_registry_main_convergence(
|
||||||
|
repo_git_dir,
|
||||||
|
version,
|
||||||
|
main_ref=main_ref,
|
||||||
|
source_registry_main_oid=source_registry_main_oid,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=record["evidence_closure_oid"],
|
||||||
|
expected_approval_plan_id=record["approval_plan_id"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**convergence,
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"allowed_peer_cgroups": [
|
|
||||||
"/system.slice/aios-gitops-model-api-v2r2c1-staging.service"
|
|
||||||
],
|
|
||||||
"allowed_peer_executable_prefix": "/usr/bin/python3",
|
|
||||||
"allowed_peer_runtime_prefix": "/opt/aios/gitops-model-api/",
|
|
||||||
"allowed_peer_runtime_suffix": "/runtime/app/aios_model_api_staging.py",
|
|
||||||
"allowed_peer_uid": 1000,
|
|
||||||
"evidence_layout": {
|
|
||||||
"artifact": "{version}/artifact.tar.gz",
|
|
||||||
"canary": "{version}/canary.json",
|
|
||||||
"gitea": "{version}/gitea.json",
|
|
||||||
"manifest": "{version}/manifest.json",
|
|
||||||
"package_closure": "{version}/checksums.sha256",
|
|
||||||
"policy": "{version}/trusted-release-policy.json",
|
|
||||||
"rollback": "{version}/rollback.json",
|
|
||||||
"secret_scan": "{version}/secret-scan.json",
|
|
||||||
"tests": "{version}/tests.json"
|
|
||||||
},
|
|
||||||
"governed_recipes": {},
|
|
||||||
"maximum_request_age_seconds": 60,
|
|
||||||
"operation": "BuildTrustedCandidateV1",
|
|
||||||
"readiness_operation": "ProbeTrustedCandidateBuilderV1",
|
|
||||||
"recipes": {
|
|
||||||
"V2R2C2": {
|
|
||||||
"base_oid": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
"candidate_id": "rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"mode": "fixed",
|
|
||||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"source_commit": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
"task_id": "v12-durable-trusted-release-controller-activation-20260829",
|
|
||||||
"version": "V2R2C2"
|
|
||||||
},
|
|
||||||
"V2R2C3": {
|
|
||||||
"derives_from": "V2R2C2",
|
|
||||||
"mode": "governed-derived-only",
|
|
||||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"version": "V2R2C3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"request_schema": "aios.trusted-candidate-builder.request.v1",
|
|
||||||
"request_timeout_seconds": 30,
|
|
||||||
"required_checks": [
|
|
||||||
"authoritative_git",
|
|
||||||
"artifact",
|
|
||||||
"canary",
|
|
||||||
"gitea",
|
|
||||||
"manifest",
|
|
||||||
"package_closure",
|
|
||||||
"rollback",
|
|
||||||
"secret_scan",
|
|
||||||
"tests"
|
|
||||||
],
|
|
||||||
"response_schema": "aios.trusted-candidate-builder.response.v1",
|
|
||||||
"schema": "aios.trusted-candidate-builder-policy.v1"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"authoritative_git_dirs": [
|
|
||||||
"/data/aios-gitops/repo.git"
|
|
||||||
],
|
|
||||||
"cutover_links": [
|
|
||||||
"/opt/aios/v2r2c2"
|
|
||||||
],
|
|
||||||
"dropin_paths": [
|
|
||||||
"/var/lib/aios-gitops/release-only/v2r2c2-d2b5a8339a34/payload/deploy/aios-v2/systemd/aios-v2r2c2-production.service"
|
|
||||||
],
|
|
||||||
"release_roots": [
|
|
||||||
"/var/lib/aios-gitops/release-only"
|
|
||||||
],
|
|
||||||
"repository_identities": [
|
|
||||||
"aios/aios"
|
|
||||||
],
|
|
||||||
"services": [
|
|
||||||
"aios-v2r2c2-production.service"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS V2R2C2 governed trusted release health service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=aios-v2r1c7
|
|
||||||
Group=aios-v2r1c7
|
|
||||||
WorkingDirectory=/opt/aios/v2r2c2
|
|
||||||
EnvironmentFile=/etc/aios/v2r2c2.env
|
|
||||||
ExecStart=/usr/bin/python3 /opt/aios/v2r2c2/active-runtime/app/v2r2c2_health_service.py --serve --host 127.0.0.1 --port ${AIOS_V2R2C2_PORT}
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectKernelLogs=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
||||||
IPAddressDeny=any
|
|
||||||
IPAddressAllow=localhost
|
|
||||||
UMask=0077
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
"""Installed entry point for the governed trusted-builder provisioner."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import runpy
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, "/opt/aios/trusted-candidate-builder/current/app")
|
|
||||||
runpy.run_module("trusted_builder_signer_provisioning", run_name="__main__")
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS owner-controlled Task initial execution projection API
|
|
||||||
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
|
||||||
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=aios-agent-task
|
|
||||||
Group=aios-agent-task
|
|
||||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
|
||||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
|
||||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
|
||||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
|
||||||
Environment=AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID=owner-initial-execution-projection-recovery-20260820
|
|
||||||
Environment=AIOS_TASK_INITIAL_EXECUTION_PROJECTION_HOST=127.0.0.1
|
|
||||||
Environment=AIOS_TASK_INITIAL_EXECUTION_PROJECTION_PORT=18943
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_initial_execution_projection.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
LockPersonality=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
RestrictRealtime=true
|
|
||||||
RestrictNamespaces=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
|
||||||
ReadWritePaths=/var/lib/aios-agent-task
|
|
||||||
UMask=0077
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -15,9 +15,17 @@ ExecStart=/usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/a
|
|||||||
--authorization ${AIOS_RELEASE_AUTHORIZATION} \
|
--authorization ${AIOS_RELEASE_AUTHORIZATION} \
|
||||||
--repository ${AIOS_GITOPS_AUTHORITATIVE_GIT_DIR} \
|
--repository ${AIOS_GITOPS_AUTHORITATIVE_GIT_DIR} \
|
||||||
--state-root ${AIOS_RELEASE_EXECUTION_STATE_ROOT} \
|
--state-root ${AIOS_RELEASE_EXECUTION_STATE_ROOT} \
|
||||||
--release-plan ${AIOS_RELEASE_PLAN} \
|
--artifact ${AIOS_RELEASE_ARTIFACT} \
|
||||||
|
--artifact-size ${AIOS_RELEASE_ARTIFACT_SIZE} \
|
||||||
|
--version ${AIOS_RELEASE_VERSION} \
|
||||||
|
--target-oid ${AIOS_RELEASE_TARGET_OID} \
|
||||||
|
--evidence-closure-oid ${AIOS_RELEASE_EVIDENCE_CLOSURE_OID} \
|
||||||
|
--artifact-sha256 ${AIOS_RELEASE_ARTIFACT_SHA256} \
|
||||||
|
--approval-plan-id ${AIOS_RELEASE_APPROVAL_PLAN_ID} \
|
||||||
|
--production-service aios-v2r1c7-production.service \
|
||||||
|
--production-port 18927 \
|
||||||
--tag-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/governed_tag_adapter.py \
|
--tag-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/governed_tag_adapter.py \
|
||||||
--deploy-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/trusted_production_adapter.py \
|
--deploy-adapter /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/v2r1c7_production_adapter.py \
|
||||||
--registry-adapter /usr/bin/env AIOS_RELEASE_METADATA_MODE=registry /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/release_metadata_adapter.py \
|
--registry-adapter /usr/bin/env AIOS_RELEASE_METADATA_MODE=registry /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/release_metadata_adapter.py \
|
||||||
--baseline-adapter /usr/bin/env AIOS_RELEASE_METADATA_MODE=baseline /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/release_metadata_adapter.py
|
--baseline-adapter /usr/bin/env AIOS_RELEASE_METADATA_MODE=baseline /usr/bin/python3 /usr/libexec/aios-approved-release-executor/current/active-runtime/app/release_metadata_adapter.py
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -4,32 +4,19 @@ After=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=aiman
|
User=aios-bridge-staging
|
||||||
Group=gitops
|
Group=aios-bridge-staging
|
||||||
PermissionsStartOnly=true
|
WorkingDirectory=/opt/aios/gitops-control-plane/current
|
||||||
WorkingDirectory=@RUNTIME_ROOT@
|
|
||||||
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
|
||||||
Environment=AIOS_GITEA_TOKEN_FILE=/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token
|
|
||||||
Environment=AIOS_GITEA_REMOTE=gitea-http
|
|
||||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops/model-api-staging/baseline/current.json
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
|
||||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
|
||||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
|
||||||
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
||||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
ExecStart=/usr/bin/python3 /opt/aios/gitops-control-plane/current/bridge-v2-staging-server.py
|
||||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/bridge-v2-staging
|
|
||||||
ExecStartPre=/usr/bin/setfacl -m u:aiman:--x /usr/local/libexec
|
|
||||||
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/app/aios_chatgpt_bridge_v2_server.py
|
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging /data/aios-gitops/repo.git /data/aios-gitops/repo.git/worktrees
|
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Non-secret activation template for a verified immutable model-api package.
|
|
||||||
# Install only through the governed runtime activation procedure.
|
|
||||||
[Service]
|
|
||||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED=true
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET=/run/aios-trusted-candidate-builder.sock
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-candidate-builder-policy.json
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=/etc/aios-gitops/trusted-builder/public-keyring.json
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
|
||||||
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-release-policy.json
|
|
||||||
LoadCredential=builder_hmac:/etc/aios-gitops/credential-references/trusted-candidate-builder-hmac
|
|
||||||
# The credential source above is a reference only; no secret value belongs here.
|
|
||||||
# Keep execution shadow-disabled and do not add Owner/deploy/tag/baseline switches.
|
|
||||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS trusted candidate builder
|
|
||||||
Requires=aios-trusted-candidate-builder.socket
|
|
||||||
After=aios-trusted-candidate-builder.socket
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
UMask=0077
|
|
||||||
LoadCredentialEncrypted=signing_private_key:/etc/credstore.encrypted/aios-trusted-candidate-builder/signing_private_key
|
|
||||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_POLICY=/opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_GIT_DIR=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_NONCE_LEDGER=/var/lib/aios-trusted-candidate-builder/nonce-ledger.json
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/trusted-candidate-builder/current/app/aios_trusted_candidate_builder.py --socket-fd 3 --policy ${AIOS_TRUSTED_BUILDER_POLICY}
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
StateDirectory=aios-trusted-candidate-builder
|
|
||||||
StateDirectoryMode=0700
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
PrivateNetwork=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectKernelLogs=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX
|
|
||||||
LockPersonality=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
SystemCallArchitectures=native
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence
|
|
||||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS trusted candidate builder AF_UNIX socket
|
|
||||||
|
|
||||||
[Socket]
|
|
||||||
ListenStream=/run/aios-trusted-candidate-builder.sock
|
|
||||||
SocketUser=root
|
|
||||||
SocketGroup=gitops
|
|
||||||
SocketMode=0660
|
|
||||||
RemoveOnStop=yes
|
|
||||||
Accept=no
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"activation": {
|
|
||||||
"credential_installation": false,
|
|
||||||
"daemon_reload": true,
|
|
||||||
"enable_units": [
|
|
||||||
"aios-trusted-candidate-builder.socket"
|
|
||||||
],
|
|
||||||
"service_start": "socket-activation-only",
|
|
||||||
"signer_invocation": false
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "active-runtime/app/trusted_builder_signer_provisioning.py"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "active-runtime/app/aios_trusted_candidate_builder.py"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "config/gitops/trusted-candidate-builder-policy.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "deploy/systemd/aios-trusted-candidate-builder.service"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "deploy/systemd/aios-trusted-candidate-builder.socket"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "deploy/systemd/aios-gitops-model-api-trusted-builder.conf.example"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"install_mode": "0755",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "deploy/libexec/aios-trusted-builder-signer-provisioner"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "deploy/trusted-builder/aios-trusted-builder-package-manifest.json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"package_name": "aios-trusted-candidate-builder",
|
|
||||||
"schema": "aios.trusted-builder-package.v1",
|
|
||||||
"source_root": "/opt/aios/trusted-candidate-builder/current",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# AIOS V2R2C1 Pre-Push Review Acceptance Record(最新机制语义版)
|
||||||
|
|
||||||
|
## 1. 验收对象
|
||||||
|
|
||||||
|
- Repository: aios/aios
|
||||||
|
- Authoritative Git: /data/aios-gitops/repo.git
|
||||||
|
- Documentation branch: agent/docs/v2r2c1-provenance-mainline-refresh-20260810
|
||||||
|
- Branch HEAD before semantic repair: 869cc007649a39035f5b74513cecc55bb1ae673f
|
||||||
|
- Follow-up parent: 869cc007649a39035f5b74513cecc55bb1ae673f
|
||||||
|
- Latest mechanism HEAD: 52b74ba61040246fffa8b10011c1199830e3d665
|
||||||
|
- Authoritative main HEAD: 3244f0f17f8845592ac94d585f5ebd65693c9f29
|
||||||
|
- Owner review: REQUIRED
|
||||||
|
|
||||||
|
文档 follow-up 不是 mechanism production release。当前 Production Baseline 仍为 V2R2C1 generation 4、PRODUCTION_DEPLOYED / ACTIVE,tag object 为 07fc2746b688f5f243f29b39de39f32edc3dd9d2,peeled target 为 998d30304787e31e5ae607c0bd86b5edb2b0e5ad。
|
||||||
|
|
||||||
|
## 2. 最新机制差异审计
|
||||||
|
|
||||||
|
差异范围分三层记录:
|
||||||
|
|
||||||
|
| 审计层 | 范围 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Initial review fix | 6ece3525 → b660e2c | 14 个文件 |
|
||||||
|
| Full mechanism audit | b660e2c → 51b5f0e → 52b74ba | 17 个路径,+1403/-79 |
|
||||||
|
| Latest tip delta | 51b5f0e → 52b74ba | 12 个路径,+334/-39 |
|
||||||
|
|
||||||
|
因此 README、requirements、architecture、acceptance 与 version differences 必须引用 52b74ba 的最新状态,而不能把 b660e2c 当作当前机制分支 tip。
|
||||||
|
|
||||||
|
## 3. GitOps 与 writer 验收
|
||||||
|
|
||||||
|
- Action service: active
|
||||||
|
- Repository identity: aios/aios
|
||||||
|
- Authoritative repository: /data/aios-gitops/repo.git
|
||||||
|
- Governed task-scoped write path: enabled for this branch task
|
||||||
|
- legacy_global_writes_enabled: false
|
||||||
|
- main write: false
|
||||||
|
- production write: false
|
||||||
|
- Baseline write: false
|
||||||
|
- tag write: false
|
||||||
|
- force push: false
|
||||||
|
- Documentation branch creation/continuation: PASS
|
||||||
|
- Non-main documentation commit: PASS
|
||||||
|
- Remote branch OID equals local commit OID: PASS
|
||||||
|
- Gitea branch and content readback: PASS
|
||||||
|
|
||||||
|
旧的 aios-gitops-action-writer.service 不作为生产写开关;本验收以当前受治理 Action service 与受控 writer route 的实际响应为准。
|
||||||
|
|
||||||
|
## 4. 文档完整性验收
|
||||||
|
|
||||||
|
五个且仅五个路径属于 follow-up diff:
|
||||||
|
|
||||||
|
- README.md
|
||||||
|
- docs/requirements/V2R2C1-pre-push-review-fix.md
|
||||||
|
- docs/architecture/release-provenance-and-registry-convergence.md
|
||||||
|
- docs/acceptance/V2R2C1-pre-push-review-acceptance.md
|
||||||
|
- docs/version-differences/V2R2C1-pre-push-mechanism-diff.md
|
||||||
|
|
||||||
|
每个文件必须满足:
|
||||||
|
|
||||||
|
- UTF-8 strict decode: PASS
|
||||||
|
- CJK count > 0: PASS
|
||||||
|
- U+FFFD count = 0: PASS
|
||||||
|
- abnormal consecutive question marks = 0: PASS
|
||||||
|
- source bytes SHA-256 equals Git blob SHA-256: PASS
|
||||||
|
- Gitea contents readback equals Git blob bytes: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
## 5. 提交方测试事实
|
||||||
|
|
||||||
|
- Related tests: 760 passed
|
||||||
|
- Skipped: 2 skipped
|
||||||
|
- Full test result: 1 failed
|
||||||
|
- Subtests: 154 passed
|
||||||
|
- Failure: tests/test_signer_contract_compatibility.py::test_real_signer_help_and_self_test_are_available
|
||||||
|
- /usr/local/libexec/aios-grant-signer-adapter self-test: return 42
|
||||||
|
- Two independent reruns: both return 42
|
||||||
|
|
||||||
|
Signer gate is FAIL / EXTERNAL. Full external gate is PARTIAL. Production ready remains false。不能把该失败解释为暂态成功,也不能把全量结果写成全通过。
|
||||||
|
|
||||||
|
## 6. 分层状态
|
||||||
|
|
||||||
|
| 层级 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| Mechanism code fix | COMPLETE |
|
||||||
|
| Documentation branch write | COMPLETE |
|
||||||
|
| Documentation remote readback | COMPLETE |
|
||||||
|
| Full external gate | PARTIAL |
|
||||||
|
| Signer gate | FAIL / EXTERNAL |
|
||||||
|
| Production acceptance | NOT_RUN |
|
||||||
|
| Owner review | REQUIRED |
|
||||||
|
|
||||||
|
Documentation branch write/readback COMPLETE 只表示非 main 文档分支的受治理提交与远端内容一致,不表示 mechanism/release production write 已发生。
|
||||||
|
|
||||||
|
## 7. 未执行生产动作
|
||||||
|
|
||||||
|
- Mechanism/release production write: NOT_RUN
|
||||||
|
- Merge to main: NOT_RUN
|
||||||
|
- Production release: NOT_RUN
|
||||||
|
- Production Baseline update: NOT_RUN
|
||||||
|
- Tag creation/deletion/rewrite: NOT_RUN
|
||||||
|
- Force push: NOT_RUN
|
||||||
|
- systemd/nginx production reload or restart: NOT_RUN
|
||||||
|
|
||||||
|
## 8. 治理结论
|
||||||
|
|
||||||
|
force_push_used=false、tag_rewritten=false、direct_main_push=false、secrets_committed=false、production_mutation=false、legacy_global_writes_enabled=false。
|
||||||
|
|
||||||
|
结论:文档语义一致性修复候选已准备为 READY_FOR_OWNER_REVIEW。Owner review 完成前不得 merge main,不得修改 Baseline、tag、历史版本身份或生产 runtime。
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# AIOS Release Provenance & Registry Convergence Architecture(最新机制语义版)
|
||||||
|
|
||||||
|
## 1. 架构目标
|
||||||
|
|
||||||
|
AIOS release path 把版本名提升为一组可验证且不可混淆的 Git、approval、artifact 与 runtime 身份。最新机制 HEAD 为 52b74ba61040246fffa8b10011c1199830e3d665;它位于非 main 的机制分支,不能被描述为新的 Production Baseline。
|
||||||
|
|
||||||
|
架构必须同时解决:
|
||||||
|
|
||||||
|
- mutable main 引起的 TOCTOU;
|
||||||
|
- annotated tag object 与 peeled target 混淆;
|
||||||
|
- source registry overlay 或 runtime projection 被误报为 Git/source convergence;
|
||||||
|
- 未绑定 approval plan 驱动 registry 生成;
|
||||||
|
- release 与 general artifact binding 混用;
|
||||||
|
- builder 与 executor 各自验证不同身份;
|
||||||
|
- legacy V2R2C1 receipt 兼容与 future binding fail closed。
|
||||||
|
|
||||||
|
## 2. 身份模型
|
||||||
|
|
||||||
|
| 符号 | 定义 | 当前或运行时绑定 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R | authoritative repository identity | aios/aios;Git 目录为 /data/aios-gitops/repo.git |
|
||||||
|
| A | annotated tag object | V2R2C1 tag object 07fc2746b688f5f243f29b39de39f32edc3dd9d2 |
|
||||||
|
| T | A 的 peeled commit,即 release target | 998d30304787e31e5ae607c0bd86b5edb2b0e5ad |
|
||||||
|
| E | evidence closure commit | a8b906d1bc24088c19647e98de6a679268f6d0a2 |
|
||||||
|
| M | 本次 source-registry snapshot 固定的 main commit | 每次构建解析一次并写入 provenance |
|
||||||
|
| C | 当前权威 main 或被 convergence probe 选定的 reference commit | 当前 authoritative main 为 3244f0f17f8845592ac94d585f5ebd65693c9f29 |
|
||||||
|
| P | Owner-approved approval plan | sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e |
|
||||||
|
|
||||||
|
R、A、T、E、M、C、P 不得用一个版本字符串互相替代。尤其是 mechanism branch HEAD、C、T 与 Production Baseline 是不同状态轴。
|
||||||
|
|
||||||
|
## 3. Git Object Model 与 lineage
|
||||||
|
|
||||||
|
对于 provenance-enabled release,代码必须建立以下关系:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
refs/tags/<version> -> A
|
||||||
|
A^{commit} = T
|
||||||
|
T = registry peeled target
|
||||||
|
T ancestor-of E
|
||||||
|
T ancestor-of M when the release lineage check requires it
|
||||||
|
M ancestor-of C when source-registry main convergence is checked
|
||||||
|
SR = bytes(M:path)
|
||||||
|
H = SHA-256(SR)
|
||||||
|
~~~
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- A 必须是 annotated tag object,不能由 lightweight tag 代替。
|
||||||
|
- E 必须是 commit,且 release lineage 证明 T 到 E。
|
||||||
|
- M 必须是 commit;source registry 的读取、校验、哈希和打包都使用 M:path 的同一字节。
|
||||||
|
- C 是当前权威 main/reference commit,不是生产 target,也不是 mechanism branch HEAD。
|
||||||
|
- M 与 C 的 convergence probe 还要比较相同 path 的精确字节;M ancestor-of C 仅在该 probe 的适用语义下成立。
|
||||||
|
- 任何 same-name but different-OID tag、target、registry、approval 或 repository identity 冲突都停止自动流程。
|
||||||
|
|
||||||
|
## 4. Pinned source registry 与 authority binding
|
||||||
|
|
||||||
|
构建开始时由 authoritative repository 解析 M,并立即保存 M、path、SR 和 H。后续不得回到 mutable main 重新取 registry。
|
||||||
|
|
||||||
|
registry record 必须绑定:
|
||||||
|
|
||||||
|
- R、version、A、T、peeled target、E;
|
||||||
|
- P 与其 authority/publication receipt;
|
||||||
|
- M、source registry path、H;
|
||||||
|
- generator 使用的 authoritative repository identity。
|
||||||
|
|
||||||
|
authority binding 不是字符串格式检查。governed caller、generator、builder 与 executor 都必须能证明 approval authority 与 release identity 的关联。
|
||||||
|
|
||||||
|
## 5. Artifact binding:release 与 general 分离
|
||||||
|
|
||||||
|
release artifact 需要完整的 release binding:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
repository + version + tag_oid + target_oid
|
||||||
|
+ peeled_target_oid + evidence_closure_oid + approval_plan_id
|
||||||
|
+ source_registry_main_oid + source_registry_path + source_registry_sha256
|
||||||
|
~~~
|
||||||
|
|
||||||
|
general artifact 可以保持普通构建用途,但不得被当作 release artifact,也不得绕过 release binding。release mode 缺少 expected binding 或字段不匹配时返回 fail-closed 状态。
|
||||||
|
|
||||||
|
artifact metadata、manifest 与 receipt 必须引用同一组对象。source registry payload 以 SR 的精确字节进入 artifact,不能只保存重新序列化后的等价 JSON。
|
||||||
|
|
||||||
|
## 6. 组件职责与独立复核
|
||||||
|
|
||||||
|
### version_registry.py
|
||||||
|
|
||||||
|
解析 M,读取 M:path,验证 registry record、A/T/E lineage、approval authority,并分别报告 registry display、runtime bundle、attested projection 与 source convergence。
|
||||||
|
|
||||||
|
### generate_source_registry.py
|
||||||
|
|
||||||
|
在生成前获得 authoritative repository、repository identity 与 approval authority,校验 tag object、target、lineage、registry bytes 与 authority 关联。
|
||||||
|
|
||||||
|
### governed_production_release.py
|
||||||
|
|
||||||
|
执行 T、E、M 的适用 lineage 检查,构建由 T payload 与 M registry bytes 组成的 deterministic release artifact,并把复合 provenance 写入 metadata、manifest 和 receipt。
|
||||||
|
|
||||||
|
### Autonomous release artifact builder
|
||||||
|
|
||||||
|
release mode 强制 expected release identity;general mode 保持独立。builder 不得用缺失或局部 binding 伪造 release 成功。
|
||||||
|
|
||||||
|
### approved_release_executor.py
|
||||||
|
|
||||||
|
executor 以自己的 Git identity 重新读取并验证 artifact、canonical registry、source path、source SHA-256、BUILD-METADATA provenance、future tag object 与 authorization receipt。executor 不接受 builder 自述作为唯一证据。
|
||||||
|
|
||||||
|
## 7. Runtime projection 与 Git convergence
|
||||||
|
|
||||||
|
版本 API 必须区分:
|
||||||
|
|
||||||
|
- legacy display list;
|
||||||
|
- runtime bundle versions;
|
||||||
|
- attested projection version;
|
||||||
|
- runtime_bundle_contains_version;
|
||||||
|
- source_registry_converged;
|
||||||
|
- registry_consistency_status。
|
||||||
|
|
||||||
|
projection 能看到版本不代表 runtime bundle 包含该版本,更不代表 M 与 C 的 source bytes 收敛。典型状态包括 PASS、RUNTIME_BUNDLE_MISSING、IDENTITY_MISMATCH、RUNTIME_ENTRY_INVALID、NO_ATTESTED_PROJECTION。
|
||||||
|
|
||||||
|
当前生产 Baseline 仍为 V2R2C1 generation 4、PRODUCTION_DEPLOYED / ACTIVE。该运行时状态不能被机制分支 HEAD 或文档分支写入替代。
|
||||||
|
|
||||||
|
## 8. Post-deploy source-registry recheck
|
||||||
|
|
||||||
|
部署后的 executor 必须重新读取 authoritative Git 与 source registry path,重新计算 H,并核对:
|
||||||
|
|
||||||
|
- repository identity;
|
||||||
|
- release binding 的 A/T/E/P;
|
||||||
|
- M、path、H;
|
||||||
|
- artifact metadata 与实际 payload;
|
||||||
|
- runtime projection 与 source convergence;
|
||||||
|
- authorization receipt。
|
||||||
|
|
||||||
|
任何 recheck 失败都必须保持 blocked,不得靠 runtime health、开放端口或 projection 可见性推断 release closure。
|
||||||
|
|
||||||
|
## 9. Fail-closed 状态
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| source registry 缺失或精确字节不可读 | ReleaseBlocked |
|
||||||
|
| repository、tag object、target、E、M、P 身份冲突 | fail closed |
|
||||||
|
| lightweight tag | reject |
|
||||||
|
| same-name different-OID tag | MANUAL_OWNER_REVIEW_REQUIRED |
|
||||||
|
| T 不是要求的 E/M lineage ancestor | blocked / Owner review |
|
||||||
|
| approval plan 未与 release identity 关联 | reject |
|
||||||
|
| release mode 缺少 binding | ARTIFACT_RELEASE_BINDING_REQUIRED |
|
||||||
|
| artifact registry digest 或 metadata 不匹配 | ReleaseBlocked |
|
||||||
|
| projection 可见但 runtime bundle 缺版本 | RUNTIME_BUNDLE_MISSING |
|
||||||
|
| runtime、projection、Git identity 不一致 | IDENTITY_MISMATCH |
|
||||||
|
| post-deploy source registry recheck 失败 | production acceptance blocked |
|
||||||
|
|
||||||
|
## 10. Legacy V2R2C1 兼容
|
||||||
|
|
||||||
|
旧 V2R2C1 receipt 可以缺少 tag_oid、binding_schema、source_registry_main_oid、source_registry_path 和 source_registry_sha256。future binding 或 provenance-enabled binding 一旦提供 source provenance 字段,就必须完整提供并严格校验。
|
||||||
|
|
||||||
|
兼容只允许旧 receipt 按旧语义读取,不允许新 release 回退到弱绑定模型,也不允许修改 versions/V2R2C1.json 的历史发布身份。
|
||||||
|
|
||||||
|
## 11. 当前文档与生产边界
|
||||||
|
|
||||||
|
文档 follow-up 分支的父提交为 869cc007649a39035f5b74513cecc55bb1ae673f,本次只允许五个 Markdown 路径。文档分支写入和远端回读是独立的 documentation closure;mechanism/release production write、main merge、Production release、Baseline update、tag rewrite、force push 与 production service mutation 均不在本任务中。
|
||||||
@@ -241,50 +241,6 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
candidate_id: {type: string}
|
candidate_id: {type: string}
|
||||||
paths:
|
paths:
|
||||||
/v2/commit-files:
|
|
||||||
post:
|
|
||||||
operationId: commitFilesGoverned
|
|
||||||
summary: Submit one atomic governed commit containing multiple files
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- task_id
|
|
||||||
- repository
|
|
||||||
- expected_branch_oid
|
|
||||||
- target_branch
|
|
||||||
- generation
|
|
||||||
- idempotency_key
|
|
||||||
- files
|
|
||||||
- message
|
|
||||||
properties:
|
|
||||||
task_id: {type: string}
|
|
||||||
job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}
|
|
||||||
source_lease_epoch: {type: integer, minimum: 1}
|
|
||||||
repository: {type: string, example: aios/aios}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
idempotency_key: {type: string}
|
|
||||||
files:
|
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [path, content_base64]
|
|
||||||
properties:
|
|
||||||
path: {type: string, minLength: 1, maxLength: 512}
|
|
||||||
content_base64: {type: string}
|
|
||||||
message: {type: string, minLength: 1, maxLength: 200}
|
|
||||||
responses:
|
|
||||||
'200': {description: Governed commit job and verified branch result}
|
|
||||||
'400': {description: Contract or policy rejection}
|
|
||||||
'409': {description: Branch CAS or idempotency conflict}
|
|
||||||
/createReleaseIntent:
|
/createReleaseIntent:
|
||||||
post:
|
post:
|
||||||
operationId: createReleaseIntent
|
operationId: createReleaseIntent
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Trusted-builder signer provisioning
|
|
||||||
|
|
||||||
`trusted_builder_signer_provisioning.py` provisions the purpose-bound
|
|
||||||
`AIOS_TRUSTED_RELEASE_BUILDER` signer only from an exact
|
|
||||||
`aios.trusted-builder-signer-provisioning.v2` payload and detached SSH signature
|
|
||||||
for `aios-task-grant-signer` in namespace `aios-owner-approval-v1`.
|
|
||||||
|
|
||||||
A fresh payload is bound to the final common Gitea, authority, and governed
|
|
||||||
`main` OID; the protected merge tree; the ordered two parents; and the selected
|
|
||||||
PR head as the merge's second parent. The provisioner rechecks those facts in
|
|
||||||
the authority repository and rejects ancestry or source drift. It does not
|
|
||||||
reuse PR69, PR70, or a release-candidate recipe binding.
|
|
||||||
|
|
||||||
The payload evidence is an exact allowlist: provisioner, candidate builder,
|
|
||||||
policy, service, socket, model-API config template, launcher, and the versioned
|
|
||||||
package manifest. Missing, extra, modified, or mode-incompatible evidence is
|
|
||||||
rejected. The package manifest is non-secret and declares daemon reload plus
|
|
||||||
socket activation, with no credential installation or signer invocation. It
|
|
||||||
requires the installed launcher target mode to be `0755`; source checkout mode
|
|
||||||
is treated separately from installed package mode. A controlled deterministic
|
|
||||||
worktree may hold ordinary source evidence at `0600`, `0640`, `0644`, or `0664`; the
|
|
||||||
installed package still enforces its declared target modes.
|
|
||||||
|
|
||||||
The provisioner generates the Ed25519 key and builder HMAC only on the host.
|
|
||||||
Plaintext exists only in its private temporary directory and is encrypted with
|
|
||||||
`systemd-creds --with-key=host`; the service receives encrypted credentials via
|
|
||||||
`LoadCredentialEncrypted`. The public keyring and service-principal binding
|
|
||||||
contain no private key.
|
|
||||||
|
|
||||||
Receipts append at
|
|
||||||
`/var/lib/aios-gitops/trusted-builder/provisioning-receipts.jsonl`. An exact
|
|
||||||
replay returns `IDEMPOTENT_REPLAY_PASS`; mismatched or unreceipted state fails
|
|
||||||
closed. Provisioning is configuration only: activation, restart persistence,
|
|
||||||
public-key verification and negative-capability tests are separate gates before
|
|
||||||
`TRUSTED_BUILDER_SIGNER_READINESS=PASS`.
|
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# AIOS V2R2C1 Pre-Push Review Fix Requirements(最新机制语义版)
|
||||||
|
|
||||||
|
## 1. 目的
|
||||||
|
|
||||||
|
本需求记录 V2R2C1 Pre-Push Review 机制从初始 b660e2c 修复到最新 mechanism HEAD 的完整事实边界。它描述 release provenance、source registry、artifact、approval 与 runtime convergence 的可证明关系,不宣称新的生产发布。
|
||||||
|
|
||||||
|
三个增量范围必须分开:
|
||||||
|
|
||||||
|
- 初始 b660e2c 修复:14 个源码/测试文件。
|
||||||
|
- b660e2c → 51b5f0e → 52b74ba 的完整审计:17 个路径,+1403/-79。
|
||||||
|
- 最新 51b5f0e → 52b74ba tip 增量:12 个路径,+334/-39。
|
||||||
|
|
||||||
|
当前机制参考为 52b74ba61040246fffa8b10011c1199830e3d665。文档 follow-up 以文档分支原 HEAD 869cc007649a39035f5b74513cecc55bb1ae673f 为父提交。
|
||||||
|
|
||||||
|
## 2. 范围
|
||||||
|
|
||||||
|
实现和审计覆盖:
|
||||||
|
|
||||||
|
- pinned source registry snapshot 与 convergence;
|
||||||
|
- governed release artifact build;
|
||||||
|
- release executor 的独立 provenance 复核;
|
||||||
|
- approval authority 绑定;
|
||||||
|
- release/general artifact binding;
|
||||||
|
- version API convergence 可观测性;
|
||||||
|
- TOCTOU、identity mismatch 与 legacy receipt 回归测试;
|
||||||
|
- 最新机制状态下的 README、requirements、architecture、acceptance、version differences 文档语义。
|
||||||
|
|
||||||
|
不在本次范围内:
|
||||||
|
|
||||||
|
- 修改 main;
|
||||||
|
- merge;
|
||||||
|
- Production release;
|
||||||
|
- Production Baseline 或 versions/V2R2C1.json;
|
||||||
|
- 创建、删除或重写正式 tag;
|
||||||
|
- force push;
|
||||||
|
- production runtime 修改;
|
||||||
|
- systemd / nginx 生产服务切换、reload 或 restart;
|
||||||
|
- 提交任何密钥。
|
||||||
|
|
||||||
|
## 3. 功能需求
|
||||||
|
|
||||||
|
### R1 — Source registry 固定 M
|
||||||
|
|
||||||
|
一次构建只能解析一次 source registry 所在 main commit M。后续 registry 校验、字节读取、SHA-256 计算与 artifact 打包必须使用 M 下同一 Git blob,禁止在同一构建中重新读取 mutable refs/heads/main。
|
||||||
|
|
||||||
|
验收要求:构建期间 main 前进时,artifact 仍绑定最初固定 M 的 registry 字节。
|
||||||
|
|
||||||
|
### R2 — Release provenance 是复合身份
|
||||||
|
|
||||||
|
release identity 至少包含:
|
||||||
|
|
||||||
|
- repository R;
|
||||||
|
- version;
|
||||||
|
- annotated tag object A;
|
||||||
|
- release target / peeled target T;
|
||||||
|
- evidence closure E;
|
||||||
|
- Owner-approved approval plan P。
|
||||||
|
|
||||||
|
启用 source-registry provenance 时还必须包含:
|
||||||
|
|
||||||
|
- pinned source-registry main commit M;
|
||||||
|
- source registry path;
|
||||||
|
- source registry 精确字节的 SHA-256。
|
||||||
|
|
||||||
|
### R3 — Git object 与 lineage 可证明
|
||||||
|
|
||||||
|
代码必须验证:
|
||||||
|
|
||||||
|
- R 的仓库身份为 aios/aios;
|
||||||
|
- refs/tags/<version> 指向 annotated tag object A;
|
||||||
|
- A^commit 等于 T;
|
||||||
|
- T 与 registry 中的 peeled target 一致;
|
||||||
|
- E 是 commit;
|
||||||
|
- T 是 E 的 ancestor;
|
||||||
|
- M 是 commit;
|
||||||
|
- 适用的 source-registry convergence 检查证明 T 到 M 的 lineage,并证明 M ancestor-of 当前权威 main/reference commit C;
|
||||||
|
- M:path 的字节与 C:path 的字节在 convergence 检查中一致。
|
||||||
|
|
||||||
|
关键身份冲突不得自动修复,必须进入 fail-closed 或 Owner review。
|
||||||
|
|
||||||
|
### R4 — Approval plan 必须绑定 release authority
|
||||||
|
|
||||||
|
仅有格式正确的 approval_plan_id 不足以授权。generator、governed caller 与 executor 必须读取权威 approval/publication receipt,并证明 authority 与 R、version、A、T、E、P 关联。缺少关联、来源不可信或字段不一致时必须拒绝。
|
||||||
|
|
||||||
|
### R5 — Source registry generator 绑定权威仓库
|
||||||
|
|
||||||
|
generate_source_registry.py 必须显式获得 authoritative repository、approval authority 与 repository identity。生成前校验 Git identity、tag object、target、lineage 与 approval authority,不得只凭本地参数生成表面合法的 registry。
|
||||||
|
|
||||||
|
### R6 — Release artifact builder 强制 binding
|
||||||
|
|
||||||
|
artifact builder 在 release mode 必须收到 expected release identity,并逐字段比较:
|
||||||
|
|
||||||
|
- version;
|
||||||
|
- repository;
|
||||||
|
- tag_oid;
|
||||||
|
- target_oid;
|
||||||
|
- peeled_target_oid;
|
||||||
|
- evidence_closure_oid;
|
||||||
|
- approval_plan_id;
|
||||||
|
- provenance-enabled 场景下的 M、path、sha256。
|
||||||
|
|
||||||
|
release mode 缺少 binding 或字段不匹配必须 fail closed。general artifact 仍可使用其独立的非 release 语义,不能与 release binding 混淆。
|
||||||
|
|
||||||
|
### R7 — Executor 独立复核 provenance
|
||||||
|
|
||||||
|
生产 executor 不得只信任 builder 自述。它必须独立验证 source registry member、canonical JSON、registry identity、path、精确字节 SHA-256、BUILD-METADATA.json provenance、future binding 的 tag object 与 authorization receipt。executor 还必须以自己的 Git identity 重新核对关键对象。
|
||||||
|
|
||||||
|
### R8 — V2R2C1 legacy receipt 兼容
|
||||||
|
|
||||||
|
旧 V2R2C1 receipt 可以省略 future-only provenance 字段。新字段仅在 future release binding 或 provenance-enabled binding 中强制。兼容路径不得削弱新 binding 的严格校验,也不得修改历史 versions/V2R2C1.json。
|
||||||
|
|
||||||
|
### R9 — 版本可见性不得冒充 convergence
|
||||||
|
|
||||||
|
版本 API 必须分别暴露:
|
||||||
|
|
||||||
|
- legacy display list;
|
||||||
|
- runtime bundle versions;
|
||||||
|
- attested projection version;
|
||||||
|
- runtime bundle 是否包含该版本;
|
||||||
|
- source registry 是否收敛;
|
||||||
|
- registry consistency status。
|
||||||
|
|
||||||
|
projection-only 可见时必须报告 RUNTIME_BUNDLE_MISSING 或等价非收敛状态,不能仅因 overlay 可见就报告 source convergence。
|
||||||
|
|
||||||
|
### R10 — 回归测试覆盖 TOCTOU 与身份错误
|
||||||
|
|
||||||
|
至少覆盖:
|
||||||
|
|
||||||
|
- pinned source registry 后 main 前进;
|
||||||
|
- lightweight tag 拒绝;
|
||||||
|
- annotated tag 与 lineage 正常;
|
||||||
|
- approval authority 缺失关联;
|
||||||
|
- release binding 每个身份字段不匹配;
|
||||||
|
- release mode 缺少 binding;
|
||||||
|
- executor 对 source digest 与 build metadata 的独立复核;
|
||||||
|
- 同名不同 tag object 进入 MANUAL_OWNER_REVIEW_REQUIRED;
|
||||||
|
- projection-only 不等于 runtime convergence;
|
||||||
|
- legacy V2R2C1 receipt 保持兼容。
|
||||||
|
|
||||||
|
## 4. 当前验收状态
|
||||||
|
|
||||||
|
| 项目 | 当前结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| Code/mechanism fix | COMPLETE |
|
||||||
|
| Documentation branch write | COMPLETE after this follow-up |
|
||||||
|
| Documentation remote readback | COMPLETE after this follow-up |
|
||||||
|
| Related test result | 760 passed |
|
||||||
|
| Skipped | 2 skipped |
|
||||||
|
| Full test result | 1 failed |
|
||||||
|
| Subtests | 154 passed |
|
||||||
|
| Full external gate | PARTIAL |
|
||||||
|
| Signer gate | FAIL / EXTERNAL |
|
||||||
|
| Production ready | false |
|
||||||
|
| Owner review | REQUIRED |
|
||||||
|
|
||||||
|
唯一失败为 tests/test_signer_contract_compatibility.py::test_real_signer_help_and_self_test_are_available。/usr/local/libexec/aios-grant-signer-adapter self-test 返回 42,两个独立重跑仍返回 42。该事实不能被解释为暂态成功,也不能将全量结果记为全通过。
|
||||||
|
|
||||||
|
## 5. 安全与治理边界
|
||||||
|
|
||||||
|
- force_push_used = false
|
||||||
|
- tag_rewritten = false
|
||||||
|
- direct_main_push = false
|
||||||
|
- secrets_committed = false
|
||||||
|
- production_mutation = false
|
||||||
|
- legacy_global_writes_enabled = false
|
||||||
|
- 未经 Owner review 不执行 merge 或 consequential production action。
|
||||||
|
- 文档分支写入、Git/Gitea 远端回读与生产发布分别验收。
|
||||||
|
- 任何 same-name / different-OID tag 冲突都进入 MANUAL_OWNER_REVIEW_REQUIRED。
|
||||||
|
- 文档刷新不改写已投产 V2R2C1 registry identity。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# V2R2C1 与 Pre-Push Mechanism Fix 版本差异(最新机制语义版)
|
||||||
|
|
||||||
|
## 1. 状态轴
|
||||||
|
|
||||||
|
| 状态 | OID / Version | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Production Baseline | V2R2C1 / generation 4 | PRODUCTION_DEPLOYED / ACTIVE |
|
||||||
|
| V2R2C1 tag object | 07fc2746b688f5f243f29b39de39f32edc3dd9d2 | annotated tag object |
|
||||||
|
| V2R2C1 release target T | 998d30304787e31e5ae607c0bd86b5edb2b0e5ad | peeled target |
|
||||||
|
| Production evidence closure E | a8b906d1bc24088c19647e98de6a679268f6d0a2 | Baseline 记录的证据闭包 |
|
||||||
|
| Authoritative main C | 3244f0f17f8845592ac94d585f5ebd65693c9f29 | 当前权威工程主线 |
|
||||||
|
| Latest mechanism HEAD | 52b74ba61040246fffa8b10011c1199830e3d665 | 非 main 待审机制分支 |
|
||||||
|
| Documentation follow-up parent | 869cc007649a39035f5b74513cecc55bb1ae673f | 语义修复提交父提交 |
|
||||||
|
|
||||||
|
以上状态不可互相替代。52b74ba 是最新 mechanism reference,不是新的 Production Baseline;文档 branch 也不是 main。
|
||||||
|
|
||||||
|
## 2. 三层差异范围
|
||||||
|
|
||||||
|
- Initial b660e2c review fix:14 个文件。
|
||||||
|
- 完整 b660e2c → 51b5f0e → 52b74ba 审计:17 个路径,+1403/-79。
|
||||||
|
- 最新 51b5f0e → 52b74ba 增量:12 个路径,+334/-39。
|
||||||
|
|
||||||
|
## 3. 已发布 V2R2C1 机制
|
||||||
|
|
||||||
|
V2R2C1 保留其 production baseline、annotated tag object、peeled target、artifact、evidence closure、approval、immutable staging 与 release executor 身份。历史 identity 保持不可变,versions/V2R2C1.json 不在本次修改范围。
|
||||||
|
|
||||||
|
## 4. 最新机制增量
|
||||||
|
|
||||||
|
52b74ba 所在机制链进一步明确:
|
||||||
|
|
||||||
|
1. pinned source registry:一次解析 M,后续统一使用 M:path 的精确字节与 SHA-256。
|
||||||
|
2. composite provenance:R、version、A、T、E、P 与 M、path、sha256 共同构成 release binding。
|
||||||
|
3. Git object proof:区分 annotated tag object A、peeled target T、evidence closure E、pinned main M 与当前 main/reference C。
|
||||||
|
4. authority proof:approval plan 必须与 release identity 真实关联。
|
||||||
|
5. registry generator hardening:generator 接入权威 bare repository 与 approval authority。
|
||||||
|
6. artifact binding:release mode 强制完整 binding,general artifact 保持独立语义。
|
||||||
|
7. executor independent verification:executor 重新校验 registry bytes、metadata、tag object、authority 与 Git identity。
|
||||||
|
8. convergence observability:区分 display list、runtime bundle、attested projection 与 source convergence。
|
||||||
|
9. post-deploy recheck:部署后重新读取 authoritative Git、source registry 与 runtime projection。
|
||||||
|
10. fail-closed:身份、digest、lineage、approval、binding 与 projection 不一致时停止自动流程。
|
||||||
|
11. legacy compatibility:旧 V2R2C1 receipt 可继续读取,新 binding 不回退到弱校验。
|
||||||
|
|
||||||
|
## 5. 兼容性差异
|
||||||
|
|
||||||
|
| 项目 | Legacy V2R2C1 | Future / provenance-enabled |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| tag_oid in executor binding | 可缺省 | 必须 |
|
||||||
|
| binding schema | 可缺省 | aios.release-binding.* |
|
||||||
|
| source registry main OID | 可缺省 | provenance 开启时必须 |
|
||||||
|
| source registry SHA-256 | 可缺省 | provenance 开启时必须 |
|
||||||
|
| source registry path | 默认/兼容 | 明确验证 |
|
||||||
|
| artifact build release binding | 旧路径兼容 | release mode 强制 |
|
||||||
|
| same-name different tag | 不允许重写 | MANUAL_OWNER_REVIEW_REQUIRED |
|
||||||
|
| post-deploy registry check | 旧 receipt 可无 | 新 release 必须 |
|
||||||
|
|
||||||
|
## 6. 当前验收状态
|
||||||
|
|
||||||
|
相关测试为 760 passed、2 skipped、1 failed,154 subtests passed。唯一失败为 tests/test_signer_contract_compatibility.py::test_real_signer_help_and_self_test_are_available;self-test 返回 42,两个独立重跑仍返回 42。因此 Signer gate 为 FAIL / EXTERNAL,Full external gate 为 PARTIAL,Production ready 为 false。
|
||||||
|
|
||||||
|
文档分支 write 与 remote readback 属于 COMPLETE 的独立 documentation closure。mechanism/release production write、main merge、Production release、Baseline update、tag rewrite 与 force push 均为 NOT_RUN。Owner review 仍为 REQUIRED。
|
||||||
|
|
||||||
|
## 7. 处理边界
|
||||||
|
|
||||||
|
不修改 versions/V2R2C1.json,不修改历史 tag 或 Production Baseline,不直接写 main,不执行 merge,不改变生产 runtime。若 Owner review 后需要正式交付机制,应建立新的受治理 release/version identity,并重新执行独立 authority 验收。
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
umask 027
|
|
||||||
|
|
||||||
ROOT="/data/aios-gitops"
|
|
||||||
NGINX_SITE="/etc/nginx/sites-enabled/aios-user-portal.conf"
|
|
||||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
BACKUP_DIR="$ROOT/backups/action-surface-final-$STAMP"
|
|
||||||
LOG="$ROOT/logs/action-surface-final-$STAMP.log"
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR" "$ROOT/logs"
|
|
||||||
exec > >(tee -a "$LOG") 2>&1
|
|
||||||
|
|
||||||
sudo cp -a "$NGINX_SITE" "$BACKUP_DIR/aios-user-portal.conf"
|
|
||||||
SITE_BACKUP="$BACKUP_DIR/aios-user-portal.conf"
|
|
||||||
|
|
||||||
python3 - "$SITE_BACKUP" "$BACKUP_DIR/aios-user-portal.conf.next" <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
source, output = map(Path, sys.argv[1:])
|
|
||||||
text = source.read_text(encoding="utf-8")
|
|
||||||
marker = " # AIOS external Action governed-read ownership"
|
|
||||||
if marker in text:
|
|
||||||
raise SystemExit("governed-read locations already present in backup")
|
|
||||||
block = r''' # AIOS external Action governed-read ownership
|
|
||||||
# These reads are owned by the authenticated 18890 facade, not the 18893 model API.
|
|
||||||
location = /api/governed-write-capabilities {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/governed-write-capabilities;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
location = /api/control-plane-snapshot {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/control-plane-snapshot;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
location = /api/online-agent-task {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/online-agent-task;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
location = /api/online-agent-task-events {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/online-agent-task-events;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
needle = " location /api/ {"
|
|
||||||
if text.count(needle) != 1:
|
|
||||||
raise SystemExit("expected exactly one generic /api/ location")
|
|
||||||
text = text.replace(needle, block + "\n" + needle, 1)
|
|
||||||
output.write_text(text, encoding="utf-8")
|
|
||||||
PY
|
|
||||||
|
|
||||||
sudo cp "$BACKUP_DIR/aios-user-portal.conf.next" "$NGINX_SITE"
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
|
|
||||||
printf 'ACTION_SURFACE_FINAL_OK backup=%s log=%s\n' "$BACKUP_DIR" "$LOG"
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "aios.canonical-task-projection-intent.v1",
|
|
||||||
"projection_id": "projection-v2r2c2-parent-recovery-20260826",
|
|
||||||
"projection_generation": 1,
|
|
||||||
"idempotency_key": "projection-v2r2c2-parent-recovery-20260826-v1",
|
|
||||||
"task": {
|
|
||||||
"task_id": "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826",
|
|
||||||
"goal": "Complete V2R2C2 staging-only control-plane convergence and repair the control-plane self-recovery defect exposed by the missing canonical Task projection.",
|
|
||||||
"instructions": "Resume the immutable runbook at runbooks/agent-v2r2c2-control-plane-convergence-20260825.md from branch agent/v2r2c2-convergence-integration-20260825. Preserve the parent task/job/generation/branch/OID. Complete canonical projection, worker claim, execution binding, heartbeat, WriterLease/CAS, required convergence code, tests, immutable staging build/deploy, first acceptance, restart-persistent second acceptance, and governed Evidence Closure. Recovery subgraphs must return to the parent goal. Never mutate main, V2R2C1 protected tag, production baseline, or production services.",
|
|
||||||
"success_criteria": [
|
|
||||||
{
|
|
||||||
"id": "control_plane_self_recovery",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "canonical_task_projection",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "worker_execution_path",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "writer_path",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "governed_mutation",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "v2r2c2_code",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tests",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "staging_deployment",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runtime_component_drift",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "release_consistency",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tag_publication",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "release_acceptance",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "restart_persistence",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "evidence_closure",
|
|
||||||
"required": true,
|
|
||||||
"expected": "PASS"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"constraints": {
|
|
||||||
"staging_only": true,
|
|
||||||
"production_write": false,
|
|
||||||
"main_direct_write": false,
|
|
||||||
"protected_tag_write": false,
|
|
||||||
"baseline_write": false,
|
|
||||||
"force_push": false,
|
|
||||||
"direct_task_db_write_outside_task_plane": false,
|
|
||||||
"secret_exposure": false,
|
|
||||||
"return_to_parent_after_recovery": true
|
|
||||||
},
|
|
||||||
"required_capabilities": [],
|
|
||||||
"metadata": {
|
|
||||||
"parent_goal": "V2R2C2 staging-only control-plane convergence",
|
|
||||||
"governed_task_id": "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826",
|
|
||||||
"bridge_job_id": "job-9eee1142f2fc8bb7b571d065c01f40e0",
|
|
||||||
"recovery_reason": "MISSING_DURABLE_CANONICAL_TASK_PROJECTION_RECOVERY",
|
|
||||||
"runbook_ref": "runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
|
||||||
"runbook_oid": "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844",
|
|
||||||
"runbook_sha256": "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
|
||||||
},
|
|
||||||
"priority": 100,
|
|
||||||
"max_attempts": 20,
|
|
||||||
"deadline": null
|
|
||||||
},
|
|
||||||
"governed_binding": {
|
|
||||||
"governed_task_id": "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826",
|
|
||||||
"bridge_job_id": "job-9eee1142f2fc8bb7b571d065c01f40e0",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch": "agent/v2r2c2-convergence-integration-20260825",
|
|
||||||
"generation": 2,
|
|
||||||
"base_oid": "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844",
|
|
||||||
"expected_branch_oid": "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844"
|
|
||||||
},
|
|
||||||
"source_artifact": {
|
|
||||||
"ref": "git:2afc2cf41fc9090cf01e3882d1b8db8d54cf1844:runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
|
||||||
"sha256": "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
|
||||||
},
|
|
||||||
"task_payload_sha256": "dcc293ffcd9db36ea8dd35b79a15e77cca57ee4ad5d6f976e24ee454d46eb0c9"
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Canonical AIOS release-version parser shared by runtime and release tools."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
# The suffix preserves already-supported annotated release names while the
|
|
||||||
# canonical VnRnCn namespace remains accepted exactly.
|
|
||||||
RELEASE_VERSION_PATTERN = r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$"
|
|
||||||
RELEASE_VERSION_RE = re.compile(RELEASE_VERSION_PATTERN)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_release_version(version: object) -> str:
|
|
||||||
value = str(version or "").strip()
|
|
||||||
if not RELEASE_VERSION_RE.fullmatch(value):
|
|
||||||
raise ValueError("invalid release version")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_release_version(version: object) -> str:
|
|
||||||
return parse_release_version(version)
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.source-registry-reconciliation.v1",
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"root_cause": "PROMOTION_STATE_MACHINE_FINALIZED_BEFORE_SOURCE_REGISTRY_CONVERGENCE",
|
||||||
|
"main_before": "3244f0f17f8845592ac94d585f5ebd65693c9f29",
|
||||||
|
"tag_oid": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
||||||
|
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||||
|
"projection_sha256": "924dfc0ec58afc4e31638f6538e551c5bc8858f804cdbee5af7dce6eba7c90fe",
|
||||||
|
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||||
|
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||||
|
"remote_tag_verified": true,
|
||||||
|
"remote_target_verified": true,
|
||||||
|
"gitea_api_verified_by_owner_acceptance": true,
|
||||||
|
"source_entry_before": "missing",
|
||||||
|
"source_entry_added": "versions/V2R2C1.json",
|
||||||
|
"source_entry_is_exact_attested_projection": true,
|
||||||
|
"release_manifest_added": false,
|
||||||
|
"release_manifest_reason": "Historical release-manifest schema differs; no canonical V2R2C1 source-manifest generator was proven. Do not synthesize one by copying old release values.",
|
||||||
|
"runtime_backing": "immutable WS/versions bundle",
|
||||||
|
"runtime_mutated": false,
|
||||||
|
"tag_rewritten": false,
|
||||||
|
"baseline_modified": false,
|
||||||
|
"production_modified": false
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Action Contract Regression Remediation — 2026-08-25
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Governed task: `fix-action-contracts-20260825-1407`
|
|
||||||
Base: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
|
||||||
Target branch: `agent/fix-action-contracts-20260825-1407`
|
|
||||||
|
|
||||||
## Verified regressions
|
|
||||||
|
|
||||||
1. `getReleaseConsistency` and `runReleaseAcceptance` fail before evaluation with:
|
|
||||||
`Gitea API URL must be HTTP(S)`.
|
|
||||||
V2R2C1 release evidence at `a8b906d1bc24088c19647e98de6a679268f6d0a2`
|
|
||||||
proves Gitea API readback passed for target `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`.
|
|
||||||
Therefore current staging/contract overlay configuration has regressed.
|
|
||||||
|
|
||||||
2. `getReleaseSaga` fails with:
|
|
||||||
`Permission denied: '/var/lib/aios-gitops/silent-release'`.
|
|
||||||
Runtime service identity must have read/traverse access to the durable release state root.
|
|
||||||
Fix ownership/group/ACL at deployment time; do not broaden to world-writable permissions.
|
|
||||||
|
|
||||||
3. `getTagPublicationStatus` rejects the authoritative approved version `V2R2C1`
|
|
||||||
as `invalid release version`, while `getGovernedWriteCapabilities` returns the same
|
|
||||||
version as the approved release scope. The two Action contracts are internally inconsistent.
|
|
||||||
Release-version validation must accept the canonical AIOS `^V[0-9]+R[0-9]+C[0-9]+$`
|
|
||||||
namespace (or, preferably, use one shared validator for all release Actions).
|
|
||||||
|
|
||||||
4. `commitFilesGoverned` is unusable from the published Action schema.
|
|
||||||
Runtime capability metadata declares required fields:
|
|
||||||
`task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message`,
|
|
||||||
but the exposed tool contract has no `files` input. Regenerate/publish the Action schema from
|
|
||||||
the same operation contract source and add a contract-parity test.
|
|
||||||
|
|
||||||
5. `reconcileGitOpsJob` returned `DURABLE_BINDING_INVALID` for a completed governed
|
|
||||||
single-file job that was otherwise read back as `COMPLETED`.
|
|
||||||
Reconciliation of a valid terminal job should be idempotent/read-only and must not require
|
|
||||||
a stale or absent active lease. Add a regression case for terminal completed jobs.
|
|
||||||
|
|
||||||
6. `getDiff` returns `ResponseTooLargeError` / `GIT_OUTPUT_LIMIT_EXCEEDED` for large diffs.
|
|
||||||
Add server-side bounded output: path/stat summary plus truncation metadata, or pagination.
|
|
||||||
The Action must never fail merely because a valid repository diff is large.
|
|
||||||
|
|
||||||
## Required implementation changes
|
|
||||||
|
|
||||||
- Centralize release-version parsing and reuse it in tag status, candidate, consistency, and acceptance.
|
|
||||||
- Validate/normalize Gitea API URL at service startup and fail health checks with a configuration-specific diagnostic.
|
|
||||||
- Ensure the deployment unit projects a valid HTTP(S) Gitea API base URL into the staging overlay.
|
|
||||||
- Correct durable release-state directory ownership/ACL for the runtime service account.
|
|
||||||
- Generate the external Action schema from the runtime operation contract; parity-test required fields.
|
|
||||||
- Make terminal reconciliation idempotent and independent of expired active writer leases.
|
|
||||||
- Bound diff output and return `truncated=true` with deterministic metadata rather than transport failure.
|
|
||||||
|
|
||||||
## Acceptance gates
|
|
||||||
|
|
||||||
All must pass on the same deployed overlay:
|
|
||||||
|
|
||||||
- `getReleaseConsistency(V2R2C1, 998d303..., a8b906d..., approved_plan)` returns a structured result, not URL error.
|
|
||||||
- `runReleaseAcceptance(...)` reaches policy evaluation, not URL error.
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` returns structured publication state.
|
|
||||||
- `getReleaseSaga(nonexistent_candidate)` returns NOT_FOUND/empty-state semantics, never filesystem permission failure.
|
|
||||||
- Published `commitFilesGoverned` schema exposes `files` and a two-file isolated branch commit succeeds.
|
|
||||||
- Reconcile of a completed governed job is idempotent.
|
|
||||||
- Large diff returns bounded/truncated data rather than transport exception.
|
|
||||||
- Existing protected-ref controls remain unchanged: no direct main/tag/baseline/production write.
|
|
||||||
|
|
||||||
## Proven stable reference
|
|
||||||
|
|
||||||
V2R2C1 evidence closure (`a8b906d1bc24088c19647e98de6a679268f6d0a2`) records:
|
|
||||||
- Gitea Git/API readback PASS
|
|
||||||
- staging runtime PASS
|
|
||||||
- restart recovery PASS
|
|
||||||
- 59 mandatory tests passed / 0 failed
|
|
||||||
|
|
||||||
This evidence should be used as the compatibility baseline when repairing the current
|
|
||||||
`contract-overlay-v2` / `scheme-a-323b615b...` staging layer.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# AIOS Action contract repair evidence — 2026-08-25
|
|
||||||
|
|
||||||
## Disposition
|
|
||||||
|
|
||||||
Final disposition: `NEEDS_REWORK`.
|
|
||||||
|
|
||||||
The contract repair is deployed to staging and the focused acceptance checks are green. The task is not eligible for `SUCCEEDED`: `runReleaseAcceptance` returns structured policy output with `accepted=false` because the existing publication state for protected tag `V2R2C1` is `REMOTE_PUSH_PENDING`, and the repository-wide suite still has the two pre-existing signer/event-bus failures recorded below. The online Task API row is still `QUEUED`; it was not changed by direct database manipulation or an unauthorized synthetic claim/result.
|
|
||||||
|
|
||||||
No production release, `main`, release tag, baseline registry, or production service was directly modified.
|
|
||||||
|
|
||||||
## Scope and pre-change facts
|
|
||||||
|
|
||||||
- Repository: `aios/aios`; governed branch: `agent/fix-action-contracts-20260825-1407`.
|
|
||||||
- Initial task branch HEAD: `35eec9afe1432e8466d60040b2fb1049bb41242a`.
|
|
||||||
- Stable reference: `V2R2C1`; source commit `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`; evidence closure `a8b906d1bc24088c19647e98de6a679268f6d0a2`.
|
|
||||||
- Pre-change refs: authority `main=3244f0f17f8845592ac94d585f5ebd65693c9f29`, tag `V2R2C1=07fc2746b688f5f243f29b39de39f32edc3dd9d2`; these remained unchanged.
|
|
||||||
- Staging services run as `User=aiman`, `Group=gitops`. The actual staging Gitea API base is `https://g.cnnetmkt.top/git/api/v1`, supplied by systemd environment rather than business-code host constants.
|
|
||||||
- Pre-change full test baseline: `707 passed, 2 skipped, 154 subtests passed, 2 failed`. The failures were the stale `/tmp/aios-agent-events-test` event-bus fixture and the real signer self-test.
|
|
||||||
|
|
||||||
## Root causes and repairs
|
|
||||||
|
|
||||||
1. Gitea URL validation occurred at release-operation time while the staging overlay's API environment was not consistently projected into all runtime paths. The canonical HTTP(S) validator is now called during model and Bridge startup; `getReleaseConsistency` and `runReleaseAcceptance` use the actual staging environment URL.
|
|
||||||
|
|
||||||
2. `silent-release` was not consistently writable by the real service identity, and the credential helper parent directory blocked `aiman` traversal. The persisted staging systemd overlay creates the state directory as `aiman:gitops` mode `0750` and grants only `u:aiman:--x` on `/usr/local/libexec`; no world-writable mode was used. The staging bridge also has the minimum repository write path required by the governed AuthorityGitExecutor/CAS flow.
|
|
||||||
|
|
||||||
3. Release version parsing was split across callers and rejected `V2R2C1`. A shared canonical parser now accepts `^V[0-9]+R[0-9]+C[0-9]+$` plus the existing bounded suffix form, and is used by capabilities, release candidate, consistency, acceptance, tag publication, and release/tag queries.
|
|
||||||
|
|
||||||
4. The runtime operation contract required `files` but the published Action schema omitted it. `commitFilesGoverned` now has one contract source and published schema projection, with parity tests. A real governed two-file commit completed on isolated branch `agent/action-contract-integration-20260825-1407`; authority, Git remote, and Gitea API all read back OID `0345cd8c70967940bc8e9132dff7d6ea11efa304`.
|
|
||||||
|
|
||||||
5. `reconcileGitOpsJob` reconstructed active lease state before checking terminal state. Terminal `COMPLETED`, `FAILED`, and `CANCELLED` jobs now short-circuit to idempotent read-back; active jobs retain strict lease validation. The two-file job reconciled as `COMPLETED` and a second reconcile returned the same receipt.
|
|
||||||
|
|
||||||
6. `getDiff` returned unbounded patch data and could fail the transport. It now returns identities, changed paths, stats, total bytes, bounded patch output, patch limit, and `truncated`. The large smoke returned `total_size_bytes=350222`, `stdout_bytes=262144`, `patch_limit_bytes=262144`, `truncated=true`; no `ResponseTooLargeError` or `GIT_OUTPUT_LIMIT_EXCEEDED` escaped.
|
|
||||||
|
|
||||||
During live integration, two additional staging-only contract defects were found and repaired: CREATE_NEW branch normalization/CAS recovery, and release executor projection of the configured staging baseline plus immutable runtime `versions/V2R2C1.json`. The latter removed the missing publication-workspace baseline/version errors without writing production state.
|
|
||||||
|
|
||||||
## Source, test, and operational changes
|
|
||||||
|
|
||||||
Source commits on the governed branch include the six contract repairs, focused regression tests, `versions/V2R2C1.json`, governed CREATE_NEW/reconcile recovery, and final runtime projection commits. The final source before this evidence commit is `2ec49005bde44f6981b14351500847d594e0276c`.
|
|
||||||
|
|
||||||
The runbook update is in `runbooks/agent-fix-action-contracts-20260825.md`. This report is the required durable evidence artifact. Existing untracked `runtime/agent-observability/` and `runtime/task-center/` directories were preserved and were not staged.
|
|
||||||
|
|
||||||
Tests run on the final source:
|
|
||||||
|
|
||||||
```text
|
|
||||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_silent_release_execution_readiness.py tests/test_silent_release_controller.py tests/test_silent_release_hardening.py tests/test_silent_release_production_contract.py tests/test_chatgpt_actions_tag.py tests/test_gitea_tag_publication.py tests/test_authoritative_git_context.py
|
|
||||||
264 passed, 28 subtests passed in 4.78s
|
|
||||||
|
|
||||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests
|
|
||||||
717 passed, 2 skipped, 154 subtests passed, 2 failed in 33.98s
|
|
||||||
```
|
|
||||||
|
|
||||||
The full-suite failures remain the known event-bus fixture failure and the real signer adapter self-test (`returncode=42`); no new mandatory-suite regression was observed. Focused contract/reconcile tests and the exact mandatory suite passed.
|
|
||||||
|
|
||||||
## Staging deployment identity
|
|
||||||
|
|
||||||
- Final deployed source OID at the last completed runtime smoke: `2ec49005bde44f6981b14351500847d594e0276c`.
|
|
||||||
- Final deployed artifact: `/opt/aios/gitops-model-api/2ec49005bde44f6981b14351500847d594e0276c-action-contract-repair/runtime`.
|
|
||||||
- Artifact SHA256: `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`.
|
|
||||||
- Deployment state: `STAGING`; production-deployed flag: `false`.
|
|
||||||
- A lexically highest staging-only systemd drop-in was installed for each affected service, followed by `daemon-reload` and restart. `systemctl is-active` returned `active` for both services, and `systemctl show` confirmed the final OID, artifact SHA, API URL, and baseline authority.
|
|
||||||
|
|
||||||
## Runtime smoke evidence
|
|
||||||
|
|
||||||
- Model health returned `status=ok`, `version=V2R2C1`, final commit/artifact identity, `deployment_state=STAGING`, and `production_deployed=false`.
|
|
||||||
- Bridge/API readback was available; the bridge's `/health` path is not exposed by this runtime and returned `404`, while its governed endpoints were healthy.
|
|
||||||
- `getReleaseConsistency("V2R2C1", ...)` returned structured `status=PASS`, with Gitea listing/peeled target and tag object matching the stable reference.
|
|
||||||
- `runReleaseAcceptance(...)` entered policy evaluation and returned structured `accepted=false`; it did not fail URL validation. The independent pre-existing tag publication state is `REMOTE_PUSH_PENDING`, with the remote rejection explicitly reporting protected tag `V2R2C1`.
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` returned structured `REMOTE_PUSH_PENDING`, not `invalid release version`.
|
|
||||||
- `getReleaseSaga` for a nonexistent candidate returned HTTP `404` with `status=NOT_FOUND`, `state=NOT_FOUND`, `execution_enabled=false`, and no filesystem permission error.
|
|
||||||
- `commitFilesGoverned` published schema includes required `files`, with per-file `path` and `content_base64`.
|
|
||||||
- Two-file governed commit: branch `agent/action-contract-integration-20260825-1407`, OID `0345cd8c70967940bc8e9132dff7d6ea11efa304` at authority/Git remote/API; protected-ref flags remained false.
|
|
||||||
- Terminal reconcile: `COMPLETED` read-back was idempotent across two calls; focused tests cover `FAILED` and `CANCELLED` terminal semantics.
|
|
||||||
- Small diff returned bounded non-truncated output; empty diff returned zero paths and zero bytes; large diff returned bounded/truncated output as recorded above.
|
|
||||||
- Main, protected tag, baseline registry, and production OIDs were re-read after deployment and remained unchanged.
|
|
||||||
|
|
||||||
## Restart persistence and remaining risk
|
|
||||||
|
|
||||||
The final staging services were restarted after deployment and health/runtime identity were re-read. The post-restart `systemctl is-active` result was `active` for both services; model health again reported OID `2ec49005bde44f6981b14351500847d594e0276c`, artifact SHA `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`, staging mode, and `production_deployed=false`. Post-restart consistency remained `PASS`, acceptance remained structured `accepted=false` for the pre-existing protected-tag publication state, saga remained `NOT_FOUND` for the nonexistent candidate, and the large diff remained bounded/truncated. The task cannot be promoted to `SUCCEEDED` until the protected-tag publication state and the two known repository-wide failures are resolved through their respective governed owners. No automatic rollback was performed because the staging services stayed healthy, mandatory tests passed, Gitea readback passed, and protected-ref boundaries remained intact.
|
|
||||||
|
|
||||||
Task disposition is `NEEDS_REWORK`, not `SUCCEEDED`: the staging contract repair is materially complete, but the deployed acceptance result is not all-green and the task row has not been legitimately claimed/verified by the online task worker.
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
# Agent Execution Contract — Action Contract Regression Repair
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Fully repair the current AIOS Action runtime/contract overlay regressions on the single server, without modifying protected refs directly and without introducing new infrastructure. The task is not complete until the deployed staging/action overlay passes all acceptance gates.
|
|
||||||
|
|
||||||
## Authority and scope
|
|
||||||
|
|
||||||
- Repository: `aios/aios`
|
|
||||||
- Base at task creation: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
|
||||||
- Governed task: `fix-action-contracts-20260825-1407`
|
|
||||||
- Target branch: `agent/fix-action-contracts-20260825-1407`
|
|
||||||
- Protected refs must not be directly written: `main`, release tags, baseline registry, production runtime.
|
|
||||||
- No new SaaS, database, agent framework, or monitoring platform may be introduced.
|
|
||||||
|
|
||||||
## Non-interactive rules
|
|
||||||
|
|
||||||
The Agent must run the whole flow unnattended:
|
|
||||||
|
|
||||||
1. Collect facts.
|
|
||||||
2. Determine root causes.
|
|
||||||
3. Make the smallest correct changes.
|
|
||||||
4. Run unit/integration/contract tests.
|
|
||||||
5. Deploy to staging only.
|
|
||||||
6. Restart only the affected non-production services.
|
|
||||||
7. Run the full Action smoke suite.
|
|
||||||
8. On failure, automatically collect logs, diagnose, fix, and retry.
|
|
||||||
9. If a change causes a new regression, automatically roll back the staging change to the previous known-good configuration and record the failure.
|
|
||||||
10. Stop only if an owner decision is required for a high-risk, irreversible, or production-boundary action.
|
|
||||||
|
|
||||||
## Fact collection — required before editing
|
|
||||||
|
|
||||||
Record all of the following in the final evidence bundle:
|
|
||||||
|
|
||||||
- `id`, `hostname`, `uname -a`, `date --iso-seconds`
|
|
||||||
- `git rev-parse --show HEAD` for authority worktree and running overlay source
|
|
||||||
- `git status --porcelaine=v2`
|
|
||||||
- `git remote -v`
|
|
||||||
- current systemd service unit(s), drop-ins, Environment/EnvironmentFile sources for GitOps/Bredge/release services
|
|
||||||
- `systemctl show <affected-service>` including `User=`, `Group=`, `Environment=`, `EnvironmentFile=`, `ExecStart=`
|
|
||||||
- resolved Gitea API base URL after env/config expansion; redact secrets but not scheme/host
|
|
||||||
- `stat -c, `namei -l`, `getfacl`, als -ld on `/var/lib/aios-gitops/silent-release` and its parents
|
|
||||||
- find Action OpenAPI/schema generator and runtime operation definitions for `commitFilesGoverned`
|
|
||||||
- find the release version validator(s) and all callers of `getTagPublicationStatus`
|
|
||||||
- find `reconcileGitOpsJob` implementation and durable binding validation logic
|
|
||||||
- find `getDiff implementation and transport output limits
|
|
||||||
|
|
||||||
Do not edit any file until the actual implementation and configuration sources have been located.
|
|
||||||
|
|
||||||
## Repair 1 — Gitea API URL
|
|
||||||
|
|
||||||
Symptoms:
|
|
||||||
|
|
||||||
- `getReleaseConsistency` → `Gitea API URL must be HTTP(S)`
|
|
||||||
- `runReleaseAcceptance` → `Gitea API URL must be HTTP(ÊX
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Determine which env/config key is projected into the current contract overlay.
|
|
||||||
- Compare the deployed overlay with V2R2C1 known-good configuration/deployment manifests.
|
|
||||||
- Restore a valid `HTTP://` or `HTTPS://` Gitea API base URL.
|
|
||||||
- Add startup config validation so invalid schemes fail at health/startup with a config-specific message, not during a release check.
|
|
||||||
- Never hard-code a different Gitea host if the existing deployment already has a single authoritative source.
|
|
||||||
|
|
||||||
## Repair 2 — durable release state permissions
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- `getReleaseSaga` → `Permission denied: '/var/lib/aios-gitops/silent-release'`
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Identify the actual runtime service User/Group.
|
|
||||||
- Grant only the required traverse/read/write permissions for the durable state directories the service must use.
|
|
||||||
- Prefer ownership + group mode or a specific ACL; keep the directory non-world-writable.
|
|
||||||
- Persist the permission in deployment/install logic so restart/reboot does not regress.
|
|
||||||
- Verify create/read/update of a staging-only saga state file under the service identity.
|
|
||||||
|
|
||||||
## Repair 3 — release version contract
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` → `invalid release version`
|
|
||||||
- The same runtime exposes `V2R2C1` as `approved_release_version`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Locate all version validators; consolidate onto one canonical parser.
|
|
||||||
- Accept the AIOS canonical namespace `^V[0-9]+R[0-9]+C[0-9]+$`.
|
|
||||||
- Preserve any additional already-supported legacy formats unless they are security-sensitive.
|
|
||||||
- Add contract tests proving that the version accepted by capabilities is accepted by status, candidate, consistency, acceptance, and tag publication.
|
|
||||||
|
|
||||||
## Repair 4 — `commitFilesGoverned` schema parity
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- Runtime capability metadata requires `files`.
|
|
||||||
- Published Action tool schema does not expose `files`.
|
|
||||||
- Call reaches Bridge and fails `BRIDGE_REQUEST_FIELDS_MISSING`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Find the canonical runtime operation contract.
|
|
||||||
- Generate the external tool schema from the same source of truth; do not manually copy a parallel schema.
|
|
||||||
- `files` must be required and must represent a list of file objects with at least `path` and `content_base64`.
|
|
||||||
- Add a parity test that compares runtime required fields with published Action required fields.
|
|
||||||
- Add a real governed commit test that writes two test files to an isolated `agent/` ibranch and verifies remote OID.
|
|
||||||
|
|
||||||
## Repair 5 — terminal reconciliation
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- A governed job read back as `COMPLETED' but `reconcileGitOpsJob` failed `DURABLE_BINDING_INVALID`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Reconciling a terminal successful job must be idempotent.
|
|
||||||
- An expired writer lease must not invalidate a durable binding for a job that is already terminal.
|
|
||||||
- Active-job reconciliation may require a live lease; terminal read-back must not.
|
|
||||||
- Add cases for COMPLETED, FAILED, CANCELLED / equivalent terminal states.
|
|
||||||
|
|
||||||
## Repair 6 — bounded `getDiff` output
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- Large valid diffs fail with `ResponseTooLargeError` or `GIT_OUTPUT_LIMIT_EXCEEDED`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Do not load unbounded diff text into the Action response.
|
|
||||||
- Return bounded summary data: commit identities, changed paths/stats, total bytes, and `truncated: true` when the full patch exceeds the safe limit.
|
|
||||||
- If pagination already exists in the contract use it; otherwise prefer deterministic truncation over a breaking schema change.
|
|
||||||
- Add tests for small, empty, and oversize diffs.
|
|
||||||
|
|
||||||
## Staging deployment procedure
|
|
||||||
|
|
||||||
1. Run the existing mandatory test suite before modification; record baseline.
|
|
||||||
2. Apply code and config changes only on the governed branch/worktree.
|
|
||||||
3. Run focused regression tests.
|
|
||||||
4. Run the full mandatory suite.
|
|
||||||
5. Build the staging contract overlay using the repository's existing build/deploy path.
|
|
||||||
6. Deploy to staging/non-production only.
|
|
||||||
7. If systemd units are changed: `systemctl daemon-reload`, then restart only the affected staging services.
|
|
||||||
8. Wait for the existing health endpoint/runtime status to become healthy.
|
|
||||||
9. Run the acceptance suite below.
|
|
||||||
|
|
||||||
If any step fails:
|
|
||||||
- collect `journalctl` logs and relevant app logs;
|
|
||||||
- diagnose root cause;
|
|
||||||
- automatically repair and retry when non-destructive;
|
|
||||||
- if a new change breaks staging, restore the previous staging artifact/config and restart the affected service;
|
|
||||||
- never silently continue after a failed gate.
|
|
||||||
|
|
||||||
## Acceptance suite
|
|
||||||
|
|
||||||
All of the following must pass on the same deployed staging overlay:
|
|
||||||
|
|
||||||
- Runtime status is `active` and healthy after restart.
|
|
||||||
- Gitea Git/API readback works from the runtime service identity.
|
|
||||||
- `getReleaseConsistency(V2R2C1, 998d30304787e31e5ae607c0bd86b5edb2b0e5ad, a8b906d1bc24088c19647e98de6a679268f6d0a2, approved_plan) returns a structured result and reaches evaluation.
|
|
||||||
- `runReleaseAcceptance` for the same identities reaches policy evaluation, not URL validation error.
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` returns structured state, not `invalid release version`.
|
|
||||||
- `getReleaseSaga` for a nonexistent candidate returns NOT_FOUND/empty-state semantics, not filesystem permission failure.
|
|
||||||
- `commitFilesGoverned` exposes its required `files` parameter in the published Action schema.
|
|
||||||
- A real two-file governed commit to an isolated `agent/` branch succeeds and remote branch OID matches.
|
|
||||||
- Resuming a COMPLETED job returns the existing `JOB_TERMINAL` semantics or idempotent success as designed.
|
|
||||||
- Reconciling a COMPLETED governed job is successful/idempotent and does not require an active lease.
|
|
||||||
- Small/empty `getDiff still works.
|
|
||||||
- Large `getDiff` returns bounded output with truncation metadata instead of a transport exception.
|
|
||||||
- Existing protected-ref controls remain unchange: no direct main/tag/baseline/production write.
|
|
||||||
|
|
||||||
## Restart / reboot persistence
|
|
||||||
|
|
||||||
After the first full pass:
|
|
||||||
|
|
||||||
- Restart the staging Action/Bridge/release services one more time without changing files.
|
|
||||||
- Rerun the acceptance suite.
|
|
||||||
- Permissions, Gitea URL, and schema must still be correct after restart.
|
|
||||||
- If the repository already has a safe staging reboot test, run it as well.
|
|
||||||
|
|
||||||
## Evidence and completion
|
|
||||||
|
|
||||||
Create a single durable report in the repository, e.g. `reports/action-contract-repair-evidence-20260825.md`, containing:
|
|
||||||
|
|
||||||
- pre-change facts
|
|
||||||
- root cause for each regression
|
|
||||||
- files changed and why
|
|
||||||
- config/permission changes
|
|
||||||
- test commands and exact results
|
|
||||||
- staging deployment identity (commit/artifact/config digest)
|
|
||||||
- restart-persistence results
|
|
||||||
- final Action smoke results
|
|
||||||
- any remaining risks
|
|
||||||
|
|
||||||
Commit all source, test, deployment config, and runbook changes to the governed task branch. Do not mark the task SUCCEEDED until all acceptance gates pass on the actually deployed staging overlay.
|
|
||||||
|
|
||||||
## Automatic rollback conditions
|
|
||||||
|
|
||||||
Automatically roll back the staging deployment if any of the following occur:
|
|
||||||
|
|
||||||
- service fails to become healthy after restart
|
|
||||||
- protected-ref write boundary is broken
|
|
||||||
- Gitea readback that worked before the change stops working
|
|
||||||
- durable saga state cannot be read or recovered
|
|
||||||
- mandatory test suite regresses below the pre-change baseline
|
|
||||||
|
|
||||||
Do not automatically roll back an existing production release. Rollback here refers only to the staging overlay/service configuration changes made by this task.
|
|
||||||
|
|
||||||
## 2026-08-25 execution record
|
|
||||||
|
|
||||||
The repair branch was deployed through the existing immutable staging builder. The final source code before the evidence commit was `2ec49005bde44f6981b14351500847d594e0276c`; the deployed artifact was built from that source with SHA256 `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`. The final staging drop-ins use a lexically highest `zzzz...-999.conf` layer so they override earlier diagnostic overlays.
|
|
||||||
|
|
||||||
The runtime configuration is staging-only: `User=aiman`, `Group=gitops`, API base `https://g.cnnetmkt.top/git/api/v1`, baseline authority `/var/lib/aios-gitops/model-api-staging/baseline/current.json`, and read/write access to the staging state plus the authoritative bare repository required by the governed Writer/CAS path. No `main`, protected tag, baseline registry, or production write is enabled.
|
|
||||||
|
|
||||||
The release executor now binds both the configured staging baseline and the immutable runtime package's `versions/V2R2C1.json` projection. The bridge passes `AIOS_GITOPS_BASELINE_AUTHORITY` into `RuntimeConfig`; this prevents release consistency and acceptance from falling back to missing publication-workspace files.
|
|
||||||
|
|
||||||
The final acceptance result is recorded in `reports/action-contract-repair-evidence-20260825.md`. Contract smoke is green, and release consistency is `PASS`; release acceptance is structured policy output but remains `accepted=false` because the pre-existing publication state is `REMOTE_PUSH_PENDING` for the protected `V2R2C1` tag. The task must therefore remain `NEEDS_REWORK` until an authorized owner resolves that independent protected-tag state.
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
# Agent Execution Contract — V2R2C2 Control-Plane Convergence
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Complete the AIOS Phase -1 authority/runtime/contract convergence on the existing
|
|
||||||
single server with no interactive prompts. The source integration branch already
|
|
||||||
contains the staging-validated Action contract repair lineage plus the new minimal
|
|
||||||
convergence contracts. The Agent's job is to verify, finish the one remaining
|
|
||||||
release-publication reconciliation defect if it is still reproducible, build and
|
|
||||||
deploy **staging only**, prove the same-source runtime, and return machine evidence.
|
|
||||||
|
|
||||||
This is not authorization to modify production, protected tags, production baseline,
|
|
||||||
or `main` directly.
|
|
||||||
|
|
||||||
## Canonical identities
|
|
||||||
|
|
||||||
- Repository: `aios/aios`
|
|
||||||
- Authoritative main observed at task start:
|
|
||||||
`3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
|
||||||
- Production baseline: `V2R2C1`
|
|
||||||
- Production target:
|
|
||||||
`998d30304787e31e5ae607c0bd86b5edb2b0e5ad`
|
|
||||||
- Production evidence closure:
|
|
||||||
`a8b906d1bc24088c19647e98de6a679268f6d0a2`
|
|
||||||
- Approval plan:
|
|
||||||
`sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e`
|
|
||||||
- Staging repair source lineage:
|
|
||||||
`174e64dd0218cef6d49dd13d58bbd90c78fecadd`
|
|
||||||
- Integration branch:
|
|
||||||
`agent/v2r2c2-convergence-integration-20260825`
|
|
||||||
- Integration branch OID at handoff:
|
|
||||||
`7526fef619edd8170c03c31a87e32cd1ea52a4ec`
|
|
||||||
|
|
||||||
## Code already completed
|
|
||||||
|
|
||||||
The integration branch includes the complete governed staging repair lineage rooted
|
|
||||||
at the authoritative main revision, including:
|
|
||||||
|
|
||||||
- canonical release-version validation;
|
|
||||||
- startup-safe Gitea API URL validation;
|
|
||||||
- operation contract metadata and `commitFilesGoverned` schema parity;
|
|
||||||
- bounded `getDiff`;
|
|
||||||
- idempotent terminal job reconciliation;
|
|
||||||
- CREATE_NEW branch CAS recovery;
|
|
||||||
- release baseline/version projection;
|
|
||||||
- external governed-read routing;
|
|
||||||
- canonical release consistency routing;
|
|
||||||
- durable NOT_FOUND release-saga semantics.
|
|
||||||
|
|
||||||
It also adds:
|
|
||||||
|
|
||||||
- `active-runtime/app/aios_convergence_contracts.py`
|
|
||||||
- `tests/test_convergence_contracts.py`
|
|
||||||
|
|
||||||
The new convergence contracts deliberately have no external side effects. They add:
|
|
||||||
|
|
||||||
- fail-closed `AcceptanceVector` / `AcceptanceResult`;
|
|
||||||
- domain-preserving final-writer ownership validation;
|
|
||||||
- `DesiredState` / `ObservedState`;
|
|
||||||
- pure reconciliation planning with stable idempotency identity;
|
|
||||||
- `SideEffectReceipt`;
|
|
||||||
- `ContinuationManifest`.
|
|
||||||
|
|
||||||
Local isolated validation before handoff: `8 passed`.
|
|
||||||
|
|
||||||
## Current observed runtime facts
|
|
||||||
|
|
||||||
At handoff:
|
|
||||||
|
|
||||||
- model API staging service is active at source
|
|
||||||
`174e64dd0218cef6d49dd13d58bbd90c78fecadd`;
|
|
||||||
- external `getControlPlaneSnapshot` is reachable;
|
|
||||||
- external `getGovernedWriteCapabilities` is reachable;
|
|
||||||
- `getReleaseConsistency(V2R2C1, ...)` returns `status=PASS` and all checks true;
|
|
||||||
- remote/local tag object is
|
|
||||||
`07fc2746b688f5f243f29b39de39f32edc3dd9d2`;
|
|
||||||
- peeled target is
|
|
||||||
`998d30304787e31e5ae607c0bd86b5edb2b0e5ad`;
|
|
||||||
- production health returns HTTP 200 and the same target/artifact identity;
|
|
||||||
- `runReleaseAcceptance` still returns `accepted=false` despite consistency PASS;
|
|
||||||
- `getTagPublicationStatus(V2R2C1)` currently returns `UNKNOWN`;
|
|
||||||
- `getControlPlaneSnapshot.runtime.runtime_release_commit` reports
|
|
||||||
`2ec49005bde44f6981b14351500847d594e0276c`, while the model API runtime
|
|
||||||
reports `174e64dd0218cef6d49dd13d58bbd90c78fecadd`.
|
|
||||||
|
|
||||||
Those last two facts are the remaining convergence defects.
|
|
||||||
|
|
||||||
## Non-interactive execution policy
|
|
||||||
|
|
||||||
Run continuously without asking the Owner to type "continue".
|
|
||||||
|
|
||||||
Automatically:
|
|
||||||
|
|
||||||
1. collect facts;
|
|
||||||
2. run tests;
|
|
||||||
3. diagnose failures;
|
|
||||||
4. make the smallest correct repair;
|
|
||||||
5. rebuild immutable staging artifacts;
|
|
||||||
6. deploy/restart only affected staging services;
|
|
||||||
7. observe external truth;
|
|
||||||
8. retry recoverable failures;
|
|
||||||
9. roll back newly introduced staging regressions;
|
|
||||||
10. produce final evidence.
|
|
||||||
|
|
||||||
Stop only for:
|
|
||||||
|
|
||||||
- authority/approval that cannot be satisfied by existing bindings;
|
|
||||||
- irreversible production change;
|
|
||||||
- protected-ref mutation;
|
|
||||||
- security-boundary change;
|
|
||||||
- genuine goal ambiguity;
|
|
||||||
- no-progress after bounded diagnosis/repair attempts.
|
|
||||||
|
|
||||||
## Phase A — pre-edit facts
|
|
||||||
|
|
||||||
Before any further edit, persist:
|
|
||||||
|
|
||||||
- current authoritative HEAD;
|
|
||||||
- integration branch OID and remote readback;
|
|
||||||
- `git merge-base --is-ancestor 3244f0f... 7526fef...`;
|
|
||||||
- staging service source/artifact identity for both model API and Bridge;
|
|
||||||
- systemd unit + drop-in ordering for both services;
|
|
||||||
- exact environment sources for baseline, Gitea API, state roots and PYTHONPATH;
|
|
||||||
- current release consistency, acceptance and tag-publication status;
|
|
||||||
- current control-plane snapshot;
|
|
||||||
- current governed-write capabilities operation contracts.
|
|
||||||
|
|
||||||
Do not trust a report if a direct runtime observation is available.
|
|
||||||
|
|
||||||
## Phase B — mandatory tests
|
|
||||||
|
|
||||||
Run at minimum:
|
|
||||||
|
|
||||||
```text
|
|
||||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_convergence_contracts.py tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py
|
|
||||||
|
|
||||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_silent_release_execution_readiness.py tests/test_silent_release_controller.py tests/test_silent_release_hardening.py tests/test_silent_release_production_contract.py tests/test_chatgpt_actions_tag.py tests/test_gitea_tag_publication.py tests/test_authoritative_git_context.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the repository-wide suite. Compare failures to the persisted pre-existing
|
|
||||||
baseline. No new regression is allowed.
|
|
||||||
|
|
||||||
## Phase C — release-publication truth reconciliation
|
|
||||||
|
|
||||||
Only execute this code repair if the following remains simultaneously true on the
|
|
||||||
same staging runtime:
|
|
||||||
|
|
||||||
```text
|
|
||||||
getReleaseConsistency.status == PASS
|
|
||||||
runReleaseAcceptance.accepted == false
|
|
||||||
getTagPublicationStatus(V2R2C1) in {UNKNOWN, REMOTE_PUSH_PENDING}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required semantics
|
|
||||||
|
|
||||||
Do **not** hard-code `accepted=true` and do not reinterpret UNKNOWN as PASS.
|
|
||||||
|
|
||||||
The publication controller must reconcile durable publication state from observed
|
|
||||||
external truth:
|
|
||||||
|
|
||||||
1. Resolve the approved version binding.
|
|
||||||
2. Read authoritative local tag object and peeled commit.
|
|
||||||
3. Read Gitea tag object and peeled commit.
|
|
||||||
4. Read registry/baseline approved tag object and target.
|
|
||||||
5. Compare all identities to the approved release binding.
|
|
||||||
6. If every required observation is present and exactly equal:
|
|
||||||
- persist the existing terminal-success publication state used by the codebase
|
|
||||||
(`PUBLISHED`/equivalent);
|
|
||||||
- mark the transition as observation-only/reconciled;
|
|
||||||
- record the observed tag object, peeled target, repository identity and evidence;
|
|
||||||
- perform **no tag create/push**.
|
|
||||||
7. If any identity differs:
|
|
||||||
- fail closed as drift/conflict;
|
|
||||||
- never mutate the protected tag.
|
|
||||||
8. If any required observation is unavailable:
|
|
||||||
- remain `UNKNOWN`/reconciling;
|
|
||||||
- do not mark accepted.
|
|
||||||
9. Replaying reconciliation must be idempotent and must not create a second
|
|
||||||
side effect.
|
|
||||||
|
|
||||||
`runReleaseAcceptance` may return accepted only after both:
|
|
||||||
|
|
||||||
```text
|
|
||||||
release consistency == PASS
|
|
||||||
AND reconciled publication state == terminal success
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mandatory regression tests
|
|
||||||
|
|
||||||
Add focused tests for:
|
|
||||||
|
|
||||||
- missing local publication journal + exact observed remote tag -> terminal success;
|
|
||||||
- stale `REMOTE_PUSH_PENDING` + exact observed remote tag -> terminal success;
|
|
||||||
- remote tag object mismatch -> fail closed;
|
|
||||||
- peeled target mismatch -> fail closed;
|
|
||||||
- Gitea observation unavailable -> UNKNOWN, not accepted;
|
|
||||||
- replay after reconciled success -> identical durable receipt, no tag write/push;
|
|
||||||
- existing protected tag is never force-updated or deleted.
|
|
||||||
|
|
||||||
Use the existing `TagPublisher`/publication store and existing reconciliation/state
|
|
||||||
model. Do not create a second release database or a second release controller.
|
|
||||||
|
|
||||||
## Phase D — same-source staging convergence
|
|
||||||
|
|
||||||
Build from integration branch OID after tests. Deploy the same immutable artifact
|
|
||||||
lineage to every staging component participating in these Actions.
|
|
||||||
|
|
||||||
Acceptance requires the externally visible snapshot to stop presenting mixed
|
|
||||||
source identity. At minimum model API and Bridge must report the same intended
|
|
||||||
source/build identity or explicitly report component-level identities without
|
|
||||||
claiming a single aggregate runtime commit.
|
|
||||||
|
|
||||||
Do not hide component drift by overwriting the snapshot value.
|
|
||||||
|
|
||||||
Preferred fix if the snapshot currently has only one `runtime_release_commit`:
|
|
||||||
extent it to expose component identities, for example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
runtime.components.model_api.source_oid
|
|
||||||
runtime.components.bridge.source_oid
|
|
||||||
runtime.aggregate_drift_status
|
|
||||||
```
|
|
||||||
|
|
||||||
Aggregate PASS is legal only if the required components are on the approved
|
|
||||||
compatible source set.
|
|
||||||
|
|
||||||
## Phase E — staging smoke
|
|
||||||
|
|
||||||
On the deployed staging artifact run:
|
|
||||||
|
|
||||||
- runtime status;
|
|
||||||
- control-plane snapshot;
|
|
||||||
- governed-write capabilities;
|
|
||||||
- release consistency;
|
|
||||||
- release acceptance;
|
|
||||||
- tag publication status;
|
|
||||||
- nonexistent release saga;
|
|
||||||
- bounded large diff;
|
|
||||||
- completed-job reconcile idempotency;
|
|
||||||
- isolated two-file governed commit on a throwaway `agent/` branch.
|
|
||||||
|
|
||||||
Required end state:
|
|
||||||
|
|
||||||
```text
|
|
||||||
release_consistency = PASS
|
|
||||||
release_acceptance.accepted = true
|
|
||||||
tag_publication = terminal success
|
|
||||||
control_plane_snapshot = reachable
|
|
||||||
governed_capabilities = reachable
|
|
||||||
runtime_component_drift = PASS
|
|
||||||
legacy_writes = false
|
|
||||||
force_push = false
|
|
||||||
main_write = false
|
|
||||||
tag_write = false
|
|
||||||
baseline_write = false
|
|
||||||
production_write = false
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase F — restart persistence
|
|
||||||
|
|
||||||
Restart only affected staging services, without changing files.
|
|
||||||
|
|
||||||
Rerun Phase E. All identities and outcomes must remain stable.
|
|
||||||
|
|
||||||
## Governance and branch handling
|
|
||||||
|
|
||||||
Do not directly modify `main`, `V2R2C1`, the production baseline registry or
|
|
||||||
production services.
|
|
||||||
|
|
||||||
The current Bridge writer binding has a known limitation: after a successful
|
|
||||||
single-file CAS commit, reacquiring the same task lease can return the original
|
|
||||||
expected branch OID, and rebinding the same task/generation can conflict.
|
|
||||||
|
|
||||||
Do not work around that with direct Git writes.
|
|
||||||
|
|
||||||
For source changes use the existing governed task/lease/commit flow. If the platform
|
|
||||||
requires one governed task binding per branch advance, create an explicitly named
|
|
||||||
follow-up governed task on the **same branch**, with:
|
|
||||||
|
|
||||||
- `base_oid` = previously verified branch OID;
|
|
||||||
- `expected_branch_oid` = same previously verified branch OID;
|
|
||||||
- same repository and branch;
|
|
||||||
- no protected-ref write.
|
|
||||||
|
|
||||||
Record this as `TASK_BINDING_EXPECTED_OID_NOT_ADVANCING` evidence.
|
|
||||||
|
|
||||||
## Final evidence
|
|
||||||
|
|
||||||
Commit a durable report:
|
|
||||||
|
|
||||||
`reports/v2r2c2-control-plane-convergence-evidence-20260825.md`
|
|
||||||
|
|
||||||
It must contain:
|
|
||||||
|
|
||||||
- pre-change facts;
|
|
||||||
- exact commits and changed paths;
|
|
||||||
- all test commands/results;
|
|
||||||
- release-publication reconciliation proof;
|
|
||||||
- proof that no protected tag write occurred during observation reconciliation;
|
|
||||||
- build artifact SHA;
|
|
||||||
- staging component source identities;
|
|
||||||
- pre/post restart smoke;
|
|
||||||
- branch authority/Git remote/API readback;
|
|
||||||
- remaining known risks;
|
|
||||||
- final disposition.
|
|
||||||
|
|
||||||
Do not declare `SUCCEEDED` unless every required end-state line in Phase E is
|
|
||||||
proven from the actually deployed staging runtime.
|
|
||||||
|
|
||||||
Production promotion is a separate Owner-approved release step and is **not** part
|
|
||||||
of this execution contract.
|
|
||||||
@@ -32,18 +32,36 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--tree-oid", required=True)
|
parser.add_argument("--tree-oid", required=True)
|
||||||
parser.add_argument("--output-directory", required=True, type=Path)
|
parser.add_argument("--output-directory", required=True, type=Path)
|
||||||
parser.add_argument("--name", default="aios-autonomous-release-candidate")
|
parser.add_argument("--name", default="aios-autonomous-release-candidate")
|
||||||
|
parser.add_argument("--release-version")
|
||||||
|
parser.add_argument("--release-binding-json", type=Path)
|
||||||
|
parser.add_argument("--artifact-mode", choices=("release", "general"))
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = build_parser().parse_args(argv)
|
args = build_parser().parse_args(argv)
|
||||||
|
release_binding = (
|
||||||
|
json.loads(args.release_binding_json.read_text(encoding="utf-8"))
|
||||||
|
if args.release_binding_json is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if release_binding is not None and not isinstance(release_binding, dict):
|
||||||
|
raise ValueError("release binding JSON must be an object")
|
||||||
output = args.output_directory
|
output = args.output_directory
|
||||||
output.mkdir(parents=True, exist_ok=True, mode=0o750)
|
output.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||||
if output.is_symlink():
|
if output.is_symlink():
|
||||||
raise RuntimeError("artifact output directory symlink rejected")
|
raise RuntimeError("artifact output directory symlink rejected")
|
||||||
os.chmod(output, output.stat().st_mode & 0o750)
|
os.chmod(output, output.stat().st_mode & 0o750)
|
||||||
builder = ArtifactBuilder()
|
builder = ArtifactBuilder()
|
||||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
built = builder.build_from_directory(
|
||||||
|
args.source_oid,
|
||||||
|
args.tree_oid,
|
||||||
|
args.source,
|
||||||
|
release_version=args.release_version,
|
||||||
|
release_binding=release_binding,
|
||||||
|
release_mode=args.release_version is not None,
|
||||||
|
artifact_mode=args.artifact_mode,
|
||||||
|
)
|
||||||
builder.verify(built)
|
builder.verify(built)
|
||||||
artifact_path = output / f"{args.name}.tar"
|
artifact_path = output / f"{args.name}.tar"
|
||||||
manifest_path = output / f"{args.name}.manifest.json"
|
manifest_path = output / f"{args.name}.manifest.json"
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Build a deterministic plan-bound production payload from one clean source tree."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse, hashlib, json, shutil, tarfile, tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
SOURCE_FILES = (
|
|
||||||
"active-runtime/app/deployment_state.py",
|
|
||||||
"active-runtime/app/v2r1c6_privacy_tools.py",
|
|
||||||
"active-runtime/app/v2r2c2_health_service.py",
|
|
||||||
"deploy/aios-v2/systemd/aios-v2r2c2-production.service",
|
|
||||||
)
|
|
||||||
|
|
||||||
def canon(value: object) -> bytes:
|
|
||||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
||||||
|
|
||||||
def digest(path: Path) -> str:
|
|
||||||
h = hashlib.sha256()
|
|
||||||
with path.open("rb") as f:
|
|
||||||
for block in iter(lambda: f.read(1024 * 1024), b""): h.update(block)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
def require_oid(value: str, label: str) -> str:
|
|
||||||
if len(value) != 40 or any(c not in "0123456789abcdef" for c in value): raise SystemExit(label + "_invalid")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def require_sha(value: str, label: str) -> str:
|
|
||||||
if len(value) != 64 or any(c not in "0123456789abcdef" for c in value): raise SystemExit(label + "_invalid")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def build(source: Path, output: Path, *, version: str, source_oid: str, tree_oid: str,
|
|
||||||
rollback_version: str, rollback_target_oid: str, rollback_artifact_sha256: str,
|
|
||||||
service: str, port: int) -> dict[str, object]:
|
|
||||||
if not source.is_dir() or source.is_symlink(): raise SystemExit("source_directory_invalid")
|
|
||||||
require_oid(source_oid, "source_oid"); require_oid(tree_oid, "tree_oid")
|
|
||||||
require_oid(rollback_target_oid, "rollback_target_oid"); require_sha(rollback_artifact_sha256, "rollback_artifact_sha256")
|
|
||||||
if version != "V2R2C2" or rollback_version == version: raise SystemExit("release_version_binding_invalid")
|
|
||||||
if service != "aios-v2r2c2-production.service" or port != 18928: raise SystemExit("adapter_policy_binding_invalid")
|
|
||||||
if output.exists(): raise SystemExit("output_exists")
|
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with tempfile.TemporaryDirectory(prefix="trusted-release-build-") as td:
|
|
||||||
payload = Path(td) / "payload"; payload.mkdir()
|
|
||||||
for rel in SOURCE_FILES:
|
|
||||||
source_file = source / rel
|
|
||||||
if not source_file.is_file() or source_file.is_symlink(): raise SystemExit("required_source_missing:" + rel)
|
|
||||||
dest = payload / rel; dest.parent.mkdir(parents=True, exist_ok=True); shutil.copyfile(source_file, dest)
|
|
||||||
(payload / "VERSION").write_text(version + "\n", encoding="utf-8")
|
|
||||||
build_meta = {"schema": "aios.trusted-release-artifact-build.v1", "version": version,
|
|
||||||
"source_commit": source_oid, "tree_oid": tree_oid, "production_deployed": False,
|
|
||||||
"tag_created": False, "baseline_changed": False, "adapter_service": service, "adapter_port": port}
|
|
||||||
(payload / "BUILD-METADATA.json").write_bytes(canon(build_meta))
|
|
||||||
rollback = {"schema": "aios.trusted-release-rollback.v1", "version": rollback_version,
|
|
||||||
"target_oid": rollback_target_oid, "artifact_sha256": rollback_artifact_sha256,
|
|
||||||
"automatic_rollback": False}
|
|
||||||
(payload / "ROLLBACK-METADATA.json").write_bytes(canon(rollback))
|
|
||||||
files = []
|
|
||||||
for item in sorted(p for p in payload.rglob("*") if p.is_file()):
|
|
||||||
if item.name != "ARTIFACT-MANIFEST.json":
|
|
||||||
files.append({"path": "payload/" + item.relative_to(payload).as_posix(), "sha256": digest(item), "size": item.stat().st_size})
|
|
||||||
manifest = {"schema": "aios.trusted-release-artifact-manifest.v1", "version": version,
|
|
||||||
"source_commit": source_oid, "tree_oid": tree_oid, "files": files,
|
|
||||||
"deployable": True, "production_deployed": False}
|
|
||||||
(payload / "ARTIFACT-MANIFEST.json").write_bytes(canon(manifest))
|
|
||||||
files.append({"path": "payload/ARTIFACT-MANIFEST.json", "sha256": digest(payload / "ARTIFACT-MANIFEST.json"), "size": (payload / "ARTIFACT-MANIFEST.json").stat().st_size})
|
|
||||||
tar_path = Path(td) / "artifact.tar"
|
|
||||||
with tarfile.open(tar_path, "w") as archive:
|
|
||||||
for item in sorted(p for p in payload.rglob("*") if p.is_file()):
|
|
||||||
info = archive.gettarinfo(str(item), arcname="payload/" + item.relative_to(payload).as_posix())
|
|
||||||
info.uid = 0; info.gid = 0; info.uname = "root"; info.gname = "root"; info.mtime = 0; info.mode = 0o644
|
|
||||||
with item.open("rb") as stream: archive.addfile(info, stream)
|
|
||||||
shutil.copyfile(tar_path, output)
|
|
||||||
receipt = {"schema": "aios.trusted-release-artifact-receipt.v1", "version": version,
|
|
||||||
"source_commit": source_oid, "tree_oid": tree_oid, "artifact_sha256": digest(output),
|
|
||||||
"artifact_size": output.stat().st_size, "manifest_sha256": hashlib.sha256(canon(manifest)).hexdigest(),
|
|
||||||
"package_closure_sha256": hashlib.sha256(canon({"source_commit": source_oid, "tree_oid": tree_oid, "files": files})).hexdigest(),
|
|
||||||
"production_deployed": False, "tag_created": False, "baseline_changed": False,
|
|
||||||
"service": service, "port": port}
|
|
||||||
output.with_suffix(".manifest.json").write_bytes(canon(receipt))
|
|
||||||
return receipt
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
p = argparse.ArgumentParser(); p.add_argument("--source", type=Path, required=True); p.add_argument("--output", type=Path, required=True)
|
|
||||||
p.add_argument("--version", required=True); p.add_argument("--source-oid", required=True); p.add_argument("--tree-oid", required=True)
|
|
||||||
p.add_argument("--rollback-version", required=True); p.add_argument("--rollback-target-oid", required=True); p.add_argument("--rollback-artifact-sha256", required=True)
|
|
||||||
p.add_argument("--service", required=True); p.add_argument("--port", type=int, required=True); a = p.parse_args()
|
|
||||||
result = build(a.source, a.output, version=a.version, source_oid=a.source_oid, tree_oid=a.tree_oid,
|
|
||||||
rollback_version=a.rollback_version, rollback_target_oid=a.rollback_target_oid,
|
|
||||||
rollback_artifact_sha256=a.rollback_artifact_sha256, service=a.service, port=a.port)
|
|
||||||
print(json.dumps(result, sort_keys=True)); return 0
|
|
||||||
if __name__ == "__main__": raise SystemExit(main())
|
|
||||||
@@ -5,7 +5,6 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
@@ -19,18 +18,6 @@ REQUIRED_APP = (
|
|||||||
'silent_release_controller.py',
|
'silent_release_controller.py',
|
||||||
'silent_release_hardening.py',
|
'silent_release_hardening.py',
|
||||||
'aios_gitops_job_controller.py',
|
'aios_gitops_job_controller.py',
|
||||||
'aios_chatgpt_bridge_v2.py',
|
|
||||||
'aios_chatgpt_bridge_v2_server.py',
|
|
||||||
'governed_production_release.py',
|
|
||||||
'v2r2c1_production_adapter.py',
|
|
||||||
'v2r1c7_production_adapter.py',
|
|
||||||
'aios_governed_task_bootstrap.py',
|
|
||||||
'aios_continuation_executor.py',
|
|
||||||
'aios_continuation_lease.py',
|
|
||||||
'aios_authority_git_executor.py',
|
|
||||||
'aios_gitops_reconciliation_core.py',
|
|
||||||
'authorized_task_writer_provisioning.py',
|
|
||||||
'signer_contract_compatibility.py',
|
|
||||||
)
|
)
|
||||||
OPTIONAL_APP = (
|
OPTIONAL_APP = (
|
||||||
'aios_chatgpt_actions_api.py',
|
'aios_chatgpt_actions_api.py',
|
||||||
@@ -69,18 +56,11 @@ def main() -> int:
|
|||||||
source = args.source_root.resolve(strict=True)
|
source = args.source_root.resolve(strict=True)
|
||||||
output = args.output_root.resolve()
|
output = args.output_root.resolve()
|
||||||
app = source / 'active-runtime' / 'app'
|
app = source / 'active-runtime' / 'app'
|
||||||
sys.path.insert(0, str(app))
|
|
||||||
from aios_chatgpt_bridge_v2 import render_openapi_contract_fragment
|
|
||||||
|
|
||||||
runtime = source / 'runtime' / 'agent-os'
|
runtime = source / 'runtime' / 'agent-os'
|
||||||
docs = source / 'docs' / 'gitops'
|
docs = source / 'docs' / 'gitops'
|
||||||
if not app.is_dir():
|
if not app.is_dir():
|
||||||
raise SystemExit('active-runtime/app missing')
|
raise SystemExit('active-runtime/app missing')
|
||||||
paths = []
|
paths = []
|
||||||
root_version = source / 'release_version.py'
|
|
||||||
if not root_version.is_file():
|
|
||||||
raise SystemExit('required source dependency missing: release_version.py')
|
|
||||||
paths.append((Path('release_version.py'), root_version))
|
|
||||||
for name in REQUIRED_APP:
|
for name in REQUIRED_APP:
|
||||||
path = app / name
|
path = app / name
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
@@ -97,8 +77,6 @@ def main() -> int:
|
|||||||
paths.append((Path('runtime-agent-os') / rel, path))
|
paths.append((Path('runtime-agent-os') / rel, path))
|
||||||
openapi = docs / 'openapi-chatgpt-actions.yaml'
|
openapi = docs / 'openapi-chatgpt-actions.yaml'
|
||||||
if openapi.is_file():
|
if openapi.is_file():
|
||||||
if ' /v2/commit-files:' not in openapi.read_text(encoding='utf-8'):
|
|
||||||
raise SystemExit('published OpenAPI is missing generated commitFilesGoverned contract')
|
|
||||||
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
|
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
|
||||||
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
|
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
|
||||||
root = source / folder
|
root = source / folder
|
||||||
|
|||||||
Executable
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate one canonical source entry for a governed non-main change.
|
||||||
|
|
||||||
|
This utility only creates the file in the caller's worktree. It never commits,
|
||||||
|
pushes, changes main, rewrites a tag, or updates a production projection. The
|
||||||
|
caller must submit the resulting file through the existing governed writer and
|
||||||
|
approval flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||||
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
|
from version_registry import ( # noqa: E402
|
||||||
|
build_source_registry_entry,
|
||||||
|
configured_authoritative_git_dir,
|
||||||
|
configured_repository_identity,
|
||||||
|
source_registry_json,
|
||||||
|
validate_source_registry_record,
|
||||||
|
verify_approval_authority,
|
||||||
|
verify_authoritative_repository,
|
||||||
|
verify_source_registry_git_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
value = argparse.ArgumentParser()
|
||||||
|
value.add_argument("--output", required=True, type=Path)
|
||||||
|
value.add_argument("--version", required=True)
|
||||||
|
value.add_argument("--tag-oid", required=True)
|
||||||
|
value.add_argument("--target-oid", required=True)
|
||||||
|
value.add_argument("--peeled-target-oid", required=True)
|
||||||
|
value.add_argument("--evidence-closure-oid", required=True)
|
||||||
|
value.add_argument("--approval-plan-id", required=True)
|
||||||
|
value.add_argument(
|
||||||
|
"--repository",
|
||||||
|
type=Path,
|
||||||
|
default=configured_authoritative_git_dir(),
|
||||||
|
help="authoritative bare repository; required for release generation",
|
||||||
|
)
|
||||||
|
value.add_argument(
|
||||||
|
"--approval-authority",
|
||||||
|
type=Path,
|
||||||
|
default=(
|
||||||
|
Path(os.environ["AIOS_GITOPS_APPROVAL_AUTHORITY"])
|
||||||
|
if os.environ.get("AIOS_GITOPS_APPROVAL_AUTHORITY")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
help="canonical approval/publication receipt for this release request",
|
||||||
|
)
|
||||||
|
value.add_argument(
|
||||||
|
"--repository-identity",
|
||||||
|
default=configured_repository_identity(),
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parser().parse_args()
|
||||||
|
if args.approval_authority is None:
|
||||||
|
raise ValueError("canonical approval authority is required")
|
||||||
|
verify_authoritative_repository(
|
||||||
|
args.repository,
|
||||||
|
repository_identity=args.repository_identity,
|
||||||
|
)
|
||||||
|
tag_oid = args.tag_oid.lower()
|
||||||
|
target_oid = args.target_oid.lower()
|
||||||
|
peeled_target_oid = args.peeled_target_oid.lower()
|
||||||
|
evidence_closure_oid = args.evidence_closure_oid.lower()
|
||||||
|
expected = build_source_registry_entry(
|
||||||
|
version=args.version,
|
||||||
|
tag_oid=tag_oid,
|
||||||
|
target_oid=target_oid,
|
||||||
|
peeled_target_oid=peeled_target_oid,
|
||||||
|
evidence_closure_oid=evidence_closure_oid,
|
||||||
|
approval_plan_id=args.approval_plan_id,
|
||||||
|
repository=args.repository_identity,
|
||||||
|
)
|
||||||
|
verify_source_registry_git_identity(args.repository, expected)
|
||||||
|
verify_approval_authority(args.approval_authority, expected)
|
||||||
|
destination = args.output.resolve()
|
||||||
|
if destination.name != f"{args.version}.json" or destination.parent.name != "versions":
|
||||||
|
raise ValueError("output must be versions/<version>.json")
|
||||||
|
payload = source_registry_json(expected)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise ValueError("existing source registry path must not be a symlink")
|
||||||
|
if destination.exists() and not destination.is_file():
|
||||||
|
raise ValueError("existing source registry path must be a regular file")
|
||||||
|
if destination.exists():
|
||||||
|
current = json.loads(destination.read_text(encoding="utf-8"))
|
||||||
|
validate_source_registry_record(
|
||||||
|
current,
|
||||||
|
expected_version=args.version,
|
||||||
|
expected_tag_oid=tag_oid,
|
||||||
|
expected_target_oid=target_oid,
|
||||||
|
expected_evidence_closure_oid=evidence_closure_oid,
|
||||||
|
expected_approval_plan_id=args.approval_plan_id,
|
||||||
|
)
|
||||||
|
print(json.dumps({"status": "EXISTING_IDENTITY_VERIFIED", "path": str(destination)}))
|
||||||
|
return 0
|
||||||
|
descriptor = os.open(
|
||||||
|
destination,
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||||
|
0o640,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.write(descriptor, payload)
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
print(json.dumps({"status": "GENERATED", "path": str(destination)}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -9,16 +9,10 @@ WORKSPACE="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
|
|||||||
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
|
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
|
||||||
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
|
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
|
||||||
|
|
||||||
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
case "$VERSION" in
|
||||||
if ! PYTHONPATH="$SCRIPT_ROOT:${PYTHONPATH:-}" python3 - "$VERSION" <<'PY'
|
V[0-9]*R[0-9]*C[0-9]*) ;;
|
||||||
import sys
|
*) echo "ERROR: version must match VxRxCx" >&2; exit 64 ;;
|
||||||
from release_version import validate_release_version
|
esac
|
||||||
validate_release_version(sys.argv[1])
|
|
||||||
PY
|
|
||||||
then
|
|
||||||
echo "ERROR: version must match VxRxCx" >&2
|
|
||||||
exit 64
|
|
||||||
fi
|
|
||||||
|
|
||||||
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
|
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
|
||||||
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
|
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
|
||||||
|
|||||||
+200
-1
@@ -8,6 +8,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
import stat
|
import stat
|
||||||
import tarfile
|
import tarfile
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
@@ -75,6 +76,30 @@ class BuiltArtifact:
|
|||||||
manifest: ArtifactManifest
|
manifest: ArtifactManifest
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleaseIdentityBinding:
|
||||||
|
"""Immutable release identity required for a release artifact build."""
|
||||||
|
|
||||||
|
version: str
|
||||||
|
repository: str
|
||||||
|
tag_oid: str
|
||||||
|
target_oid: str
|
||||||
|
evidence_closure_oid: str
|
||||||
|
approval_plan_id: str
|
||||||
|
peeled_target_oid: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"version": self.version,
|
||||||
|
"repository": self.repository,
|
||||||
|
"tag_oid": self.tag_oid,
|
||||||
|
"target_oid": self.target_oid,
|
||||||
|
"peeled_target_oid": self.peeled_target_oid or self.target_oid,
|
||||||
|
"evidence_closure_oid": self.evidence_closure_oid,
|
||||||
|
"approval_plan_id": self.approval_plan_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ArtifactBuilder:
|
class ArtifactBuilder:
|
||||||
MAX_FILE_BYTES = 8 * 1024 * 1024
|
MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||||
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
|
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
|
||||||
@@ -92,14 +117,188 @@ class ArtifactBuilder:
|
|||||||
if not is_git_oid(value):
|
if not is_git_oid(value):
|
||||||
raise ValueError(f"{name} must be a full lowercase Git OID")
|
raise ValueError(f"{name} must be a full lowercase Git OID")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _binding_dict(
|
||||||
|
binding: ReleaseIdentityBinding | Mapping[str, str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if isinstance(binding, ReleaseIdentityBinding):
|
||||||
|
return binding.as_dict()
|
||||||
|
values = {str(key): str(value) for key, value in binding.items()}
|
||||||
|
values.setdefault("peeled_target_oid", values.get("target_oid", ""))
|
||||||
|
return values
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_source_registry(
|
||||||
|
cls,
|
||||||
|
root: Path,
|
||||||
|
release_version: str,
|
||||||
|
release_binding: ReleaseIdentityBinding | Mapping[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
release_mode: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "release version is not canonical"
|
||||||
|
)
|
||||||
|
path = root / "versions" / f"{release_version}.json"
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_MISSING",
|
||||||
|
f"artifact source is missing versions/{release_version}.json",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID",
|
||||||
|
f"artifact source registry cannot be read: {path}",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry is not an object"
|
||||||
|
)
|
||||||
|
if value.get("version") != release_version or value.get("repository") != "aios/aios":
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||||
|
"source registry version or repository does not match the artifact",
|
||||||
|
)
|
||||||
|
if value.get("schema") not in {
|
||||||
|
"aios.source-registry.v1",
|
||||||
|
"aios.production-registry.v2",
|
||||||
|
}:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry schema is invalid"
|
||||||
|
)
|
||||||
|
for field in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||||
|
item = value.get(field)
|
||||||
|
if not isinstance(item, str) or not re.fullmatch(r"[0-9a-f]{40}", item):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID",
|
||||||
|
f"source registry field is invalid: {field}",
|
||||||
|
)
|
||||||
|
if value["peeled_target_oid"] != value["target_oid"]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||||
|
"source registry peeled target differs from target",
|
||||||
|
)
|
||||||
|
if not isinstance(value.get("approval_plan_id"), str) or not re.fullmatch(
|
||||||
|
r"sha256:[0-9a-f]{64}", value["approval_plan_id"]
|
||||||
|
):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_INVALID", "source registry approval plan is invalid"
|
||||||
|
)
|
||||||
|
if release_mode and release_binding is None:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_REQUIRED",
|
||||||
|
"release artifact builds require an expected release identity",
|
||||||
|
)
|
||||||
|
if release_binding is not None:
|
||||||
|
expected = cls._binding_dict(release_binding)
|
||||||
|
required = (
|
||||||
|
"version",
|
||||||
|
"repository",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
if any(not expected.get(name) for name in required):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
"expected release identity is incomplete",
|
||||||
|
)
|
||||||
|
for name in ("tag_oid", "target_oid", "peeled_target_oid", "evidence_closure_oid"):
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", expected[name]):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
f"expected release identity field is invalid: {name}",
|
||||||
|
)
|
||||||
|
if not re.fullmatch(r"sha256:[0-9a-f]{64}", expected["approval_plan_id"]):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
"expected release identity approval plan is invalid",
|
||||||
|
)
|
||||||
|
for name in required:
|
||||||
|
if value.get(name) != expected[name]:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_SOURCE_REGISTRY_IDENTITY_MISMATCH",
|
||||||
|
f"source registry does not match expected release identity: {name}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"version": str(value["version"]),
|
||||||
|
"repository": str(value["repository"]),
|
||||||
|
"tag_oid": str(value["tag_oid"]),
|
||||||
|
"target_oid": str(value["target_oid"]),
|
||||||
|
"peeled_target_oid": str(value["peeled_target_oid"]),
|
||||||
|
"evidence_closure_oid": str(value["evidence_closure_oid"]),
|
||||||
|
"approval_plan_id": str(value["approval_plan_id"]),
|
||||||
|
}
|
||||||
|
|
||||||
def build_from_directory(
|
def build_from_directory(
|
||||||
self, source_oid: str, tree_oid: str, source: str | Path
|
self,
|
||||||
|
source_oid: str,
|
||||||
|
tree_oid: str,
|
||||||
|
source: str | Path,
|
||||||
|
release_version: str | None = None,
|
||||||
|
release_binding: ReleaseIdentityBinding | Mapping[str, str] | None = None,
|
||||||
|
release_mode: bool | None = None,
|
||||||
|
artifact_mode: str | None = None,
|
||||||
) -> BuiltArtifact:
|
) -> BuiltArtifact:
|
||||||
root = Path(source)
|
root = Path(source)
|
||||||
if root.is_symlink() or not root.is_dir():
|
if root.is_symlink() or not root.is_dir():
|
||||||
raise ReleaseBlocked(
|
raise ReleaseBlocked(
|
||||||
"ARTIFACT_SOURCE_LINK_REJECTED", "artifact source must be a real directory"
|
"ARTIFACT_SOURCE_LINK_REJECTED", "artifact source must be a real directory"
|
||||||
)
|
)
|
||||||
|
if artifact_mode not in {None, "release", "general"}:
|
||||||
|
raise ValueError("artifact_mode must be release, general, or omitted")
|
||||||
|
if artifact_mode == "release" and release_mode is False:
|
||||||
|
raise ValueError("artifact_mode=release conflicts with release_mode=False")
|
||||||
|
if artifact_mode == "general" and release_mode is True:
|
||||||
|
raise ValueError("artifact_mode=general conflicts with release_mode=True")
|
||||||
|
|
||||||
|
explicit_release_version = release_version is not None
|
||||||
|
detected_canonical_version = False
|
||||||
|
if release_version is None:
|
||||||
|
version_file = root / "VERSION"
|
||||||
|
if version_file.is_file() and not version_file.is_symlink():
|
||||||
|
release_version = version_file.read_text(encoding="utf-8").strip()
|
||||||
|
detected_canonical_version = bool(
|
||||||
|
re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version)
|
||||||
|
)
|
||||||
|
elif re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", release_version):
|
||||||
|
detected_canonical_version = True
|
||||||
|
|
||||||
|
binding_expected: dict[str, str] | None = None
|
||||||
|
if release_binding is not None:
|
||||||
|
binding_expected = self._binding_dict(release_binding)
|
||||||
|
if not binding_expected.get("version"):
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_RELEASE_BINDING_INVALID",
|
||||||
|
"release identity binding must include version",
|
||||||
|
)
|
||||||
|
release_version = release_version or binding_expected["version"]
|
||||||
|
|
||||||
|
release_requested = bool(
|
||||||
|
explicit_release_version
|
||||||
|
or detected_canonical_version
|
||||||
|
or release_binding is not None
|
||||||
|
or release_mode is True
|
||||||
|
or artifact_mode == "release"
|
||||||
|
)
|
||||||
|
if artifact_mode == "general" and release_requested:
|
||||||
|
raise ReleaseBlocked(
|
||||||
|
"ARTIFACT_GENERAL_MODE_RELEASE_VERSION",
|
||||||
|
"general artifacts cannot contain canonical release metadata",
|
||||||
|
)
|
||||||
|
if release_requested:
|
||||||
|
release_mode = True
|
||||||
|
self._validate_source_registry(
|
||||||
|
root,
|
||||||
|
release_version or "",
|
||||||
|
release_binding,
|
||||||
|
release_mode=True,
|
||||||
|
)
|
||||||
files: list[ArtifactFile] = []
|
files: list[ArtifactFile] = []
|
||||||
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
||||||
current = Path(directory)
|
current = Path(directory)
|
||||||
|
|||||||
+17
-1
@@ -61,6 +61,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
artifact_build.add_argument("--source-oid", required=True)
|
artifact_build.add_argument("--source-oid", required=True)
|
||||||
artifact_build.add_argument("--tree-oid", required=True)
|
artifact_build.add_argument("--tree-oid", required=True)
|
||||||
artifact_build.add_argument("--output-directory", required=True, type=Path)
|
artifact_build.add_argument("--output-directory", required=True, type=Path)
|
||||||
|
artifact_build.add_argument("--release-version")
|
||||||
|
artifact_build.add_argument("--release-binding-json", type=Path)
|
||||||
|
artifact_build.add_argument("--artifact-mode", choices=("release", "general"))
|
||||||
artifact_verify = artifact_sub.add_parser("verify")
|
artifact_verify = artifact_sub.add_parser("verify")
|
||||||
artifact_verify.add_argument("--artifact", required=True, type=Path)
|
artifact_verify.add_argument("--artifact", required=True, type=Path)
|
||||||
artifact_verify.add_argument("--manifest", required=True, type=Path)
|
artifact_verify.add_argument("--manifest", required=True, type=Path)
|
||||||
@@ -145,7 +148,20 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if args.command == "artifact":
|
if args.command == "artifact":
|
||||||
builder = ArtifactBuilder()
|
builder = ArtifactBuilder()
|
||||||
if args.artifact_command == "build":
|
if args.artifact_command == "build":
|
||||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
release_binding = (
|
||||||
|
load_json(args.release_binding_json)
|
||||||
|
if args.release_binding_json is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
built = builder.build_from_directory(
|
||||||
|
args.source_oid,
|
||||||
|
args.tree_oid,
|
||||||
|
args.source,
|
||||||
|
release_version=args.release_version,
|
||||||
|
release_binding=release_binding,
|
||||||
|
release_mode=args.release_version is not None,
|
||||||
|
artifact_mode=args.artifact_mode,
|
||||||
|
)
|
||||||
builder.verify(built)
|
builder.verify(built)
|
||||||
artifact_path = args.output_directory / "candidate.tar"
|
artifact_path = args.output_directory / "candidate.tar"
|
||||||
manifest_path = args.output_directory / "manifest.json"
|
manifest_path = args.output_directory / "manifest.json"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import hashlib,json,re,tarfile
|
import hashlib,json,re,tarfile
|
||||||
from release_version import RELEASE_VERSION_RE
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=RELEASE_VERSION_RE
|
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=re.compile(r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
REQUIRED_SOURCE={"schema","version","repository","base_ref","base_oid","binding_mode","release_readiness","production_deployed","tag_created","baseline_changed","production_service","production_port","rollback_metadata","controller_schema","required_checks"}
|
REQUIRED_SOURCE={"schema","version","repository","base_ref","base_oid","binding_mode","release_readiness","production_deployed","tag_created","baseline_changed","production_service","production_port","rollback_metadata","controller_schema","required_checks"}
|
||||||
REQUEST_KEYS={"schema","version","repository","base_oid","target_oid","artifact_uri","artifact_sha256","artifact_size","evidence_closure_oid","approval_plan_id","execute","production_deployed","production_service","production_port","rollback_metadata","current_tag_oid","source_manifest_sha256"}
|
REQUEST_KEYS={"schema","version","repository","base_oid","target_oid","artifact_uri","artifact_sha256","artifact_size","evidence_closure_oid","approval_plan_id","execute","production_deployed","production_service","production_port","rollback_metadata","current_tag_oid","source_manifest_sha256"}
|
||||||
class ManifestValidationError(ValueError):
|
class ManifestValidationError(ValueError):
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder, ArtifactFile, BuiltArtifact
|
from aios_autonomous_release_candidate.artifact import (
|
||||||
|
ArtifactBuilder,
|
||||||
|
ArtifactFile,
|
||||||
|
BuiltArtifact,
|
||||||
|
ReleaseIdentityBinding,
|
||||||
|
)
|
||||||
from aios_autonomous_release_candidate.errors import EvidenceIncomplete, ReleaseBlocked
|
from aios_autonomous_release_candidate.errors import EvidenceIncomplete, ReleaseBlocked
|
||||||
|
|
||||||
|
|
||||||
|
def _source_registry(root: Path) -> None:
|
||||||
|
target = "2" * 40
|
||||||
|
value = {
|
||||||
|
"schema": "aios.source-registry.v1",
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": "1" * 40,
|
||||||
|
"tag_object": "1" * 40,
|
||||||
|
"target_oid": target,
|
||||||
|
"peeled_target_oid": target,
|
||||||
|
"evidence_closure_oid": "3" * 40,
|
||||||
|
"approval_plan_id": "sha256:" + "4" * 64,
|
||||||
|
}
|
||||||
|
path = root / "versions" / "V2R2C1.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(json.dumps(value) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_is_deterministic_and_reverified() -> None:
|
def test_artifact_is_deterministic_and_reverified() -> None:
|
||||||
builder = ArtifactBuilder()
|
builder = ArtifactBuilder()
|
||||||
files = (
|
files = (
|
||||||
@@ -32,6 +56,143 @@ def test_source_tree_rejects_symlink_and_hardlink(tmp_path: Path) -> None:
|
|||||||
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_source_tree_requires_source_registry_entry(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SOURCE_REGISTRY_MISSING"):
|
||||||
|
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_version_without_binding_fails_closed(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_REQUIRED"):
|
||||||
|
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_source_tree_requires_expected_release_identity(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
binding = ReleaseIdentityBinding(
|
||||||
|
version="V2R2C1",
|
||||||
|
repository="aios/aios",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
target_oid="2" * 40,
|
||||||
|
evidence_closure_oid="3" * 40,
|
||||||
|
approval_plan_id="sha256:" + "4" * 64,
|
||||||
|
)
|
||||||
|
built = ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_binding=binding,
|
||||||
|
)
|
||||||
|
ArtifactBuilder().verify(built)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_version_with_malformed_binding_fails_closed(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_INVALID"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_binding={
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": "not-an-oid",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_general_artifact_without_release_version_passes(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "README.txt").write_text("general candidate\n", encoding="utf-8")
|
||||||
|
built = ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
artifact_mode="general",
|
||||||
|
)
|
||||||
|
assert "README.txt" in built.manifest.files
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_general_mode_rejects_canonical_version(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_GENERAL_MODE_RELEASE_VERSION"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
artifact_mode="general",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"field,wrong",
|
||||||
|
[
|
||||||
|
("repository", "other/repository"),
|
||||||
|
("version", "V2R2C2"),
|
||||||
|
("tag_oid", "9" * 40),
|
||||||
|
("target_oid", "8" * 40),
|
||||||
|
("peeled_target_oid", "7" * 40),
|
||||||
|
("evidence_closure_oid", "6" * 40),
|
||||||
|
("approval_plan_id", "sha256:" + "5" * 64),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_release_source_tree_rejects_every_identity_mismatch(
|
||||||
|
tmp_path: Path, field: str, wrong: str
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
binding = {
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": "1" * 40,
|
||||||
|
"target_oid": "2" * 40,
|
||||||
|
"peeled_target_oid": "2" * 40,
|
||||||
|
"evidence_closure_oid": "3" * 40,
|
||||||
|
"approval_plan_id": "sha256:" + "4" * 64,
|
||||||
|
}
|
||||||
|
binding[field] = wrong
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SOURCE_REGISTRY"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_binding=binding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_mode_without_binding_fails_closed(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "VERSION").write_text("V2R2C1\n", encoding="utf-8")
|
||||||
|
_source_registry(root)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_RELEASE_BINDING_REQUIRED"):
|
||||||
|
ArtifactBuilder().build_from_directory(
|
||||||
|
"a" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
root,
|
||||||
|
release_version="V2R2C1",
|
||||||
|
release_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_rejects_secret_markers() -> None:
|
def test_artifact_rejects_secret_markers() -> None:
|
||||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SECRET_MARKER"):
|
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SECRET_MARKER"):
|
||||||
ArtifactBuilder().build(
|
ArtifactBuilder().build(
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
sys.path.insert(0, str(ROOT / "runtime/agent-os"))
|
|
||||||
|
|
||||||
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
|
||||||
OPERATION_REQUIRED_FIELDS,
|
|
||||||
operation_contracts,
|
|
||||||
render_openapi_contract_fragment,
|
|
||||||
)
|
|
||||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime # noqa: E402
|
|
||||||
from aios_gitops_job_controller import JobState # noqa: E402
|
|
||||||
from gitea_tag_publication import validate_gitea_api_url # noqa: E402
|
|
||||||
from silent_release_hardening import ReleaseSagaStore, SilentReleaseActionService # noqa: E402
|
|
||||||
from release_version import validate_release_version # noqa: E402
|
|
||||||
import aios_model_api_staging as model_api # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_release_version_is_shared_and_accepts_v2r2c1():
|
|
||||||
assert validate_release_version("V2R2C1") == "V2R2C1"
|
|
||||||
assert validate_release_version("V2R2C1-repair") == "V2R2C1-repair"
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_release_version("v2r2c1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_gitea_url_validation_is_startup_safe():
|
|
||||||
assert validate_gitea_api_url("https://g.cnnetmkt.top/git/api/v1") == (
|
|
||||||
"https://g.cnnetmkt.top/git/api/v1"
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="HTTP"):
|
|
||||||
validate_gitea_api_url("g.cnnetmkt.top/git/api/v1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_release_reads_use_the_bridge_canonical_evaluator():
|
|
||||||
query = {
|
|
||||||
"version": ["V2R2C1"],
|
|
||||||
"target_oid": ["998d30304787e31e5ae607c0bd86b5edb2b0e5ad"],
|
|
||||||
"evidence_closure_oid": ["a8b906d1bc24088c19647e98de6a679268f6d0a2"],
|
|
||||||
"approval_plan_id": ["sha256:plan"],
|
|
||||||
}
|
|
||||||
assert model_api.governed_release_query_payload(query) == {
|
|
||||||
"version": "V2R2C1",
|
|
||||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
|
||||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
|
||||||
"approval_plan_id": "sha256:plan",
|
|
||||||
}
|
|
||||||
source = inspect.getsource(model_api.H.do_GET)
|
|
||||||
assert "publication_publisher()" in source
|
|
||||||
assert "get_release_consistency" in source
|
|
||||||
assert "run_release_acceptance" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_governed_read_routes_are_bound_to_authenticated_facade():
|
|
||||||
route_script = (
|
|
||||||
ROOT / "ops/gitops-migration/bind_external_action_surface_final_staging.sh"
|
|
||||||
).read_text()
|
|
||||||
for path in (
|
|
||||||
"/api/governed-write-capabilities",
|
|
||||||
"/api/control-plane-snapshot",
|
|
||||||
"/api/online-agent-task",
|
|
||||||
"/api/online-agent-task-events",
|
|
||||||
):
|
|
||||||
assert f"location = {path}" in route_script
|
|
||||||
assert f"proxy_pass http://127.0.0.1:18890{path};" in route_script
|
|
||||||
assert "location /api/ {" in route_script
|
|
||||||
assert "Authorization $http_authorization" in route_script
|
|
||||||
|
|
||||||
|
|
||||||
def test_commit_files_runtime_and_published_schema_have_same_required_fields():
|
|
||||||
runtime = operation_contracts()["commitFilesGoverned"]["required_fields"]
|
|
||||||
assert runtime == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
|
||||||
fragment = render_openapi_contract_fragment()
|
|
||||||
published = (ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
|
||||||
assert fragment in published
|
|
||||||
for field in runtime:
|
|
||||||
assert f" - {field}" in fragment
|
|
||||||
assert "required: [path, content_base64]" in fragment
|
|
||||||
|
|
||||||
|
|
||||||
class _Store:
|
|
||||||
def __init__(self, row):
|
|
||||||
self.row = row
|
|
||||||
self.receipt_calls = 0
|
|
||||||
|
|
||||||
def read(self, _job_id):
|
|
||||||
return self.row
|
|
||||||
|
|
||||||
def _receipt(self, row, duplicate=False):
|
|
||||||
self.receipt_calls += 1
|
|
||||||
return type("Receipt", (), {"as_dict": lambda _self: {
|
|
||||||
"job_id": row["job_id"],
|
|
||||||
"state": row["state"],
|
|
||||||
"duplicate": duplicate,
|
|
||||||
}})()
|
|
||||||
|
|
||||||
|
|
||||||
class _Controller:
|
|
||||||
def __init__(self, row):
|
|
||||||
self.store = _Store(row)
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_for(row):
|
|
||||||
runtime = object.__new__(GovernedRuntime)
|
|
||||||
controller = _Controller(row)
|
|
||||||
runtime._controller_for_job = lambda _job_id: controller
|
|
||||||
runtime._executor_from_row = lambda *_args: (_ for _ in ()).throw(
|
|
||||||
AssertionError("terminal reconcile must not reconstruct an executor")
|
|
||||||
)
|
|
||||||
return runtime, controller
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"state",
|
|
||||||
[
|
|
||||||
JobState.COMPLETED.value,
|
|
||||||
JobState.FAILED.value,
|
|
||||||
JobState.CANCELLED.value,
|
|
||||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED.value,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_terminal_reconcile_is_idempotent_without_durable_binding(state):
|
|
||||||
row = {
|
|
||||||
"job_id": "job-terminal",
|
|
||||||
"state": state,
|
|
||||||
"request_json": json.dumps({}),
|
|
||||||
}
|
|
||||||
runtime, controller = _runtime_for(row)
|
|
||||||
result = GovernedRuntime.job(runtime, {"job_id": row["job_id"]}, "reconcile")
|
|
||||||
assert result["duplicate"] is True
|
|
||||||
assert controller.store.receipt_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_active_reconcile_still_uses_executor():
|
|
||||||
row = {
|
|
||||||
"job_id": "job-active",
|
|
||||||
"state": JobState.RECONCILIATION_REQUIRED.value,
|
|
||||||
"request_json": json.dumps({}),
|
|
||||||
}
|
|
||||||
runtime, controller = _runtime_for(row)
|
|
||||||
class Executor:
|
|
||||||
def reconcile(self, job_id):
|
|
||||||
return {"job_id": job_id, "state": "COMPLETED"}
|
|
||||||
runtime._executor_from_row = lambda *_args: Executor()
|
|
||||||
assert GovernedRuntime.job(runtime, {"job_id": row["job_id"]}, "reconcile") == {
|
|
||||||
"job_id": "job-active",
|
|
||||||
"state": "COMPLETED",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_release_saga_is_a_not_found_empty_state(tmp_path):
|
|
||||||
service = object.__new__(SilentReleaseActionService)
|
|
||||||
service.sagas = ReleaseSagaStore(tmp_path)
|
|
||||||
service.execution_enabled = False
|
|
||||||
result = service.get_release_saga("never-created-action-contract")
|
|
||||||
assert result["status"] == "NOT_FOUND"
|
|
||||||
assert result["state"] == "NOT_FOUND"
|
|
||||||
assert not list(tmp_path.rglob("*"))
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT=Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
|
||||||
|
|
||||||
from aios_agent_task_initial_execution_projection import (
|
|
||||||
InitialExecutionProjectionError,
|
|
||||||
InitialExecutionProjectionRequest,
|
|
||||||
InitialExecutionProjectionStore,
|
|
||||||
SourceCaptureProjectionRecoveryRequest,
|
|
||||||
)
|
|
||||||
from aios_online_agent_execution import (
|
|
||||||
GovernedExecutionBinding,
|
|
||||||
project_execution_envelope,
|
|
||||||
)
|
|
||||||
|
|
||||||
AUTHORIZATION="owner-initial-execution-projection-recovery-20260820"
|
|
||||||
TASK_ID="task-initial-projection-a"
|
|
||||||
JOB_ID="job-initial-projection-a"
|
|
||||||
BASE="a"*40
|
|
||||||
BRANCH="agent/task-initial-projection-a"
|
|
||||||
IDEMPOTENCY="task-initial-projection-binding-v1"
|
|
||||||
CAPABILITY="agent.runtime.openclaw"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _auth(monkeypatch):
|
|
||||||
monkeypatch.setenv(
|
|
||||||
"AIOS_AGENT_TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZATION_ID",
|
|
||||||
AUTHORIZATION,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def canon(value):
|
|
||||||
return hashlib.sha256(
|
|
||||||
json.dumps(value,sort_keys=True,separators=(",",":")).encode()
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def binding():
|
|
||||||
return GovernedExecutionBinding.from_mapping({
|
|
||||||
"task_id":TASK_ID,
|
|
||||||
"job_id":JOB_ID,
|
|
||||||
"repository":"aios/aios",
|
|
||||||
"base_oid":BASE,
|
|
||||||
"expected_branch_oid":BASE,
|
|
||||||
"target_branch":BRANCH,
|
|
||||||
"branch_mode":"CREATE_NEW",
|
|
||||||
"generation":1,
|
|
||||||
"idempotency_key":IDEMPOTENCY,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_input():
|
|
||||||
return {
|
|
||||||
"goal":"Supervise release preparation",
|
|
||||||
"instructions":"Observe and resume only the same lineage.",
|
|
||||||
"success_criteria":[{"id":"heartbeat","description":"Heartbeat observed"}],
|
|
||||||
"constraints":{"production_write":False},
|
|
||||||
"scope":{"scope_type":"legacy","tenant_id":None,"project_id":None},
|
|
||||||
"trace_id":TASK_ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def envelope(*, job_id=JOB_ID, action="agent_runtime", capability=CAPABILITY):
|
|
||||||
bound=binding()
|
|
||||||
if job_id != JOB_ID:
|
|
||||||
bound=GovernedExecutionBinding.from_mapping({**bound.public(),"job_id":job_id})
|
|
||||||
return project_execution_envelope(
|
|
||||||
bound,
|
|
||||||
{
|
|
||||||
"action":action,
|
|
||||||
"adapter":"openclaw" if action=="agent_runtime" else "source_capture",
|
|
||||||
"capability_scope":[capability],
|
|
||||||
"runtime_input":runtime_input() if action=="agent_runtime" else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_db(tmp_path, *, claimed_by=None, lease=False, metadata=None, caps=None, event="TASK_CREATED"):
|
|
||||||
path=tmp_path/"state.sqlite3"
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
con.executescript("""
|
|
||||||
CREATE TABLE tasks(
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
goal TEXT NOT NULL,
|
|
||||||
instructions TEXT NOT NULL,
|
|
||||||
success_criteria_json TEXT NOT NULL,
|
|
||||||
required_capabilities_json TEXT NOT NULL,
|
|
||||||
constraints_json TEXT NOT NULL,
|
|
||||||
metadata_json TEXT NOT NULL,
|
|
||||||
deadline TEXT,
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
max_attempts INTEGER NOT NULL,
|
|
||||||
attempt INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
claimed_by TEXT,
|
|
||||||
lease_token_hash TEXT,
|
|
||||||
lease_expires_at TEXT,
|
|
||||||
result_json TEXT,
|
|
||||||
result_digest TEXT,
|
|
||||||
result_idempotency_key TEXT,
|
|
||||||
verification_json TEXT,
|
|
||||||
revision INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE events(
|
|
||||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_id TEXT,
|
|
||||||
agent_id TEXT,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
metadata={} if metadata is None else metadata
|
|
||||||
caps=[] if caps is None else caps
|
|
||||||
con.execute(
|
|
||||||
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
TASK_ID,"Supervise release preparation",
|
|
||||||
"Observe and resume only the same lineage.",
|
|
||||||
json.dumps([{"id":"heartbeat","description":"Heartbeat observed"}]),
|
|
||||||
json.dumps(caps),json.dumps({"production_write":False}),json.dumps(metadata),
|
|
||||||
None,100,20,0,"QUEUED",claimed_by,
|
|
||||||
"lease-hash" if lease else None,
|
|
||||||
"2099-01-01T00:00:00Z" if lease else None,
|
|
||||||
None,None,None,None,1,"2026-08-20T00:00:00Z","2026-08-20T00:00:00Z",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
con.execute(
|
|
||||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES (?,?,?,?,?)",
|
|
||||||
(TASK_ID,None,event,"{}","2026-08-20T00:00:00Z"),
|
|
||||||
)
|
|
||||||
con.commit();con.close()
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def request(*, digest=None, key="initial-projection-a-v1"):
|
|
||||||
return InitialExecutionProjectionRequest.from_mapping({
|
|
||||||
"expected_status":"QUEUED",
|
|
||||||
"expected_revision":1,
|
|
||||||
"expected_attempt":0,
|
|
||||||
"expected_metadata_sha256":canon({}),
|
|
||||||
"expected_required_capabilities_sha256":canon([]),
|
|
||||||
"expected_job_id":JOB_ID,
|
|
||||||
"expected_execution_digest":digest or envelope()["execution_digest"],
|
|
||||||
"required_capability":CAPABILITY,
|
|
||||||
"adapter":"openclaw",
|
|
||||||
"authorization_id":AUTHORIZATION,
|
|
||||||
"idempotency_key":key,
|
|
||||||
"reason":"initial governed supervisor runtime projection",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def resolver(_task_id,_job_id,_capability,_adapter,received_runtime_input):
|
|
||||||
assert received_runtime_input==runtime_input()
|
|
||||||
return envelope()
|
|
||||||
|
|
||||||
|
|
||||||
def source_capture_result():
|
|
||||||
old=envelope(action="source_capture",capability="git.read")
|
|
||||||
return {
|
|
||||||
"task_id":TASK_ID,
|
|
||||||
"job_id":JOB_ID,
|
|
||||||
"repository":"aios/aios",
|
|
||||||
"base_oid":BASE,
|
|
||||||
"expected_branch_oid":BASE,
|
|
||||||
"target_branch":BRANCH,
|
|
||||||
"generation":1,
|
|
||||||
"status":"SUCCEEDED",
|
|
||||||
"classification":"NEEDS_REWORK_SOURCE_CAPTURE",
|
|
||||||
"execution_mode":"source_capture",
|
|
||||||
"execution_digest":old["execution_digest"],
|
|
||||||
"changed_files":[],
|
|
||||||
"writer_lease":"NONE",
|
|
||||||
"verification_state":"NEEDS_REWORK",
|
|
||||||
"direct_main_write":False,
|
|
||||||
"production_write":False,
|
|
||||||
"force_push":False,
|
|
||||||
"protected_ref_bypass":False,
|
|
||||||
"secret_material_exposed":False,
|
|
||||||
"source_snapshot":{"schema":"test.snapshot.v1","payload_sha256":"d"*64},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def make_source_capture_rework_db(tmp_path, *, mutate=None, events=None):
|
|
||||||
path=make_db(tmp_path)
|
|
||||||
result=source_capture_result()
|
|
||||||
verification={
|
|
||||||
"decision":"NEEDS_REWORK",
|
|
||||||
"reason":"SOURCE_CAPTURE_COMPLETE_RUNTIME_PROJECTION_REQUIRED",
|
|
||||||
}
|
|
||||||
if mutate:
|
|
||||||
mutate(result,verification)
|
|
||||||
event_types=events or [
|
|
||||||
"TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED","TASK_VERIFIED"
|
|
||||||
]
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
con.execute(
|
|
||||||
"UPDATE tasks SET attempt=1,status='QUEUED',claimed_by=NULL,revision=5,"
|
|
||||||
"result_json=?,result_digest=?,result_idempotency_key=?,verification_json=?",
|
|
||||||
(
|
|
||||||
json.dumps(result,sort_keys=True,separators=(",",":")),
|
|
||||||
canon(result),"source-capture-result-v1",
|
|
||||||
json.dumps(verification,sort_keys=True,separators=(",",":")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for event_type in event_types:
|
|
||||||
payload={}
|
|
||||||
if event_type=="TASK_VERIFIED":
|
|
||||||
payload={"decision":"NEEDS_REWORK","next_state":"QUEUED"}
|
|
||||||
con.execute(
|
|
||||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) "
|
|
||||||
"VALUES (?,?,?,?,?)",
|
|
||||||
(TASK_ID,"generic-worker",event_type,json.dumps(payload),"2026-08-20T00:01:00Z"),
|
|
||||||
)
|
|
||||||
con.commit();con.close()
|
|
||||||
return path,result,verification
|
|
||||||
|
|
||||||
|
|
||||||
def recovery_request(result,verification,*,digest=None,key="source-capture-recovery-a-v1"):
|
|
||||||
return SourceCaptureProjectionRecoveryRequest.from_mapping({
|
|
||||||
"expected_status":"QUEUED",
|
|
||||||
"expected_revision":5,
|
|
||||||
"expected_attempt":1,
|
|
||||||
"expected_metadata_sha256":canon({}),
|
|
||||||
"expected_required_capabilities_sha256":canon([]),
|
|
||||||
"previous_verification_sha256":canon(verification),
|
|
||||||
"expected_source_capture_result_sha256":canon(result),
|
|
||||||
"expected_source_capture_execution_digest":result["execution_digest"],
|
|
||||||
"expected_job_id":JOB_ID,
|
|
||||||
"expected_execution_digest":digest or envelope()["execution_digest"],
|
|
||||||
"required_capability":CAPABILITY,
|
|
||||||
"adapter":"openclaw",
|
|
||||||
"authorization_id":AUTHORIZATION,
|
|
||||||
"idempotency_key":key,
|
|
||||||
"reason":"first durable runtime projection after governed source capture",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_projection_cas_persists_envelope_capability_and_event(tmp_path):
|
|
||||||
path=make_db(tmp_path)
|
|
||||||
result=InitialExecutionProjectionStore(path,resolver).project(TASK_ID,request())
|
|
||||||
assert result["revision"]==2
|
|
||||||
assert result["attempt"]==0
|
|
||||||
assert result["idempotent"] is False
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
row=con.execute(
|
|
||||||
"SELECT status,attempt,revision,claimed_by,metadata_json,required_capabilities_json FROM tasks"
|
|
||||||
).fetchone()
|
|
||||||
assert row[:4]==("QUEUED",0,2,None)
|
|
||||||
metadata=json.loads(row[4])
|
|
||||||
assert metadata["execution"]["action"]=="agent_runtime"
|
|
||||||
assert metadata["execution"]["adapter"]=="openclaw"
|
|
||||||
assert metadata["execution"]["governance"]["writer_lease"] is False
|
|
||||||
assert json.loads(row[5])==[CAPABILITY]
|
|
||||||
events=con.execute("SELECT event_type,payload_json FROM events ORDER BY event_id").fetchall()
|
|
||||||
assert [event[0] for event in events]==[
|
|
||||||
"TASK_CREATED","TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED"
|
|
||||||
]
|
|
||||||
payload=json.loads(events[-1][1])
|
|
||||||
assert payload["job_id"]==JOB_ID
|
|
||||||
assert payload["main_write"] is False
|
|
||||||
assert payload["production_write"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_projection_idempotent_exact_replay(tmp_path):
|
|
||||||
path=make_db(tmp_path)
|
|
||||||
store=InitialExecutionProjectionStore(path,resolver)
|
|
||||||
first=store.project(TASK_ID,request())
|
|
||||||
second=store.project(TASK_ID,request())
|
|
||||||
assert first["idempotent"] is False
|
|
||||||
assert second["idempotent"] is True
|
|
||||||
assert second["execution_digest"]==first["execution_digest"]
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM task_initial_execution_projections").fetchone()[0]==1
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0]==2
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_capture_recovery_projects_first_runtime_envelope(tmp_path):
|
|
||||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
|
||||||
response=InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
|
||||||
TASK_ID,recovery_request(result,verification)
|
|
||||||
)
|
|
||||||
assert response["revision"]==6
|
|
||||||
assert response["attempt"]==1
|
|
||||||
assert response["recovery_from_source_capture"] is True
|
|
||||||
assert response["idempotent"] is False
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
row=con.execute(
|
|
||||||
"SELECT status,attempt,revision,claimed_by,metadata_json,"
|
|
||||||
"required_capabilities_json,result_json,verification_json FROM tasks"
|
|
||||||
).fetchone()
|
|
||||||
assert row[:4]==("QUEUED",1,6,None)
|
|
||||||
metadata=json.loads(row[4])
|
|
||||||
assert metadata["execution"]["action"]=="agent_runtime"
|
|
||||||
assert metadata["execution"]["adapter"]=="openclaw"
|
|
||||||
assert metadata["execution"]["governance"]["writer_lease"] is False
|
|
||||||
assert json.loads(row[5])==[CAPABILITY]
|
|
||||||
assert json.loads(row[6])==result
|
|
||||||
assert json.loads(row[7])==verification
|
|
||||||
events=con.execute("SELECT event_type,payload_json FROM events ORDER BY event_id").fetchall()
|
|
||||||
assert [event[0] for event in events]==[
|
|
||||||
"TASK_CREATED","TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED",
|
|
||||||
"TASK_VERIFIED","TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
|
||||||
]
|
|
||||||
payload=json.loads(events[-1][1])
|
|
||||||
assert payload["recovery_from_source_capture"] is True
|
|
||||||
assert payload["source_capture_execution_digest"]==result["execution_digest"]
|
|
||||||
assert payload["main_write"] is False
|
|
||||||
assert payload["production_write"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_capture_recovery_idempotent_exact_replay(tmp_path):
|
|
||||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
|
||||||
store=InitialExecutionProjectionStore(path,resolver)
|
|
||||||
first=store.project_from_source_capture(TASK_ID,recovery_request(result,verification))
|
|
||||||
second=store.project_from_source_capture(TASK_ID,recovery_request(result,verification))
|
|
||||||
assert first["idempotent"] is False
|
|
||||||
assert second["idempotent"] is True
|
|
||||||
assert second["execution_digest"]==first["execution_digest"]
|
|
||||||
con=sqlite3.connect(path)
|
|
||||||
assert con.execute(
|
|
||||||
"SELECT COUNT(*) FROM task_source_capture_projection_recoveries"
|
|
||||||
).fetchone()[0]==1
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0]==6
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"mutate,error",
|
|
||||||
[
|
|
||||||
(lambda result,verification: result.__setitem__("changed_files",["x"]),
|
|
||||||
"SOURCE_CAPTURE_RESULT_CONTRACT_INVALID"),
|
|
||||||
(lambda result,verification: result.__setitem__("job_id","job-other"),
|
|
||||||
"SOURCE_CAPTURE_RESULT_CONTRACT_INVALID"),
|
|
||||||
(lambda result,verification: verification.__setitem__("decision","SUCCEEDED"),
|
|
||||||
"SOURCE_CAPTURE_NEEDS_REWORK_VERIFICATION_REQUIRED"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_source_capture_recovery_rejects_unsafe_result_or_verification(
|
|
||||||
tmp_path,mutate,error
|
|
||||||
):
|
|
||||||
path,result,verification=make_source_capture_rework_db(tmp_path,mutate=mutate)
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match=error):
|
|
||||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
|
||||||
TASK_ID,recovery_request(result,verification)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"events",
|
|
||||||
[
|
|
||||||
["TASK_CLAIMED","RESULT_SUBMITTED","TASK_VERIFIED"],
|
|
||||||
["TASK_CLAIMED","TASK_HEARTBEAT","RESULT_SUBMITTED","TASK_VERIFIED","TASK_CLAIMED"],
|
|
||||||
["TASK_CLAIMED","TASK_HEARTBEAT","TASK_VERIFIED","RESULT_SUBMITTED"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_source_capture_recovery_rejects_invalid_event_chain(tmp_path,events):
|
|
||||||
path,result,verification=make_source_capture_rework_db(tmp_path,events=events)
|
|
||||||
with pytest.raises(
|
|
||||||
InitialExecutionProjectionError,match="SOURCE_CAPTURE_RECOVERY_EVENT_CHAIN_INVALID"
|
|
||||||
):
|
|
||||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
|
||||||
TASK_ID,recovery_request(result,verification)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_capture_recovery_rejects_execution_digest_drift(tmp_path):
|
|
||||||
path,result,verification=make_source_capture_rework_db(tmp_path)
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match="EXECUTION_DIGEST_DRIFT"):
|
|
||||||
InitialExecutionProjectionStore(path,resolver).project_from_source_capture(
|
|
||||||
TASK_ID,recovery_request(result,verification,digest="f"*64)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_capture_recovery_request_is_strict(tmp_path):
|
|
||||||
_path,result,verification=make_source_capture_rework_db(tmp_path)
|
|
||||||
data=recovery_request(result,verification).public()
|
|
||||||
data["metadata_patch"]={"execution":"forbidden"}
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match="FIELDS_INVALID"):
|
|
||||||
SourceCaptureProjectionRecoveryRequest.from_mapping(data)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"kwargs,error",
|
|
||||||
[
|
|
||||||
({"claimed_by":"worker-a"},"TASK_ALREADY_CLAIMED"),
|
|
||||||
({"lease":True},"ACTIVE_LEASE_DENY"),
|
|
||||||
({"metadata":{"execution":{}}},"TASK_EXECUTION_ALREADY_PROJECTED"),
|
|
||||||
({"caps":["git.read"]},"TASK_INITIAL_CAPABILITIES_MUST_BE_EMPTY"),
|
|
||||||
({"event":"TASK_CLAIMED"},"TASK_INITIAL_EVENT_CHAIN_REQUIRED"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_initial_projection_rejects_noninitial_state(tmp_path,kwargs,error):
|
|
||||||
path=make_db(tmp_path,**kwargs)
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match=error):
|
|
||||||
InitialExecutionProjectionStore(path,resolver).project(TASK_ID,request())
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_projection_rejects_binding_drift(tmp_path):
|
|
||||||
path=make_db(tmp_path)
|
|
||||||
def wrong(*_args):
|
|
||||||
return envelope(job_id="job-other")
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match="GOVERNED_BINDING_CONFLICT"):
|
|
||||||
InitialExecutionProjectionStore(path,wrong).project(TASK_ID,request())
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_projection_rejects_nonruntime_envelope(tmp_path):
|
|
||||||
path=make_db(tmp_path)
|
|
||||||
def wrong(*_args):
|
|
||||||
return envelope(action="source_capture",capability="git.read")
|
|
||||||
with pytest.raises(InitialExecutionProjectionError):
|
|
||||||
InitialExecutionProjectionStore(path,wrong).project(TASK_ID,request())
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_projection_rejects_execution_digest_drift(tmp_path):
|
|
||||||
path=make_db(tmp_path)
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match="EXECUTION_DIGEST_DRIFT"):
|
|
||||||
InitialExecutionProjectionStore(path,resolver).project(
|
|
||||||
TASK_ID,request(digest="f"*64)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_is_narrow_and_initial_state_bound():
|
|
||||||
data=request().public()
|
|
||||||
data["arbitrary_metadata"]={"x":1}
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match="FIELDS_INVALID"):
|
|
||||||
InitialExecutionProjectionRequest.from_mapping(data)
|
|
||||||
data=request().public();data["expected_attempt"]=1
|
|
||||||
with pytest.raises(InitialExecutionProjectionError,match="INITIAL_STATE_REQUIRED"):
|
|
||||||
InitialExecutionProjectionRequest.from_mapping(data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_projection_unit_is_unprivileged_and_owner_credential_bound():
|
|
||||||
unit=(ROOT/"deploy/systemd/aios-agent-task-initial-execution-projection.service").read_text()
|
|
||||||
assert "User=aios-agent-task" in unit
|
|
||||||
assert "Group=aios-agent-task" in unit
|
|
||||||
assert "LoadCredential=agent_task_control_token:" in unit
|
|
||||||
assert "Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892" in unit
|
|
||||||
assert "RestrictAddressFamilies=AF_UNIX AF_INET" in unit
|
|
||||||
assert "ReadWritePaths=/var/lib/aios-agent-task" in unit
|
|
||||||
assert "User=root" not in unit
|
|
||||||
@@ -158,7 +158,7 @@ def test_execute_orders_registry_before_baseline_and_baseline_last(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"approved_release_executor.verify_artifact",
|
"approved_release_executor.verify_artifact",
|
||||||
lambda binding: {"verified": True},
|
lambda binding, **kwargs: {"verified": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class AuthoritativeGitContextTests(unittest.TestCase):
|
|||||||
json.dumps({"branch": "patch/context"}), encoding="utf-8"
|
json.dumps({"branch": "patch/context"}), encoding="utf-8"
|
||||||
)
|
)
|
||||||
self.env = {
|
self.env = {
|
||||||
|
"AIOS_TEST_MODE": "1",
|
||||||
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(self.bare),
|
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(self.bare),
|
||||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import base64
|
import base64
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aios_authority_git_executor import AuthorityGitExecutor
|
from aios_authority_git_executor import AuthorityGitExecutor
|
||||||
@@ -25,22 +24,6 @@ class Probe:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def test_authority_create_task_branch_is_agent_scoped_and_idempotent(tmp_path):
|
|
||||||
bare = tmp_path / "repo.git"
|
|
||||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
|
||||||
source = tmp_path / "source"
|
|
||||||
subprocess.run(["git", "init", str(source)], check=True, capture_output=True)
|
|
||||||
(source / "README.md").write_text("fixture\n", encoding="utf-8")
|
|
||||||
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
|
||||||
subprocess.run(["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"], check=True, capture_output=True)
|
|
||||||
base = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip()
|
|
||||||
executor = AuthorityGitExecutor(bare, tmp_path / "worktrees")
|
|
||||||
subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
|
||||||
assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base
|
|
||||||
assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base
|
|
||||||
assert executor.read_ref("refs/heads/agent/contract-test") == base
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_new_lease_is_exact_base_and_absent():
|
def test_create_new_lease_is_exact_base_and_absent():
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
||||||
@@ -92,7 +75,6 @@ def test_slash_branch_uses_paginated_collection():
|
|||||||
def test_http_route_contract_is_complete():
|
def test_http_route_contract_is_complete():
|
||||||
expected = {
|
expected = {
|
||||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||||
"/v2/execution-envelope",
|
|
||||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||||
"/v2/tag/approval-request",
|
"/v2/tag/approval-request",
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
|
||||||
|
|
||||||
from aios_agent_task_projection_recovery import (
|
|
||||||
CanonicalTaskProjectionIntent,
|
|
||||||
CanonicalTaskProjectionStore,
|
|
||||||
ProjectionRecoveryError,
|
|
||||||
ProjectionState,
|
|
||||||
sha256_json,
|
|
||||||
)
|
|
||||||
from aios_agent_task_projection_recovery_http import recover_from_json
|
|
||||||
|
|
||||||
|
|
||||||
TASK_ID = "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826"
|
|
||||||
JOB_ID = "job-9eee1142f2fc8bb7b571d065c01f40e0"
|
|
||||||
OID = "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844"
|
|
||||||
RUNBOOK_SHA = "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
|
||||||
|
|
||||||
|
|
||||||
def make_db(tmp_path: Path) -> Path:
|
|
||||||
path = tmp_path / "state.sqlite3"
|
|
||||||
con = sqlite3.connect(path)
|
|
||||||
con.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE tasks(
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
goal TEXT NOT NULL,
|
|
||||||
instructions TEXT NOT NULL,
|
|
||||||
success_criteria_json TEXT NOT NULL,
|
|
||||||
required_capabilities_json TEXT NOT NULL,
|
|
||||||
constraints_json TEXT NOT NULL,
|
|
||||||
metadata_json TEXT NOT NULL,
|
|
||||||
deadline TEXT,
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
max_attempts INTEGER NOT NULL,
|
|
||||||
attempt INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
claimed_by TEXT,
|
|
||||||
lease_token_hash TEXT,
|
|
||||||
lease_expires_at TEXT,
|
|
||||||
result_json TEXT,
|
|
||||||
result_digest TEXT,
|
|
||||||
result_idempotency_key TEXT,
|
|
||||||
verification_json TEXT,
|
|
||||||
revision INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE events(
|
|
||||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_id TEXT,
|
|
||||||
agent_id TEXT,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
con.commit()
|
|
||||||
con.close()
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def manifest() -> dict:
|
|
||||||
task = {
|
|
||||||
"task_id": TASK_ID,
|
|
||||||
"goal": "Complete V2R2C2 staging convergence",
|
|
||||||
"instructions": "Resume the immutable runbook and close the parent goal.",
|
|
||||||
"success_criteria": [{"id": "closure", "required": True, "expected": "PASS"}],
|
|
||||||
"constraints": {
|
|
||||||
"staging_only": True,
|
|
||||||
"production_write": False,
|
|
||||||
"main_direct_write": False,
|
|
||||||
},
|
|
||||||
"required_capabilities": [],
|
|
||||||
"metadata": {
|
|
||||||
"governed_task_id": TASK_ID,
|
|
||||||
"bridge_job_id": JOB_ID,
|
|
||||||
"runbook_sha256": RUNBOOK_SHA,
|
|
||||||
},
|
|
||||||
"priority": 100,
|
|
||||||
"max_attempts": 20,
|
|
||||||
"deadline": None,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"schema": "aios.canonical-task-projection-intent.v1",
|
|
||||||
"projection_id": "projection-v2r2c2-parent-recovery-20260826",
|
|
||||||
"projection_generation": 1,
|
|
||||||
"idempotency_key": "projection-v2r2c2-parent-recovery-20260826-v1",
|
|
||||||
"task": task,
|
|
||||||
"governed_binding": {
|
|
||||||
"governed_task_id": TASK_ID,
|
|
||||||
"bridge_job_id": JOB_ID,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch": "agent/v2r2c2-convergence-integration-20260825",
|
|
||||||
"generation": 2,
|
|
||||||
"base_oid": OID,
|
|
||||||
"expected_branch_oid": OID,
|
|
||||||
},
|
|
||||||
"source_artifact": {
|
|
||||||
"ref": f"git:{OID}:runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
|
||||||
"sha256": RUNBOOK_SHA,
|
|
||||||
},
|
|
||||||
"task_payload_sha256": sha256_json(task),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_task_is_recovered_with_same_identity(tmp_path):
|
|
||||||
db = make_db(tmp_path)
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
||||||
result = CanonicalTaskProjectionStore(db).reconcile(intent)
|
|
||||||
|
|
||||||
assert result["state"] == ProjectionState.CONVERGED.value
|
|
||||||
assert result["canonical_task_id"] == TASK_ID
|
|
||||||
assert result["bridge_job_id"] == JOB_ID
|
|
||||||
assert result["created_new_row"] is True
|
|
||||||
|
|
||||||
con = sqlite3.connect(db)
|
|
||||||
row = con.execute(
|
|
||||||
"SELECT task_id,status,attempt,revision,claimed_by,"
|
|
||||||
"required_capabilities_json,metadata_json FROM tasks WHERE task_id=?",
|
|
||||||
(TASK_ID,),
|
|
||||||
).fetchone()
|
|
||||||
assert row[:5] == (TASK_ID, "QUEUED", 0, 1, None)
|
|
||||||
assert json.loads(row[5]) == []
|
|
||||||
metadata = json.loads(row[6])
|
|
||||||
assert "execution" not in metadata
|
|
||||||
assert "execution_projection" not in metadata
|
|
||||||
events = con.execute(
|
|
||||||
"SELECT event_type FROM events WHERE task_id=? ORDER BY event_id",
|
|
||||||
(TASK_ID,),
|
|
||||||
).fetchall()
|
|
||||||
assert events == [("TASK_CREATED",)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_replay_is_idempotent_and_creates_no_duplicate_event(tmp_path):
|
|
||||||
db = make_db(tmp_path)
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
||||||
store = CanonicalTaskProjectionStore(db)
|
|
||||||
first = store.reconcile(intent)
|
|
||||||
second = store.reconcile(intent)
|
|
||||||
|
|
||||||
assert first["idempotent"] is False
|
|
||||||
assert second["idempotent"] is True
|
|
||||||
con = sqlite3.connect(db)
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
|
||||||
assert con.execute(
|
|
||||||
"SELECT COUNT(*) FROM task_projection_receipts"
|
|
||||||
).fetchone()[0] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_matching_row_converges_without_second_task_created_event(tmp_path):
|
|
||||||
db = make_db(tmp_path)
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
||||||
store = CanonicalTaskProjectionStore(db)
|
|
||||||
store.reconcile(intent)
|
|
||||||
|
|
||||||
con = sqlite3.connect(db)
|
|
||||||
con.execute("DELETE FROM task_projection_receipts")
|
|
||||||
con.execute("DELETE FROM task_projection_intents")
|
|
||||||
con.commit()
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
result = store.reconcile(intent)
|
|
||||||
assert result["created_new_row"] is False
|
|
||||||
|
|
||||||
con = sqlite3.connect(db)
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
|
||||||
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_different_task_payload_fails_closed(tmp_path):
|
|
||||||
db = make_db(tmp_path)
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
||||||
store = CanonicalTaskProjectionStore(db)
|
|
||||||
store.reconcile(intent)
|
|
||||||
|
|
||||||
con = sqlite3.connect(db)
|
|
||||||
con.execute("DELETE FROM task_projection_receipts")
|
|
||||||
con.execute("DELETE FROM task_projection_intents")
|
|
||||||
con.execute("UPDATE tasks SET goal='drifted'")
|
|
||||||
con.commit()
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
ProjectionRecoveryError,
|
|
||||||
match="CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
|
||||||
):
|
|
||||||
store.reconcile(intent)
|
|
||||||
|
|
||||||
|
|
||||||
def test_intent_replay_with_same_generation_but_different_request_conflicts(tmp_path):
|
|
||||||
db = make_db(tmp_path)
|
|
||||||
first = manifest()
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(first)
|
|
||||||
store = CanonicalTaskProjectionStore(db)
|
|
||||||
store.persist_intent(intent)
|
|
||||||
|
|
||||||
other = manifest()
|
|
||||||
other["task"]["instructions"] = "different"
|
|
||||||
other["task_payload_sha256"] = sha256_json(other["task"])
|
|
||||||
other_intent = CanonicalTaskProjectionIntent.from_mapping(other)
|
|
||||||
with pytest.raises(
|
|
||||||
ProjectionRecoveryError,
|
|
||||||
match="PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
|
||||||
):
|
|
||||||
store.persist_intent(other_intent)
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_refuses_non_pristine_execution_metadata(tmp_path):
|
|
||||||
value = manifest()
|
|
||||||
value["task"]["metadata"]["execution"] = {"action": "agent_runtime"}
|
|
||||||
value["task_payload_sha256"] = sha256_json(value["task"])
|
|
||||||
with pytest.raises(
|
|
||||||
ProjectionRecoveryError,
|
|
||||||
match="TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
|
||||||
):
|
|
||||||
CanonicalTaskProjectionIntent.from_mapping(value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_refuses_runtime_capability_in_pristine_row(tmp_path):
|
|
||||||
value = manifest()
|
|
||||||
value["task"]["required_capabilities"] = ["agent.runtime.openclaw"]
|
|
||||||
value["task_payload_sha256"] = sha256_json(value["task"])
|
|
||||||
with pytest.raises(
|
|
||||||
ProjectionRecoveryError,
|
|
||||||
match="TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
|
||||||
):
|
|
||||||
CanonicalTaskProjectionIntent.from_mapping(value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_adapter_rejects_path_body_task_mismatch(tmp_path):
|
|
||||||
db = make_db(tmp_path)
|
|
||||||
status, body = recover_from_json(db, "TASK-other-123", manifest())
|
|
||||||
assert status == 409
|
|
||||||
assert body["error"] == "PROJECTION_TASK_IDENTITY_MISMATCH"
|
|
||||||
|
|
||||||
|
|
||||||
def test_projection_readback_contains_durable_mapping_and_receipt(tmp_path):
|
|
||||||
db = make_db(tmp_path)
|
|
||||||
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
||||||
store = CanonicalTaskProjectionStore(db)
|
|
||||||
store.reconcile(intent)
|
|
||||||
|
|
||||||
projection = store.get_projection(TASK_ID)
|
|
||||||
assert projection is not None
|
|
||||||
assert projection["task_id"] == TASK_ID
|
|
||||||
assert projection["bridge_job_id"] == JOB_ID
|
|
||||||
assert projection["state"] == "CONVERGED"
|
|
||||||
assert projection["receipt"]["canonical_task_id"] == TASK_ID
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from aios_gitops_job_controller import BindingConflict, GitOpsJobController, JobError, JobState, JobStore, sha256_json
|
|
||||||
|
|
||||||
|
|
||||||
TASK = "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826"
|
|
||||||
EXTERNAL = "job-external-v7"
|
|
||||||
BRANCH = "agent/v2r2c2-convergence-integration-20260825"
|
|
||||||
BASE = "e" * 40
|
|
||||||
COMMIT = "1" * 40
|
|
||||||
|
|
||||||
|
|
||||||
def make_store(tmp_path: Path) -> tuple[GitOpsJobController, str, dict]:
|
|
||||||
store = JobStore(tmp_path / "job-root")
|
|
||||||
controller = GitOpsJobController(store)
|
|
||||||
request = {
|
|
||||||
"idempotency_key": "v7-replay",
|
|
||||||
"campaign_id": TASK,
|
|
||||||
"task_id": TASK,
|
|
||||||
"external_job_id": EXTERNAL,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"operation": "CONTINUATION_COMMIT_FILES",
|
|
||||||
"branch": BRANCH,
|
|
||||||
"target_branch": BRANCH,
|
|
||||||
"branch_mode": "CONTINUE_EXISTING",
|
|
||||||
"base_oid": BASE,
|
|
||||||
"lineage_anchor_oid": BASE,
|
|
||||||
"expected_branch_oid": BASE,
|
|
||||||
"generation": 2,
|
|
||||||
"payload_digest": "a" * 64,
|
|
||||||
"files_digest": "b" * 64,
|
|
||||||
"message_digest": "c" * 64,
|
|
||||||
"worker_principal": "aios-autonomy-agent",
|
|
||||||
"worker_lineage": "governed-continuation",
|
|
||||||
}
|
|
||||||
receipt = controller.submit(request)
|
|
||||||
controller.advance(receipt.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
|
||||||
row = store.read(receipt.job_id)
|
|
||||||
controller.lease_job(
|
|
||||||
receipt.job_id,
|
|
||||||
lease_id="lease-v7-epoch-3",
|
|
||||||
lineage_anchor_oid=BASE,
|
|
||||||
current_head_oid=BASE,
|
|
||||||
generation=2,
|
|
||||||
lease_expires_at="2099-01-01T00:00:00Z",
|
|
||||||
expected_revision=row["revision"],
|
|
||||||
)
|
|
||||||
with store._connect() as connection:
|
|
||||||
connection.execute("ALTER TABLE branch_leases ADD COLUMN epoch INTEGER")
|
|
||||||
connection.execute("UPDATE branch_leases SET epoch=3, state='USED' WHERE job_id=?", (receipt.job_id,))
|
|
||||||
connection.execute("CREATE TABLE branch_lease_events (lease_id TEXT NOT NULL, state TEXT NOT NULL, current_head_oid TEXT NOT NULL, generation INTEGER NOT NULL)")
|
|
||||||
connection.execute("INSERT INTO branch_lease_events VALUES (?, 'USED', ?, 2)", ("lease-v7-epoch-3", BASE))
|
|
||||||
controller.advance(receipt.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
|
||||||
controller.advance(receipt.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
|
||||||
completed = {
|
|
||||||
"commit_oid": COMMIT,
|
|
||||||
"authority_oid": COMMIT,
|
|
||||||
"remote_verified": True,
|
|
||||||
"worker_principal": request["worker_principal"],
|
|
||||||
"worker_lineage": request["worker_lineage"],
|
|
||||||
}
|
|
||||||
controller.advance(receipt.job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=completed)
|
|
||||||
final = controller.advance(receipt.job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=completed)
|
|
||||||
row = store.read(receipt.job_id)
|
|
||||||
envelope = {
|
|
||||||
"task_id": TASK,
|
|
||||||
"external_job_id": EXTERNAL,
|
|
||||||
"internal_job_id": receipt.job_id,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"target_branch": BRANCH,
|
|
||||||
"generation": 2,
|
|
||||||
"branch_mode": "CONTINUE_EXISTING",
|
|
||||||
"payload_digest": request["payload_digest"],
|
|
||||||
"files_digest": request["files_digest"],
|
|
||||||
"message_digest": request["message_digest"],
|
|
||||||
"commit_oid": COMMIT,
|
|
||||||
"authority_head_oid": COMMIT,
|
|
||||||
"worker_principal": request["worker_principal"],
|
|
||||||
"worker_lineage": request["worker_lineage"],
|
|
||||||
"source_lease_epoch": 3,
|
|
||||||
"historical_lease_head_oid": BASE,
|
|
||||||
"durable_revision": row["revision"],
|
|
||||||
"completion_identity": sha256_json({"job_id": receipt.job_id, "revision": row["revision"], "receipt": completed}),
|
|
||||||
"reason": "COMPLETED_REPLAY_LEASE_EVIDENCE_RECONCILIATION",
|
|
||||||
"provenance": {"source": "v7-test", "authority_readback": True},
|
|
||||||
}
|
|
||||||
assert final.state is JobState.COMPLETED
|
|
||||||
return controller, receipt.job_id, envelope
|
|
||||||
|
|
||||||
|
|
||||||
def test_completed_replay_is_append_only_and_exactly_idempotent(tmp_path: Path):
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
with controller.store._connect() as connection:
|
|
||||||
before = dict(connection.execute("SELECT * FROM branch_leases WHERE job_id=?", (job_id,)).fetchone())
|
|
||||||
before_events = [dict(row) for row in connection.execute("SELECT * FROM branch_lease_events WHERE lease_id=?", ("lease-v7-epoch-3",)).fetchall()]
|
|
||||||
old_receipt = connection.execute("SELECT receipt_json FROM jobs WHERE job_id=?", (job_id,)).fetchone()[0]
|
|
||||||
first = controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
|
||||||
second = controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
|
||||||
restarted = GitOpsJobController(JobStore(controller.store.root))
|
|
||||||
third = restarted.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid=COMMIT)
|
|
||||||
assert first["reconciliation_id"] == second["reconciliation_id"] == third["reconciliation_id"]
|
|
||||||
assert first["current_head_oid"] == COMMIT
|
|
||||||
with controller.store._connect() as connection:
|
|
||||||
after = dict(connection.execute("SELECT * FROM branch_leases WHERE job_id=?", (job_id,)).fetchone())
|
|
||||||
after_events = [dict(row) for row in connection.execute("SELECT * FROM branch_lease_events WHERE lease_id=?", ("lease-v7-epoch-3",)).fetchall()]
|
|
||||||
new_receipt = connection.execute("SELECT receipt_json FROM jobs WHERE job_id=?", (job_id,)).fetchone()[0]
|
|
||||||
count = connection.execute("SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?", (job_id,)).fetchone()[0]
|
|
||||||
assert before == after
|
|
||||||
assert before_events == after_events
|
|
||||||
assert old_receipt == new_receipt
|
|
||||||
assert count == 1
|
|
||||||
with pytest.raises(sqlite3.IntegrityError, match="COMPLETED_REPLAY_APPEND_ONLY"):
|
|
||||||
with controller.store._connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE completed_replay_reconciliations SET reason='tampered' WHERE job_id=?",
|
|
||||||
(job_id,),
|
|
||||||
)
|
|
||||||
with pytest.raises(sqlite3.IntegrityError, match="COMPLETED_REPLAY_APPEND_ONLY"):
|
|
||||||
with controller.store._connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"DELETE FROM completed_replay_reconciliations WHERE job_id=?",
|
|
||||||
(job_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_completed_replay_authority_conflict_fails_closed(tmp_path: Path):
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
with pytest.raises(BindingConflict, match="AUTHORITY_CONFLICT"):
|
|
||||||
controller.store.append_completed_replay_reconciliation(job_id, envelope, authority_head_oid="2" * 40)
|
|
||||||
|
|
||||||
|
|
||||||
def test_completed_replay_binding_conflict_fails_closed(tmp_path: Path):
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
conflicting = {**envelope, "files_digest": "d" * 64}
|
|
||||||
with pytest.raises(BindingConflict, match="BINDING_CONFLICT"):
|
|
||||||
controller.store.append_completed_replay_reconciliation(job_id, conflicting, authority_head_oid=COMMIT)
|
|
||||||
|
|
||||||
|
|
||||||
def test_identical_reconciliation_converges_under_contention(tmp_path: Path):
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
|
|
||||||
def run(_: int) -> str:
|
|
||||||
return controller.store.append_completed_replay_reconciliation(
|
|
||||||
job_id, envelope, authority_head_oid=COMMIT
|
|
||||||
)["reconciliation_id"]
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
||||||
values = list(pool.map(run, range(16)))
|
|
||||||
assert set(values) == {values[0]}
|
|
||||||
assert controller.store.completed_replay_evidence(job_id)["commit_oid"] == COMMIT
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_job_projection_exposes_external_reconciled_head(tmp_path: Path):
|
|
||||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
|
||||||
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
reconciliation = controller.store.append_completed_replay_reconciliation(
|
|
||||||
job_id, envelope, authority_head_oid=COMMIT
|
|
||||||
)
|
|
||||||
|
|
||||||
class Authority:
|
|
||||||
def read_ref(self, ref: str):
|
|
||||||
assert ref == "refs/heads/" + BRANCH
|
|
||||||
return COMMIT
|
|
||||||
|
|
||||||
runtime = object.__new__(GovernedRuntime)
|
|
||||||
runtime.authority = Authority()
|
|
||||||
runtime._controller_for_job = lambda _job_id: controller
|
|
||||||
result = GovernedRuntime.job(runtime, {"job_id": job_id}, "get")
|
|
||||||
assert result["job_id"] == EXTERNAL
|
|
||||||
assert result["current_head_oid"] == COMMIT
|
|
||||||
assert result["completed_replay_reconciliation_id"] == reconciliation["reconciliation_id"]
|
|
||||||
assert result["receipt"]["commit_oid"] == result["receipt"]["authority_oid"] == COMMIT
|
|
||||||
|
|
||||||
|
|
||||||
def test_writer_lease_admits_exact_completed_replay_without_new_lease(tmp_path: Path):
|
|
||||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
|
||||||
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
controller.store.append_completed_replay_reconciliation(
|
|
||||||
job_id, envelope, authority_head_oid=COMMIT
|
|
||||||
)
|
|
||||||
|
|
||||||
class Authority:
|
|
||||||
def read_ref(self, ref: str):
|
|
||||||
assert ref == "refs/heads/" + BRANCH
|
|
||||||
return COMMIT
|
|
||||||
|
|
||||||
runtime = object.__new__(GovernedRuntime)
|
|
||||||
runtime.config = SimpleNamespace(state_root=tmp_path)
|
|
||||||
runtime.controllers = {"job": controller}
|
|
||||||
runtime.authority = Authority()
|
|
||||||
body = {
|
|
||||||
"task_id": TASK,
|
|
||||||
"job_id": EXTERNAL,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch_mode": "CONTINUE_EXISTING",
|
|
||||||
"base_oid": BASE,
|
|
||||||
"expected_branch_oid": BASE,
|
|
||||||
"target_branch": BRANCH,
|
|
||||||
"generation": 2,
|
|
||||||
"lease_seconds": 900,
|
|
||||||
}
|
|
||||||
result = GovernedRuntime.acquire_lease(runtime, body)
|
|
||||||
assert result["completed_replay"] is True
|
|
||||||
assert result["lease_created"] is False
|
|
||||||
assert result["expected_branch_oid"] == BASE
|
|
||||||
assert result["current_head_oid"] == COMMIT
|
|
||||||
assert result["completed_replay_reconciliation_id"].startswith("reconcile-")
|
|
||||||
with controller.store._connect() as connection:
|
|
||||||
assert connection.execute(
|
|
||||||
"SELECT COUNT(*) FROM branch_leases WHERE job_id=?", (job_id,)
|
|
||||||
).fetchone()[0] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_conflicting_reconciliation_has_no_ambiguous_winner(tmp_path: Path):
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
conflicting = {**envelope, "reason": "CONFLICTING_PROVENANCE"}
|
|
||||||
values = [envelope, conflicting] * 8
|
|
||||||
|
|
||||||
def run(candidate: dict) -> str:
|
|
||||||
try:
|
|
||||||
result = controller.store.append_completed_replay_reconciliation(
|
|
||||||
job_id, candidate, authority_head_oid=COMMIT
|
|
||||||
)
|
|
||||||
return "ok:" + result["identity_digest"]
|
|
||||||
except BindingConflict:
|
|
||||||
return "conflict"
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
||||||
outcomes = list(pool.map(run, values))
|
|
||||||
winners = {item for item in outcomes if item.startswith("ok:")}
|
|
||||||
assert len(winners) == 1
|
|
||||||
assert "conflict" in outcomes
|
|
||||||
with controller.store._connect() as connection:
|
|
||||||
assert connection.execute(
|
|
||||||
"SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?", (job_id,)
|
|
||||||
).fetchone()[0] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconciliation_rejects_head_alias_before_append(tmp_path: Path):
|
|
||||||
controller, job_id, envelope = make_store(tmp_path)
|
|
||||||
conflicting = {**envelope, "historical_lease_head_oid": COMMIT}
|
|
||||||
with pytest.raises(BindingConflict, match="HISTORICAL_LEASE_CONFLICT"):
|
|
||||||
controller.store.append_completed_replay_reconciliation(
|
|
||||||
job_id, conflicting, authority_head_oid=COMMIT
|
|
||||||
)
|
|
||||||
with controller.store._connect() as connection:
|
|
||||||
assert connection.execute(
|
|
||||||
"SELECT COUNT(*) FROM completed_replay_reconciliations WHERE job_id=?",
|
|
||||||
(job_id,),
|
|
||||||
).fetchone()[0] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_commit_allowlist_carries_replay_identity(tmp_path: Path):
|
|
||||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
|
||||||
|
|
||||||
handler = object.__new__(BridgeHandler)
|
|
||||||
body = {
|
|
||||||
"task_id": TASK,
|
|
||||||
"job_id": EXTERNAL,
|
|
||||||
"source_lease_epoch": 3,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"expected_branch_oid": BASE,
|
|
||||||
"target_branch": BRANCH,
|
|
||||||
"generation": 2,
|
|
||||||
"idempotency_key": "v7-replay",
|
|
||||||
"files": [{"path": "x.txt", "content_base64": "eA=="}],
|
|
||||||
"message": "replay",
|
|
||||||
}
|
|
||||||
handler._allowed("commitFilesGoverned", body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cold_append_requires_existing_database(tmp_path: Path):
|
|
||||||
missing = tmp_path / "missing-job-root"
|
|
||||||
with pytest.raises(JobError, match="JOB_ROOT_NOT_FOUND"):
|
|
||||||
JobStore.for_existing_append(missing)
|
|
||||||
assert not missing.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
|
||||||
from http.client import HTTPConnection
|
|
||||||
from http.server import HTTPServer
|
|
||||||
from threading import Thread
|
|
||||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
|
||||||
|
|
||||||
observed = {}
|
|
||||||
class Adapter:
|
|
||||||
def commitFilesGoverned(self, body):
|
|
||||||
observed.update(body)
|
|
||||||
return {"job_id": body["job_id"], "current_head_oid": COMMIT}
|
|
||||||
|
|
||||||
server = HTTPServer(("127.0.0.1", 0), BridgeHandler)
|
|
||||||
BridgeHandler.adapter = Adapter()
|
|
||||||
thread = Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
body = {
|
|
||||||
"task_id": TASK,
|
|
||||||
"job_id": EXTERNAL,
|
|
||||||
"source_lease_epoch": 3,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"expected_branch_oid": BASE,
|
|
||||||
"target_branch": BRANCH,
|
|
||||||
"generation": 2,
|
|
||||||
"idempotency_key": "v7-replay",
|
|
||||||
"files": [{"path": "x.txt", "content_base64": "eA=="}],
|
|
||||||
"message": "replay",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5)
|
|
||||||
connection.request("POST", "/v2/commit-files", body=json.dumps(body), headers={"Content-Type": "application/json"})
|
|
||||||
response = connection.getresponse()
|
|
||||||
payload = json.loads(response.read().decode())
|
|
||||||
connection.close()
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
thread.join(timeout=5)
|
|
||||||
BridgeHandler.adapter = None
|
|
||||||
assert response.status == 200
|
|
||||||
assert observed["job_id"] == EXTERNAL
|
|
||||||
assert observed["source_lease_epoch"] == 3
|
|
||||||
assert payload["job_id"] == EXTERNAL
|
|
||||||
assert payload["current_head_oid"] == COMMIT
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
|
||||||
|
|
||||||
from aios_convergence_contracts import (
|
|
||||||
AcceptanceCriterion,
|
|
||||||
AcceptanceDecision,
|
|
||||||
AcceptanceInvariant,
|
|
||||||
AcceptanceVector,
|
|
||||||
ContinuationManifest,
|
|
||||||
ContinuationStopReason,
|
|
||||||
ConvergenceContractError,
|
|
||||||
DesiredState,
|
|
||||||
EvidenceState,
|
|
||||||
ObservedHealth,
|
|
||||||
ObservedState,
|
|
||||||
ReconciliationState,
|
|
||||||
StateOwnership,
|
|
||||||
evaluate_acceptance,
|
|
||||||
ownership_index,
|
|
||||||
plan_reconciliation,
|
|
||||||
validate_state_ownership,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_acceptance_never_promotes_required_unknown():
|
|
||||||
vector = AcceptanceVector(
|
|
||||||
subject_type="goal",
|
|
||||||
subject_id="goal-1",
|
|
||||||
subject_revision=1,
|
|
||||||
criteria=(
|
|
||||||
AcceptanceCriterion(
|
|
||||||
criterion_id="runtime",
|
|
||||||
required=True,
|
|
||||||
state=EvidenceState.UNKNOWN,
|
|
||||||
verifier_id="runtime-proof",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result = evaluate_acceptance(vector)
|
|
||||||
assert result.decision == AcceptanceDecision.UNKNOWN
|
|
||||||
assert result.reasons == ("criterion_unknown:runtime",)
|
|
||||||
|
|
||||||
|
|
||||||
def test_acceptance_rejects_failed_invariant():
|
|
||||||
vector = AcceptanceVector(
|
|
||||||
subject_type="release",
|
|
||||||
subject_id="V2R2C2",
|
|
||||||
subject_revision=1,
|
|
||||||
criteria=(
|
|
||||||
AcceptanceCriterion("tests", True, EvidenceState.PASS, "pytest"),
|
|
||||||
),
|
|
||||||
invariants=(
|
|
||||||
AcceptanceInvariant("protected-ref", EvidenceState.FAIL),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert evaluate_acceptance(vector).decision == AcceptanceDecision.REJECT
|
|
||||||
|
|
||||||
|
|
||||||
def test_acceptance_blocks_on_unresolved_blocker():
|
|
||||||
vector = AcceptanceVector(
|
|
||||||
subject_type="goal",
|
|
||||||
subject_id="goal-1",
|
|
||||||
subject_revision=1,
|
|
||||||
criteria=(AcceptanceCriterion("code", True, EvidenceState.PASS, "ci"),),
|
|
||||||
unresolved_blockers=("blocker-1",),
|
|
||||||
)
|
|
||||||
assert evaluate_acceptance(vector).decision == AcceptanceDecision.BLOCKED
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_ownership_is_unique_and_final_writer_is_enforced():
|
|
||||||
custom = (
|
|
||||||
StateOwnership(
|
|
||||||
object_type="goal",
|
|
||||||
authoritative_service="goal-kernel",
|
|
||||||
authoritative_store="goal-store",
|
|
||||||
final_writer="goal-kernel",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
validate_state_ownership("goal", "goal-kernel", custom, final_state=True)
|
|
||||||
with pytest.raises(ConvergenceContractError, match="STATE_OWNERSHIP_VIOLATION"):
|
|
||||||
validate_state_ownership("goal", "worker", custom, final_state=True)
|
|
||||||
with pytest.raises(ConvergenceContractError, match="duplicate_state_owner"):
|
|
||||||
ownership_index(custom + custom)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_or_unknown_observation_never_converges():
|
|
||||||
desired = DesiredState("contract:gpt", "external_projection", 2, {"hash": "abc"})
|
|
||||||
missing = plan_reconciliation(desired, None)
|
|
||||||
assert missing.state == ReconciliationState.OBSERVING
|
|
||||||
assert missing.observation_required is True
|
|
||||||
|
|
||||||
unknown = ObservedState(
|
|
||||||
resource_id="contract:gpt",
|
|
||||||
observed_generation=None,
|
|
||||||
observed_spec={},
|
|
||||||
health=ObservedHealth.UNKNOWN,
|
|
||||||
source_identity="gpt-action",
|
|
||||||
observed_at="2026-08-25T00:00:00Z",
|
|
||||||
)
|
|
||||||
result = plan_reconciliation(desired, unknown)
|
|
||||||
assert result.state == ReconciliationState.OBSERVING
|
|
||||||
|
|
||||||
|
|
||||||
def test_equal_healthy_observation_converges_and_key_is_stable():
|
|
||||||
desired = DesiredState("worker:a", "worker_binding", 3, {"build": "x"})
|
|
||||||
observed = ObservedState(
|
|
||||||
resource_id="worker:a",
|
|
||||||
observed_generation=3,
|
|
||||||
observed_spec={"build": "x"},
|
|
||||||
health=ObservedHealth.HEALTHY,
|
|
||||||
source_identity="worker-registry",
|
|
||||||
observed_at="2026-08-25T00:00:00Z",
|
|
||||||
)
|
|
||||||
one = plan_reconciliation(desired, observed)
|
|
||||||
two = plan_reconciliation(desired, observed)
|
|
||||||
assert one.state == ReconciliationState.CONVERGED
|
|
||||||
assert one.idempotency_key == two.idempotency_key
|
|
||||||
|
|
||||||
|
|
||||||
def test_drift_is_diff_found_not_auto_applied():
|
|
||||||
desired = DesiredState("projection:openapi", "external_projection", 4, {"hash": "new"})
|
|
||||||
observed = ObservedState(
|
|
||||||
resource_id="projection:openapi",
|
|
||||||
observed_generation=3,
|
|
||||||
observed_spec={"hash": "old"},
|
|
||||||
health=ObservedHealth.HEALTHY,
|
|
||||||
source_identity="published-openapi",
|
|
||||||
observed_at="2026-08-25T00:00:00Z",
|
|
||||||
)
|
|
||||||
result = plan_reconciliation(desired, observed)
|
|
||||||
assert result.state == ReconciliationState.DIFF_FOUND
|
|
||||||
assert result.observation_required is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_continuation_manifest_is_durable_and_hashable():
|
|
||||||
manifest = ContinuationManifest(
|
|
||||||
trace_id="trace-1",
|
|
||||||
goal_id="goal-1",
|
|
||||||
goal_revision=1,
|
|
||||||
task_id="task-1",
|
|
||||||
run_id="run-1",
|
|
||||||
current_node_id="node-4",
|
|
||||||
acceptance_vector_ref="acceptance:goal-1:1",
|
|
||||||
next_action="resume node-4",
|
|
||||||
stop_reason=ContinuationStopReason.CONTEXT_BOUNDARY,
|
|
||||||
checkpoint_ref="checkpoint-9",
|
|
||||||
unresolved_blocker_refs=("blocker-a",),
|
|
||||||
)
|
|
||||||
assert manifest.as_dict()["stop_reason"] == "context_boundary"
|
|
||||||
assert manifest.content_hash.startswith("sha256:")
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||||
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
|
import governed_production_release as release # noqa: E402
|
||||||
|
from version_registry import build_source_registry_entry, source_registry_json # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _git(work: Path, *args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", str(work), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(work: Path, message: str) -> str:
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "-A"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", message], check=True)
|
||||||
|
return _git(work, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_uses_pinned_source_registry_commit_when_main_advances(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
if shutil.which("zstd") is None:
|
||||||
|
pytest.skip("zstd is required for governed artifact integration")
|
||||||
|
work = tmp_path / "work"
|
||||||
|
bare = tmp_path / "repo.git"
|
||||||
|
subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True)
|
||||||
|
_git(work, "config", "user.name", "test")
|
||||||
|
_git(work, "config", "user.email", "test@example.invalid")
|
||||||
|
(work / "active-runtime/app").mkdir(parents=True)
|
||||||
|
(work / "active-runtime/app/deployment_state.py").write_text("state = 'candidate'\n")
|
||||||
|
(work / "active-runtime/app/v2r1c6_privacy_tools.py").write_text("def main(): return 0\n")
|
||||||
|
target_oid = _commit(work, "release target")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(work), "tag", "-a", "V2R2C1", target_oid, "-m", "release"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
tag_oid = _git(work, "rev-parse", "refs/tags/V2R2C1")
|
||||||
|
(work / "evidence.txt").write_text("evidence\n")
|
||||||
|
evidence_oid = _commit(work, "evidence closure")
|
||||||
|
record = build_source_registry_entry(
|
||||||
|
version="V2R2C1",
|
||||||
|
tag_oid=tag_oid,
|
||||||
|
target_oid=target_oid,
|
||||||
|
peeled_target_oid=target_oid,
|
||||||
|
evidence_closure_oid=evidence_oid,
|
||||||
|
approval_plan_id="sha256:" + "4" * 64,
|
||||||
|
)
|
||||||
|
source_path = work / "versions/V2R2C1.json"
|
||||||
|
source_path.parent.mkdir(parents=True)
|
||||||
|
source_path.write_bytes(source_registry_json(record))
|
||||||
|
main_oid = _commit(work, "source registry convergence")
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "remote", "add", "authority", str(bare)], check=True)
|
||||||
|
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||||
|
|
||||||
|
monkeypatch.setattr(release, "TAG_OID", tag_oid)
|
||||||
|
monkeypatch.setattr(release, "TARGET_OID", target_oid)
|
||||||
|
monkeypatch.setattr(release, "EVIDENCE_CLOSURE_OID", evidence_oid)
|
||||||
|
monkeypatch.setattr(release, "APPROVAL_PLAN_ID", record["approval_plan_id"])
|
||||||
|
binding = release.ReleaseBinding(
|
||||||
|
tag_oid=tag_oid,
|
||||||
|
target_oid=target_oid,
|
||||||
|
evidence_closure_oid=evidence_oid,
|
||||||
|
approval_plan_id=record["approval_plan_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
original_payload = release._payload_file
|
||||||
|
advanced = False
|
||||||
|
|
||||||
|
def advance_after_first_payload(git_dir: Path, oid: str, path: str) -> bytes:
|
||||||
|
nonlocal advanced
|
||||||
|
result = original_payload(git_dir, oid, path)
|
||||||
|
if not advanced and path == "active-runtime/app/deployment_state.py":
|
||||||
|
updated = dict(record)
|
||||||
|
updated["review_marker"] = "main-advanced-after-validation"
|
||||||
|
source_path.write_bytes(source_registry_json(updated))
|
||||||
|
new_main = _commit(work, "advance main after source validation")
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", "authority", "main"], check=True)
|
||||||
|
assert new_main != main_oid
|
||||||
|
advanced = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(release, "_payload_file", advance_after_first_payload)
|
||||||
|
receipt = release.build_production_artifact(bare, binding, tmp_path / "artifacts")
|
||||||
|
assert advanced is True
|
||||||
|
assert receipt["release_target_oid"] == target_oid
|
||||||
|
assert receipt["source_registry_main_oid"] == main_oid
|
||||||
|
assert receipt["source_registry_path"] == "versions/V2R2C1.json"
|
||||||
|
|
||||||
|
expected_bytes = subprocess.run(
|
||||||
|
["git", f"--git-dir={bare}", "show", f"{main_oid}:versions/V2R2C1.json"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
).stdout
|
||||||
|
assert receipt["source_registry_sha256"] == __import__("hashlib").sha256(expected_bytes).hexdigest()
|
||||||
|
compressed = Path(receipt["artifact"]).read_bytes()
|
||||||
|
raw_tar = subprocess.run(["zstd", "-dc", "-"], input=compressed, capture_output=True, check=True).stdout
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(raw_tar), mode="r:") as archive:
|
||||||
|
packaged = archive.extractfile("payload/versions/V2R2C1.json").read()
|
||||||
|
metadata = json.loads(archive.extractfile("payload/BUILD-METADATA.json").read())
|
||||||
|
assert packaged == expected_bytes
|
||||||
|
assert metadata["source_registry_main_oid"] == main_oid
|
||||||
|
assert metadata["source_registry_sha256"] == receipt["source_registry_sha256"]
|
||||||
@@ -25,6 +25,18 @@ def test_model_api_staging_unit_is_non_production_and_read_only():
|
|||||||
assert 'production' not in unit.lower().replace('model-api-v2r2c1-staging', '')
|
assert 'production' not in unit.lower().replace('model-api-v2r2c1-staging', '')
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_api_source_convergence_never_falls_back_to_generic_git_dir():
|
||||||
|
source = (ROOT / 'active-runtime' / 'app' / 'aios_model_api_staging.py').read_text(encoding='utf-8')
|
||||||
|
assert "repo_git_dir=AUTHORITATIVE_GIT_DIR or GIT_DIR" not in source
|
||||||
|
assert "repo_git_dir=AUTHORITATIVE_GIT_DIR" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_adapter_never_claims_source_git_convergence():
|
||||||
|
source = (ROOT / 'active-runtime' / 'app' / 'v2r2c1_production_adapter.py').read_text(encoding='utf-8')
|
||||||
|
assert '"source_registry_converged": True' not in source
|
||||||
|
assert '"source_registry_converged": "not_checked"' in source
|
||||||
|
|
||||||
|
|
||||||
def test_model_api_builder_manifest_contract():
|
def test_model_api_builder_manifest_contract():
|
||||||
builder = (ROOT / 'scripts' / 'build_v2r2c1_model_api_staging.py').read_text(encoding='utf-8')
|
builder = (ROOT / 'scripts' / 'build_v2r2c1_model_api_staging.py').read_text(encoding='utf-8')
|
||||||
assert 'aios.v2r2c1.model-api-staging-manifest.v1' in builder
|
assert 'aios.v2r2c1.model-api-staging-manifest.v1' in builder
|
||||||
|
|||||||
@@ -1,506 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import dataclasses
|
|
||||||
import hashlib
|
|
||||||
import importlib
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
||||||
|
|
||||||
|
|
||||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
|
||||||
AGENT_OS = Path(__file__).resolve().parents[1] / "runtime" / "agent-os"
|
|
||||||
for import_root in (APP, AGENT_OS):
|
|
||||||
if str(import_root) not in sys.path:
|
|
||||||
sys.path.insert(0, str(import_root))
|
|
||||||
|
|
||||||
from silent_release_hardening import ( # noqa: E402
|
|
||||||
BuilderAttestation,
|
|
||||||
BuilderAttestationKeyRing,
|
|
||||||
BuilderAttestationVerifier,
|
|
||||||
CapabilityAuthorizationError,
|
|
||||||
SilentReleaseActionService,
|
|
||||||
TrustedCandidateBuilderUnavailable,
|
|
||||||
)
|
|
||||||
import silent_release_hardening as hardening # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
COMMIT = "a" * 40
|
|
||||||
TREE = "b" * 40
|
|
||||||
SHA = "c" * 64
|
|
||||||
GITEA = {"consistent": True}
|
|
||||||
|
|
||||||
|
|
||||||
def _af_inet_available() -> bool:
|
|
||||||
try:
|
|
||||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
probe.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_digest(value: dict) -> str:
|
|
||||||
return hashlib.sha256(
|
|
||||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _instant(delta: timedelta = timedelta()) -> str:
|
|
||||||
return (datetime.now(timezone.utc) + delta).isoformat().replace(
|
|
||||||
"+00:00", "Z"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _keyring_payload(private: Ed25519PrivateKey, *, purpose: str) -> dict:
|
|
||||||
public = private.public_key().public_bytes(
|
|
||||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
||||||
)
|
|
||||||
record = {
|
|
||||||
"algorithm": "Ed25519",
|
|
||||||
"purpose": purpose,
|
|
||||||
"key_id": "builder-key-001",
|
|
||||||
"generation": 1,
|
|
||||||
"not_before": _instant(timedelta(minutes=-1)),
|
|
||||||
"expires_at": _instant(timedelta(days=1)),
|
|
||||||
"status": "ACTIVE",
|
|
||||||
"public_key": base64.b64encode(public).decode("ascii"),
|
|
||||||
"public_key_sha256": hashlib.sha256(public).hexdigest(),
|
|
||||||
}
|
|
||||||
payload = {
|
|
||||||
"algorithm": "Ed25519",
|
|
||||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
"keys": [record],
|
|
||||||
}
|
|
||||||
payload["keyring_sha256"] = hashlib.sha256(
|
|
||||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
||||||
).hexdigest()
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_keyring_digest(payload: dict) -> dict:
|
|
||||||
canonical = {
|
|
||||||
"algorithm": payload["algorithm"],
|
|
||||||
"purpose": payload["purpose"],
|
|
||||||
"keys": payload["keys"],
|
|
||||||
}
|
|
||||||
payload["keyring_sha256"] = hashlib.sha256(
|
|
||||||
json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
||||||
).hexdigest()
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _attestation(private: Ed25519PrivateKey) -> BuilderAttestation:
|
|
||||||
unsigned = BuilderAttestation(
|
|
||||||
builder_attestation_id="builder-attestation-001",
|
|
||||||
builder_key_id="builder-key-001",
|
|
||||||
source_commit=COMMIT,
|
|
||||||
tree_oid=TREE,
|
|
||||||
artifact_sha256=SHA,
|
|
||||||
manifest_sha256=SHA,
|
|
||||||
package_closure_sha256=SHA,
|
|
||||||
test_evidence_sha256=SHA,
|
|
||||||
canary_evidence_sha256=SHA,
|
|
||||||
issued_at=_instant(),
|
|
||||||
signature="",
|
|
||||||
builder_key_generation=1,
|
|
||||||
signature_algorithm="Ed25519",
|
|
||||||
signature_purpose="AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
gitea_evidence_sha256=_canonical_digest(GITEA),
|
|
||||||
rollback_evidence_sha256=_canonical_digest({
|
|
||||||
"ready": True,
|
|
||||||
"rollback_sha256": SHA,
|
|
||||||
"source_commit": COMMIT,
|
|
||||||
}),
|
|
||||||
policy_evidence_sha256=SHA,
|
|
||||||
secret_scan_evidence_sha256=SHA,
|
|
||||||
)
|
|
||||||
return BuilderAttestation(
|
|
||||||
**{
|
|
||||||
**unsigned.canonical_payload,
|
|
||||||
"signature": base64.b64encode(
|
|
||||||
private.sign(unsigned.canonical_bytes())
|
|
||||||
).decode("ascii"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_verifier_rejects_owner_key_record_cross_purpose(tmp_path):
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
payload = _keyring_payload(private, purpose="owner_approval")
|
|
||||||
path = tmp_path / "builder-keyring.json"
|
|
||||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="purpose"):
|
|
||||||
BuilderAttestationKeyRing.from_file(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_verifier_checks_domain_key_window_digest_and_signature(tmp_path):
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
path = tmp_path / "builder-keyring.json"
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(_keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
verifier = BuilderAttestationVerifier(
|
|
||||||
BuilderAttestationKeyRing.from_file(path)
|
|
||||||
)
|
|
||||||
candidate = SimpleNamespace(
|
|
||||||
source_commit=COMMIT,
|
|
||||||
tree_oid=TREE,
|
|
||||||
artifact_sha256=SHA,
|
|
||||||
manifest_sha256=SHA,
|
|
||||||
gitea_consistency=GITEA,
|
|
||||||
rollback_ready=True,
|
|
||||||
)
|
|
||||||
plan = SimpleNamespace(
|
|
||||||
deployment=SimpleNamespace(
|
|
||||||
package_closure_sha256=SHA,
|
|
||||||
rollback_sha256=SHA,
|
|
||||||
),
|
|
||||||
policy_sha256=SHA,
|
|
||||||
)
|
|
||||||
assert verifier.verify(_attestation(private), candidate, plan).builder_key_id == "builder-key-001"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("state", ["REVOKED", "EXPIRED", "FUTURE"])
|
|
||||||
def test_builder_verifier_rejects_unavailable_key_lifecycle_states(tmp_path, state):
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
payload = _keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")
|
|
||||||
record = payload["keys"][0]
|
|
||||||
if state == "REVOKED":
|
|
||||||
record["status"] = "REVOKED"
|
|
||||||
elif state == "EXPIRED":
|
|
||||||
record["not_before"] = _instant(timedelta(days=-2))
|
|
||||||
record["expires_at"] = _instant(timedelta(days=-1))
|
|
||||||
else:
|
|
||||||
record["not_before"] = _instant(timedelta(days=1))
|
|
||||||
record["expires_at"] = _instant(timedelta(days=2))
|
|
||||||
path = tmp_path / "builder-keyring.json"
|
|
||||||
path.write_text(json.dumps(_refresh_keyring_digest(payload)), encoding="utf-8")
|
|
||||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
|
||||||
candidate = SimpleNamespace(
|
|
||||||
source_commit=COMMIT, tree_oid=TREE,
|
|
||||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
|
||||||
gitea_consistency=GITEA, rollback_ready=True,
|
|
||||||
)
|
|
||||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
|
||||||
with pytest.raises(CapabilityAuthorizationError, match="validity|revoked|unavailable"):
|
|
||||||
verifier.verify(_attestation(private), candidate, plan)
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_verifier_rejects_unknown_key_invalid_signature_and_keyring_digest(tmp_path):
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
payload = _keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")
|
|
||||||
path = tmp_path / "builder-keyring.json"
|
|
||||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
||||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
|
||||||
candidate = SimpleNamespace(
|
|
||||||
source_commit=COMMIT, tree_oid=TREE,
|
|
||||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
|
||||||
gitea_consistency=GITEA, rollback_ready=True,
|
|
||||||
)
|
|
||||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
|
||||||
with pytest.raises(CapabilityAuthorizationError, match="not trusted"):
|
|
||||||
verifier.verify(
|
|
||||||
dataclasses.replace(_attestation(private), builder_key_id="unknown-builder-key"),
|
|
||||||
candidate,
|
|
||||||
plan,
|
|
||||||
)
|
|
||||||
with pytest.raises(CapabilityAuthorizationError, match="signature"):
|
|
||||||
verifier.verify(
|
|
||||||
dataclasses.replace(_attestation(private), signature=base64.b64encode(b"x" * 64).decode()),
|
|
||||||
candidate,
|
|
||||||
plan,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload["keyring_sha256"] = "0" * 64
|
|
||||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
||||||
with pytest.raises(ValueError, match="keyring digest"):
|
|
||||||
BuilderAttestationKeyRing.from_file(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_attestation_rejects_tampered_digest_and_signing_metadata():
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
raw = _attestation(private).to_dict()
|
|
||||||
raw["fingerprint"] = "0" * 64
|
|
||||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
|
||||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"field,value",
|
|
||||||
[
|
|
||||||
("builder_key_generation", 2),
|
|
||||||
("signature_algorithm", "RSA"),
|
|
||||||
("signature_purpose", "owner_approval"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_builder_keyring_rejects_attestation_metadata_mismatch(tmp_path, field, value):
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
path = tmp_path / "builder-keyring.json"
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(_keyring_payload(private, purpose="AIOS_TRUSTED_RELEASE_BUILDER")),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
verifier = BuilderAttestationVerifier(
|
|
||||||
BuilderAttestationKeyRing.from_file(path)
|
|
||||||
)
|
|
||||||
candidate = SimpleNamespace(
|
|
||||||
source_commit=COMMIT, tree_oid=TREE,
|
|
||||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
|
||||||
gitea_consistency=GITEA, rollback_ready=True,
|
|
||||||
)
|
|
||||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
|
||||||
with pytest.raises(CapabilityAuthorizationError, match="metadata"):
|
|
||||||
verifier.verify(
|
|
||||||
dataclasses.replace(_attestation(private), **{field: value}),
|
|
||||||
candidate,
|
|
||||||
plan,
|
|
||||||
)
|
|
||||||
raw = _attestation(private).to_dict()
|
|
||||||
raw["builder_key_generation"] = 2
|
|
||||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
|
||||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_builder_unavailable_intent_is_bounded_retryable_and_recovers_without_secret(tmp_path, monkeypatch):
|
|
||||||
class UnavailableBuilder:
|
|
||||||
def build(self, intent):
|
|
||||||
raise TrustedCandidateBuilderUnavailable("socket token=should-not-leak")
|
|
||||||
|
|
||||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
|
||||||
service = SilentReleaseActionService(
|
|
||||||
tmp_path,
|
|
||||||
candidate_builder=UnavailableBuilder(),
|
|
||||||
max_builder_attempts=2,
|
|
||||||
)
|
|
||||||
payload = {"event_id": "event-builder-001", "source_commit": COMMIT}
|
|
||||||
|
|
||||||
first = service.create_release_intent(payload)
|
|
||||||
second = service.create_release_intent(payload)
|
|
||||||
|
|
||||||
assert first["status"] == "BUILDER_RETRYABLE_FAILURE"
|
|
||||||
assert first["retryable"] is True
|
|
||||||
assert first["attempt_count"] == 1
|
|
||||||
assert "should-not-leak" not in json.dumps(first)
|
|
||||||
assert second["status"] == "BUILDER_TERMINAL_FAILURE"
|
|
||||||
assert second["retryable"] is False
|
|
||||||
assert second["attempt_count"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_stale_building_intent_recovers_after_restart_without_double_running_active_attempt(tmp_path, monkeypatch):
|
|
||||||
class CountingUnavailableBuilder:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
def build(self, intent):
|
|
||||||
self.calls += 1
|
|
||||||
raise TrustedCandidateBuilderUnavailable("nonsecret")
|
|
||||||
|
|
||||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
|
||||||
builder = CountingUnavailableBuilder()
|
|
||||||
service = SilentReleaseActionService(
|
|
||||||
tmp_path,
|
|
||||||
candidate_builder=builder,
|
|
||||||
max_builder_attempts=3,
|
|
||||||
builder_attempt_timeout_seconds=1,
|
|
||||||
)
|
|
||||||
payload = {"event_id": "event-builder-crash-001", "source_commit": COMMIT}
|
|
||||||
intent_id = "intent-" + hashlib.sha256(
|
|
||||||
f"{payload['event_id']}\0{COMMIT}".encode()
|
|
||||||
).hexdigest()[:24]
|
|
||||||
intent_path = tmp_path / "intents" / f"{intent_id}.json"
|
|
||||||
intent_path.parent.mkdir(parents=True)
|
|
||||||
active_record = {
|
|
||||||
"attempt_count": 1,
|
|
||||||
"attempt_started_at": _instant(),
|
|
||||||
"created_at": _instant(timedelta(minutes=-1)),
|
|
||||||
"event_id": payload["event_id"],
|
|
||||||
"intent_id": intent_id,
|
|
||||||
"source_commit": COMMIT,
|
|
||||||
"status": "BUILDING",
|
|
||||||
}
|
|
||||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
|
||||||
|
|
||||||
active = service.create_release_intent(payload)
|
|
||||||
assert active["status"] == "BUILDING"
|
|
||||||
assert active["idempotent"] is True
|
|
||||||
assert builder.calls == 0
|
|
||||||
|
|
||||||
active_record["attempt_started_at"] = _instant(timedelta(minutes=-5))
|
|
||||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
|
||||||
recovered = service.create_release_intent(payload)
|
|
||||||
assert recovered["status"] == "BUILDER_RETRYABLE_FAILURE"
|
|
||||||
assert recovered["attempt_count"] == 2
|
|
||||||
assert recovered["failure_code"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
|
||||||
assert recovered["last_recovery_code"] == "TRUSTED_BUILDER_CRASH_RECOVERED"
|
|
||||||
assert recovered["recovery_count"] == 1
|
|
||||||
assert builder.calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
not _af_inet_available(),
|
|
||||||
reason="isolated executor permits AF_UNIX only; live HTTP probe runs at activation",
|
|
||||||
)
|
|
||||||
def test_model_api_keeps_process_liveness_separate_from_builder_readiness(monkeypatch):
|
|
||||||
api = importlib.import_module("aios_model_api_staging")
|
|
||||||
|
|
||||||
class FakeService:
|
|
||||||
ready = False
|
|
||||||
|
|
||||||
def get_builder_readiness(self):
|
|
||||||
return {"status": "READY" if self.ready else "UNAVAILABLE"}
|
|
||||||
|
|
||||||
def create_release_candidate(self, payload):
|
|
||||||
if not payload:
|
|
||||||
raise ValueError("event_id is required")
|
|
||||||
return {"status": "READY_FOR_APPROVAL"}
|
|
||||||
|
|
||||||
service = FakeService()
|
|
||||||
monkeypatch.setattr(api, "silent_release_service", lambda: service)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
api,
|
|
||||||
"authenticate",
|
|
||||||
lambda token: (
|
|
||||||
{"account": "test", "role": "owner", "scopes": ["release:candidate"]}
|
|
||||||
if token == "test-token"
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api.H)
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
base = f"http://127.0.0.1:{server.server_port}"
|
|
||||||
|
|
||||||
def request(path, body=None):
|
|
||||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
||||||
value = urllib.request.Request(
|
|
||||||
base + path,
|
|
||||||
data=data,
|
|
||||||
headers={"Authorization": "Bearer test-token"},
|
|
||||||
method="POST" if body is not None else "GET",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(value, timeout=5) as response:
|
|
||||||
return response.status, json.load(response)
|
|
||||||
except urllib.error.HTTPError as error:
|
|
||||||
return error.code, json.load(error)
|
|
||||||
|
|
||||||
try:
|
|
||||||
health_code, health = request("/health")
|
|
||||||
unavailable_code, unavailable = request("/createReleaseCandidate", {})
|
|
||||||
service.ready = True
|
|
||||||
invalid_code, invalid = request("/createReleaseCandidate", {})
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
thread.join(timeout=5)
|
|
||||||
|
|
||||||
assert health_code == 200
|
|
||||||
assert health["process_liveness"] == "PASS"
|
|
||||||
assert health["builder_readiness"]["status"] == "UNAVAILABLE"
|
|
||||||
assert unavailable_code == 503
|
|
||||||
assert unavailable["error"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
|
||||||
assert invalid_code == 400
|
|
||||||
assert invalid["error"] == "event_id is required"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
|
||||||
def test_host_adapter_accepts_a_real_builder_bundle_response():
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"trusted_builder_public_seam",
|
|
||||||
APP.parents[1] / "tests" / "test_trusted_candidate_builder.py",
|
|
||||||
)
|
|
||||||
assert spec and spec.loader
|
|
||||||
backend_seam = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(backend_seam)
|
|
||||||
server, client, _ = backend_seam.make_seam()
|
|
||||||
candidate_id = "rc-v2r2c2-d2b5a8339a34"
|
|
||||||
source_commit = "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6"
|
|
||||||
response = server.handle_request(
|
|
||||||
client.create_request(
|
|
||||||
"V2R2C2", candidate_id, source_commit, nonce="z" * 32
|
|
||||||
),
|
|
||||||
connection=object(),
|
|
||||||
)
|
|
||||||
|
|
||||||
class ResponseClient:
|
|
||||||
def build(self, version, supplied_candidate_id, supplied_source, *, nonce):
|
|
||||||
assert version == "V2R2C2"
|
|
||||||
assert supplied_candidate_id == candidate_id
|
|
||||||
assert supplied_source == source_commit
|
|
||||||
assert len(nonce) >= 32
|
|
||||||
return response
|
|
||||||
|
|
||||||
api = importlib.import_module("aios_model_api_staging")
|
|
||||||
bundle = api._HostTrustedCandidateBuilderAdapter(ResponseClient()).build(
|
|
||||||
{
|
|
||||||
"candidate_id": candidate_id,
|
|
||||||
"source_commit": source_commit,
|
|
||||||
"version": "V2R2C2",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert bundle.validate().candidate.source_commit == response["base_oid"]
|
|
||||||
assert bundle.attestation.builder_key_id == response["attestation"]["builder_key_id"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
|
||||||
def test_host_adapter_forwards_v2r2c3_to_the_policy_and_fails_closed_when_unavailable():
|
|
||||||
from aios_trusted_candidate_builder import BuilderError
|
|
||||||
api = importlib.import_module("aios_model_api_staging")
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
class DerivedOnlyClient:
|
|
||||||
def build(self, version, candidate_id, source_commit, *, nonce):
|
|
||||||
calls.append((version, candidate_id, source_commit, nonce))
|
|
||||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
|
||||||
|
|
||||||
with pytest.raises(TrustedCandidateBuilderUnavailable):
|
|
||||||
api._HostTrustedCandidateBuilderAdapter(DerivedOnlyClient()).build(
|
|
||||||
{
|
|
||||||
"candidate_id": "r42-release-candidate-v2r2c3-20260820",
|
|
||||||
"source_commit": COMMIT,
|
|
||||||
"version": "V2R2C3",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert calls[0][:3] == (
|
|
||||||
"V2R2C3",
|
|
||||||
"r42-release-candidate-v2r2c3-20260820",
|
|
||||||
COMMIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_host_adapter_accepts_intent_only_without_caller_candidate_id():
|
|
||||||
api = importlib.import_module('aios_model_api_staging')
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
class IntentClient:
|
|
||||||
def build(self, version, candidate_id, source_commit, *, nonce):
|
|
||||||
calls.append((version, candidate_id, source_commit, nonce))
|
|
||||||
raise RuntimeError('intent-only probe')
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='intent-only probe'):
|
|
||||||
api._HostTrustedCandidateBuilderAdapter(IntentClient()).build({
|
|
||||||
'event_id': 'event-intent-only',
|
|
||||||
'intent_id': 'intent-intent-only',
|
|
||||||
'source_commit': COMMIT,
|
|
||||||
'version': 'V2R2C2',
|
|
||||||
})
|
|
||||||
assert calls[0][0] == 'V2R2C2'
|
|
||||||
assert calls[0][1] == ''
|
|
||||||
assert calls[0][2] == COMMIT
|
|
||||||
assert len(calls[0][3]) >= 32
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
FIXTURE_SOURCE = '"""x"""\nimport re\nfrom aios_online_agent_execution import (\n GovernedExecutionBinding,\n validate_execution_envelope,\n)\n\nclass InitialExecutionProjectionHandler:\n store = None\n def _authorized(self):\n return True\n def _json(self, value, status):\n pass\n def do_GET(self):\n if self.path == "/health":\n self._json(\n {"status": "ok", "service": "aios-agent-task-initial-execution-projection"},\n 200,\n )\n else:\n self._json({"error": "NOT_FOUND"}, 404)\n\n def do_POST(self):\n initial_match = re.fullmatch(\n r"/v1/tasks/([^/]+)/initial-execution-projection", self.path\n )\n recovery_match = re.fullmatch(\n r"/v1/tasks/([^/]+)/source-capture-projection-recovery", self.path\n )\n if initial_match is None and recovery_match is None:\n self._json({"error": "NOT_FOUND"}, 404)\n return\n if not self._authorized():\n self._json({"error": "UNAUTHORIZED"}, 401)\n return\n try:\n value = {}\n if self.store is None:\n raise RuntimeError("RUNTIME_UNAVAILABLE")\n if initial_match is not None:\n request = InitialExecutionProjectionRequest.from_mapping(value)\n result = self.store.project(initial_match.group(1), request)\n else:\n recovery_request = SourceCaptureProjectionRecoveryRequest.from_mapping(value)\n result = self.store.project_from_source_capture(\n recovery_match.group(1), recovery_request\n )\n self._json(result, 200 if result["idempotent"] else 201)\n except Exception:\n pass\n'
|
|
||||||
|
|
||||||
|
|
||||||
def test_patcher_is_strict_and_idempotent(tmp_path):
|
|
||||||
source = tmp_path / "aios_agent_task_initial_execution_projection.py"
|
|
||||||
source.write_text(FIXTURE_SOURCE, encoding="utf-8")
|
|
||||||
patcher = Path(__file__).resolve().parents[1] / "tools" / "integrate_projection_recovery.py"
|
|
||||||
|
|
||||||
first = subprocess.run(
|
|
||||||
[sys.executable, str(patcher), str(source)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert first.returncode == 0, first.stderr
|
|
||||||
assert first.stdout.strip() == "CHANGED"
|
|
||||||
text = source.read_text()
|
|
||||||
assert "/canonical-projection-recovery" in text
|
|
||||||
assert "/canonical-projection" in text
|
|
||||||
assert "recover_canonical_projection" in text
|
|
||||||
|
|
||||||
second = subprocess.run(
|
|
||||||
[sys.executable, str(patcher), str(source)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert second.returncode == 0
|
|
||||||
assert second.stdout.strip() == "ALREADY_APPLIED"
|
|
||||||
|
|
||||||
|
|
||||||
def test_patcher_fails_closed_on_unexpected_source(tmp_path):
|
|
||||||
source = tmp_path / "wrong.py"
|
|
||||||
source.write_text("print('different')\n")
|
|
||||||
patcher = Path(__file__).resolve().parents[1] / "tools" / "integrate_projection_recovery.py"
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, str(patcher), str(source)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert result.returncode == 2
|
|
||||||
assert "ANCHOR_COUNT" in result.stderr
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import ast
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_r7_amendment_module_is_valid_and_fail_closed():
|
|
||||||
text = (
|
|
||||||
ROOT
|
|
||||||
/ "active-runtime/app/aios_task_deterministic_execution_amendment.py"
|
|
||||||
).read_text()
|
|
||||||
ast.parse(text)
|
|
||||||
assert "validate_execution_envelope" in text
|
|
||||||
assert "SIGNED_EXECUTION_AMENDMENT_REQUIRED" in text
|
|
||||||
assert "BEFORE UPDATE OF status ON tasks" in text
|
|
||||||
assert "APPLY_STATES" in text
|
|
||||||
assert '"FAILED"' in text and '"QUEUED"' in text
|
|
||||||
assert "AMENDMENT_EXECUTION_OPERATIONS_REQUIRED" in text
|
|
||||||
assert "AMENDMENT_PROTECTED_WRITE_FORBIDDEN" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_bridge_unit_keeps_strict_sandbox_and_required_projection():
|
|
||||||
text = (
|
|
||||||
ROOT
|
|
||||||
/ "deploy/systemd/aios-chatgpt-bridge-v2-staging.service"
|
|
||||||
).read_text()
|
|
||||||
assert "ProtectSystem=strict" in text
|
|
||||||
assert "ProtectHome=true" in text
|
|
||||||
assert (
|
|
||||||
"LoadCredential=gitea_token:"
|
|
||||||
) in text
|
|
||||||
assert "/data/aios-gitops/repo.git/worktrees" in text
|
|
||||||
@@ -47,6 +47,8 @@ def old_baseline(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def test_baseline_record_uses_real_previous_production_baseline(tmp_path, monkeypatch):
|
def test_baseline_record_uses_real_previous_production_baseline(tmp_path, monkeypatch):
|
||||||
|
# This test isolates previous-baseline semantics; convergence is tested separately.
|
||||||
|
monkeypatch.setattr(adapter, "assert_source_registry_convergence", lambda *a, **kw: None)
|
||||||
baseline = tmp_path / "baseline.json"
|
baseline = tmp_path / "baseline.json"
|
||||||
previous_sha = old_baseline(baseline)
|
previous_sha = old_baseline(baseline)
|
||||||
registry_root = tmp_path / "versions"
|
registry_root = tmp_path / "versions"
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ class SignerContractCompatibilityTests(unittest.TestCase):
|
|||||||
signer = "/usr/local/libexec/aios-grant-signer-adapter"
|
signer = "/usr/local/libexec/aios-grant-signer-adapter"
|
||||||
if not Path(signer).is_file():
|
if not Path(signer).is_file():
|
||||||
self.skipTest("deployed signer is not present")
|
self.skipTest("deployed signer is not present")
|
||||||
if os.geteuid() != 0:
|
|
||||||
self.skipTest("deployed signer self-test requires root")
|
|
||||||
help_result = subprocess.run([signer, "sign", "--help"], capture_output=True, text=True, check=False)
|
help_result = subprocess.run([signer, "sign", "--help"], capture_output=True, text=True, check=False)
|
||||||
self.assertEqual(help_result.returncode, 0)
|
self.assertEqual(help_result.returncode, 0)
|
||||||
self.assertTrue(deployed_signer_help(help_result.stdout, help_result.stderr))
|
self.assertTrue(deployed_signer_help(help_result.stdout, help_result.stderr))
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
|
||||||
sys.path.insert(0, str(APP))
|
|
||||||
import trusted_builder_signer_provisioning as provisioning # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _source(tmp_path: Path) -> tuple[Path, list[str]]:
|
|
||||||
root = tmp_path / "source"
|
|
||||||
for relative in provisioning.SOURCE_EVIDENCE_PATHS:
|
|
||||||
path = root / relative
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if relative != provisioning.PACKAGE_MANIFEST_PATH:
|
|
||||||
path.write_text("source:" + relative + "\n", encoding="utf-8")
|
|
||||||
os.chmod(path, 0o644)
|
|
||||||
manifest = root / provisioning.PACKAGE_MANIFEST_PATH
|
|
||||||
manifest.write_bytes(provisioning.pretty_json(provisioning.package_contract_document()))
|
|
||||||
os.chmod(manifest, 0o644)
|
|
||||||
return root, list(provisioning.SOURCE_EVIDENCE_PATHS)
|
|
||||||
|
|
||||||
|
|
||||||
def _payload(tmp_path: Path):
|
|
||||||
source, paths = _source(tmp_path)
|
|
||||||
output = tmp_path / "payload.json"
|
|
||||||
value = provisioning.build_payload(
|
|
||||||
source, output, main_oid="c" * 40, tree_oid="d" * 40,
|
|
||||||
pr_number=70, pr_head_oid="e" * 40,
|
|
||||||
merge_parent_oids=["a" * 40, "e" * 40], source_files=paths,
|
|
||||||
nonce="test-provisioning-nonce-001",
|
|
||||||
)
|
|
||||||
return source, output, value
|
|
||||||
|
|
||||||
|
|
||||||
def test_fresh_payload_is_bound_to_final_common_main_and_ordered_pr_parent(tmp_path: Path):
|
|
||||||
_source_root, output, payload = _payload(tmp_path)
|
|
||||||
assert output.read_bytes().startswith(b"{")
|
|
||||||
provisioning._validate_payload(payload)
|
|
||||||
assert payload["source"]["gitea_main_oid"] == payload["source"]["governed_main_oid"]
|
|
||||||
assert payload["source"]["merge_parent_oids"][1] == payload["source"]["pr_head_oid"]
|
|
||||||
assert set(payload["source"]["source_evidence_sha256"]) == set(provisioning.SOURCE_EVIDENCE_PATHS)
|
|
||||||
assert set(payload["allowed_capabilities"]) == provisioning.ALLOWED_CAPABILITIES
|
|
||||||
assert set(payload["denied_capabilities"]) == provisioning.DENIED_CAPABILITIES
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field", ["allowed_capabilities", "denied_capabilities"])
|
|
||||||
def test_payload_rejects_capability_scope_tampering(tmp_path: Path, field: str):
|
|
||||||
_source_root, _output, payload = _payload(tmp_path)
|
|
||||||
payload[field] = ["owner_release_approval"]
|
|
||||||
with pytest.raises(provisioning.ProvisioningError, match="CAPABILITY_SCOPE_INVALID"):
|
|
||||||
provisioning._validate_payload(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_rejects_main_divergence_and_wrong_second_parent(tmp_path: Path):
|
|
||||||
_source_root, _output, payload = _payload(tmp_path)
|
|
||||||
payload["source"]["governed_main_oid"] = "f" * 40
|
|
||||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_MAIN_NOT_CONVERGED"):
|
|
||||||
provisioning._validate_payload(payload)
|
|
||||||
_source_root, _output, payload = _payload(tmp_path / "second")
|
|
||||||
payload["source"]["merge_parent_oids"][1] = "a" * 40
|
|
||||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_PR_HEAD_SECOND_PARENT_INVALID"):
|
|
||||||
provisioning._validate_payload(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_rejects_missing_or_extra_source_evidence(tmp_path: Path):
|
|
||||||
_source_root, _output, payload = _payload(tmp_path)
|
|
||||||
payload["source"]["source_evidence_sha256"].pop(next(iter(payload["source"]["source_evidence_sha256"])))
|
|
||||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_EVIDENCE_SET_INVALID"):
|
|
||||||
provisioning._validate_payload(payload)
|
|
||||||
_source_root, _output, payload = _payload(tmp_path / "second")
|
|
||||||
payload["source"]["source_evidence_sha256"]["extra.txt"] = "0" * 64
|
|
||||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_EVIDENCE_SET_INVALID"):
|
|
||||||
provisioning._validate_payload(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_package_contract_rejects_incompatible_source_mode_and_tampering(tmp_path: Path):
|
|
||||||
source, _paths = _source(tmp_path)
|
|
||||||
service = source / "deploy/systemd/aios-trusted-candidate-builder.service"
|
|
||||||
os.chmod(service, 0o601)
|
|
||||||
with pytest.raises(provisioning.ProvisioningError, match="SOURCE_EVIDENCE_MODE_INVALID"):
|
|
||||||
provisioning.verify_package_contract(source)
|
|
||||||
os.chmod(service, 0o644)
|
|
||||||
manifest = source / provisioning.PACKAGE_MANIFEST_PATH
|
|
||||||
value = json.loads(manifest.read_text(encoding="utf-8"))
|
|
||||||
value["activation"]["credential_installation"] = True
|
|
||||||
manifest.write_text(json.dumps(value), encoding="utf-8")
|
|
||||||
with pytest.raises(provisioning.ProvisioningError, match="PACKAGE_CONTRACT_INVALID"):
|
|
||||||
provisioning.verify_package_contract(source)
|
|
||||||
|
|
||||||
|
|
||||||
def test_package_contract_declares_socket_activation_no_credentials_and_0755_launcher(tmp_path: Path):
|
|
||||||
source, _paths = _source(tmp_path)
|
|
||||||
provisioning.verify_package_contract(source)
|
|
||||||
value = json.loads((source / provisioning.PACKAGE_MANIFEST_PATH).read_text(encoding="utf-8"))
|
|
||||||
launcher = next(item for item in value["files"] if item["source_path"] == "deploy/libexec/aios-trusted-builder-signer-provisioner")
|
|
||||||
assert launcher["install_mode"] == "0755"
|
|
||||||
assert value["activation"] == {
|
|
||||||
"daemon_reload": True,
|
|
||||||
"enable_units": ["aios-trusted-candidate-builder.socket"],
|
|
||||||
"service_start": "socket-activation-only",
|
|
||||||
"credential_installation": False,
|
|
||||||
"signer_invocation": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_provisioner_self_test_has_no_mutation(capsys):
|
|
||||||
assert provisioning.main(["--self-test"]) == 0
|
|
||||||
assert capsys.readouterr().out.strip() == "PASS"
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
|
||||||
|
|
||||||
import aios_trusted_candidate_builder as builder_runtime # noqa: E402
|
|
||||||
from aios_trusted_candidate_builder import ( # noqa: E402
|
|
||||||
BuilderError,
|
|
||||||
BuilderKeyring,
|
|
||||||
DeterministicEvidenceCollector,
|
|
||||||
PeerIdentity,
|
|
||||||
ServerOwnedEvidenceSource,
|
|
||||||
TrustedCandidateBuilder,
|
|
||||||
TrustedCandidateBuilderConfig,
|
|
||||||
UnixTrustedCandidateBuilderClient,
|
|
||||||
load_builder_policy,
|
|
||||||
)
|
|
||||||
from silent_release_controller import ReleaseCandidate, ReleaseState # noqa: E402
|
|
||||||
from silent_release_hardening import ( # noqa: E402
|
|
||||||
BuilderAttestation,
|
|
||||||
BuilderAttestationKeyRecord,
|
|
||||||
BuilderAttestationKeyRing,
|
|
||||||
BuilderAttestationVerifier,
|
|
||||||
DeploymentPlan,
|
|
||||||
FinalizationPlan,
|
|
||||||
ReleasePlan,
|
|
||||||
TagPlan,
|
|
||||||
TrustedCandidateBundle,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
POLICY_PATH = ROOT / "config" / "gitops" / "trusted-candidate-builder-policy.json"
|
|
||||||
NOW = 1_725_000_000
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingDomainSigner:
|
|
||||||
key_id = "test-builder-key"
|
|
||||||
key_generation = 1
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[tuple[str, bytes]] = []
|
|
||||||
|
|
||||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
|
||||||
self.calls.append((purpose, payload))
|
|
||||||
return hashlib.sha256(b"test-signature\0" + payload).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def make_seam(*, peer: PeerIdentity | None = None, cgroup: str = "/system.slice/aios-gitops-model-api-v2r2c1-staging.service", process=("/usr/bin/python3.12", ("/usr/bin/python3", "/opt/aios/gitops-model-api/test/runtime/app/aios_model_api_staging.py")), server_clock=lambda: NOW, client_clock=lambda: NOW, collector=None, nonce_ledger_path=None, signer_override=None) -> tuple[TrustedCandidateBuilder, UnixTrustedCandidateBuilderClient, RecordingDomainSigner]:
|
|
||||||
policy = load_builder_policy(POLICY_PATH)
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
|
||||||
keyring = BuilderKeyring(builder_hmac=b"h" * 32)
|
|
||||||
signer = signer_override or RecordingDomainSigner()
|
|
||||||
server = TrustedCandidateBuilder(
|
|
||||||
config,
|
|
||||||
keyring,
|
|
||||||
collector=collector or DeterministicEvidenceCollector(config, evidence_source=passing_bundle),
|
|
||||||
signer=signer,
|
|
||||||
peer_identity_resolver=lambda _connection: peer or PeerIdentity(pid=77, uid=1000, gid=1000),
|
|
||||||
cgroup_resolver=lambda _pid: cgroup,
|
|
||||||
process_identity_resolver=lambda _pid: process,
|
|
||||||
clock=server_clock,
|
|
||||||
nonce_ledger_path=nonce_ledger_path,
|
|
||||||
)
|
|
||||||
return server, UnixTrustedCandidateBuilderClient(config, keyring, clock=client_clock), signer
|
|
||||||
|
|
||||||
|
|
||||||
def passing_bundle(recipe_name, recipe):
|
|
||||||
base_oid = recipe["base_oid"]
|
|
||||||
digest = lambda label: hashlib.sha256(f"{label}\0{base_oid}".encode()).hexdigest()
|
|
||||||
candidate = ReleaseCandidate(
|
|
||||||
candidate_id=recipe["candidate_id"], source_commit=base_oid,
|
|
||||||
tree_oid=digest("tree")[:40], artifact_sha256=digest("artifact"),
|
|
||||||
manifest_sha256=digest("manifest"), tests={"passed": True, "receipt": digest("tests")},
|
|
||||||
canary={"passed": True, "receipt": digest("canary")}, rollback_ready=True,
|
|
||||||
git_consistency={"consistent": True}, gitea_consistency={"consistent": True},
|
|
||||||
state=ReleaseState.READY_FOR_APPROVAL,
|
|
||||||
)
|
|
||||||
policy_sha = digest("policy")
|
|
||||||
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=digest("rollback"),
|
|
||||||
source_commit=base_oid, tree_oid=candidate.tree_oid,
|
|
||||||
artifact_sha256=candidate.artifact_sha256, manifest_sha256=candidate.manifest_sha256,
|
|
||||||
package_closure_sha256=digest("closure"), authoritative_git_dir="/data/aios-gitops/repo.git",
|
|
||||||
repository_identity="aios/aios", 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=digest("tag")[:40],
|
|
||||||
expected_target_oid=base_oid, evidence_closure_oid=digest("closure-oid")[:40],
|
|
||||||
approval_plan_id="owner-plan-trusted-v2r2c2", repository_identity="aios/aios",
|
|
||||||
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=digest("tag")[:40], expected_target_oid=base_oid,
|
|
||||||
expected_previous_baseline_sha256=digest("baseline"), registry_expected_record={"base": base_oid},
|
|
||||||
baseline_expected_record={"base": base_oid}, final_consistency_contract="trusted-builder/v1",
|
|
||||||
), policy_sha256=policy_sha,
|
|
||||||
)
|
|
||||||
checks = {name: True for name in [
|
|
||||||
"authoritative_git", "artifact", "canary", "gitea", "manifest",
|
|
||||||
"package_closure", "rollback", "secret_scan", "tests",
|
|
||||||
]}
|
|
||||||
TrustedCandidateBundle(candidate, plan, checks).validate()
|
|
||||||
canonical_digest = lambda value: hashlib.sha256(
|
|
||||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
||||||
).hexdigest()
|
|
||||||
evidence_digests = {
|
|
||||||
"artifact": candidate.artifact_sha256,
|
|
||||||
"manifest": candidate.manifest_sha256,
|
|
||||||
"package_closure": plan.deployment.package_closure_sha256,
|
|
||||||
"tests": canonical_digest(candidate.tests),
|
|
||||||
"canary": canonical_digest(candidate.canary),
|
|
||||||
"gitea": canonical_digest(candidate.gitea_consistency),
|
|
||||||
"rollback": canonical_digest({
|
|
||||||
"ready": True,
|
|
||||||
"rollback_sha256": plan.deployment.rollback_sha256,
|
|
||||||
"source_commit": candidate.source_commit,
|
|
||||||
}),
|
|
||||||
"policy": plan.policy_sha256,
|
|
||||||
"secret_scan": digest("secret-scan"),
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"candidate": candidate.to_dict(),
|
|
||||||
"release_plan": plan.to_dict(),
|
|
||||||
"checks": checks,
|
|
||||||
"evidence_digests": evidence_digests,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def rehmac(request: dict) -> None:
|
|
||||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
|
||||||
request["hmac"] = hmac.new(
|
|
||||||
b"h" * 32,
|
|
||||||
json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode(),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_build_seam_accepts_only_the_fixed_v2r2c2_recipe_and_returns_server_owned_evidence() -> None:
|
|
||||||
server, client, signer = make_seam()
|
|
||||||
|
|
||||||
request = client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="a" * 32)
|
|
||||||
result = server.handle_request(request, connection=object())
|
|
||||||
|
|
||||||
assert result["schema"] == "aios.trusted-candidate-builder.response.v1"
|
|
||||||
assert result["status"] == "BUILT"
|
|
||||||
assert result["recipe"] == "V2R2C2"
|
|
||||||
assert result["repository"] == "aios/aios"
|
|
||||||
assert result["task_id"] == "v12-durable-trusted-release-controller-activation-20260829"
|
|
||||||
assert result["silent_release_bundle"]["candidate"]["candidate_id"] == "rc-v2r2c2-d2b5a8339a34"
|
|
||||||
assert result["base_oid"] == "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6"
|
|
||||||
assert set(result["checks"]) == set(server.config.required_checks)
|
|
||||||
assert all(result["checks"].values())
|
|
||||||
assert result["attestation"]["trust_domain"] == "aios.trusted-builder.attestation.v1"
|
|
||||||
assert signer.calls and signer.calls[0][0] == "AIOS_TRUSTED_RELEASE_BUILDER"
|
|
||||||
assert result["attestation"]["fingerprint"] == hashlib.sha256(signer.calls[0][1]).hexdigest()
|
|
||||||
assert BuilderAttestation.from_dict(result["attestation"]).canonical_bytes() == signer.calls[0][1]
|
|
||||||
silent_bundle = result["silent_release_bundle"]
|
|
||||||
round_trip = TrustedCandidateBundle(
|
|
||||||
ReleaseCandidate.from_dict(silent_bundle["candidate"]),
|
|
||||||
ReleasePlan.from_dict(silent_bundle["release_plan"]),
|
|
||||||
silent_bundle["checks"],
|
|
||||||
)
|
|
||||||
assert round_trip.validate().candidate.candidate_id == result["evidence"]["candidate_id"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"mutate, error",
|
|
||||||
[
|
|
||||||
(lambda request: request.update({"base_oid": "f" * 40}), "REQUEST_HMAC_INVALID"),
|
|
||||||
(lambda request: request.update({"artifact_sha256": "0" * 64}), "REQUEST_SCHEMA_INVALID"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_public_build_seam_rejects_tampering_and_caller_owned_evidence(mutate, error) -> None:
|
|
||||||
server, client, _ = make_seam()
|
|
||||||
request = client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="b" * 32)
|
|
||||||
mutate(request)
|
|
||||||
|
|
||||||
with pytest.raises(BuilderError, match=error):
|
|
||||||
server.handle_request(request, connection=object())
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field,value", [
|
|
||||||
("repository", "other/repository"),
|
|
||||||
("task_id", "other-task"),
|
|
||||||
("base_oid", "f" * 40),
|
|
||||||
("purpose", "owner_approval"),
|
|
||||||
])
|
|
||||||
def test_public_build_seam_rejects_reauthenticated_scope_rebinding(field, value) -> None:
|
|
||||||
server, client, _ = make_seam()
|
|
||||||
request = client.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
"rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
nonce="s" * 32,
|
|
||||||
)
|
|
||||||
request[field] = value
|
|
||||||
rehmac(request)
|
|
||||||
with pytest.raises(BuilderError, match="REQUEST_SCOPE_INVALID"):
|
|
||||||
server.handle_request(request, connection=object())
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_build_seam_rejects_wrong_hmac() -> None:
|
|
||||||
server, client, _ = make_seam()
|
|
||||||
request = client.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
"rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
nonce="w" * 32,
|
|
||||||
)
|
|
||||||
request["hmac"] = "0" * 64
|
|
||||||
with pytest.raises(BuilderError, match="REQUEST_HMAC_INVALID"):
|
|
||||||
server.handle_request(request, connection=object())
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_build_seam_returns_the_original_result_for_exact_nonce_replay_and_rejects_conflicts() -> None:
|
|
||||||
server, client, _ = make_seam()
|
|
||||||
request = client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="c" * 32)
|
|
||||||
first = server.handle_request(request, connection=object())
|
|
||||||
assert server.handle_request(request, connection=object()) == first
|
|
||||||
|
|
||||||
conflict = client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="c" * 32)
|
|
||||||
conflict["issued_at"] += 1
|
|
||||||
unsigned = {key: value for key, value in conflict.items() if key != "hmac"}
|
|
||||||
conflict["hmac"] = hmac.new(b"h" * 32, json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode(), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
with pytest.raises(BuilderError, match="REPLAY_CONFLICT"):
|
|
||||||
server.handle_request(conflict, connection=object())
|
|
||||||
|
|
||||||
|
|
||||||
def test_exact_replay_survives_restart_and_the_original_freshness_window(tmp_path) -> None:
|
|
||||||
ledger = tmp_path / "nonce-ledger.json"
|
|
||||||
server, client, _ = make_seam(nonce_ledger_path=ledger)
|
|
||||||
request = client.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
"rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
nonce="r" * 32,
|
|
||||||
)
|
|
||||||
first = server.handle_request(request, connection=object())
|
|
||||||
|
|
||||||
restarted, _, _ = make_seam(
|
|
||||||
server_clock=lambda: NOW + 3600,
|
|
||||||
nonce_ledger_path=ledger,
|
|
||||||
)
|
|
||||||
assert restarted.handle_request(request, connection=object()) == first
|
|
||||||
|
|
||||||
|
|
||||||
def test_readiness_probe_checks_collector_and_signer_without_signing() -> None:
|
|
||||||
server, client, signer = make_seam()
|
|
||||||
request = client.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
"rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
nonce="q" * 32,
|
|
||||||
operation=server.config.readiness_operation,
|
|
||||||
)
|
|
||||||
result = server.handle_request(request, connection=object())
|
|
||||||
|
|
||||||
assert result["status"] == "READY"
|
|
||||||
assert result["collector_ready"] is True
|
|
||||||
assert result["signer_ready"] is True
|
|
||||||
assert result["builder_key_id"] == signer.key_id
|
|
||||||
assert result["policy_sha256"] == server.config.policy_sha256
|
|
||||||
assert "signature" not in result
|
|
||||||
assert "silent_release_bundle" not in result
|
|
||||||
assert signer.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_readiness_fails_closed_when_signer_is_unavailable() -> None:
|
|
||||||
class UnavailableSigner:
|
|
||||||
@property
|
|
||||||
def key_id(self):
|
|
||||||
raise BuilderError("SIGNER_UNAVAILABLE")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_generation(self):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def sign_for_purpose(self, purpose, payload):
|
|
||||||
raise AssertionError("readiness must not attempt signing")
|
|
||||||
|
|
||||||
server, client, _ = make_seam(signer_override=UnavailableSigner())
|
|
||||||
request = client.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
"rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
nonce="u" * 32,
|
|
||||||
operation=server.config.readiness_operation,
|
|
||||||
)
|
|
||||||
with pytest.raises(BuilderError, match="SIGNER_UNAVAILABLE"):
|
|
||||||
server.handle_request(request, connection=object())
|
|
||||||
|
|
||||||
|
|
||||||
def test_unix_client_applies_bounded_timeout_and_fails_closed(monkeypatch) -> None:
|
|
||||||
observed = {}
|
|
||||||
|
|
||||||
class TimeoutSocket:
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *_args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def settimeout(self, seconds):
|
|
||||||
observed["timeout"] = seconds
|
|
||||||
|
|
||||||
def connect(self, _path):
|
|
||||||
raise TimeoutError("bounded test timeout")
|
|
||||||
|
|
||||||
monkeypatch.setattr(builder_runtime.socket, "socket", lambda *_args: TimeoutSocket())
|
|
||||||
monkeypatch.setattr(builder_runtime.socket, "AF_UNIX", 1, raising=False)
|
|
||||||
monkeypatch.setattr(builder_runtime.socket, "SOCK_STREAM", 1, raising=False)
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
|
||||||
client = UnixTrustedCandidateBuilderClient(
|
|
||||||
config,
|
|
||||||
BuilderKeyring(builder_hmac=b"h" * 32),
|
|
||||||
socket_path=Path("/run/aios-trusted-candidate-builder.sock"),
|
|
||||||
clock=lambda: NOW,
|
|
||||||
)
|
|
||||||
with pytest.raises(BuilderError, match="BUILDER_TRANSPORT_UNAVAILABLE"):
|
|
||||||
client.build(
|
|
||||||
"V2R2C2",
|
|
||||||
"rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
nonce="t" * 32,
|
|
||||||
)
|
|
||||||
assert observed["timeout"] == config.request_timeout_seconds
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_attestation_round_trip_verifies_with_production_keyring() -> None:
|
|
||||||
private = Ed25519PrivateKey.generate()
|
|
||||||
|
|
||||||
class Ed25519Signer:
|
|
||||||
key_id = "builder-key-001"
|
|
||||||
key_generation = 1
|
|
||||||
|
|
||||||
def sign_for_purpose(self, purpose, payload):
|
|
||||||
assert purpose == "AIOS_TRUSTED_RELEASE_BUILDER"
|
|
||||||
return private.sign(payload)
|
|
||||||
|
|
||||||
server, client, _ = make_seam(signer_override=Ed25519Signer())
|
|
||||||
response = server.handle_request(
|
|
||||||
client.create_request(
|
|
||||||
"V2R2C2",
|
|
||||||
"rc-v2r2c2-d2b5a8339a34",
|
|
||||||
"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
|
||||||
nonce="v" * 32,
|
|
||||||
),
|
|
||||||
connection=object(),
|
|
||||||
)
|
|
||||||
public_key = private.public_key().public_bytes(
|
|
||||||
serialization.Encoding.Raw,
|
|
||||||
serialization.PublicFormat.Raw,
|
|
||||||
)
|
|
||||||
verifier = BuilderAttestationVerifier(
|
|
||||||
BuilderAttestationKeyRing(
|
|
||||||
[BuilderAttestationKeyRecord(
|
|
||||||
key_id="builder-key-001",
|
|
||||||
public_key=public_key,
|
|
||||||
algorithm="Ed25519",
|
|
||||||
purpose="AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
generation=1,
|
|
||||||
not_before="2020-01-01T00:00:00Z",
|
|
||||||
expires_at="2099-01-01T00:00:00Z",
|
|
||||||
status="ACTIVE",
|
|
||||||
)],
|
|
||||||
require_attestation_metadata=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raw = response["silent_release_bundle"]
|
|
||||||
bundle = TrustedCandidateBundle(
|
|
||||||
ReleaseCandidate.from_dict(raw["candidate"]),
|
|
||||||
ReleasePlan.from_dict(raw["release_plan"]),
|
|
||||||
raw["checks"],
|
|
||||||
BuilderAttestation.from_dict(raw["attestation"], require_fingerprint=True),
|
|
||||||
)
|
|
||||||
assert bundle.validate(verifier).candidate.candidate_id == response["evidence"]["candidate_id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_build_seam_rejects_direct_unbound_v2r2c3_and_authentication_boundaries() -> None:
|
|
||||||
server, client, _ = make_seam()
|
|
||||||
with pytest.raises(BuilderError, match="GOVERNED_V2R2C3_DERIVATION_REQUIRED"):
|
|
||||||
client.create_request("V2R2C3", "unknown", "f" * 40, nonce="d" * 32)
|
|
||||||
denied_peer, denied_client, _ = make_seam(peer=PeerIdentity(pid=77, uid=0, gid=0))
|
|
||||||
with pytest.raises(BuilderError, match="PEER_IDENTITY_DENIED"):
|
|
||||||
denied_peer.handle_request(denied_client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="e" * 32), connection=object())
|
|
||||||
denied_cgroup, denied_client, _ = make_seam(cgroup="/system.slice/untrusted.service")
|
|
||||||
with pytest.raises(BuilderError, match="PEER_CGROUP_DENIED"):
|
|
||||||
denied_cgroup.handle_request(denied_client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="f" * 32), connection=object())
|
|
||||||
denied_process, denied_client, _ = make_seam(
|
|
||||||
process=("/usr/bin/python3.12", ("/usr/bin/python3", "/tmp/aios_model_api_staging.py"))
|
|
||||||
)
|
|
||||||
with pytest.raises(BuilderError, match="PEER_PROCESS_DENIED"):
|
|
||||||
denied_process.handle_request(denied_client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="p" * 32), connection=object())
|
|
||||||
stale_server, stale_client, _ = make_seam(client_clock=lambda: NOW - 61)
|
|
||||||
with pytest.raises(BuilderError, match="REQUEST_STALE"):
|
|
||||||
stale_server.handle_request(stale_client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="g" * 32), connection=object())
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_build_seam_fails_closed_when_server_owned_collector_is_incomplete() -> None:
|
|
||||||
def incomplete_evidence(recipe_name, recipe):
|
|
||||||
value = passing_bundle(recipe_name, recipe)
|
|
||||||
value["checks"]["canary"] = False
|
|
||||||
return value
|
|
||||||
|
|
||||||
policy = load_builder_policy(POLICY_PATH)
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
|
||||||
server, client, _ = make_seam(
|
|
||||||
collector=DeterministicEvidenceCollector(config, evidence_source=incomplete_evidence)
|
|
||||||
)
|
|
||||||
with pytest.raises(BuilderError, match="COLLECTOR_CHECKS_INCOMPLETE"):
|
|
||||||
server.handle_request(client.create_request("V2R2C2", "rc-v2r2c2-d2b5a8339a34", "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6", nonce="h" * 32), connection=object())
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_owned_source_rehashes_every_fixed_evidence_file_and_rejects_tampering(tmp_path) -> None:
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
|
||||||
recipe = config.fixed_recipe("V2R2C2")
|
|
||||||
raw = passing_bundle("V2R2C2", recipe)
|
|
||||||
candidate = ReleaseCandidate.from_dict(raw["candidate"])
|
|
||||||
plan = ReleasePlan.from_dict(raw["release_plan"])
|
|
||||||
evidence_root = tmp_path / "evidence"
|
|
||||||
version_root = evidence_root / "V2R2C2"
|
|
||||||
version_root.mkdir(parents=True)
|
|
||||||
|
|
||||||
def write_bytes(name: str, value: bytes) -> str:
|
|
||||||
path = version_root / name
|
|
||||||
path.write_bytes(value)
|
|
||||||
path.chmod(0o600)
|
|
||||||
return hashlib.sha256(value).hexdigest()
|
|
||||||
|
|
||||||
def canonical(value: dict) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
||||||
|
|
||||||
tests = {"passed": True, "receipt": "tests-fixed"}
|
|
||||||
canary = {"passed": True, "receipt": "canary-fixed"}
|
|
||||||
gitea = {
|
|
||||||
"consistent": True,
|
|
||||||
"remote_ref_oid": recipe["source_commit"],
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"source_commit": recipe["source_commit"],
|
|
||||||
"tree_oid": candidate.tree_oid,
|
|
||||||
}
|
|
||||||
git_consistency = {
|
|
||||||
"consistent": True,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"source_commit": recipe["source_commit"],
|
|
||||||
"tree_oid": candidate.tree_oid,
|
|
||||||
}
|
|
||||||
rollback = {
|
|
||||||
"ready": True,
|
|
||||||
"rollback_sha256": plan.deployment.rollback_sha256,
|
|
||||||
"source_commit": recipe["source_commit"],
|
|
||||||
}
|
|
||||||
artifact_bytes = b"immutable-artifact"
|
|
||||||
manifest_bytes = b'{"manifest":"fixed"}\n'
|
|
||||||
closure_bytes = b"closure artifact.tar.gz\n"
|
|
||||||
policy_bytes = b'{"policy":"fixed"}\n'
|
|
||||||
artifact_sha = write_bytes("artifact.tar.gz", artifact_bytes)
|
|
||||||
manifest_sha = write_bytes("manifest.json", manifest_bytes)
|
|
||||||
closure_sha = write_bytes("checksums.sha256", closure_bytes)
|
|
||||||
tests_sha = write_bytes("tests.json", canonical(tests))
|
|
||||||
canary_sha = write_bytes("canary.json", canonical(canary))
|
|
||||||
gitea_sha = write_bytes("gitea.json", canonical(gitea))
|
|
||||||
rollback_sha = write_bytes("rollback.json", canonical(rollback))
|
|
||||||
policy_sha = write_bytes("trusted-release-policy.json", policy_bytes)
|
|
||||||
secret_scan = {"artifact_sha256": artifact_sha, "passed": True}
|
|
||||||
secret_sha = write_bytes("secret-scan.json", canonical(secret_scan))
|
|
||||||
|
|
||||||
candidate.artifact_sha256 = artifact_sha
|
|
||||||
candidate.manifest_sha256 = manifest_sha
|
|
||||||
candidate.tests = tests
|
|
||||||
candidate.canary = canary
|
|
||||||
candidate.git_consistency = git_consistency
|
|
||||||
candidate.gitea_consistency = gitea
|
|
||||||
deployment = dataclasses.replace(
|
|
||||||
plan.deployment,
|
|
||||||
artifact_sha256=artifact_sha,
|
|
||||||
manifest_sha256=manifest_sha,
|
|
||||||
package_closure_sha256=closure_sha,
|
|
||||||
policy_sha256=policy_sha,
|
|
||||||
)
|
|
||||||
plan = dataclasses.replace(plan, deployment=deployment, policy_sha256=policy_sha)
|
|
||||||
receipt = {
|
|
||||||
"schema": "aios.trusted-candidate-builder.evidence.v1",
|
|
||||||
"version": "V2R2C2",
|
|
||||||
"candidate": candidate.to_dict(),
|
|
||||||
"release_plan": plan.to_dict(),
|
|
||||||
}
|
|
||||||
write_bytes("receipt.json", canonical(receipt))
|
|
||||||
|
|
||||||
git_state = {"tree": candidate.tree_oid, "ancestry": 0}
|
|
||||||
|
|
||||||
def git_runner(argv, **_kwargs):
|
|
||||||
if "rev-parse" in argv:
|
|
||||||
return SimpleNamespace(stdout=git_state["tree"] + "\n", returncode=0)
|
|
||||||
return SimpleNamespace(stdout="", returncode=git_state["ancestry"])
|
|
||||||
|
|
||||||
source = ServerOwnedEvidenceSource(
|
|
||||||
config,
|
|
||||||
tmp_path / "repo.git",
|
|
||||||
evidence_root,
|
|
||||||
expected_owner_uid=None,
|
|
||||||
git_runner=git_runner,
|
|
||||||
)
|
|
||||||
collected = source("V2R2C2", recipe)
|
|
||||||
assert collected["candidate"]["artifact_sha256"] == artifact_sha
|
|
||||||
assert collected["evidence_digests"] == {
|
|
||||||
"artifact": artifact_sha,
|
|
||||||
"manifest": manifest_sha,
|
|
||||||
"package_closure": closure_sha,
|
|
||||||
"tests": tests_sha,
|
|
||||||
"canary": canary_sha,
|
|
||||||
"gitea": gitea_sha,
|
|
||||||
"rollback": rollback_sha,
|
|
||||||
"policy": policy_sha,
|
|
||||||
"secret_scan": secret_sha,
|
|
||||||
}
|
|
||||||
|
|
||||||
(version_root / "manifest.json").write_bytes(b"tampered")
|
|
||||||
with pytest.raises(BuilderError, match="COLLECTOR_DIGEST_MISMATCH"):
|
|
||||||
source("V2R2C2", recipe)
|
|
||||||
(version_root / "manifest.json").write_bytes(manifest_bytes)
|
|
||||||
|
|
||||||
for name, tampered, expected in (
|
|
||||||
("artifact.tar.gz", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
|
||||||
("checksums.sha256", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
|
||||||
("trusted-release-policy.json", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
|
||||||
("tests.json", canonical({"passed": True, "receipt": "wrong"}), "COLLECTOR_TESTS_MISMATCH"),
|
|
||||||
("canary.json", canonical({"passed": True, "receipt": "wrong"}), "COLLECTOR_CANARY_MISMATCH"),
|
|
||||||
("gitea.json", canonical({**gitea, "consistent": False}), "COLLECTOR_GITEA_MISMATCH"),
|
|
||||||
("rollback.json", canonical({**rollback, "ready": False}), "COLLECTOR_ROLLBACK_MISMATCH"),
|
|
||||||
("secret-scan.json", canonical({**secret_scan, "passed": False}), "COLLECTOR_SECRET_SCAN_MISMATCH"),
|
|
||||||
):
|
|
||||||
path = version_root / name
|
|
||||||
original = path.read_bytes()
|
|
||||||
path.write_bytes(tampered)
|
|
||||||
with pytest.raises(BuilderError, match=expected):
|
|
||||||
source("V2R2C2", recipe)
|
|
||||||
path.write_bytes(original)
|
|
||||||
|
|
||||||
git_state["tree"] = "f" * 40
|
|
||||||
with pytest.raises(BuilderError, match="COLLECTOR_GIT_MISMATCH"):
|
|
||||||
source("V2R2C2", recipe)
|
|
||||||
git_state["tree"] = candidate.tree_oid
|
|
||||||
git_state["ancestry"] = 1
|
|
||||||
with pytest.raises(BuilderError, match="COLLECTOR_GIT_MISMATCH"):
|
|
||||||
source("V2R2C2", recipe)
|
|
||||||
|
|
||||||
|
|
||||||
def test_systemd_socket_and_service_are_af_unix_hardened_and_credential_bound() -> None:
|
|
||||||
socket_unit = (ROOT / "deploy/systemd/aios-trusted-candidate-builder.socket").read_text()
|
|
||||||
service_unit = (ROOT / "deploy/systemd/aios-trusted-candidate-builder.service").read_text()
|
|
||||||
|
|
||||||
assert "ListenStream=/run/aios-trusted-candidate-builder.sock" in socket_unit
|
|
||||||
assert "SocketMode=0660" in socket_unit
|
|
||||||
assert "LoadCredentialEncrypted=signing_private_key:" in service_unit
|
|
||||||
assert "LoadCredentialEncrypted=builder_hmac:" in service_unit
|
|
||||||
assert "NoNewPrivileges=true" in service_unit
|
|
||||||
assert "ProtectSystem=strict" in service_unit
|
|
||||||
assert "PrivateNetwork=true" in service_unit
|
|
||||||
assert "RestrictAddressFamilies=AF_UNIX" in service_unit
|
|
||||||
assert "StateDirectory=aios-trusted-candidate-builder" in service_unit
|
|
||||||
assert "AIOS_TRUSTED_BUILDER_KEY_GENERATION=1" in service_unit
|
|
||||||
assert "aios-approved-release-executor" not in service_unit
|
|
||||||
assert "--socket-fd 3" in service_unit
|
|
||||||
policy = load_builder_policy(POLICY_PATH)
|
|
||||||
assert policy["allowed_peer_uid"] == 1000
|
|
||||||
assert policy["allowed_peer_cgroups"] == [
|
|
||||||
"/system.slice/aios-gitops-model-api-v2r2c1-staging.service"
|
|
||||||
]
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
|
||||||
from governed_production_release import ReleaseBlocked, TrustedPlanReleaseExecutor, build_executor_config
|
|
||||||
from trusted_production_adapter import AdapterBlocked, _binding
|
|
||||||
|
|
||||||
|
|
||||||
def config(tmp_path: Path, version: str = "V2R2C2") -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(
|
|
||||||
state_root=tmp_path,
|
|
||||||
target_version=version,
|
|
||||||
release_plan_path=None,
|
|
||||||
authoritative_git_dir=tmp_path,
|
|
||||||
baseline_path=tmp_path / "baseline.json",
|
|
||||||
registry_root=tmp_path / "registry",
|
|
||||||
api_base="http://127.0.0.1:3000/api/v1",
|
|
||||||
token_path=tmp_path / "token",
|
|
||||||
remote_name="gitea-http",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_legacy_runtime_selects_trusted_plan_handler(tmp_path: Path):
|
|
||||||
handler = build_executor_config(config(tmp_path))
|
|
||||||
assert isinstance(handler, TrustedPlanReleaseExecutor)
|
|
||||||
|
|
||||||
|
|
||||||
def test_trusted_handler_requires_server_side_plan(tmp_path: Path):
|
|
||||||
handler = build_executor_config(config(tmp_path))
|
|
||||||
with pytest.raises(ReleaseBlocked, match="TRUSTED_RELEASE_PLAN_REQUIRED"):
|
|
||||||
handler.consistency({"version": "V2R2C2"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_runtime_keeps_compatibility_path(tmp_path: Path):
|
|
||||||
handler = build_executor_config(config(tmp_path, "V2R2C1"))
|
|
||||||
assert handler.__class__.__name__ == "GovernedProductionReleaseExecutor"
|
|
||||||
|
|
||||||
|
|
||||||
def test_generic_adapter_requires_candidate_and_plan_binding():
|
|
||||||
value = {
|
|
||||||
"version": "V2R2C2",
|
|
||||||
"target_oid": "1" * 40,
|
|
||||||
"artifact_sha256": "2" * 64,
|
|
||||||
"artifact_path": "/opt/aios/releases/V2R2C2.tar",
|
|
||||||
"artifact_size": 1,
|
|
||||||
"production_service": "aios-v2r2c2-production.service",
|
|
||||||
"production_port": 18928,
|
|
||||||
"candidate_id": "rc-v2r2c2-001",
|
|
||||||
"release_plan_sha256": "sha256:" + "3" * 64,
|
|
||||||
}
|
|
||||||
assert _binding(value)["candidate_id"] == "rc-v2r2c2-001"
|
|
||||||
with pytest.raises(AdapterBlocked, match="legacy_or_unbound_release_rejected"):
|
|
||||||
_binding({**value, "version": "V2R1C7"})
|
|
||||||
with pytest.raises(AdapterBlocked, match="trusted_binding_incomplete"):
|
|
||||||
_binding({key: item for key, item in value.items() if key != "release_plan_sha256"})
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import json, subprocess, sys
|
|
||||||
|
|
||||||
def test_trusted_builder_emits_plan_bound_payload(tmp_path):
|
|
||||||
source = tmp_path / "source"; source.mkdir()
|
|
||||||
files = {
|
|
||||||
"active-runtime/app/deployment_state.py": "x\n",
|
|
||||||
"active-runtime/app/v2r1c6_privacy_tools.py": "x\n",
|
|
||||||
"active-runtime/app/v2r2c2_health_service.py": "x\n",
|
|
||||||
"deploy/aios-v2/systemd/aios-v2r2c2-production.service": "x\n",
|
|
||||||
}
|
|
||||||
for rel, text in files.items():
|
|
||||||
path = source / rel; path.parent.mkdir(parents=True, exist_ok=True); path.write_text(text)
|
|
||||||
output = tmp_path / "release.tar"
|
|
||||||
script = Path(__file__).resolve().parents[1] / "scripts/autonomous-release/build_trusted_release_artifact.py"
|
|
||||||
result = subprocess.run([sys.executable, str(script), "--source", str(source), "--output", str(output),
|
|
||||||
"--version", "V2R2C2", "--source-oid", "1" * 40, "--tree-oid", "2" * 40,
|
|
||||||
"--rollback-version", "V2R2C1", "--rollback-target-oid", "3" * 40,
|
|
||||||
"--rollback-artifact-sha256", "4" * 64, "--service", "aios-v2r2c2-production.service", "--port", "18928"],
|
|
||||||
check=True, capture_output=True, text=True)
|
|
||||||
receipt = json.loads(result.stdout)
|
|
||||||
assert receipt["source_commit"] == "1" * 40
|
|
||||||
assert output.is_file() and receipt["artifact_size"] == output.stat().st_size
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
|
||||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan, canonical_json
|
|
||||||
|
|
||||||
|
|
||||||
def plan():
|
|
||||||
return {
|
|
||||||
"schema": "aios.trusted-release-execution-plan.v1",
|
|
||||||
"version": "V2R2C2",
|
|
||||||
"candidate_id": "rc-v2r2c2-contract-001",
|
|
||||||
"source_commit": "1" * 40,
|
|
||||||
"tree_oid": "2" * 40,
|
|
||||||
"artifact_sha256": "3" * 64,
|
|
||||||
"manifest_sha256": "4" * 64,
|
|
||||||
"package_closure_sha256": "5" * 64,
|
|
||||||
"deployment_plan_sha256": "6" * 64,
|
|
||||||
"policy_sha256": "7" * 64,
|
|
||||||
"release_plan_id": "sha256:" + "8" * 64,
|
|
||||||
"approval_plan_id": "sha256:" + "9" * 64,
|
|
||||||
"evidence_closure_oid": "a" * 40,
|
|
||||||
"campaign_authorization_sha256": "b" * 64,
|
|
||||||
"campaign_generation": 4,
|
|
||||||
"artifact": {"path": "/var/lib/aios/releases/V2R2C2/artifact.tar.zst", "size": 123},
|
|
||||||
"rollback": {"version": "V2R2C1", "target_oid": "c" * 40, "artifact_sha256": "d" * 64},
|
|
||||||
"baseline": {"version": "V2R2C1", "target_oid": "c" * 40},
|
|
||||||
"adapter_policy": {"policy_id": "aios.production-adapter.allowlisted.v1", "service": "aios-gitops-model-api", "port": 18927},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_plan_digest_is_recomputed_and_binding_is_exact():
|
|
||||||
value = plan()
|
|
||||||
parsed = TrustedReleasePlan.from_mapping(value)
|
|
||||||
assert parsed.release_plan_sha256 == hashlib.sha256(canonical_json(value)).hexdigest()
|
|
||||||
assert parsed.candidate_binding_sha256 == hashlib.sha256(canonical_json({k: value[k] for k in ("candidate_id", "source_commit", "tree_oid", "artifact_sha256", "manifest_sha256")})).hexdigest()
|
|
||||||
parsed.assert_binding({"version": "V2R2C2", "target_oid": "1" * 40, "evidence_closure_oid": "a" * 40, "artifact_sha256": "3" * 64, "artifact_size": 123, "approval_plan_id": "sha256:" + "9" * 64, "candidate_id": "rc-v2r2c2-contract-001"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_claimed_digest_or_candidate_tamper_fails_closed():
|
|
||||||
value = plan()
|
|
||||||
value["release_plan_sha256"] = "f" * 64
|
|
||||||
with pytest.raises(PlanError, match="release_plan_sha256_mismatch"):
|
|
||||||
TrustedReleasePlan.from_mapping(value)
|
|
||||||
value = plan()
|
|
||||||
value["candidate_id"] = "different-candidate"
|
|
||||||
parsed = TrustedReleasePlan.from_mapping(value)
|
|
||||||
with pytest.raises(PlanError, match="binding_plan_mismatch"):
|
|
||||||
parsed.assert_binding({"version": "V2R2C2", "target_oid": "1" * 40, "evidence_closure_oid": "a" * 40, "artifact_sha256": "3" * 64, "artifact_size": 123, "approval_plan_id": "sha256:" + "9" * 64, "candidate_id": "rc-v2r2c2-contract-001"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_plan_cannot_supply_executable_or_shell_path():
|
|
||||||
value = plan()
|
|
||||||
value["adapter_policy"] = {"policy_id": "aios.production-adapter.allowlisted.v1", "service": "aios-gitops-model-api", "port": 18927, "executable": "/tmp/evil"}
|
|
||||||
with pytest.raises(PlanError, match="release_plan_fields_invalid|forbidden_plan_key"):
|
|
||||||
TrustedReleasePlan.from_mapping(value)
|
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from approved_release_executor import (
|
||||||
|
Binding,
|
||||||
|
ManualOwnerReviewRequired,
|
||||||
|
ReleaseBlocked,
|
||||||
|
ReconciliationRequired,
|
||||||
|
deploy_step,
|
||||||
|
execute,
|
||||||
|
tag_step,
|
||||||
|
verify_artifact,
|
||||||
|
verify_authorization,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _binding(tmp_path: Path, **overrides) -> Binding:
|
||||||
|
artifact = tmp_path / "artifact.tar.zst"
|
||||||
|
artifact.write_bytes(b"x")
|
||||||
|
values = {
|
||||||
|
"version": "VTEST",
|
||||||
|
"target_oid": "a" * 40,
|
||||||
|
"evidence_closure_oid": "b" * 40,
|
||||||
|
"artifact_sha256": "c" * 64,
|
||||||
|
"approval_plan_id": "sha256:" + "d" * 64,
|
||||||
|
"artifact_path": str(artifact),
|
||||||
|
"artifact_size": 1,
|
||||||
|
"production_service": "test.service",
|
||||||
|
"production_port": 18927,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return Binding(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorization(binding: Binding) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"accepted": True,
|
||||||
|
"binding_replaced": False,
|
||||||
|
"version": binding.version,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"artifact_sha256": binding.artifact_sha256,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
}
|
||||||
|
if binding.tag_oid is not None:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"binding_schema": binding.binding_schema or "aios.release-binding.v2",
|
||||||
|
"tag_oid": binding.tag_oid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if binding.source_registry_main_oid is not None:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"source_registry_main_oid": binding.source_registry_main_oid,
|
||||||
|
"source_registry_path": binding.source_registry_path
|
||||||
|
or f"versions/{binding.version}.json",
|
||||||
|
"source_registry_sha256": binding.source_registry_sha256,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_v2r2c1_style_authorization_remains_compatible(tmp_path: Path) -> None:
|
||||||
|
binding = _binding(tmp_path)
|
||||||
|
assert verify_authorization(_authorization(binding), binding)["accepted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_binding_requires_explicit_tag_oid(tmp_path: Path) -> None:
|
||||||
|
binding = _binding(
|
||||||
|
tmp_path,
|
||||||
|
version="V2R2C2",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
binding_schema="aios.release-binding.v2",
|
||||||
|
)
|
||||||
|
assert verify_authorization(_authorization(binding), binding)["tag_oid"] == "1" * 40
|
||||||
|
wrong = _authorization(binding)
|
||||||
|
wrong["tag_oid"] = "2" * 40
|
||||||
|
with pytest.raises(ManualOwnerReviewRequired, match="tag_oid"):
|
||||||
|
verify_authorization(wrong, binding)
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_same_name_different_tag_object_requires_manual_review(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
binding = _binding(
|
||||||
|
tmp_path,
|
||||||
|
version="V2R2C2",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
binding_schema="aios.release-binding.v2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake(_command, _request):
|
||||||
|
return {
|
||||||
|
"status": "APPLIED_VERIFIED",
|
||||||
|
"version": binding.version,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"artifact_sha256": binding.artifact_sha256,
|
||||||
|
"tag_oid": "2" * 40,
|
||||||
|
"peeled_target_oid": binding.target_oid,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("approved_release_executor.run_adapter", fake)
|
||||||
|
with pytest.raises(ManualOwnerReviewRequired, match="same-name tag object"):
|
||||||
|
tag_step(["/tag"], binding)
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_rechecks_source_registry_digest_and_build_provenance(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
if shutil.which("zstd") is None:
|
||||||
|
pytest.skip("zstd is required for archive verification")
|
||||||
|
source = {
|
||||||
|
"schema": "aios.source-registry.v1",
|
||||||
|
"version": "V2R2C2",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag": "V2R2C2",
|
||||||
|
"tag_oid": "1" * 40,
|
||||||
|
"tag_object": "1" * 40,
|
||||||
|
"target_oid": "2" * 40,
|
||||||
|
"peeled_target_oid": "2" * 40,
|
||||||
|
"evidence_closure_oid": "3" * 40,
|
||||||
|
"approval_plan_id": "sha256:" + "4" * 64,
|
||||||
|
}
|
||||||
|
source_bytes = (json.dumps(source, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode()
|
||||||
|
source_digest = hashlib.sha256(source_bytes).hexdigest()
|
||||||
|
repo_work = tmp_path / "authority-work"
|
||||||
|
repo_bare = tmp_path / "authority.git"
|
||||||
|
(repo_work / "versions").mkdir(parents=True)
|
||||||
|
(repo_work / "versions" / "V2R2C2.json").write_bytes(source_bytes)
|
||||||
|
subprocess.run(["git", "init", "-q", str(repo_work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "branch", "-M", "main"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo_work), "config", "user.email", "test@example.invalid"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "commit", "-qm", "source"], check=True)
|
||||||
|
source_main_oid = subprocess.run(
|
||||||
|
["git", "-C", str(repo_work), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(repo_work), str(repo_bare)], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", f"--git-dir={repo_bare}", "config", "remote.gitea.url", "https://g.cnnetmkt.top/aios/aios.git"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(repo_bare))
|
||||||
|
metadata = {
|
||||||
|
"release_target_oid": "2" * 40,
|
||||||
|
"source_registry_main_oid": source_main_oid,
|
||||||
|
"source_registry_path": "versions/V2R2C2.json",
|
||||||
|
"source_registry_sha256": source_digest,
|
||||||
|
}
|
||||||
|
tar_path = tmp_path / "release.tar"
|
||||||
|
with tarfile.open(tar_path, "w") as archive:
|
||||||
|
for name, content in (
|
||||||
|
("payload/versions/V2R2C2.json", source_bytes),
|
||||||
|
("payload/BUILD-METADATA.json", (json.dumps(metadata, sort_keys=True) + "\n").encode()),
|
||||||
|
):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
info.size = len(content)
|
||||||
|
archive.addfile(info, io.BytesIO(content))
|
||||||
|
artifact = tmp_path / "release.tar.zst"
|
||||||
|
subprocess.run(["zstd", "-q", "-19", "-f", str(tar_path), "-o", str(artifact)], check=True)
|
||||||
|
binding = _binding(
|
||||||
|
tmp_path,
|
||||||
|
version="V2R2C2",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
binding_schema="aios.release-binding.v2",
|
||||||
|
target_oid="2" * 40,
|
||||||
|
evidence_closure_oid="3" * 40,
|
||||||
|
approval_plan_id="sha256:" + "4" * 64,
|
||||||
|
source_registry_main_oid=source_main_oid,
|
||||||
|
source_registry_path="versions/V2R2C2.json",
|
||||||
|
source_registry_sha256=source_digest,
|
||||||
|
artifact_path=str(artifact),
|
||||||
|
artifact_size=artifact.stat().st_size,
|
||||||
|
artifact_sha256=hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
||||||
|
)
|
||||||
|
result = verify_artifact(binding)
|
||||||
|
assert result["source_registry_sha256"] == source_digest
|
||||||
|
assert result["build_metadata_provenance_verified"] is True
|
||||||
|
|
||||||
|
bad_metadata = dict(metadata)
|
||||||
|
bad_metadata["source_registry_sha256"] = "0" * 64
|
||||||
|
bad_tar = tmp_path / "bad-metadata.tar"
|
||||||
|
with tarfile.open(bad_tar, "w") as archive:
|
||||||
|
for name, content in (
|
||||||
|
("payload/versions/V2R2C2.json", source_bytes),
|
||||||
|
("payload/BUILD-METADATA.json", (json.dumps(bad_metadata, sort_keys=True) + "\n").encode()),
|
||||||
|
):
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
info.size = len(content)
|
||||||
|
archive.addfile(info, io.BytesIO(content))
|
||||||
|
bad_artifact = tmp_path / "bad-metadata.tar.zst"
|
||||||
|
subprocess.run(["zstd", "-q", "-19", "-f", str(bad_tar), "-o", str(bad_artifact)], check=True)
|
||||||
|
bad_binding = _binding(
|
||||||
|
tmp_path,
|
||||||
|
version="V2R2C2",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
binding_schema="aios.release-binding.v2",
|
||||||
|
target_oid="2" * 40,
|
||||||
|
evidence_closure_oid="3" * 40,
|
||||||
|
approval_plan_id="sha256:" + "4" * 64,
|
||||||
|
source_registry_main_oid=source_main_oid,
|
||||||
|
source_registry_path="versions/V2R2C2.json",
|
||||||
|
source_registry_sha256=source_digest,
|
||||||
|
artifact_path=str(bad_artifact),
|
||||||
|
artifact_size=bad_artifact.stat().st_size,
|
||||||
|
artifact_sha256=hashlib.sha256(bad_artifact.read_bytes()).hexdigest(),
|
||||||
|
)
|
||||||
|
with pytest.raises(ReleaseBlocked, match="BUILD-METADATA"):
|
||||||
|
verify_artifact(bad_binding)
|
||||||
|
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "remote", "add", "origin", str(repo_bare)], check=True)
|
||||||
|
changed = dict(source)
|
||||||
|
changed["review_marker"] = "changed-after-M"
|
||||||
|
changed_bytes = (json.dumps(changed, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode()
|
||||||
|
(repo_work / "versions" / "V2R2C2.json").write_bytes(changed_bytes)
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "commit", "-qm", "change-source"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_work), "push", "-q", "origin", "HEAD:main"], check=True)
|
||||||
|
with pytest.raises(ManualOwnerReviewRequired, match="current Authority main source registry"):
|
||||||
|
verify_artifact(binding)
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_requires_runtime_bundle_convergence_for_provenance_binding(tmp_path: Path):
|
||||||
|
binding = _binding(
|
||||||
|
tmp_path,
|
||||||
|
version="V2R2C2",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
binding_schema="aios.release-binding.v2",
|
||||||
|
source_registry_main_oid="5" * 40,
|
||||||
|
source_registry_path="versions/V2R2C2.json",
|
||||||
|
source_registry_sha256="6" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_missing(_command, _request):
|
||||||
|
return {
|
||||||
|
"status": "ACTIVE_VERIFIED",
|
||||||
|
"version": binding.version,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"artifact_sha256": binding.artifact_sha256,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
"deployment_state": "ACTIVE",
|
||||||
|
"production_deployed": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch = pytest.MonkeyPatch()
|
||||||
|
monkeypatch.setattr("approved_release_executor.run_adapter", fake_missing)
|
||||||
|
try:
|
||||||
|
with pytest.raises(ReconciliationRequired, match="runtime bundle/runtime registry"):
|
||||||
|
deploy_step(["/deploy"], binding)
|
||||||
|
finally:
|
||||||
|
monkeypatch.undo()
|
||||||
|
|
||||||
|
def fake_converged(_command, _request):
|
||||||
|
return {
|
||||||
|
"status": "ACTIVE_VERIFIED",
|
||||||
|
"version": binding.version,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"artifact_sha256": binding.artifact_sha256,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
"deployment_state": "ACTIVE",
|
||||||
|
"production_deployed": True,
|
||||||
|
"runtime_bundle_contains_current_version": True,
|
||||||
|
"runtime_registry_converged": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("approved_release_executor.run_adapter", fake_converged)
|
||||||
|
try:
|
||||||
|
assert deploy_step(["/deploy"], binding)["runtime_registry_converged"] is True
|
||||||
|
finally:
|
||||||
|
monkeypatch.undo()
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_rechecks_authoritative_git_after_runtime_before_registry(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
):
|
||||||
|
work = tmp_path / "authority-work"
|
||||||
|
bare = tmp_path / "authority.git"
|
||||||
|
(work / "versions").mkdir(parents=True)
|
||||||
|
subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
source = {
|
||||||
|
"schema": "aios.source-registry.v1",
|
||||||
|
"version": "VTEST",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"tag_oid": "1" * 40,
|
||||||
|
"tag_object": "1" * 40,
|
||||||
|
"target_oid": "a" * 40,
|
||||||
|
"peeled_target_oid": "a" * 40,
|
||||||
|
"evidence_closure_oid": "b" * 40,
|
||||||
|
"approval_plan_id": "sha256:" + "d" * 64,
|
||||||
|
}
|
||||||
|
source_path = work / "versions" / "VTEST.json"
|
||||||
|
source_path.write_bytes((json.dumps(source, sort_keys=True, indent=2) + "\n").encode())
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "source"], check=True)
|
||||||
|
pinned_main = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
source_sha = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
||||||
|
binding = _binding(
|
||||||
|
tmp_path,
|
||||||
|
source_registry_main_oid=pinned_main,
|
||||||
|
source_registry_path="versions/VTEST.json",
|
||||||
|
source_registry_sha256=source_sha,
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||||
|
monkeypatch.setattr("approved_release_executor.secure_json", lambda _path: _authorization(binding))
|
||||||
|
monkeypatch.setattr("approved_release_executor.verify_git_lineage", lambda *_args: {"verified": True})
|
||||||
|
monkeypatch.setattr("approved_release_executor.verify_artifact", lambda *_args, **_kwargs: {"verified": True})
|
||||||
|
|
||||||
|
deployed = False
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_adapter(command, request):
|
||||||
|
nonlocal deployed
|
||||||
|
name = command[0]
|
||||||
|
operation = request["operation"]
|
||||||
|
calls.append((name, operation))
|
||||||
|
common = {
|
||||||
|
"version": binding.version,
|
||||||
|
"target_oid": binding.target_oid,
|
||||||
|
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||||
|
"artifact_sha256": binding.artifact_sha256,
|
||||||
|
"approval_plan_id": binding.approval_plan_id,
|
||||||
|
}
|
||||||
|
if name == "/tag":
|
||||||
|
return {
|
||||||
|
**common,
|
||||||
|
"status": "APPLIED_VERIFIED",
|
||||||
|
"tag_oid": "1" * 40,
|
||||||
|
"peeled_target_oid": binding.target_oid,
|
||||||
|
}
|
||||||
|
if name == "/deploy":
|
||||||
|
if operation == "probe" and not deployed:
|
||||||
|
changed = dict(source)
|
||||||
|
changed["review_marker"] = "changed-after-runtime"
|
||||||
|
source_path.write_bytes((json.dumps(changed, sort_keys=True, indent=2) + "\n").encode())
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "advance-main"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", str(bare), "HEAD:main"], check=True)
|
||||||
|
deployed = True
|
||||||
|
return {
|
||||||
|
**common,
|
||||||
|
"status": "ACTIVE_VERIFIED",
|
||||||
|
"deployment_state": "ACTIVE",
|
||||||
|
"production_deployed": True,
|
||||||
|
"runtime_bundle_contains_current_version": True,
|
||||||
|
"runtime_registry_converged": True,
|
||||||
|
}
|
||||||
|
raise AssertionError(f"unexpected adapter call: {name}")
|
||||||
|
|
||||||
|
monkeypatch.setattr("approved_release_executor.run_adapter", fake_adapter)
|
||||||
|
with pytest.raises(ManualOwnerReviewRequired, match="current Authority main source registry"):
|
||||||
|
execute(
|
||||||
|
binding=binding,
|
||||||
|
authorization_path=tmp_path / "authorization.json",
|
||||||
|
repository=bare,
|
||||||
|
state_root=tmp_path / "state",
|
||||||
|
tag_command=["/tag"],
|
||||||
|
deploy_command=["/deploy"],
|
||||||
|
registry_command=["/registry"],
|
||||||
|
baseline_command=["/baseline"],
|
||||||
|
)
|
||||||
|
assert deployed is True
|
||||||
|
assert not any(name in {"/registry", "/baseline"} for name, _ in calls)
|
||||||
@@ -0,0 +1,850 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||||
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
|
import release_metadata_adapter as adapter # noqa: E402
|
||||||
|
import version_registry as registry_module # noqa: E402
|
||||||
|
from version_registry import ( # noqa: E402
|
||||||
|
RegistryConvergenceError,
|
||||||
|
assert_runtime_registry_convergence,
|
||||||
|
assert_source_registry_convergence,
|
||||||
|
build_source_registry_entry,
|
||||||
|
configured_approval_authority_root,
|
||||||
|
configured_authoritative_git_dir,
|
||||||
|
list_registry_versions,
|
||||||
|
list_registry_versions_report,
|
||||||
|
source_registry_snapshot,
|
||||||
|
verify_authoritative_repository,
|
||||||
|
verify_approval_authority,
|
||||||
|
verify_source_registry_main_convergence,
|
||||||
|
verify_source_registry_git_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, obj: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(obj, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
*,
|
||||||
|
version: str = "V2R2C1",
|
||||||
|
tag_oid: str = "1" * 40,
|
||||||
|
target_oid: str = "2" * 40,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"schema": "aios.production-registry.v2",
|
||||||
|
"version": version,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
|
"production_deployed": True,
|
||||||
|
"tag_oid": tag_oid,
|
||||||
|
"target_oid": target_oid,
|
||||||
|
"peeled_target_oid": target_oid,
|
||||||
|
"evidence_closure_oid": "3" * 40,
|
||||||
|
"approval_plan_id": "sha256:" + "4" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_approval(root: Path, record: dict) -> Path:
|
||||||
|
owner = {
|
||||||
|
"approved": True,
|
||||||
|
"status": "APPROVED",
|
||||||
|
"approved_by": "aiman",
|
||||||
|
"approval_source": "explicit_owner_approval_in_chat",
|
||||||
|
}
|
||||||
|
binding = {
|
||||||
|
key: record[key]
|
||||||
|
for key in (
|
||||||
|
"version",
|
||||||
|
"repository",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
authority = {
|
||||||
|
"schema": "aios.v2r2c1.tag-approval-request.v1",
|
||||||
|
"state": "APPROVED",
|
||||||
|
"status": "APPROVED",
|
||||||
|
"execute": False,
|
||||||
|
"approval_plan_id": record["approval_plan_id"],
|
||||||
|
"binding": binding,
|
||||||
|
"owner_approval": owner,
|
||||||
|
"owner_approval_digest_sha256": hashlib.sha256(
|
||||||
|
(json.dumps(owner, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||||
|
).hexdigest(),
|
||||||
|
}
|
||||||
|
path = root / f"{record['version']}.json"
|
||||||
|
_write_json(path, authority)
|
||||||
|
path.chmod(0o640)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _explicit_test_authority_mode(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""Permit only these unit tests to inject temporary authority roots."""
|
||||||
|
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_registry_versions_adds_only_baseline_attested_projection(tmp_path: Path):
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
(ws / "versions").mkdir(parents=True)
|
||||||
|
(ws / "versions" / "V2R1C7.json").write_text("{}\n")
|
||||||
|
|
||||||
|
projection_root = tmp_path / "projection"
|
||||||
|
projection = projection_root / "V2R2C1.json"
|
||||||
|
_write_json(projection, _record())
|
||||||
|
digest = hashlib.sha256(projection.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
baseline = tmp_path / "baseline" / "current.json"
|
||||||
|
_write_json(
|
||||||
|
baseline,
|
||||||
|
{
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
|
"production_deployed": True,
|
||||||
|
"registry_source": str(projection),
|
||||||
|
"registry_digest": digest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_registry_versions(
|
||||||
|
ws,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
) == ["V2R1C7.json", "V2R2C1.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_registry_versions_rejects_projection_digest_drift(tmp_path: Path):
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
(ws / "versions").mkdir(parents=True)
|
||||||
|
projection_root = tmp_path / "projection"
|
||||||
|
projection = projection_root / "V2R2C1.json"
|
||||||
|
_write_json(projection, _record())
|
||||||
|
|
||||||
|
baseline = tmp_path / "baseline.json"
|
||||||
|
_write_json(
|
||||||
|
baseline,
|
||||||
|
{
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
|
"production_deployed": True,
|
||||||
|
"registry_source": str(projection),
|
||||||
|
"registry_digest": "0" * 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_registry_versions(
|
||||||
|
ws,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_only_version_is_not_runtime_artifact_convergence(tmp_path: Path):
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
(ws / "versions").mkdir(parents=True)
|
||||||
|
projection_root = tmp_path / "projection"
|
||||||
|
projection = projection_root / "V2R2C1.json"
|
||||||
|
_write_json(projection, _record())
|
||||||
|
baseline = tmp_path / "baseline.json"
|
||||||
|
_write_json(
|
||||||
|
baseline,
|
||||||
|
{
|
||||||
|
"version": "V2R2C1",
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
|
"production_deployed": True,
|
||||||
|
"registry_source": str(projection),
|
||||||
|
"registry_digest": hashlib.sha256(projection.read_bytes()).hexdigest(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
report = list_registry_versions_report(
|
||||||
|
ws,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
)
|
||||||
|
assert report["registry_versions"] == ["V2R2C1.json"]
|
||||||
|
assert report["attested_projection_version"] == "V2R2C1"
|
||||||
|
assert report["runtime_bundle_contains_version"] is False
|
||||||
|
assert report["source_registry_converged"] == "unknown"
|
||||||
|
assert report["runtime_registry_converged"] is False
|
||||||
|
assert report["runtime_bundle_contains_current_version"] is False
|
||||||
|
assert report["registry_consistency_status"] == "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="runtime_bundle_missing"):
|
||||||
|
assert_runtime_registry_convergence(
|
||||||
|
ws,
|
||||||
|
"V2R2C1",
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attested_projection(tmp_path: Path, record: dict) -> tuple[Path, Path]:
|
||||||
|
projection_root = tmp_path / "projection"
|
||||||
|
projection = projection_root / f"{record['version']}.json"
|
||||||
|
_write_json(projection, record)
|
||||||
|
baseline = tmp_path / "baseline.json"
|
||||||
|
_write_json(
|
||||||
|
baseline,
|
||||||
|
{
|
||||||
|
"version": record["version"],
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
|
"production_deployed": True,
|
||||||
|
"registry_source": str(projection),
|
||||||
|
"registry_digest": hashlib.sha256(projection.read_bytes()).hexdigest(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return projection_root, baseline
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_report_source_good_runtime_missing_is_not_fully_consistent(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
bare, branch = _init_bare_repo(tmp_path, record)
|
||||||
|
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||||
|
ws = tmp_path / "runtime"
|
||||||
|
(ws / "versions").mkdir(parents=True)
|
||||||
|
report = list_registry_versions_report(
|
||||||
|
ws,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
assert report["source_registry_converged"] is True
|
||||||
|
assert report["runtime_registry_converged"] is False
|
||||||
|
assert report["runtime_bundle_contains_current_version"] is False
|
||||||
|
assert report["registry_consistency_status"] == "RUNTIME_ARTIFACT_RECONCILIATION_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_report_source_runtime_projection_good_is_fully_consistent(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
bare, branch = _init_bare_repo(tmp_path, record)
|
||||||
|
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||||
|
ws = tmp_path / "runtime"
|
||||||
|
versions = ws / "versions"
|
||||||
|
versions.mkdir(parents=True)
|
||||||
|
_write_json(versions / f"{record['version']}.json", record)
|
||||||
|
report = list_registry_versions_report(
|
||||||
|
ws,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
assert report["source_registry_converged"] is True
|
||||||
|
assert report["runtime_registry_converged"] is True
|
||||||
|
assert report["runtime_bundle_contains_current_version"] is True
|
||||||
|
assert report["registry_consistency_status"] == "FULLY_CONSISTENT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_report_source_missing_is_not_converged(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
work = tmp_path / "no-source-work"
|
||||||
|
bare = tmp_path / "no-source.git"
|
||||||
|
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
(work / "README").write_text("no source\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "no source"], check=True)
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||||
|
ws = tmp_path / "runtime"
|
||||||
|
(ws / "versions").mkdir(parents=True)
|
||||||
|
_write_json(ws / "versions" / f"{record['version']}.json", record)
|
||||||
|
report = list_registry_versions_report(
|
||||||
|
ws,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
assert report["source_registry_converged"] is False
|
||||||
|
assert report["runtime_registry_converged"] is True
|
||||||
|
assert report["registry_consistency_status"] == "SOURCE_REGISTRY_NOT_CONVERGED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_registry_versions_excludes_non_version_entries(tmp_path: Path):
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
versions = ws / "versions"
|
||||||
|
versions.mkdir(parents=True)
|
||||||
|
(versions / "README.txt").write_text("do not expose\n", encoding="utf-8")
|
||||||
|
(versions / "scratch.json").write_text("{}\n", encoding="utf-8")
|
||||||
|
(versions / "proposals").mkdir()
|
||||||
|
(versions / "V2R1C7.json").write_text("{}\n", encoding="utf-8")
|
||||||
|
assert list_registry_versions(
|
||||||
|
ws,
|
||||||
|
projection_root=tmp_path / "projection",
|
||||||
|
baseline_file=tmp_path / "baseline.json",
|
||||||
|
) == ["V2R1C7.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def _init_bare_repo(tmp_path: Path, record: dict) -> tuple[Path, str]:
|
||||||
|
work = tmp_path / "work"
|
||||||
|
bare = tmp_path / "repo.git"
|
||||||
|
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||||
|
_write_json(work / "versions" / f"{record['version']}.json", record)
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry"], check=True)
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
return bare, branch
|
||||||
|
|
||||||
|
|
||||||
|
def _init_bare_repo_with_work(tmp_path: Path, record: dict) -> tuple[Path, Path, str]:
|
||||||
|
work = tmp_path / "history-work"
|
||||||
|
bare = tmp_path / "history.git"
|
||||||
|
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
_write_json(work / "versions" / f"{record['version']}.json", record)
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry"], check=True)
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "remote", "add", "origin", str(bare)], check=True)
|
||||||
|
return work, bare, branch
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_source_registry_convergence_passes_matching_identity(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
bare, branch = _init_bare_repo(tmp_path, record)
|
||||||
|
projection = tmp_path / "projection.json"
|
||||||
|
_write_json(projection, record)
|
||||||
|
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
projection,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_source_registry_convergence_fails_missing_entry(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
work = tmp_path / "work"
|
||||||
|
bare = tmp_path / "repo.git"
|
||||||
|
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||||
|
(work / "README").write_text("x\n")
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "init"], check=True)
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "-C", str(work), "symbolic-ref", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
|
||||||
|
projection = tmp_path / "projection.json"
|
||||||
|
_write_json(projection, record)
|
||||||
|
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="source_registry_entry_missing"):
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
projection,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_source_registry_convergence_fails_identity_mismatch(tmp_path: Path):
|
||||||
|
source = _record(tag_oid="5" * 40)
|
||||||
|
projection_record = _record(tag_oid="1" * 40)
|
||||||
|
bare, branch = _init_bare_repo(tmp_path, source)
|
||||||
|
projection = tmp_path / "projection.json"
|
||||||
|
_write_json(projection, projection_record)
|
||||||
|
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="source_registry_mismatch:tag_oid"):
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
projection,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_source_registry_convergence_requires_all_identity_fields(tmp_path: Path):
|
||||||
|
source = _record()
|
||||||
|
source.pop("peeled_target_oid")
|
||||||
|
bare, branch = _init_bare_repo(tmp_path, source)
|
||||||
|
projection = tmp_path / "projection.json"
|
||||||
|
_write_json(projection, _record())
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="peeled_target_oid"):
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
projection,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_source_registry_convergence_rejects_wrong_repository(tmp_path: Path):
|
||||||
|
source = _record()
|
||||||
|
source["repository"] = "other/repository"
|
||||||
|
bare, branch = _init_bare_repo(tmp_path, source)
|
||||||
|
projection = tmp_path / "projection.json"
|
||||||
|
_write_json(projection, _record())
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="repository"):
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
projection,
|
||||||
|
repo_git_dir=bare,
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_main_convergence_accepts_unrelated_later_main_commit(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||||
|
source_main = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
(work / "README").write_text("unrelated later main commit\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "unrelated"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||||
|
result = verify_source_registry_main_convergence(
|
||||||
|
bare,
|
||||||
|
record["version"],
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
source_registry_main_oid=source_main,
|
||||||
|
expected_tag_oid=record["tag_oid"],
|
||||||
|
expected_target_oid=record["target_oid"],
|
||||||
|
expected_evidence_closure_oid=record["evidence_closure_oid"],
|
||||||
|
expected_approval_plan_id=record["approval_plan_id"],
|
||||||
|
)
|
||||||
|
assert result["source_registry_main_is_ancestor_of_current_main"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_main_convergence_rejects_side_branch_only_commit(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||||
|
base = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "-C", str(work), "checkout", "-qb", "side"], check=True)
|
||||||
|
(work / "side.txt").write_text("side\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "side.txt"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "side"], check=True)
|
||||||
|
side = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "-C", str(work), "checkout", "-q", branch], check=True)
|
||||||
|
(work / "main.txt").write_text("main\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "main.txt"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "main"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||||
|
assert base != side
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="not_ancestor"):
|
||||||
|
verify_source_registry_main_convergence(
|
||||||
|
bare,
|
||||||
|
record["version"],
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
source_registry_main_oid=side,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_main_convergence_rejects_removed_entry(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||||
|
source_main = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
(work / "versions" / f"{record['version']}.json").unlink()
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "-u", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "remove"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="entry_missing"):
|
||||||
|
verify_source_registry_main_convergence(
|
||||||
|
bare,
|
||||||
|
record["version"],
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
source_registry_main_oid=source_main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_main_convergence_rejects_altered_entry(tmp_path: Path):
|
||||||
|
record = _record()
|
||||||
|
work, bare, branch = _init_bare_repo_with_work(tmp_path, record)
|
||||||
|
source_main = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
altered = dict(record)
|
||||||
|
altered["approval_plan_id"] = "sha256:" + "9" * 64
|
||||||
|
_write_json(work / "versions" / f"{record['version']}.json", altered)
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "alter"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", branch], check=True)
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="entry_changed"):
|
||||||
|
verify_source_registry_main_convergence(
|
||||||
|
bare,
|
||||||
|
record["version"],
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
source_registry_main_oid=source_main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_generator_predeclares_release_identity():
|
||||||
|
value = build_source_registry_entry(
|
||||||
|
version="V2R2C1",
|
||||||
|
tag_oid="1" * 40,
|
||||||
|
target_oid="2" * 40,
|
||||||
|
peeled_target_oid="2" * 40,
|
||||||
|
evidence_closure_oid="3" * 40,
|
||||||
|
approval_plan_id="sha256:" + "4" * 64,
|
||||||
|
)
|
||||||
|
assert set(
|
||||||
|
(
|
||||||
|
"version",
|
||||||
|
"tag_oid",
|
||||||
|
"target_oid",
|
||||||
|
"peeled_target_oid",
|
||||||
|
"evidence_closure_oid",
|
||||||
|
"approval_plan_id",
|
||||||
|
)
|
||||||
|
).issubset(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoritative_repository_rejects_arbitrary_repository(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
record = _record()
|
||||||
|
bare, _ = _init_bare_repo(tmp_path, record)
|
||||||
|
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="path_mismatch"):
|
||||||
|
verify_authoritative_repository(bare)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_authority_defaults_are_fixed_and_env_overrides_fail(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||||
|
monkeypatch.delenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", raising=False)
|
||||||
|
monkeypatch.delenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", raising=False)
|
||||||
|
assert configured_authoritative_git_dir() == Path("/data/aios-gitops/repo.git")
|
||||||
|
assert configured_approval_authority_root() == Path(
|
||||||
|
"/var/lib/aios-gitops/approval-authority"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(tmp_path / "fake.git"))
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="override_forbidden"):
|
||||||
|
configured_authoritative_git_dir()
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(tmp_path / "fake-root"))
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="override_forbidden"):
|
||||||
|
configured_approval_authority_root()
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_canonical_repository_and_test_injected_authority(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
record = _record()
|
||||||
|
bare, _ = _init_bare_repo(tmp_path, record)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={bare}",
|
||||||
|
"config",
|
||||||
|
"remote.gitea.url",
|
||||||
|
"https://g.cnnetmkt.top/aios/aios.git",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||||
|
monkeypatch.setattr(registry_module, "DEFAULT_AUTHORITATIVE_GIT_DIR", bare)
|
||||||
|
assert verify_authoritative_repository(bare)["repository_identity"] == "aios/aios"
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIOS_TEST_MODE", "1")
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||||
|
assert verify_authoritative_repository(bare)["configured_path"] == str(bare.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoritative_git_unavailable_stays_unknown_and_closure_fails(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
record = _record()
|
||||||
|
projection_root, baseline = _attested_projection(tmp_path, record)
|
||||||
|
ws = tmp_path / "runtime"
|
||||||
|
(ws / "versions").mkdir(parents=True)
|
||||||
|
_write_json(ws / "versions" / f"{record['version']}.json", record)
|
||||||
|
missing = tmp_path / "missing-authority.git"
|
||||||
|
report = list_registry_versions_report(
|
||||||
|
ws,
|
||||||
|
projection_root=projection_root,
|
||||||
|
baseline_file=baseline,
|
||||||
|
repo_git_dir=missing,
|
||||||
|
)
|
||||||
|
assert report["source_registry_converged"] == "unknown"
|
||||||
|
assert report["source_registry_convergence_error"] == "authoritative_repository_unavailable"
|
||||||
|
monkeypatch.delenv("AIOS_TEST_MODE")
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="path_unavailable"):
|
||||||
|
assert_source_registry_convergence(
|
||||||
|
projection_root / f"{record['version']}.json",
|
||||||
|
repo_git_dir=missing,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_snapshot_stays_pinned_after_main_advances(tmp_path: Path):
|
||||||
|
work = tmp_path / "work"
|
||||||
|
bare = tmp_path / "repo.git"
|
||||||
|
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(work), "config", "user.email", "test@example.invalid"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
record = _record()
|
||||||
|
_write_json(work / "versions" / "V2R2C1.json", record)
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry-1"], check=True)
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "-C", str(work), "branch", "--show-current"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
pinned = source_registry_snapshot(
|
||||||
|
bare,
|
||||||
|
"V2R2C1",
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = dict(record)
|
||||||
|
updated["review_marker"] = "main-advanced"
|
||||||
|
_write_json(work / "versions" / "V2R2C1.json", updated)
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "registry-2"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "remote", "add", "authority", str(bare)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "-q", "authority", branch], check=True)
|
||||||
|
|
||||||
|
repinned = source_registry_snapshot(
|
||||||
|
bare,
|
||||||
|
"V2R2C1",
|
||||||
|
main_ref=f"refs/heads/{branch}",
|
||||||
|
source_registry_main_oid=pinned["source_registry_main_oid"],
|
||||||
|
)
|
||||||
|
assert repinned["source_registry_main_oid"] == pinned["source_registry_main_oid"]
|
||||||
|
assert repinned["bytes"] == pinned["bytes"]
|
||||||
|
assert repinned["source_registry_sha256"] == pinned["source_registry_sha256"]
|
||||||
|
assert repinned["record"].get("review_marker") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _git_commit_with_release_tag(tmp_path: Path, *, annotated: bool = True):
|
||||||
|
work = tmp_path / "authority-work"
|
||||||
|
bare = tmp_path / "authority.git"
|
||||||
|
subprocess.run(["git", "init", "-q", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.invalid"], check=True)
|
||||||
|
(work / "README").write_text("release\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "README"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "target"], check=True)
|
||||||
|
target = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
tag_args = ["git", "-C", str(work), "tag"]
|
||||||
|
if annotated:
|
||||||
|
tag_args += ["-a", "V2R2C1", target, "-m", "release"]
|
||||||
|
else:
|
||||||
|
tag_args += ["V2R2C1", target]
|
||||||
|
subprocess.run(tag_args, check=True)
|
||||||
|
tag_oid = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "refs/tags/V2R2C1"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "--allow-empty", "-qm", "evidence"], check=True)
|
||||||
|
evidence = subprocess.run(
|
||||||
|
["git", "-C", str(work), "rev-parse", "HEAD"], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
record = _record(tag_oid=tag_oid, target_oid=target)
|
||||||
|
record["evidence_closure_oid"] = evidence
|
||||||
|
_write_json(work / "versions" / "V2R2C1.json", record)
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "versions"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-qm", "source registry"], check=True)
|
||||||
|
branch = subprocess.run(
|
||||||
|
["git", "-C", str(work), "branch", "--show-current"], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True)
|
||||||
|
return work, bare, branch, record, target, evidence, tag_oid
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_git_identity_rejects_lightweight_tag(tmp_path: Path):
|
||||||
|
_, bare, _, record, _, _, _ = _git_commit_with_release_tag(tmp_path, annotated=False)
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="tag_not_annotated"):
|
||||||
|
verify_source_registry_git_identity(bare, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_git_identity_proves_annotated_tag_and_lineage(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
_, bare, _, record, target, evidence, tag_oid = _git_commit_with_release_tag(tmp_path)
|
||||||
|
result = verify_source_registry_git_identity(bare, record)
|
||||||
|
assert result["tag_oid"] == tag_oid
|
||||||
|
assert result["target_oid"] == target
|
||||||
|
assert result["evidence_closure_oid"] == evidence
|
||||||
|
|
||||||
|
authority_root = tmp_path / "authority-root"
|
||||||
|
authority_root.mkdir()
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||||
|
authority = _canonical_approval(authority_root, record)
|
||||||
|
assert verify_approval_authority(authority, record)["release_binding_verified"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_generator_requires_authoritative_git_and_approval(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
|
_, bare, _, record, _, _, _ = _git_commit_with_release_tag(tmp_path)
|
||||||
|
subprocess.run(
|
||||||
|
["git", f"--git-dir={bare}", "config", "remote.gitea.url", "https://g.cnnetmkt.top/aios/aios.git"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
authority_root = tmp_path / "authority-root"
|
||||||
|
authority_root.mkdir()
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", str(bare))
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||||
|
authority = _canonical_approval(authority_root, record)
|
||||||
|
output = tmp_path / "generated" / "versions" / "V2R2C1.json"
|
||||||
|
script = Path(__file__).resolve().parents[1] / "scripts" / "generate_source_registry.py"
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
str(script),
|
||||||
|
"--output",
|
||||||
|
str(output),
|
||||||
|
"--version",
|
||||||
|
record["version"],
|
||||||
|
"--tag-oid",
|
||||||
|
record["tag_oid"],
|
||||||
|
"--target-oid",
|
||||||
|
record["target_oid"],
|
||||||
|
"--peeled-target-oid",
|
||||||
|
record["peeled_target_oid"],
|
||||||
|
"--evidence-closure-oid",
|
||||||
|
record["evidence_closure_oid"],
|
||||||
|
"--approval-plan-id",
|
||||||
|
record["approval_plan_id"],
|
||||||
|
"--repository",
|
||||||
|
str(bare),
|
||||||
|
"--approval-authority",
|
||||||
|
str(authority),
|
||||||
|
]
|
||||||
|
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(output.read_text(encoding="utf-8"))["tag_oid"] == record["tag_oid"]
|
||||||
|
|
||||||
|
wrong = list(command)
|
||||||
|
wrong[wrong.index("--approval-plan-id") + 1] = "sha256:" + "f" * 64
|
||||||
|
failed = subprocess.run(wrong, capture_output=True, text=True, check=False)
|
||||||
|
assert failed.returncode != 0
|
||||||
|
other = tmp_path / "other.git"
|
||||||
|
subprocess.run(["git", "clone", "-q", "--bare", str(bare), str(other)], check=True)
|
||||||
|
arbitrary = list(command)
|
||||||
|
arbitrary[arbitrary.index("--repository") + 1] = str(other)
|
||||||
|
failed_authority = subprocess.run(arbitrary, capture_output=True, text=True, check=False)
|
||||||
|
assert failed_authority.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_authority_rejects_unassociated_plan(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
record = _record()
|
||||||
|
authority_root = tmp_path / "authority-root"
|
||||||
|
authority_root.mkdir()
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||||
|
authority = authority_root / f"{record['version']}.json"
|
||||||
|
_write_json(
|
||||||
|
authority,
|
||||||
|
{
|
||||||
|
"schema": "aios.v2r2c1.tag-approval-request.v1",
|
||||||
|
"state": "APPROVED",
|
||||||
|
"approval_plan_id": record["approval_plan_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
authority.chmod(0o640)
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="binding_missing"):
|
||||||
|
verify_approval_authority(authority, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_authority_rejects_untrusted_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
authority_root = tmp_path / "authority-root"
|
||||||
|
authority_root.mkdir()
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||||
|
authority = tmp_path / "approval.json"
|
||||||
|
_write_json(authority, {"schema": "aios.v2r2c1.tag-approval-request.v1"})
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="untrusted_path"):
|
||||||
|
verify_approval_authority(authority, _record())
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_authority_rejects_revoked_receipt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
authority_root = tmp_path / "authority-root"
|
||||||
|
authority_root.mkdir()
|
||||||
|
monkeypatch.setenv("AIOS_GITOPS_APPROVAL_AUTHORITY_ROOT", str(authority_root))
|
||||||
|
record = _record()
|
||||||
|
authority = _canonical_approval(authority_root, record)
|
||||||
|
value = json.loads(authority.read_text(encoding="utf-8"))
|
||||||
|
value["revoked"] = True
|
||||||
|
authority.write_text(json.dumps(value) + "\n", encoding="utf-8")
|
||||||
|
authority.chmod(0o640)
|
||||||
|
with pytest.raises(RegistryConvergenceError, match="revoked"):
|
||||||
|
verify_approval_authority(authority, record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_baseline_apply_wires_convergence_to_caller_repository():
|
||||||
|
source = inspect.getsource(adapter.baseline_apply)
|
||||||
|
assert "assert_source_registry_convergence(" in source
|
||||||
|
assert "repo_git_dir=args.repository" in source
|
||||||
|
# The gate must execute before the dict that writes final production status.
|
||||||
|
assert source.index("assert_source_registry_convergence(") < source.index(
|
||||||
|
'"status": "PRODUCTION_DEPLOYED"'
|
||||||
|
)
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
IMPORT_LINE = (
|
|
||||||
"from aios_agent_task_projection_recovery_http import "
|
|
||||||
"get_projection as get_canonical_projection, "
|
|
||||||
"recover_from_json as recover_canonical_projection\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
IMPORT_ANCHOR = '''from aios_online_agent_execution import (
|
|
||||||
GovernedExecutionBinding,
|
|
||||||
validate_execution_envelope,
|
|
||||||
)
|
|
||||||
'''
|
|
||||||
|
|
||||||
GET_OLD = ''' if self.path == "/health":
|
|
||||||
self._json(
|
|
||||||
{"status": "ok", "service": "aios-agent-task-initial-execution-projection"},
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._json({"error": "NOT_FOUND"}, 404)
|
|
||||||
'''
|
|
||||||
|
|
||||||
GET_NEW = ''' if self.path == "/health":
|
|
||||||
self._json(
|
|
||||||
{"status": "ok", "service": "aios-agent-task-initial-execution-projection"},
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
canonical_match = re.fullmatch(
|
|
||||||
r"/v1/tasks/([^/]+)/canonical-projection", self.path
|
|
||||||
)
|
|
||||||
if canonical_match is None:
|
|
||||||
self._json({"error": "NOT_FOUND"}, 404)
|
|
||||||
return
|
|
||||||
if not self._authorized():
|
|
||||||
self._json({"error": "UNAUTHORIZED"}, 401)
|
|
||||||
return
|
|
||||||
if self.store is None:
|
|
||||||
self._json({"error": "RUNTIME_UNAVAILABLE"}, 503)
|
|
||||||
return
|
|
||||||
status, result = get_canonical_projection(
|
|
||||||
self.store.db_path, canonical_match.group(1)
|
|
||||||
)
|
|
||||||
self._json(result, status)
|
|
||||||
'''
|
|
||||||
|
|
||||||
POST_MATCH_ANCHOR = ''' initial_match = re.fullmatch(
|
|
||||||
r"/v1/tasks/([^/]+)/initial-execution-projection", self.path
|
|
||||||
)
|
|
||||||
'''
|
|
||||||
|
|
||||||
POST_MATCH_REPLACEMENT = POST_MATCH_ANCHOR + ''' canonical_match = re.fullmatch(
|
|
||||||
r"/v1/tasks/([^/]+)/canonical-projection-recovery", self.path
|
|
||||||
)
|
|
||||||
'''
|
|
||||||
|
|
||||||
POST_VALIDATION_OLD = ''' if initial_match is None and recovery_match is None:
|
|
||||||
self._json({"error": "NOT_FOUND"}, 404)
|
|
||||||
return
|
|
||||||
'''
|
|
||||||
|
|
||||||
POST_VALIDATION_NEW = ''' if (
|
|
||||||
initial_match is None
|
|
||||||
and recovery_match is None
|
|
||||||
and canonical_match is None
|
|
||||||
):
|
|
||||||
self._json({"error": "NOT_FOUND"}, 404)
|
|
||||||
return
|
|
||||||
'''
|
|
||||||
|
|
||||||
VALUE_HANDLER_OLD = ''' if initial_match is not None:
|
|
||||||
request = InitialExecutionProjectionRequest.from_mapping(value)
|
|
||||||
'''
|
|
||||||
|
|
||||||
VALUE_HANDLER_NEW = ''' if canonical_match is not None:
|
|
||||||
status, result = recover_canonical_projection(
|
|
||||||
self.store.db_path, canonical_match.group(1), value
|
|
||||||
)
|
|
||||||
self._json(result, status)
|
|
||||||
return
|
|
||||||
if initial_match is not None:
|
|
||||||
request = InitialExecutionProjectionRequest.from_mapping(value)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
class PatchError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
|
||||||
count = text.count(old)
|
|
||||||
if count != 1:
|
|
||||||
raise PatchError(f"{label}_ANCHOR_COUNT_{count}")
|
|
||||||
return text.replace(old, new, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def apply(path: Path) -> bool:
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
if "canonical-projection-recovery" in text and IMPORT_LINE.strip() in text:
|
|
||||||
return False
|
|
||||||
text = replace_once(text, IMPORT_ANCHOR, IMPORT_ANCHOR + IMPORT_LINE, "IMPORT")
|
|
||||||
text = replace_once(text, GET_OLD, GET_NEW, "GET")
|
|
||||||
text = replace_once(
|
|
||||||
text, POST_MATCH_ANCHOR, POST_MATCH_REPLACEMENT, "POST_MATCH"
|
|
||||||
)
|
|
||||||
text = replace_once(
|
|
||||||
text, POST_VALIDATION_OLD, POST_VALIDATION_NEW, "POST_VALIDATION"
|
|
||||||
)
|
|
||||||
text = replace_once(
|
|
||||||
text, VALUE_HANDLER_OLD, VALUE_HANDLER_NEW, "VALUE_HANDLER"
|
|
||||||
)
|
|
||||||
path.write_text(text, encoding="utf-8")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("path", type=Path)
|
|
||||||
args = parser.parse_args()
|
|
||||||
try:
|
|
||||||
changed = apply(args.path)
|
|
||||||
except (OSError, PatchError) as exc:
|
|
||||||
print(f"ERROR:{exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
print("CHANGED" if changed else "ALREADY_APPLIED")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user